From 823f7a88002f6983ca2de030b0fa19454c55554b Mon Sep 17 00:00:00 2001 From: hq1 Date: Fri, 7 Jun 2024 13:10:01 +0200 Subject: [PATCH] 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 --- config/test.exs | 2 +- extra/lib/plausible/funnel/step.ex | 8 +- extra/lib/plausible/funnels.ex | 72 ++-- extra/lib/plausible/plugins/api/funnels.ex | 61 +++ .../plugins/api/controllers/funnels.ex | 120 ++++++ .../plugins/api/schemas/funnel.ex | 55 +++ .../api/schemas/funnel/create_request.ex | 56 +++ .../api/schemas/funnel/list_response.ex | 26 ++ .../plausible_web/plugins/api/views/funnel.ex | 50 +++ lib/plausible_web/router.ex | 6 + .../api/stats_controller/funnels_test.exs | 2 +- .../plugins/api/controllers/funnels_test.exs | 405 ++++++++++++++++++ 12 files changed, 836 insertions(+), 27 deletions(-) create mode 100644 extra/lib/plausible/plugins/api/funnels.ex create mode 100644 extra/lib/plausible_web/plugins/api/controllers/funnels.ex create mode 100644 extra/lib/plausible_web/plugins/api/schemas/funnel.ex create mode 100644 extra/lib/plausible_web/plugins/api/schemas/funnel/create_request.ex create mode 100644 extra/lib/plausible_web/plugins/api/schemas/funnel/list_response.ex create mode 100644 extra/lib/plausible_web/plugins/api/views/funnel.ex create mode 100644 test/plausible_web/plugins/api/controllers/funnels_test.exs diff --git a/config/test.exs b/config/test.exs index 5c76d8dff..110c9965c 100644 --- a/config/test.exs +++ b/config/test.exs @@ -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 diff --git a/extra/lib/plausible/funnel/step.ex b/extra/lib/plausible/funnel/step.ex index b57d22c40..a25014cf7 100644 --- a/extra/lib/plausible/funnel/step.ex +++ b/extra/lib/plausible/funnel/step.ex @@ -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) diff --git a/extra/lib/plausible/funnels.ex b/extra/lib/plausible/funnels.ex index 3a4e82824..831228cff 100644 --- a/extra/lib/plausible/funnels.ex +++ b/extra/lib/plausible/funnels.ex @@ -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 diff --git a/extra/lib/plausible/plugins/api/funnels.ex b/extra/lib/plausible/plugins/api/funnels.ex new file mode 100644 index 000000000..fac624809 --- /dev/null +++ b/extra/lib/plausible/plugins/api/funnels.ex @@ -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 diff --git a/extra/lib/plausible_web/plugins/api/controllers/funnels.ex b/extra/lib/plausible_web/plugins/api/controllers/funnels.ex new file mode 100644 index 000000000..f76cdc16d --- /dev/null +++ b/extra/lib/plausible_web/plugins/api/controllers/funnels.ex @@ -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 diff --git a/extra/lib/plausible_web/plugins/api/schemas/funnel.ex b/extra/lib/plausible_web/plugins/api/schemas/funnel.ex new file mode 100644 index 000000000..bdd7b40f3 --- /dev/null +++ b/extra/lib/plausible_web/plugins/api/schemas/funnel.ex @@ -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 diff --git a/extra/lib/plausible_web/plugins/api/schemas/funnel/create_request.ex b/extra/lib/plausible_web/plugins/api/schemas/funnel/create_request.ex new file mode 100644 index 000000000..8e1916e7a --- /dev/null +++ b/extra/lib/plausible_web/plugins/api/schemas/funnel/create_request.ex @@ -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 diff --git a/extra/lib/plausible_web/plugins/api/schemas/funnel/list_response.ex b/extra/lib/plausible_web/plugins/api/schemas/funnel/list_response.ex new file mode 100644 index 000000000..111aa9e62 --- /dev/null +++ b/extra/lib/plausible_web/plugins/api/schemas/funnel/list_response.ex @@ -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 diff --git a/extra/lib/plausible_web/plugins/api/views/funnel.ex b/extra/lib/plausible_web/plugins/api/views/funnel.ex new file mode 100644 index 000000000..7547d8293 --- /dev/null +++ b/extra/lib/plausible_web/plugins/api/views/funnel.ex @@ -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 diff --git a/lib/plausible_web/router.ex b/lib/plausible_web/router.ex index f42776764..5b8adbd41 100644 --- a/lib/plausible_web/router.ex +++ b/lib/plausible_web/router.ex @@ -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) diff --git a/test/plausible_web/controllers/api/stats_controller/funnels_test.exs b/test/plausible_web/controllers/api/stats_controller/funnels_test.exs index 0539674d0..b745992d6 100644 --- a/test/plausible_web/controllers/api/stats_controller/funnels_test.exs +++ b/test/plausible_web/controllers/api/stats_controller/funnels_test.exs @@ -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 diff --git a/test/plausible_web/plugins/api/controllers/funnels_test.exs b/test/plausible_web/plugins/api/controllers/funnels_test.exs new file mode 100644 index 000000000..ac09eddbc --- /dev/null +++ b/test/plausible_web/plugins/api/controllers/funnels_test.exs @@ -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