From 0310cecef875c0261423e53f59684d5c6acc46e1 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Fri, 19 Jul 2024 10:05:54 +0200 Subject: [PATCH] Add listing sites, goals and custom props to Sites API (#4302) * Add listing sites, goals and custom props to Sites API * Rename exmaple props in tests * Rename `allowed_custom_props` -> `custom_properties` * Expose goal name in GET endpoints for goals in Sites API * Bump default pagination limit to 100 and max to 1000 * Introduce Goal.name/display_name and use the first one for name in API * Extend goal list response but hide currency * Settle on `display_name` instead of `name` * Allow viewer members to get site details and list site goals * Don't include currency in goal's display name --- .../api/external_sites_controller.ex | 157 +++++++--- lib/plausible/goal/schema.ex | 34 +-- lib/plausible/sites.ex | 9 + lib/plausible_web/live/goal_settings/list.ex | 17 +- lib/plausible_web/router.ex | 2 + .../api/external_sites_controller_test.exs | 279 +++++++++++++++++- .../plugins/api/controllers/goals_test.exs | 4 +- 7 files changed, 428 insertions(+), 74 deletions(-) diff --git a/extra/lib/plausible_web/controllers/api/external_sites_controller.ex b/extra/lib/plausible_web/controllers/api/external_sites_controller.ex index 78da9e0dd8ca..2f0a5851bcf4 100644 --- a/extra/lib/plausible_web/controllers/api/external_sites_controller.ex +++ b/extra/lib/plausible_web/controllers/api/external_sites_controller.ex @@ -2,12 +2,64 @@ defmodule PlausibleWeb.Api.ExternalSitesController do use PlausibleWeb, :controller use Plausible.Repo use PlausibleWeb.Plugs.ErrorHandler + + import Plausible.Pagination + alias Plausible.Sites + alias Plausible.Goal alias Plausible.Goals alias PlausibleWeb.Api.Helpers, as: H + @pagination_opts [cursor_fields: [{:id, :desc}], limit: 100, maximum_limit: 1000] + + def index(conn, params) do + user = conn.assigns.current_user + + page = + user + |> Sites.for_user_query() + |> paginate(params, @pagination_opts) + + json(conn, %{ + sites: page.entries, + meta: pagination_meta(page.metadata) + }) + end + + def goals_index(conn, params) do + user = conn.assigns.current_user + + with {:ok, site_id} <- expect_param_key(params, "site_id"), + {:ok, site} <- get_site(user, site_id, [:owner, :admin, :viewer]) do + page = + site + |> Plausible.Goals.for_site_query() + |> paginate(params, @pagination_opts) + + json(conn, %{ + goals: + Enum.map(page.entries, fn goal -> + %{ + id: goal.id, + display_name: Goal.display_name(goal), + goal_type: Goal.type(goal), + event_name: goal.event_name, + page_path: goal.page_path + } + end), + meta: pagination_meta(page.metadata) + }) + else + {:missing, "site_id"} -> + H.bad_request(conn, "Parameter `site_id` is required to list goals") + + {:error, :site_not_found} -> + H.not_found(conn, "Site could not be found") + end + end + def create_site(conn, params) do - user = conn.assigns[:current_user] + user = conn.assigns.current_user case Sites.create(user, params) do {:ok, %{site: site}} -> @@ -29,57 +81,50 @@ defmodule PlausibleWeb.Api.ExternalSitesController do end def get_site(conn, %{"site_id" => site_id}) do - site = Sites.get_for_user(conn.assigns[:current_user].id, site_id, [:owner, :admin]) + case get_site(conn.assigns.current_user, site_id, [:owner, :admin, :viewer]) do + {:ok, site} -> + json(conn, %{ + domain: site.domain, + timezone: site.timezone, + custom_properties: site.allowed_event_props || [] + }) - if site do - json(conn, site) - else - H.not_found(conn, "Site could not be found") + {:error, :site_not_found} -> + H.not_found(conn, "Site could not be found") end end def delete_site(conn, %{"site_id" => site_id}) do - site = Sites.get_for_user(conn.assigns[:current_user].id, site_id, [:owner]) + case get_site(conn.assigns.current_user, site_id, [:owner]) do + {:ok, site} -> + {:ok, _} = Plausible.Site.Removal.run(site.domain) + json(conn, %{"deleted" => true}) - if site do - {:ok, _} = Plausible.Site.Removal.run(site.domain) - json(conn, %{"deleted" => true}) - else - H.not_found(conn, "Site could not be found") + {:error, :site_not_found} -> + H.not_found(conn, "Site could not be found") end end def update_site(conn, %{"site_id" => site_id} = params) do # for now this only allows to change the domain - site = Sites.get_for_user(conn.assigns[:current_user].id, site_id, [:owner, :admin]) - - if site do - case Plausible.Site.Domain.change(site, params["domain"]) do - {:ok, site} -> - json(conn, site) - - {:error, changeset} -> - conn - |> put_status(400) - |> json(serialize_errors(changeset)) - end + with {:ok, site} <- get_site(conn.assigns.current_user, site_id, [:owner, :admin]), + {:ok, site} <- Plausible.Site.Domain.change(site, params["domain"]) do + json(conn, site) else - H.not_found(conn, "Site could not be found") - end - end + {:error, :site_not_found} -> + H.not_found(conn, "Site could not be found") - defp expect_param_key(params, key) do - case Map.fetch(params, key) do - :error -> {:missing, key} - res -> res + {:error, %Ecto.Changeset{} = changeset} -> + conn + |> put_status(400) + |> json(serialize_errors(changeset)) end end def find_or_create_shared_link(conn, params) do with {:ok, site_id} <- expect_param_key(params, "site_id"), {:ok, link_name} <- expect_param_key(params, "name"), - site when not is_nil(site) <- - Sites.get_for_user(conn.assigns[:current_user].id, site_id, [:owner, :admin]) do + {:ok, site} <- get_site(conn.assigns.current_user, site_id, [:owner, :admin]) do shared_link = Repo.get_by(Plausible.Site.SharedLink, site_id: site.id, name: link_name) shared_link = @@ -96,7 +141,7 @@ defmodule PlausibleWeb.Api.ExternalSitesController do }) end else - nil -> + {:error, :site_not_found} -> H.not_found(conn, "Site could not be found") {:missing, "site_id"} -> @@ -113,12 +158,11 @@ defmodule PlausibleWeb.Api.ExternalSitesController do def find_or_create_goal(conn, params) do with {:ok, site_id} <- expect_param_key(params, "site_id"), {:ok, _} <- expect_param_key(params, "goal_type"), - site when not is_nil(site) <- - Sites.get_for_user(conn.assigns[:current_user].id, site_id, [:owner, :admin]), + {:ok, site} <- get_site(conn.assigns.current_user, site_id, [:owner, :admin]), {:ok, goal} <- Goals.find_or_create(site, params) do json(conn, goal) else - nil -> + {:error, :site_not_found} -> H.not_found(conn, "Site could not be found") {:missing, param} -> @@ -132,19 +176,16 @@ defmodule PlausibleWeb.Api.ExternalSitesController do def delete_goal(conn, params) do with {:ok, site_id} <- expect_param_key(params, "site_id"), {:ok, goal_id} <- expect_param_key(params, "goal_id"), - site when not is_nil(site) <- - Sites.get_for_user(conn.assigns[:current_user].id, site_id, [:owner, :admin]) do - case Goals.delete(goal_id, site) do - :ok -> - json(conn, %{"deleted" => true}) - - {:error, :not_found} -> - H.not_found(conn, "Goal could not be found") - end + {:ok, site} <- get_site(conn.assigns.current_user, site_id, [:owner, :admin]), + :ok <- Goals.delete(goal_id, site) do + json(conn, %{"deleted" => true}) else - nil -> + {:error, :site_not_found} -> H.not_found(conn, "Site could not be found") + {:error, :not_found} -> + H.not_found(conn, "Goal could not be found") + {:missing, "site_id"} -> H.bad_request(conn, "Parameter `site_id` is required to delete a goal") @@ -156,9 +197,31 @@ defmodule PlausibleWeb.Api.ExternalSitesController do end end + defp pagination_meta(meta) do + %{ + after: meta.after, + before: meta.before, + limit: meta.limit + } + end + + defp get_site(user, site_id, roles) do + case Sites.get_for_user(user.id, site_id, roles) do + nil -> {:error, :site_not_found} + site -> {:ok, site} + end + end + defp serialize_errors(changeset) do {field, {msg, _opts}} = List.first(changeset.errors) error_msg = Atom.to_string(field) <> ": " <> msg %{"error" => error_msg} end + + defp expect_param_key(params, key) do + case Map.fetch(params, key) do + :error -> {:missing, key} + res -> res + end + end end diff --git a/lib/plausible/goal/schema.ex b/lib/plausible/goal/schema.ex index 0a8ca41cace3..a0719a00d263 100644 --- a/lib/plausible/goal/schema.ex +++ b/lib/plausible/goal/schema.ex @@ -47,6 +47,19 @@ defmodule Plausible.Goal do |> maybe_drop_currency() end + @spec display_name(t()) :: String.t() + def display_name(%{page_path: path}) when is_binary(path) do + "Visit " <> path + end + + def display_name(%{event_name: name}) when is_binary(name) do + name + end + + @spec type(t()) :: :event | :page + def type(%{event_name: event_name}) when is_binary(event_name), do: :event + def type(%{page_path: page_path}) when is_binary(page_path), do: :page + defp update_leading_slash(changeset) do case get_field(changeset, :page_path) do "/" <> _ -> @@ -91,33 +104,20 @@ end defimpl Jason.Encoder, for: Plausible.Goal do def encode(value, opts) do - goal_type = - cond do - value.event_name -> :event - value.page_path -> :page - end - domain = value.site.domain value - |> Map.put(:goal_type, goal_type) + |> Map.put(:goal_type, Plausible.Goal.type(value)) |> Map.take([:id, :goal_type, :event_name, :page_path]) |> Map.put(:domain, domain) + |> Map.put(:display_name, Plausible.Goal.display_name(value)) |> Jason.Encode.map(opts) end end defimpl String.Chars, for: Plausible.Goal do - def to_string(%{page_path: page_path}) when is_binary(page_path) do - "Visit " <> page_path - end - - def to_string(%{event_name: name, currency: nil}) when is_binary(name) do - name - end - - def to_string(%{event_name: name, currency: currency}) when is_binary(name) do - name <> " (#{currency})" + def to_string(goal) do + Plausible.Goal.display_name(goal) end end diff --git a/lib/plausible/sites.ex b/lib/plausible/sites.ex index a23c72952d4b..abd33e438da8 100644 --- a/lib/plausible/sites.ex +++ b/lib/plausible/sites.ex @@ -159,6 +159,15 @@ defmodule Plausible.Sites do %{result | entries: entries} end + @spec for_user_query(Auth.User.t()) :: Ecto.Query.t() + def for_user_query(user) do + from(s in Site, + inner_join: sm in assoc(s, :memberships), + on: sm.user_id == ^user.id, + order_by: [desc: s.id] + ) + end + defp maybe_filter_by_domain(query, domain) when byte_size(domain) >= 1 and byte_size(domain) <= 64 do where(query, [s], ilike(s.domain, ^"%#{domain}%")) diff --git a/lib/plausible_web/live/goal_settings/list.ex b/lib/plausible_web/live/goal_settings/list.ex index 8bbb8e723cfd..79bee83d0a91 100644 --- a/lib/plausible_web/live/goal_settings/list.ex +++ b/lib/plausible_web/live/goal_settings/list.ex @@ -14,7 +14,8 @@ defmodule PlausibleWeb.Live.GoalSettings.List do def render(assigns) do revenue_goals_enabled? = Plausible.Billing.Feature.RevenueGoals.enabled?(assigns.site) - assigns = assign(assigns, :revenue_goals_enabled?, revenue_goals_enabled?) + goals = Enum.map(assigns.goals, &{goal_label(&1), &1}) + assigns = assign(assigns, goals: goals, revenue_goals_enabled?: revenue_goals_enabled?) ~H"""
@@ -55,7 +56,7 @@ defmodule PlausibleWeb.Live.GoalSettings.List do
<%= if Enum.count(@goals) > 0 do %>
- <%= for goal <- @goals do %> + <%= for {goal_label, goal} <- @goals do %>
@@ -63,10 +64,10 @@ defmodule PlausibleWeb.Live.GoalSettings.List do <%= if not @revenue_goals_enabled? && goal.currency do %>
- <%= goal %> + <%= goal_label %>
<% else %> - <%= goal %> + <%= goal_label %> <% end %> Pageview @@ -116,6 +117,14 @@ defmodule PlausibleWeb.Live.GoalSettings.List do """ end + defp goal_label(%{currency: currency} = goal) when not is_nil(currency) do + to_string(goal) <> " (#{currency})" + end + + defp goal_label(goal) do + to_string(goal) + end + defp delete_confirmation_text(goal) do if Enum.empty?(goal.funnels) do """ diff --git a/lib/plausible_web/router.ex b/lib/plausible_web/router.ex index 4cdea939c8e5..7944890db442 100644 --- a/lib/plausible_web/router.ex +++ b/lib/plausible_web/router.ex @@ -188,6 +188,8 @@ defmodule PlausibleWeb.Router do scope assigns: %{api_scope: "sites:read:*"} do pipe_through PlausibleWeb.Plugs.AuthorizePublicAPI + get "/", ExternalSitesController, :index + get "/goals", ExternalSitesController, :goals_index get "/:site_id", ExternalSitesController, :get_site end diff --git a/test/plausible_web/controllers/api/external_sites_controller_test.exs b/test/plausible_web/controllers/api/external_sites_controller_test.exs index 0a079648ac8b..c938738c6ba5 100644 --- a/test/plausible_web/controllers/api/external_sites_controller_test.exs +++ b/test/plausible_web/controllers/api/external_sites_controller_test.exs @@ -265,6 +265,7 @@ defmodule PlausibleWeb.Api.ExternalSitesControllerTest do res = json_response(conn, 200) assert res["goal_type"] == "event" + assert res["display_name"] == "Signup" assert res["event_name"] == "Signup" assert res["domain"] == site.domain end @@ -280,6 +281,7 @@ defmodule PlausibleWeb.Api.ExternalSitesControllerTest do res = json_response(conn, 200) assert res["goal_type"] == "page" assert res["page_path"] == "/signup" + assert res["display_name"] == "Visit /signup" assert res["domain"] == site.domain end @@ -299,6 +301,7 @@ defmodule PlausibleWeb.Api.ExternalSitesControllerTest do res = json_response(conn, 200) assert res["goal_type"] == "event" assert res["event_name"] == "Signup" + assert res["display_name"] == "Signup" assert res["domain"] == new_domain end @@ -489,13 +492,120 @@ defmodule PlausibleWeb.Api.ExternalSitesControllerTest do end end + describe "GET /api/v1/sites" do + test "returns empty when there are no sites for user", %{conn: conn} do + conn = get(conn, "/api/v1/sites") + + assert json_response(conn, 200) == %{ + "sites" => [], + "meta" => %{ + "before" => nil, + "after" => nil, + "limit" => 100 + } + } + end + + test "returns sites when present", %{conn: conn, user: user} do + [site1, site2] = insert_list(2, :site, members: [user]) + _unrelated_site = insert(:site) + + conn = get(conn, "/api/v1/sites") + + assert json_response(conn, 200) == %{ + "sites" => [ + %{"domain" => site2.domain, "timezone" => site2.timezone}, + %{"domain" => site1.domain, "timezone" => site1.timezone} + ], + "meta" => %{ + "before" => nil, + "after" => nil, + "limit" => 100 + } + } + end + + test "returns sites where user is only a viewer", %{conn: conn, user: user} do + %{domain: owned_site_domain} = insert(:site, members: [user]) + other_site = %{domain: other_site_domain} = insert(:site) + insert(:site_membership, site: other_site, user: user, role: :viewer) + + conn = get(conn, "/api/v1/sites") + + assert %{ + "sites" => [ + %{"domain" => ^other_site_domain}, + %{"domain" => ^owned_site_domain} + ] + } = json_response(conn, 200) + end + + test "handles pagination correctly", %{conn: conn, user: user} do + [ + %{domain: site1_domain}, + %{domain: site2_domain}, + %{domain: site3_domain} + ] = insert_list(3, :site, members: [user]) + + conn1 = get(conn, "/api/v1/sites?limit=2") + + assert %{ + "sites" => [ + %{"domain" => ^site3_domain}, + %{"domain" => ^site2_domain} + ], + "meta" => %{ + "before" => nil, + "after" => after_cursor, + "limit" => 2 + } + } = json_response(conn1, 200) + + conn2 = get(conn, "/api/v1/sites?limit=2&after=" <> after_cursor) + + assert %{ + "sites" => [ + %{"domain" => ^site1_domain} + ], + "meta" => %{ + "before" => before_cursor, + "after" => nil, + "limit" => 2 + } + } = json_response(conn2, 200) + + assert is_binary(before_cursor) + end + + test "lists sites for user with read-only scope", %{conn: conn, user: user} do + %{domain: site_domain} = insert(:site, members: [user]) + api_key = insert(:api_key, user: user, scopes: ["stats:read:*"]) + + conn = + conn + |> Plug.Conn.put_req_header("authorization", "Bearer #{api_key.key}") + |> get("/api/v1/sites") + + assert %{"sites" => [%{"domain" => ^site_domain}]} = json_response(conn, 200) + end + end + describe "GET /api/v1/sites/:site_id" do setup :create_new_site test "get a site by its domain", %{conn: conn, site: site} do + site = + site + |> Ecto.Changeset.change(allowed_event_props: ["logged_in", "author"]) + |> Repo.update!() + conn = get(conn, "/api/v1/sites/" <> site.domain) - assert json_response(conn, 200) == %{"domain" => site.domain, "timezone" => site.timezone} + assert json_response(conn, 200) == %{ + "domain" => site.domain, + "timezone" => site.timezone, + "custom_properties" => ["logged_in", "author"] + } end test "get a site by old site_id after domain change", %{conn: conn, site: site} do @@ -506,10 +616,14 @@ defmodule PlausibleWeb.Api.ExternalSitesControllerTest do conn = get(conn, "/api/v1/sites/" <> old_domain) - assert json_response(conn, 200) == %{"domain" => new_domain, "timezone" => site.timezone} + assert json_response(conn, 200) == %{ + "domain" => new_domain, + "timezone" => site.timezone, + "custom_properties" => [] + } end - test "get a site with basic scope config", %{conn: conn, user: user, site: site} do + test "get a site for user with read-only scope", %{conn: conn, user: user, site: site} do api_key = insert(:api_key, user: user, scopes: ["stats:read:*"]) conn = @@ -517,7 +631,11 @@ defmodule PlausibleWeb.Api.ExternalSitesControllerTest do |> Plug.Conn.put_req_header("authorization", "Bearer #{api_key.key}") |> get("/api/v1/sites/" <> site.domain) - assert json_response(conn, 200) == %{"domain" => site.domain, "timezone" => site.timezone} + assert json_response(conn, 200) == %{ + "domain" => site.domain, + "timezone" => site.timezone, + "custom_properties" => [] + } end test "is 404 when site cannot be found", %{conn: conn} do @@ -525,6 +643,159 @@ defmodule PlausibleWeb.Api.ExternalSitesControllerTest do assert json_response(conn, 404) == %{"error" => "Site could not be found"} end + + test "is 404 when user is not a member of the site", %{conn: conn} do + site = insert(:site) + + conn = get(conn, "/api/v1/sites/" <> site.domain) + + assert json_response(conn, 404) == %{"error" => "Site could not be found"} + end + end + + describe "GET /api/v1/goals" do + setup :create_new_site + + test "returns empty when there are no goals for site", %{conn: conn, site: site} do + conn = get(conn, "/api/v1/sites/goals?site_id=" <> site.domain) + + assert json_response(conn, 200) == %{ + "goals" => [], + "meta" => %{ + "before" => nil, + "after" => nil, + "limit" => 100 + } + } + end + + test "returns goals when present", %{conn: conn, site: site} do + goal1 = insert(:goal, %{site: site, page_path: "/login"}) + goal2 = insert(:goal, %{site: site, event_name: "Signup"}) + goal3 = insert(:goal, %{site: site, event_name: "Purchase", currency: "USD"}) + + conn = get(conn, "/api/v1/sites/goals?site_id=" <> site.domain) + + assert json_response(conn, 200) == %{ + "goals" => [ + %{ + "id" => goal3.id, + "display_name" => "Purchase", + "goal_type" => "event", + "event_name" => "Purchase", + "page_path" => nil + }, + %{ + "id" => goal2.id, + "display_name" => "Signup", + "goal_type" => "event", + "event_name" => "Signup", + "page_path" => nil + }, + %{ + "id" => goal1.id, + "display_name" => "Visit /login", + "goal_type" => "page", + "event_name" => nil, + "page_path" => "/login" + } + ], + "meta" => %{ + "before" => nil, + "after" => nil, + "limit" => 100 + } + } + end + + test "returns goals for site where user is viewer", %{conn: conn, user: user, site: site} do + Repo.update_all( + from(sm in Plausible.Site.Membership, + where: sm.site_id == ^site.id and sm.user_id == ^user.id + ), + set: [role: :viewer] + ) + + %{id: goal_id} = insert(:goal, %{site: site, event_name: "Signup"}) + + conn = get(conn, "/api/v1/sites/goals?site_id=" <> site.domain) + + assert %{"goals" => [%{"id" => ^goal_id}]} = json_response(conn, 200) + end + + test "handles pagination correctly", %{conn: conn, site: site} do + %{id: goal1_id} = insert(:goal, %{site: site, page_path: "/login"}) + %{id: goal2_id} = insert(:goal, %{site: site, event_name: "Signup"}) + %{id: goal3_id} = insert(:goal, %{site: site, event_name: "Purchase", currency: "USD"}) + + conn1 = get(conn, "/api/v1/sites/goals?limit=2&site_id=" <> site.domain) + + assert %{ + "goals" => [ + %{"id" => ^goal3_id}, + %{"id" => ^goal2_id} + ], + "meta" => %{ + "before" => nil, + "after" => after_cursor, + "limit" => 2 + } + } = json_response(conn1, 200) + + conn2 = + get(conn, "/api/v1/sites/goals?limit=2&after=#{after_cursor}&site_id=" <> site.domain) + + assert %{ + "goals" => [ + %{"id" => ^goal1_id} + ], + "meta" => %{ + "before" => before_cursor, + "after" => nil, + "limit" => 2 + } + } = json_response(conn2, 200) + + assert is_binary(before_cursor) + end + + test "lists goals for user with read-only scope", %{conn: conn, user: user, site: site} do + %{id: goal_id} = insert(:goal, %{site: site, page_path: "/login"}) + api_key = insert(:api_key, user: user, scopes: ["stats:read:*"]) + + conn = + conn + |> Plug.Conn.put_req_header("authorization", "Bearer #{api_key.key}") + |> get("/api/v1/sites/goals?site_id=" <> site.domain) + + assert %{"goals" => [%{"id" => ^goal_id}]} = json_response(conn, 200) + end + + test "returns error when `site_id` parameter is missing", %{conn: conn} do + conn = get(conn, "/api/v1/sites/goals") + + assert json_response(conn, 400) == %{ + "error" => "Parameter `site_id` is required to list goals" + } + end + + test "returns error when `site_id` parameter is invalid", %{conn: conn} do + conn = get(conn, "/api/v1/sites/goals?site_id=does.not.exist") + + assert json_response(conn, 404) == %{ + "error" => "Site could not be found" + } + end + + test "returns error when user is not a member of the site", %{conn: conn} do + site = insert(:site) + + conn = get(conn, "/api/v1/sites/goals?site_id=" <> site.domain) + + assert json_response(conn, 404) == %{ + "error" => "Site could not be found" + } + end end describe "PUT /api/v1/sites/:site_id" do diff --git a/test/plausible_web/plugins/api/controllers/goals_test.exs b/test/plausible_web/plugins/api/controllers/goals_test.exs index ca9a9f08ca63..ab55f474af9f 100644 --- a/test/plausible_web/plugins/api/controllers/goals_test.exs +++ b/test/plausible_web/plugins/api/controllers/goals_test.exs @@ -496,7 +496,7 @@ defmodule PlausibleWeb.Plugins.API.Controllers.GoalsTest do assert resp.goal.id == goal.id assert resp.goal_type == "Goal.Revenue" - assert resp.goal.display_name == "Purchase (EUR)" + assert resp.goal.display_name == "Purchase" end test "retrieves pageview goal by ID", %{conn: conn, site: site, token: token} do @@ -582,7 +582,7 @@ defmodule PlausibleWeb.Plugins.API.Controllers.GoalsTest do assert checkout.goal.id == g3.id assert checkout.goal_type == "Goal.Pageview" - assert purchase.goal.display_name == "Purchase (EUR)" + assert purchase.goal.display_name == "Purchase" assert purchase.goal.currency == "EUR" assert purchase.goal.event_name == "Purchase" assert purchase.goal.id == g2.id