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:
hq1 2024-06-07 13:10:01 +02:00 committed by GitHub
parent 62cd18e5e7
commit 823f7a8800
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 836 additions and 27 deletions

View File

@ -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

View File

@ -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)

View File

@ -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

View 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

View 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

View 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

View File

@ -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

View File

@ -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

View 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

View File

@ -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)

View File

@ -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

View 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