mirror of
https://github.com/plausible/analytics.git
synced 2025-01-08 19:17:06 +03:00
Plugins API: Get/Create Funnels (#4192)
* Bootstrap OpenAPI Funnel schemas * Implement Plugins API Funnel view * Allow casting funnel step directly from `%Goal{}` * Check feature availability on funnel creation just like it's done when inserting goals * Implement Plugins API context module for Funnels * Implement GET/PUT funnels via Plugins API * Fix typo * A rare event in which dialyzer found an actual bug, wow! * Format * Wrap creation request with a root `funnel` key * Format * Extract common funnel get query * Remove redundant tag * Refactor queries a bit
This commit is contained in:
parent
62cd18e5e7
commit
823f7a8800
@ -10,7 +10,7 @@ config :plausible, Plausible.Repo, pool: Ecto.Adapters.SQL.Sandbox
|
||||
|
||||
config :plausible, Plausible.ClickhouseRepo,
|
||||
loggers: [Ecto.LogEntry],
|
||||
pool_size: 5
|
||||
pool_size: 15
|
||||
|
||||
config :plausible, Plausible.Mailer, adapter: Bamboo.TestAdapter
|
||||
|
||||
|
@ -15,7 +15,13 @@ defmodule Plausible.Funnel.Step do
|
||||
timestamps()
|
||||
end
|
||||
|
||||
def changeset(step, attrs \\ %{}) do
|
||||
def changeset(step, goal_or_attrs \\ %{})
|
||||
|
||||
def changeset(step, %Plausible.Goal{id: goal_id}) do
|
||||
changeset(step, %{goal_id: goal_id})
|
||||
end
|
||||
|
||||
def changeset(step, attrs) do
|
||||
step
|
||||
|> cast(attrs, [:goal_id])
|
||||
|> cast_assoc(:goal)
|
||||
|
@ -15,12 +15,20 @@ defmodule Plausible.Funnels do
|
||||
|
||||
@spec create(Plausible.Site.t(), String.t(), [map()]) ::
|
||||
{:ok, Funnel.t()}
|
||||
| {:error, Ecto.Changeset.t() | :invalid_funnel_size}
|
||||
| {:error, Ecto.Changeset.t() | :invalid_funnel_size | :upgrade_required}
|
||||
def create(site, name, steps)
|
||||
when is_list(steps) and length(steps) in Funnel.min_steps()..Funnel.max_steps() do
|
||||
site
|
||||
|> create_changeset(name, steps)
|
||||
|> Repo.insert()
|
||||
site = Plausible.Repo.preload(site, :owner)
|
||||
|
||||
case Plausible.Billing.Feature.Funnels.check_availability(site.owner) do
|
||||
{:error, _} = error ->
|
||||
error
|
||||
|
||||
:ok ->
|
||||
site
|
||||
|> create_changeset(name, steps)
|
||||
|> Repo.insert()
|
||||
end
|
||||
end
|
||||
|
||||
def create(_site, _name, _goals) do
|
||||
@ -43,16 +51,17 @@ defmodule Plausible.Funnels do
|
||||
@spec list(Plausible.Site.t()) :: [
|
||||
%{name: String.t(), id: pos_integer(), steps_count: pos_integer()}
|
||||
]
|
||||
def list(%Plausible.Site{id: site_id}) do
|
||||
Repo.all(
|
||||
def list(%Plausible.Site{} = site) do
|
||||
q =
|
||||
from(f in Funnel,
|
||||
inner_join: steps in assoc(f, :steps),
|
||||
where: f.site_id == ^site_id,
|
||||
select: %{name: f.name, id: f.id, steps_count: count(steps)},
|
||||
where: f.site_id == ^site.id,
|
||||
group_by: f.id,
|
||||
order_by: [desc: :id]
|
||||
order_by: [desc: :id],
|
||||
select: %{name: f.name, id: f.id, steps_count: count(steps)}
|
||||
)
|
||||
)
|
||||
|
||||
Repo.all(q)
|
||||
end
|
||||
|
||||
@spec delete(Plausible.Site.t() | pos_integer(), pos_integer()) :: :ok
|
||||
@ -71,25 +80,40 @@ defmodule Plausible.Funnels do
|
||||
:ok
|
||||
end
|
||||
|
||||
@spec get(Plausible.Site.t() | pos_integer(), pos_integer()) ::
|
||||
@spec get(Plausible.Site.t() | pos_integer(), pos_integer() | String.t()) ::
|
||||
Funnel.t() | nil
|
||||
def get(%Plausible.Site{id: site_id}, by) do
|
||||
get(site_id, by)
|
||||
end
|
||||
|
||||
def get(site_id, funnel_id) when is_integer(site_id) and is_integer(funnel_id) do
|
||||
q =
|
||||
from(f in Funnel,
|
||||
where: f.site_id == ^site_id,
|
||||
where: f.id == ^funnel_id,
|
||||
inner_join: steps in assoc(f, :steps),
|
||||
inner_join: goal in assoc(steps, :goal),
|
||||
order_by: steps.step_order,
|
||||
preload: [
|
||||
steps: {steps, goal: goal}
|
||||
]
|
||||
)
|
||||
def get(site_id, funnel_name) when is_integer(site_id) and is_binary(funnel_name) do
|
||||
site_id |> base_get_query() |> where([f], f.name == ^funnel_name) |> Repo.one()
|
||||
end
|
||||
|
||||
Repo.one(q)
|
||||
def get(site_id, funnel_id) when is_integer(site_id) and is_integer(funnel_id) do
|
||||
site_id |> base_get_query() |> where([f], f.id == ^funnel_id) |> Repo.one()
|
||||
end
|
||||
|
||||
@spec with_goals_query(Plausible.Site.t()) :: Ecto.Query.t()
|
||||
def with_goals_query(site) do
|
||||
from(f in Funnel,
|
||||
inner_join: steps in assoc(f, :steps),
|
||||
where: f.site_id == ^site.id,
|
||||
group_by: f.id,
|
||||
order_by: [desc: :id],
|
||||
preload: [steps: :goal]
|
||||
)
|
||||
end
|
||||
|
||||
defp base_get_query(site_id) do
|
||||
from(f in Funnel,
|
||||
where: f.site_id == ^site_id,
|
||||
inner_join: steps in assoc(f, :steps),
|
||||
inner_join: goal in assoc(steps, :goal),
|
||||
order_by: steps.step_order,
|
||||
preload: [
|
||||
steps: {steps, goal: goal}
|
||||
]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
61
extra/lib/plausible/plugins/api/funnels.ex
Normal file
61
extra/lib/plausible/plugins/api/funnels.ex
Normal file
@ -0,0 +1,61 @@
|
||||
defmodule Plausible.Plugins.API.Funnels do
|
||||
@moduledoc """
|
||||
Plugins API context module for Funnels.
|
||||
All high level Funnel operations should be implemented here.
|
||||
"""
|
||||
use Plausible
|
||||
|
||||
import Plausible.Pagination
|
||||
|
||||
alias Plausible.Repo
|
||||
alias PlausibleWeb.Plugins.API.Schemas.Funnel.CreateRequest
|
||||
|
||||
@type create_request() :: CreateRequest.t()
|
||||
|
||||
@spec create(
|
||||
Plausible.Site.t(),
|
||||
create_request()
|
||||
) ::
|
||||
{:ok, Plausible.Funnel.t()}
|
||||
| {:error, Ecto.Changeset.t()}
|
||||
| {:error, :upgrade_required}
|
||||
def create(site, create_request) do
|
||||
Repo.transaction(fn ->
|
||||
with {:ok, goals} <- Plausible.Plugins.API.Goals.create(site, create_request.funnel.steps),
|
||||
{:ok, funnel} <- get_or_create(site, create_request.funnel.name, goals) do
|
||||
funnel
|
||||
else
|
||||
{:error, error} ->
|
||||
Repo.rollback(error)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp get_or_create(site, name, goals) do
|
||||
case get(site, name) do
|
||||
%Plausible.Funnel{} = funnel ->
|
||||
{:ok, funnel}
|
||||
|
||||
nil ->
|
||||
case Plausible.Funnels.create(site, name, goals) do
|
||||
{:ok, funnel} ->
|
||||
# reload result with steps included
|
||||
{:ok, get(site, funnel.id)}
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@spec get_funnels(Plausible.Site.t(), map()) :: {:ok, Paginator.Page.t()}
|
||||
def get_funnels(site, params) do
|
||||
query = Plausible.Funnels.with_goals_query(site)
|
||||
{:ok, paginate(query, params, cursor_fields: [{:id, :desc}])}
|
||||
end
|
||||
|
||||
@spec get(Plausible.Site.t(), pos_integer() | String.t()) :: nil | Plausible.Funnel.t()
|
||||
def get(site, by) when is_integer(by) or is_binary(by) do
|
||||
Plausible.Funnels.get(site, by)
|
||||
end
|
||||
end
|
120
extra/lib/plausible_web/plugins/api/controllers/funnels.ex
Normal file
120
extra/lib/plausible_web/plugins/api/controllers/funnels.ex
Normal file
@ -0,0 +1,120 @@
|
||||
defmodule PlausibleWeb.Plugins.API.Controllers.Funnels do
|
||||
@moduledoc """
|
||||
Controller for the Funnel resource under Plugins API
|
||||
"""
|
||||
use PlausibleWeb, :plugins_api_controller
|
||||
|
||||
operation(:create,
|
||||
id: "Funnel.GetOrCreate",
|
||||
summary: "Get or create Funnel",
|
||||
request_body: {"Funnel params", "application/json", Schemas.Funnel.CreateRequest},
|
||||
responses: %{
|
||||
created: {"Funnel", "application/json", Schemas.Funnel},
|
||||
unauthorized: {"Unauthorized", "application/json", Schemas.Unauthorized},
|
||||
payment_required: {"Payment required", "application/json", Schemas.PaymentRequired},
|
||||
unprocessable_entity:
|
||||
{"Unprocessable entity", "application/json", Schemas.UnprocessableEntity}
|
||||
}
|
||||
)
|
||||
|
||||
def create(
|
||||
%{private: %{open_api_spex: %{body_params: body_params}}} = conn,
|
||||
_params
|
||||
) do
|
||||
site = conn.assigns.authorized_site
|
||||
|
||||
case Plausible.Plugins.API.Funnels.create(site, body_params) do
|
||||
{:ok, funnel} ->
|
||||
headers = [{"location", plugins_api_funnels_url(conn, :get, funnel.id)}]
|
||||
|
||||
conn
|
||||
|> prepend_resp_headers(headers)
|
||||
|> put_view(Views.Funnel)
|
||||
|> put_status(:created)
|
||||
|> render("funnel.json", funnel: funnel, authorized_site: site)
|
||||
|
||||
{:error, :upgrade_required} ->
|
||||
payment_required(conn)
|
||||
|
||||
{:error, changeset} ->
|
||||
Errors.error(conn, 422, changeset)
|
||||
end
|
||||
end
|
||||
|
||||
operation(:index,
|
||||
summary: "Retrieve Funnels",
|
||||
parameters: [
|
||||
limit: [in: :query, type: :integer, description: "Maximum entries per page", example: 10],
|
||||
after: [
|
||||
in: :query,
|
||||
type: :string,
|
||||
description: "Cursor value to seek after - generated internally"
|
||||
],
|
||||
before: [
|
||||
in: :query,
|
||||
type: :string,
|
||||
description: "Cursor value to seek before - generated internally"
|
||||
]
|
||||
],
|
||||
responses: %{
|
||||
ok: {"Funnels response", "application/json", Schemas.Funnel.ListResponse},
|
||||
unauthorized: {"Unauthorized", "application/json", Schemas.Unauthorized}
|
||||
}
|
||||
)
|
||||
|
||||
@spec index(Plug.Conn.t(), %{}) :: Plug.Conn.t()
|
||||
def index(conn, _params) do
|
||||
{:ok, pagination} = API.Funnels.get_funnels(conn.assigns.authorized_site, conn.query_params)
|
||||
|
||||
conn
|
||||
|> put_view(Views.Funnel)
|
||||
|> render("index.json", %{pagination: pagination})
|
||||
end
|
||||
|
||||
operation(:get,
|
||||
summary: "Retrieve Funnel by ID",
|
||||
parameters: [
|
||||
id: [
|
||||
in: :path,
|
||||
type: :integer,
|
||||
description: "Funnel ID",
|
||||
example: 123,
|
||||
required: true
|
||||
]
|
||||
],
|
||||
responses: %{
|
||||
ok: {"Goal", "application/json", Schemas.Funnel},
|
||||
not_found: {"NotFound", "application/json", Schemas.NotFound},
|
||||
unauthorized: {"Unauthorized", "application/json", Schemas.Unauthorized},
|
||||
unprocessable_entity:
|
||||
{"Unprocessable entity", "application/json", Schemas.UnprocessableEntity}
|
||||
}
|
||||
)
|
||||
|
||||
@spec get(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||
def get(%{private: %{open_api_spex: %{params: %{id: id}}}} = conn, _params) do
|
||||
site = conn.assigns.authorized_site
|
||||
|
||||
case API.Funnels.get(site, id) do
|
||||
nil ->
|
||||
conn
|
||||
|> put_view(Views.Error)
|
||||
|> put_status(:not_found)
|
||||
|> render("404.json")
|
||||
|
||||
funnel ->
|
||||
conn
|
||||
|> put_view(Views.Funnel)
|
||||
|> put_status(:ok)
|
||||
|> render("funnel.json", funnel: funnel, authorized_site: site)
|
||||
end
|
||||
end
|
||||
|
||||
defp payment_required(conn) do
|
||||
Errors.error(
|
||||
conn,
|
||||
402,
|
||||
"#{Plausible.Billing.Feature.Funnels.display_name()} is part of the Plausible Business plan. To get access to this feature, please upgrade your account."
|
||||
)
|
||||
end
|
||||
end
|
55
extra/lib/plausible_web/plugins/api/schemas/funnel.ex
Normal file
55
extra/lib/plausible_web/plugins/api/schemas/funnel.ex
Normal file
@ -0,0 +1,55 @@
|
||||
defmodule PlausibleWeb.Plugins.API.Schemas.Funnel do
|
||||
@moduledoc """
|
||||
OpenAPI schema for Funnel
|
||||
"""
|
||||
use Plausible.Funnel.Const
|
||||
use PlausibleWeb, :open_api_schema
|
||||
|
||||
OpenApiSpex.schema(%{
|
||||
title: "Funnel",
|
||||
description: "Funnel object",
|
||||
type: :object,
|
||||
properties: %{
|
||||
funnel: %Schema{
|
||||
type: :object,
|
||||
required: [:id, :name, :steps],
|
||||
properties: %{
|
||||
id: %Schema{type: :integer, description: "Funnel ID"},
|
||||
name: %Schema{type: :string, description: "Name"},
|
||||
steps: %Schema{
|
||||
description: "Funnel Steps",
|
||||
type: :array,
|
||||
minItems: 2,
|
||||
maxItems: Funnel.Const.max_steps(),
|
||||
items: Schemas.Goal
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
example: %{
|
||||
funnel: %{
|
||||
id: 123,
|
||||
name: "My Marketing Funnel",
|
||||
steps: [
|
||||
%{
|
||||
goal_type: "Goal.Pageview",
|
||||
goal: %{
|
||||
id: 1,
|
||||
display_name: "Visit /product/1",
|
||||
path: "/product/1"
|
||||
}
|
||||
},
|
||||
%{
|
||||
goal_type: "Goal.Revenue",
|
||||
goal: %{
|
||||
id: 2,
|
||||
display_name: "Purchase",
|
||||
currency: "EUR",
|
||||
event_name: "Purchase"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
end
|
@ -0,0 +1,56 @@
|
||||
defmodule PlausibleWeb.Plugins.API.Schemas.Funnel.CreateRequest do
|
||||
@moduledoc """
|
||||
OpenAPI schema for Funnel creation request - get or creates goals along the way
|
||||
"""
|
||||
use Plausible.Funnel.Const
|
||||
use PlausibleWeb, :open_api_schema
|
||||
|
||||
OpenApiSpex.schema(%{
|
||||
title: "Funnel.CreateRequest",
|
||||
description: "Funnel creation params",
|
||||
type: :object,
|
||||
required: [:funnel],
|
||||
properties: %{
|
||||
funnel: %Schema{
|
||||
type: :object,
|
||||
required: [:steps, :name],
|
||||
properties: %{
|
||||
steps: %Schema{
|
||||
description: "Funnel Steps",
|
||||
type: :array,
|
||||
minItems: 2,
|
||||
maxItems: Funnel.Const.max_steps(),
|
||||
items: %Schema{
|
||||
oneOf: [
|
||||
Schemas.Goal.CreateRequest.CustomEvent,
|
||||
Schemas.Goal.CreateRequest.Revenue,
|
||||
Schemas.Goal.CreateRequest.Pageview
|
||||
]
|
||||
}
|
||||
},
|
||||
name: %Schema{type: :string, description: "Funnel Name"}
|
||||
}
|
||||
}
|
||||
},
|
||||
example: %{
|
||||
funnel: %{
|
||||
name: "My First Funnel",
|
||||
steps: [
|
||||
%{
|
||||
goal_type: "Goal.Pageview",
|
||||
goal: %{
|
||||
path: "/product/123"
|
||||
}
|
||||
},
|
||||
%{
|
||||
goal_type: "Goal.Revenue",
|
||||
goal: %{
|
||||
currency: "EUR",
|
||||
event_name: "Purchase"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
end
|
@ -0,0 +1,26 @@
|
||||
defmodule PlausibleWeb.Plugins.API.Schemas.Funnel.ListResponse do
|
||||
@moduledoc """
|
||||
OpenAPI schema for Funnel list response
|
||||
"""
|
||||
use PlausibleWeb, :open_api_schema
|
||||
|
||||
OpenApiSpex.schema(%{
|
||||
title: "Funnel.ListResponse",
|
||||
description: "Funnels list response",
|
||||
type: :object,
|
||||
required: [:funnels, :meta],
|
||||
properties: %{
|
||||
funnels: %Schema{
|
||||
items: Schemas.Funnel,
|
||||
type: :array
|
||||
},
|
||||
meta: %Schema{
|
||||
required: [:pagination],
|
||||
type: :object,
|
||||
properties: %{
|
||||
pagination: Schemas.PaginationMetadata
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
end
|
50
extra/lib/plausible_web/plugins/api/views/funnel.ex
Normal file
50
extra/lib/plausible_web/plugins/api/views/funnel.ex
Normal file
@ -0,0 +1,50 @@
|
||||
defmodule PlausibleWeb.Plugins.API.Views.Funnel do
|
||||
@moduledoc """
|
||||
View for rendering Funnels in the Plugins API
|
||||
"""
|
||||
|
||||
use PlausibleWeb, :plugins_api_view
|
||||
|
||||
def render("index.json", %{
|
||||
pagination: %{entries: funnels, metadata: metadata},
|
||||
authorized_site: site,
|
||||
conn: conn
|
||||
}) do
|
||||
%{
|
||||
funnels: render_many(funnels, __MODULE__, "funnel.json", authorized_site: site),
|
||||
meta: render_metadata_links(metadata, :plugins_api_funnels_url, :index, conn.query_params)
|
||||
}
|
||||
end
|
||||
|
||||
def render("index.json", %{
|
||||
funnels: funnels,
|
||||
authorized_site: site,
|
||||
conn: conn
|
||||
}) do
|
||||
%{
|
||||
funnels: render_many(funnels, __MODULE__, "funnel.json", authorized_site: site),
|
||||
meta: render_metadata_links(%{}, :plugins_api_funnels_url, :index, conn.query_params)
|
||||
}
|
||||
end
|
||||
|
||||
def render(
|
||||
"funnel.json",
|
||||
%{
|
||||
funnel: funnel,
|
||||
authorized_site: site
|
||||
}
|
||||
) do
|
||||
goals = Enum.map(funnel.steps, & &1.goal)
|
||||
|
||||
%{
|
||||
funnel: %{
|
||||
name: funnel.name,
|
||||
id: funnel.id,
|
||||
steps:
|
||||
render_many(goals, PlausibleWeb.Plugins.API.Views.Goal, "goal.json",
|
||||
authorized_site: site
|
||||
)
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
@ -118,6 +118,12 @@ defmodule PlausibleWeb.Router do
|
||||
get("/goals/:id", Goals, :get)
|
||||
put("/goals", Goals, :create)
|
||||
|
||||
on_ee do
|
||||
get("/funnels/:id", Funnels, :get)
|
||||
get("/funnels", Funnels, :index)
|
||||
put("/funnels", Funnels, :create)
|
||||
end
|
||||
|
||||
delete("/goals/:id", Goals, :delete)
|
||||
delete("/goals", Goals, :delete_bulk)
|
||||
|
||||
|
@ -228,8 +228,8 @@ defmodule PlausibleWeb.Api.StatsController.FunnelsTest do
|
||||
user: user,
|
||||
site: site
|
||||
} do
|
||||
insert(:growth_subscription, user: user)
|
||||
{:ok, funnel} = setup_funnel(site, @build_funnel_with)
|
||||
insert(:growth_subscription, user: user)
|
||||
|
||||
resp =
|
||||
conn
|
||||
|
405
test/plausible_web/plugins/api/controllers/funnels_test.exs
Normal file
405
test/plausible_web/plugins/api/controllers/funnels_test.exs
Normal file
@ -0,0 +1,405 @@
|
||||
defmodule PlausibleWeb.Plugins.API.Controllers.FunnelsTest do
|
||||
use PlausibleWeb.PluginsAPICase, async: true
|
||||
use Plausible
|
||||
|
||||
@moduletag :ee_only
|
||||
|
||||
on_ee do
|
||||
alias PlausibleWeb.Plugins.API.Schemas
|
||||
|
||||
describe "examples" do
|
||||
test "Funnel" do
|
||||
assert_schema(
|
||||
Schemas.Funnel.schema().example,
|
||||
"Funnel",
|
||||
spec()
|
||||
)
|
||||
end
|
||||
|
||||
test "Funnel.CreateRequest" do
|
||||
assert_schema(
|
||||
Schemas.Funnel.CreateRequest.schema().example,
|
||||
"Funnel.CreateRequest",
|
||||
spec()
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe "unauthorized calls" do
|
||||
for {method, url} <- [
|
||||
{:get, Routes.plugins_api_funnels_url(PlausibleWeb.Endpoint, :index)},
|
||||
{:get, Routes.plugins_api_funnels_url(PlausibleWeb.Endpoint, :get, 1)},
|
||||
{:put, Routes.plugins_api_funnels_url(PlausibleWeb.Endpoint, :create, %{})}
|
||||
] do
|
||||
test "unauthorized call: #{method} #{url}", %{conn: conn} do
|
||||
conn
|
||||
|> unquote(method)(unquote(url))
|
||||
|> json_response(401)
|
||||
|> assert_schema("UnauthorizedError", spec())
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "get /funnels/:id" do
|
||||
test "validates input out of the box", %{conn: conn, token: token, site: site} do
|
||||
url = Routes.plugins_api_funnels_url(PlausibleWeb.Endpoint, :get, "hello")
|
||||
|
||||
resp =
|
||||
conn
|
||||
|> authenticate(site.domain, token)
|
||||
|> get(url)
|
||||
|> json_response(422)
|
||||
|> assert_schema("UnprocessableEntityError", spec())
|
||||
|
||||
assert %{errors: [%{detail: "Invalid integer. Got: string"}]} = resp
|
||||
end
|
||||
|
||||
test "retrieves no funnel on non-existing ID", %{conn: conn, token: token, site: site} do
|
||||
url = Routes.plugins_api_funnels_url(PlausibleWeb.Endpoint, :get, 9999)
|
||||
|
||||
resp =
|
||||
conn
|
||||
|> authenticate(site.domain, token)
|
||||
|> get(url)
|
||||
|> json_response(404)
|
||||
|> assert_schema("NotFoundError", spec())
|
||||
|
||||
assert %{errors: [%{detail: "Plugins API: resource not found"}]} = resp
|
||||
end
|
||||
|
||||
test "retrieves funnel by ID", %{conn: conn, site: site, token: token} do
|
||||
{:ok, g1} = Plausible.Goals.create(site, %{"page_path" => "/product/123"})
|
||||
|
||||
{:ok, g2} =
|
||||
Plausible.Goals.create(site, %{"event_name" => "Purchase", "currency" => "EUR"})
|
||||
|
||||
{:ok, g3} = Plausible.Goals.create(site, %{"event_name" => "FiveStarReview"})
|
||||
|
||||
{:ok, funnel} =
|
||||
Plausible.Funnels.create(
|
||||
site,
|
||||
"Peek & buy",
|
||||
[g1, g2, g3]
|
||||
)
|
||||
|
||||
url = Routes.plugins_api_funnels_url(PlausibleWeb.Endpoint, :get, funnel.id)
|
||||
|
||||
resp =
|
||||
conn
|
||||
|> authenticate(site.domain, token)
|
||||
|> get(url)
|
||||
|> json_response(200)
|
||||
|> assert_schema("Funnel", spec())
|
||||
|
||||
assert resp.funnel.id == funnel.id
|
||||
assert resp.funnel.name == "Peek & buy"
|
||||
[s1, s2, s3] = resp.funnel.steps
|
||||
|
||||
assert_schema(s1, "Goal.Pageview", spec())
|
||||
assert_schema(s2, "Goal.Revenue", spec())
|
||||
assert_schema(s3, "Goal.CustomEvent", spec())
|
||||
end
|
||||
end
|
||||
|
||||
describe "get /funnels" do
|
||||
test "returns an empty funnels list if there's none", %{
|
||||
conn: conn,
|
||||
token: token,
|
||||
site: site
|
||||
} do
|
||||
url = Routes.plugins_api_funnels_url(PlausibleWeb.Endpoint, :index)
|
||||
|
||||
resp =
|
||||
conn
|
||||
|> authenticate(site.domain, token)
|
||||
|> get(url)
|
||||
|> json_response(200)
|
||||
|> assert_schema("Funnel.ListResponse", spec())
|
||||
|
||||
assert resp.funnels == []
|
||||
assert resp.meta.pagination.has_next_page == false
|
||||
assert resp.meta.pagination.has_prev_page == false
|
||||
assert resp.meta.pagination.links == %{}
|
||||
end
|
||||
|
||||
test "retrieves all funnels", %{conn: conn, site: site, token: token} do
|
||||
{:ok, g1} = Plausible.Goals.create(site, %{"page_path" => "/product/123"})
|
||||
|
||||
{:ok, g2} =
|
||||
Plausible.Goals.create(site, %{"event_name" => "Purchase", "currency" => "EUR"})
|
||||
|
||||
{:ok, g3} = Plausible.Goals.create(site, %{"event_name" => "FiveStarReview"})
|
||||
|
||||
for i <- 1..3 do
|
||||
{:ok, _} =
|
||||
Plausible.Funnels.create(
|
||||
site,
|
||||
"Funnel #{i}",
|
||||
[g1, g2, g3]
|
||||
)
|
||||
end
|
||||
|
||||
url = Routes.plugins_api_funnels_url(PlausibleWeb.Endpoint, :index)
|
||||
|
||||
resp =
|
||||
conn
|
||||
|> authenticate(site.domain, token)
|
||||
|> get(url)
|
||||
|> json_response(200)
|
||||
|> assert_schema("Funnel.ListResponse", spec())
|
||||
|
||||
assert Enum.count(resp.funnels) == 3
|
||||
end
|
||||
|
||||
test "retrieves funnels with pagination", %{conn: conn, site: site, token: token} do
|
||||
{:ok, g1} = Plausible.Goals.create(site, %{"page_path" => "/product/123"})
|
||||
|
||||
{:ok, g2} =
|
||||
Plausible.Goals.create(site, %{"event_name" => "Purchase", "currency" => "EUR"})
|
||||
|
||||
{:ok, g3} = Plausible.Goals.create(site, %{"event_name" => "FiveStarReview"})
|
||||
|
||||
initial_order = Enum.shuffle([g1, g2, g3])
|
||||
|
||||
for i <- 1..3 do
|
||||
{:ok, _} =
|
||||
Plausible.Funnels.create(
|
||||
site,
|
||||
"Funnel #{i}",
|
||||
initial_order
|
||||
)
|
||||
end
|
||||
|
||||
url = Routes.plugins_api_funnels_url(PlausibleWeb.Endpoint, :index, limit: 2)
|
||||
initial_conn = authenticate(conn, site.domain, token)
|
||||
|
||||
page1 =
|
||||
initial_conn
|
||||
|> get(url)
|
||||
|> json_response(200)
|
||||
|> assert_schema("Funnel.ListResponse", spec())
|
||||
|
||||
assert Enum.count(page1.funnels) == 2
|
||||
assert page1.meta.pagination.has_next_page == true
|
||||
assert page1.meta.pagination.has_prev_page == false
|
||||
|
||||
assert [%{funnel: %{steps: steps}}, %{funnel: %{steps: steps}}] = page1.funnels
|
||||
assert Enum.map(steps, & &1.goal.id) == Enum.map(initial_order, & &1.id)
|
||||
|
||||
page2 =
|
||||
initial_conn
|
||||
|> get(page1.meta.pagination.links.next.url)
|
||||
|> json_response(200)
|
||||
|> assert_schema("Funnel.ListResponse", spec())
|
||||
|
||||
assert Enum.count(page2.funnels) == 1
|
||||
assert page2.meta.pagination.has_next_page == false
|
||||
assert page2.meta.pagination.has_prev_page == true
|
||||
end
|
||||
end
|
||||
|
||||
describe "put /funnels - funnel creation" do
|
||||
test "creates a funnel including its goals", %{conn: conn, token: token, site: site} do
|
||||
url = Routes.plugins_api_funnels_url(PlausibleWeb.Endpoint, :create)
|
||||
|
||||
payload = %{
|
||||
funnel: %{
|
||||
name: "My Test Funnel",
|
||||
steps: [
|
||||
%{
|
||||
goal_type: "Goal.CustomEvent",
|
||||
goal: %{event_name: "Signup"}
|
||||
},
|
||||
%{
|
||||
goal_type: "Goal.Pageview",
|
||||
goal: %{path: "/checkout"}
|
||||
},
|
||||
%{
|
||||
goal_type: "Goal.Revenue",
|
||||
goal: %{event_name: "Purchase", currency: "EUR"}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
assert_request_schema(payload, "Funnel.CreateRequest", spec())
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> authenticate(site.domain, token)
|
||||
|> put_req_header("content-type", "application/json")
|
||||
|> put(url, payload)
|
||||
|
||||
resp =
|
||||
conn
|
||||
|> json_response(201)
|
||||
|> assert_schema("Funnel", spec())
|
||||
|
||||
[location] = get_resp_header(conn, "location")
|
||||
|
||||
assert location ==
|
||||
Routes.plugins_api_funnels_url(PlausibleWeb.Endpoint, :get, resp.funnel.id)
|
||||
|
||||
funnel = Plausible.Funnels.get(site, resp.funnel.id)
|
||||
|
||||
assert funnel.name == resp.funnel.name
|
||||
assert funnel.site_id == site.id
|
||||
assert Enum.count(funnel.steps) == 3
|
||||
end
|
||||
|
||||
test "fails for insufficient plan", %{conn: conn, token: token, site: site} do
|
||||
site = Plausible.Repo.preload(site, :owner)
|
||||
insert(:growth_subscription, user: site.owner)
|
||||
|
||||
url = Routes.plugins_api_funnels_url(PlausibleWeb.Endpoint, :create)
|
||||
|
||||
payload = %{
|
||||
funnel: %{
|
||||
name: "My Test Funnel",
|
||||
steps: [
|
||||
%{
|
||||
goal_type: "Goal.CustomEvent",
|
||||
goal: %{event_name: "Signup"}
|
||||
},
|
||||
%{
|
||||
goal_type: "Goal.Pageview",
|
||||
goal: %{path: "/checkout"}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
assert_request_schema(payload, "Funnel.CreateRequest", spec())
|
||||
|
||||
conn
|
||||
|> authenticate(site.domain, token)
|
||||
|> put_req_header("content-type", "application/json")
|
||||
|> put(url, payload)
|
||||
|> json_response(402)
|
||||
|> assert_schema("PaymentRequiredError", spec())
|
||||
end
|
||||
|
||||
test "fails with only one step - guarded by the schema", %{
|
||||
conn: conn,
|
||||
token: token,
|
||||
site: site
|
||||
} do
|
||||
url = Routes.plugins_api_funnels_url(PlausibleWeb.Endpoint, :create)
|
||||
|
||||
payload = %{
|
||||
funnel: %{
|
||||
name: "My Test Funnel",
|
||||
steps: [
|
||||
%{
|
||||
goal_type: "Goal.CustomEvent",
|
||||
goal: %{event_name: "Signup"}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
resp =
|
||||
conn
|
||||
|> authenticate(site.domain, token)
|
||||
|> put_req_header("content-type", "application/json")
|
||||
|> put(url, payload)
|
||||
|> json_response(422)
|
||||
|> assert_schema("UnprocessableEntityError", spec())
|
||||
|
||||
assert %{errors: [%{detail: "Array length 1 is smaller than minItems: 2"}]} = resp
|
||||
end
|
||||
|
||||
test "is idempotent on full creation", %{conn: conn, token: token, site: site} do
|
||||
url = Routes.plugins_api_funnels_url(PlausibleWeb.Endpoint, :create)
|
||||
|
||||
{:ok, _g1} =
|
||||
Plausible.Goals.create(site, %{"event_name" => "Purchase", "currency" => "EUR"})
|
||||
|
||||
payload = %{
|
||||
funnel: %{
|
||||
name: "My Test Funnel",
|
||||
steps: [
|
||||
%{
|
||||
goal_type: "Goal.CustomEvent",
|
||||
goal: %{event_name: "Signup"}
|
||||
},
|
||||
%{
|
||||
goal_type: "Goal.Pageview",
|
||||
goal: %{path: "/checkout"}
|
||||
},
|
||||
%{
|
||||
goal_type: "Goal.Revenue",
|
||||
goal: %{event_name: "Purchase", currency: "EUR"}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
assert_request_schema(payload, "Funnel.CreateRequest", spec())
|
||||
|
||||
initial_conn =
|
||||
conn
|
||||
|> authenticate(site.domain, token)
|
||||
|> put_req_header("content-type", "application/json")
|
||||
|
||||
resp1 =
|
||||
initial_conn
|
||||
|> put(url, payload)
|
||||
|> json_response(201)
|
||||
|> assert_schema("Funnel", spec())
|
||||
|
||||
resp2 =
|
||||
initial_conn
|
||||
|> put(url, payload)
|
||||
|> json_response(201)
|
||||
|> assert_schema("Funnel", spec())
|
||||
|
||||
assert resp1.funnel == resp2.funnel
|
||||
end
|
||||
|
||||
test "edge case - different currency goal already exists", %{
|
||||
conn: conn,
|
||||
token: token,
|
||||
site: site
|
||||
} do
|
||||
url = Routes.plugins_api_funnels_url(PlausibleWeb.Endpoint, :create)
|
||||
|
||||
{:ok, _g1} =
|
||||
Plausible.Goals.create(site, %{"event_name" => "Purchase", "currency" => "USD"})
|
||||
|
||||
payload = %{
|
||||
funnel: %{
|
||||
name: "My Test Funnel",
|
||||
steps: [
|
||||
%{
|
||||
goal_type: "Goal.CustomEvent",
|
||||
goal: %{event_name: "Signup"}
|
||||
},
|
||||
%{
|
||||
goal_type: "Goal.Pageview",
|
||||
goal: %{path: "/checkout"}
|
||||
},
|
||||
%{
|
||||
goal_type: "Goal.Revenue",
|
||||
goal: %{event_name: "Purchase", currency: "EUR"}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
assert_request_schema(payload, "Funnel.CreateRequest", spec())
|
||||
|
||||
resp =
|
||||
conn
|
||||
|> authenticate(site.domain, token)
|
||||
|> put_req_header("content-type", "application/json")
|
||||
|> put(url, payload)
|
||||
|> json_response(422)
|
||||
|> assert_schema("UnprocessableEntityError", spec())
|
||||
|
||||
assert [%{detail: "event_name: 'Purchase' (with currency: USD) has already been taken"}] =
|
||||
resp.errors
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
Loading…
Reference in New Issue
Block a user