defmodule Plausible.GoalsTest do use Plausible.DataCase alias Plausible.Goals test "create/2 creates goals and trims input" do site = new_site() {:ok, goal} = Goals.create(site, %{"page_path" => "/foo bar "}) assert goal.page_path == "/foo bar" assert goal.display_name == "Visit /foo bar" {:ok, goal} = Goals.create(site, %{ "event_name" => " some event name ", "display_name" => " DisplayName " }) assert goal.event_name == "some event name" assert goal.display_name == "DisplayName" end test "create/2 creates pageview goal and adds a leading slash if missing" do site = new_site() {:ok, goal} = Goals.create(site, %{"page_path" => "foo bar"}) assert goal.page_path == "/foo bar" end test "create/2 validates goal name is at most 120 chars" do site = new_site() assert {:error, changeset} = Goals.create(site, %{"event_name" => String.duplicate("a", 130)}) assert {"should be at most %{count} character(s)", _} = changeset.errors[:event_name] end test "create/2 validates scroll_threshold in range [-1, 100]" do site = new_site() {:error, changeset} = Goals.create(site, %{"page_path" => "/blog/post-1", "scroll_threshold" => -2}) assert {"Should be -1 (missing) or in range [0, 100]", _} = changeset.errors[:scroll_threshold] {:error, changeset} = Goals.create(site, %{"page_path" => "/blog/post-1", "scroll_threshold" => 101}) assert {"Should be -1 (missing) or in range [0, 100]", _} = changeset.errors[:scroll_threshold] assert {:ok, _} = Goals.create(site, %{"page_path" => "/blog/post-1", "scroll_threshold" => -1}) assert {:ok, _} = Goals.create(site, %{"page_path" => "/blog/post-2", "scroll_threshold" => 50}) end test "create/2 validates page path exists for scroll goals" do site = new_site() {:error, changeset} = Goals.create(site, %{"event_name" => "Signup", "scroll_threshold" => 50}) assert {"page_path field missing for page scroll goal", _} = changeset.errors[:scroll_threshold] end test "create/2 fails when same pageview+scroll threshold config exists with different display name" do site = new_site() {:ok, _} = Goals.create(site, %{ "page_path" => "/blog/post-1", "scroll_threshold" => 50, "display_name" => "Scroll 50" }) {:ok, _} = Goals.create(site, %{ "page_path" => "/blog/post-1", "scroll_threshold" => 75, "display_name" => "Scroll 75" }) {:error, changeset} = Goals.create(site, %{ "page_path" => "/blog/post-1", "scroll_threshold" => 50, "display_name" => "Scroll 50 another" }) assert {"has already been taken", _} = changeset.errors[:page_path] end test "create/2 fails to create the same pageview goal twice" do site = new_site() {:ok, _} = Goals.create(site, %{"page_path" => "foo bar", "display_name" => "one"}) assert {:error, changeset} = Goals.create(site, %{"page_path" => "foo bar", "display_name" => "two"}) assert {"has already been taken", _} = changeset.errors[:page_path] end test "create/2 fails to create the same custom event goal twice" do site = new_site() {:ok, _} = Goals.create(site, %{"event_name" => "foo bar"}) assert {:error, changeset} = Goals.create(site, %{"event_name" => "foo bar"}) assert {"has already been taken", _} = changeset.errors[:display_name] end test "create/2 succeeds to create the same custom event goal thrice with different custom props and different display names each" do site = new_site() {:ok, _} = Goals.create(site, %{ "event_name" => "Purchase", "display_name" => "Tablet Purchase", "custom_props" => %{"product" => "tablet"} }) {:ok, _} = Goals.create(site, %{ "event_name" => "Purchase", "display_name" => "Speaker Purchase", "custom_props" => %{"product" => "speaker"} }) {:ok, _} = Goals.create(site, %{ "event_name" => "Purchase", "display_name" => "General Purchase" }) end test "create/2 fails to create the same custom event goal twice with different display names but no props each" do site = new_site() {:ok, _} = Goals.create(site, %{ "event_name" => "Purchase", "display_name" => "General Purchase" }) {:error, changeset} = Goals.create(site, %{ "event_name" => "Purchase", "display_name" => "General Purchase 2" }) assert {"has already been taken", _} = changeset.errors[:event_name] end test "create/3 fails to create a goal with more than #{Plausible.Goal.max_custom_props_per_goal()} custom props" do site = new_site() {:error, changeset} = Goals.create(site, %{ "event_name" => "Purchase", "display_name" => "General Purchase", "custom_props" => %{ "variant" => "A", "promo" => "true", "product" => "tablet", "limit" => "hit" } }) assert {"use at most 3 properties per goal", _} = changeset.errors[:custom_props] end test "create/3 fails to create a goal with non-string custom prop values" do site = new_site() {:error, changeset} = Goals.create(site, %{ "event_name" => "Purchase", "custom_props" => %{"count" => 42} }) assert {"must be a map with string keys and string values" <> _, _} = changeset.errors[:custom_props] end test "create/3 fails to create a goal with null custom prop values" do site = new_site() {:error, changeset} = Goals.create(site, %{ "event_name" => "Purchase", "custom_props" => %{"product" => nil} }) assert {"must be a map with string keys and string values", _} = changeset.errors[:custom_props] end test "create/3 fails to create custom prop with key over #{Plausible.Props.max_prop_key_length()}" do site = new_site() {:error, changeset} = Goals.create(site, %{ "event_name" => "Purchase", "custom_props" => %{ :binary.copy("a", Plausible.Props.max_prop_key_length() + 1) => "value" } }) assert {"key length is 1..300 characters", _} = changeset.errors[:custom_props] end test "create/3 fails to create custom prop with value over #{Plausible.Props.max_prop_value_length()}" do site = new_site() {:error, changeset} = Goals.create(site, %{ "event_name" => "Purchase", "custom_props" => %{ "key" => :binary.copy("a", Plausible.Props.max_prop_value_length() + 1) } }) assert {"value length is 1..2000 characters", _} = changeset.errors[:custom_props] end test "create/2 succeeds to create the same custom event twice with different props and different display names each" do site = new_site() {:ok, _} = Goals.create(site, %{ "event_name" => "Purchase", "display_name" => "General Purchase", "custom_props" => %{"variant" => "A"} }) {:ok, _} = Goals.create(site, %{ "event_name" => "Purchase", "display_name" => "General Purchase 2", "custom_props" => %{"variant" => "A", "foo" => "bar"} }) end test "create/2 succeeds to create two pageview goals with different displayn names and custom props each" do site = new_site() {:ok, _} = Goals.create(site, %{"page_path" => "/index", "display_name" => "Index"}) {:ok, _} = Goals.create(site, %{ "page_path" => "/index", "display_name" => "Index 2", "custom_props" => %{"foo" => "bar"} }) end test "create/2 fails to create the same currency goal twice" do site = new_site() {:ok, _} = Goals.create(site, %{"event_name" => "foo bar", "currency" => "EUR"}) assert {:error, changeset} = Goals.create(site, %{ "event_name" => "foo bar", "currency" => "EUR", "display_name" => "Purchase copy" }) assert {"has already been taken", _} = changeset.errors[:event_name] end test "create/2 fails to create two pageview goals with same display name" do site = new_site() {:ok, _} = Goals.create(site, %{"page_path" => "/index", "display_name" => "Index"}) assert {:error, changeset} = Goals.create(site, %{"page_path" => "/index-2", "display_name" => "Index"}) assert {"has already been taken", _} = changeset.errors[:display_name] end test "create/2 fails when same custom props config exists with different display name" do site = new_site() {:ok, _} = Goals.create(site, %{ "event_name" => "Purchase", "display_name" => "Purchase Event", "custom_props" => %{"product" => "tablet"} }) {:error, changeset} = Goals.create(site, %{ "event_name" => "Purchase", "display_name" => "Different Display Name", "custom_props" => %{"product" => "tablet"} }) assert {"has already been taken", _} = changeset.errors[:event_name] end test "create/2 fails when same display name exists despite different custom props config" do site = new_site() {:ok, _} = Goals.create(site, %{ "event_name" => "Purchase", "display_name" => "My Goal", "custom_props" => %{"product" => "tablet"} }) {:error, changeset} = Goals.create(site, %{ "event_name" => "Purchase", "display_name" => "My Goal", "custom_props" => %{"product" => "speaker"} }) assert {"has already been taken", _} = changeset.errors[:display_name] end test "create/2 fails when same display name exists between event and pageview goals" do site = new_site() {:ok, _} = Goals.create(site, %{ "event_name" => "Signup", "display_name" => "User Action" }) {:error, changeset} = Goals.create(site, %{ "page_path" => "/signup", "display_name" => "User Action" }) assert {"has already been taken", _} = changeset.errors[:display_name] end test "create/2 fails to create a goal with 'engagement' as event_name (reserved)" do site = new_site() assert {:error, changeset} = Goals.create(site, %{"event_name" => "engagement"}) assert {"The event name 'engagement' is reserved and cannot be used as a goal", _} = changeset.errors[:event_name] end on_ee do @journey_end_event Plausible.Stats.Exploration.Journey.Step.journey_end_event() test "create/2 fails to create a goal with '#{@journey_end_event}' as event_name (reserved)" do site = new_site() assert {:error, changeset} = Goals.create(site, %{"event_name" => @journey_end_event}) assert {"The event name '#{@journey_end_event}' is reserved and cannot be used as a goal", _} = changeset.errors[:event_name] end test "create/2 sets site.updated_at for revenue goal" do site_1 = new_site(updated_at: DateTime.add(DateTime.utc_now(), -3600)) {:ok, _goal_1} = Goals.create(site_1, %{"event_name" => "Checkout", "currency" => "BRL"}) assert NaiveDateTime.compare(site_1.updated_at, Plausible.Repo.reload!(site_1).updated_at) == :lt site_2 = new_site(updated_at: DateTime.add(DateTime.utc_now(), -3600)) {:ok, _goal_2} = Goals.create(site_2, %{"event_name" => "Read Article", "currency" => nil}) assert NaiveDateTime.compare(site_2.updated_at, Plausible.Repo.reload!(site_2).updated_at) == :eq end test "create/2 creates revenue goal" do site = new_site() {:ok, goal} = Goals.create(site, %{"event_name" => "Purchase", "currency" => "EUR"}) assert goal.event_name == "Purchase" assert goal.page_path == nil assert goal.currency == :EUR end test "create/2 returns error when site does not have access to revenue goals" do user = new_user() |> subscribe_to_growth_plan() site = new_site(owner: user) {:error, :upgrade_required} = Goals.create(site, %{"event_name" => "Purchase", "currency" => "EUR"}) end test "create/2 returns error when site does not have access to custom props on goals" do user = new_user() |> subscribe_to_growth_plan() site = new_site(owner: user) {:error, :upgrade_required} = Goals.create(site, %{"event_name" => "Signup", "custom_props" => %{"plan" => "premium"}}) end test "for_site/2 with include_goals_with_custom_props?: false excludes goals with custom props" do site = new_site() _goal_with_props = insert(:goal, site: site, event_name: "Purchase", custom_props: %{"product" => "Shirt"}) goal_without_props = insert(:goal, site: site, event_name: "Signup") filtered = Goals.for_site(site, include_goals_with_custom_props?: false) assert length(filtered) == 1 assert hd(filtered).id == goal_without_props.id end test "for_site/2 includes all goals by default (include_goals_with_custom_props? defaults to true)" do user = new_user() site = new_site(owner: user) {:ok, _goal_with_props} = Goals.create(site, %{ "event_name" => "Purchase", "custom_props" => %{"product" => "Shirt"} }) {:ok, _goal_without_props} = Goals.create(site, %{"event_name" => "Signup"}) all_goals = Goals.for_site(site) assert length(all_goals) == 2 end test "create/2 returns error when creating a revenue goal for consolidated view" do user = new_user() new_site(owner: user) new_site(owner: user) {:ok, team} = Plausible.Teams.get_or_create(user) site = new_consolidated_view(team) {:error, :revenue_goals_unavailable} = Goals.create(site, %{"event_name" => "Purchase", "currency" => "EUR"}) end test "create/2 fails for unknown currency code" do site = new_site() assert {:error, changeset} = Goals.create(site, %{"event_name" => "Purchase", "currency" => "Euro"}) assert [currency: {"is invalid", _}] = changeset.errors end end test "update/2 updates a goal" do site = new_site() {:ok, goal1} = Goals.create(site, %{"page_path" => "/foo bar "}) {:ok, goal2} = Goals.update(goal1, %{"page_path" => "/", "display_name" => "Homepage"}) assert goal1.id == goal2.id assert goal2.page_path == "/" assert goal2.display_name == "Homepage" end test "update/2 cannot move goal to another site by passing site_id param" do site = new_site() victim_site = new_site() {:ok, goal} = Goals.create(site, %{"event_name" => "Purchase"}) {:ok, _updated} = Goals.update(goal, %{ "event_name" => "Purchase", "site_id" => victim_site.id }) reloaded = Plausible.Repo.reload!(goal) assert reloaded.site_id == site.id assert reloaded.site_id != victim_site.id end test "update/2 also updates all segments the goal is a part of" do user = new_user() site = new_site(owner: user) {:ok, goal1} = Goals.create(site, %{"event_name" => "Signup"}) {:ok, _goal2} = Goals.create(site, %{"event_name" => "Signup from nav"}) {:ok, segment1} = Plausible.Segments.insert_one(user.id, site, :editor, %{ "type" => "site", "segment_data" => %{ "filters" => [ ["is", "event:page", ["/blog"]], ["is", "event:goal", ["Signup from nav", "Signup"]], ["is", "event:props:variant", ["A"]] ] }, "name" => "Site segment" }) {:ok, segment2} = Plausible.Segments.insert_one(user.id, site, :editor, %{ "type" => "personal", "segment_data" => %{ "filters" => [ ["is", "event:goal", ["Signup"]] ] }, "name" => "Personal segment" }) Goals.update(goal1, %{"display_name" => "SIGNUP"}) assert Repo.reload!(segment1).segment_data == %{ "filters" => [ ["is", "event:page", ["/blog"]], ["is", "event:goal", ["Signup from nav", "SIGNUP"]], ["is", "event:props:variant", ["A"]] ] } assert Repo.reload!(segment2).segment_data == %{ "filters" => [ ["is", "event:goal", ["SIGNUP"]] ] } end test "update/2 prevents renaming event_name of a special goal" do site = new_site() for event_name <- Plausible.Goal.special_goals() do {:ok, goal} = Goals.create(site, %{"event_name" => event_name}) assert {:error, changeset} = Goals.update(goal, %{"event_name" => "Renamed #{event_name}"}) assert {"cannot be changed for an automated goal", _} = changeset.errors[:event_name] end end test "update/2 prevents renaming display_name of a special goal to a non-canonical value" do site = new_site() for event_name <- Plausible.Goal.special_goals() do {:ok, goal} = Goals.create(site, %{"event_name" => event_name}) assert {:error, changeset} = Goals.update(goal, %{"display_name" => "Renamed #{event_name}"}) assert {"cannot be changed for an automated goal", _} = changeset.errors[:display_name] end end test "update/2 allows restoring display_name of a special goal to its canonical value" do site = new_site() {:ok, goal} = Goals.create(site, %{"event_name" => "File Download"}) # Simulate a previously broken goal by directly updating the DB Plausible.Repo.update_all( Ecto.Query.where(Plausible.Goal, id: ^goal.id), set: [display_name: "My File Downloads"] ) broken_goal = Plausible.Repo.reload!(goal) assert broken_goal.display_name == "My File Downloads" assert {:ok, fixed} = Goals.update(broken_goal, %{"display_name" => "File Download"}) assert fixed.display_name == "File Download" end test "update/2 allows updating non-name fields of a special goal" do site = new_site() {:ok, goal} = Goals.create(site, %{"event_name" => "File Download"}) assert {:ok, updated} = Goals.update(goal, %{ "event_name" => "File Download", "display_name" => "File Download", "custom_props" => %{"path" => "/file.jpg"} }) assert updated.custom_props == %{"path" => "/file.jpg"} end test "update/2 allows renaming event_name of a regular goal" do site = new_site() {:ok, goal} = Goals.create(site, %{"event_name" => "Signup"}) assert {:ok, updated} = Goals.update(goal, %{"event_name" => "Register"}) assert updated.event_name == "Register" end test "update/2 allows renaming display_name of a regular goal" do site = new_site() {:ok, goal} = Goals.create(site, %{"event_name" => "Signup"}) assert {:ok, updated} = Goals.update(goal, %{"display_name" => "User Registration"}) assert updated.display_name == "User Registration" end on_ee do test "update/2 prevents changing currency of existing revenue goal" do site = new_site() {:ok, goal} = Goals.create(site, %{"event_name" => "Purchase", "currency" => "EUR"}) assert {:error, changeset} = Goals.update(goal, %{"currency" => "USD"}) assert {"cannot change currency of existing goal", _} = changeset.errors[:currency] end test "list_revenue_goals/1 lists event_names and currencies for each revenue goal" do site = new_site() Goals.create(site, %{"event_name" => "One", "currency" => "EUR"}) Goals.create(site, %{"event_name" => "Two", "currency" => "EUR"}) Goals.create(site, %{"event_name" => "Three", "currency" => "USD"}) Goals.create(site, %{"event_name" => "Four"}) Goals.create(site, %{"page_path" => "/some-page"}) revenue_goals = Goals.list_revenue_goals(site) assert length(revenue_goals) == 3 assert %{display_name: "One", currency: :EUR} in revenue_goals assert %{display_name: "Two", currency: :EUR} in revenue_goals assert %{display_name: "Three", currency: :USD} in revenue_goals end end test "create/2 clears currency for pageview goals" do site = new_site() {:ok, goal} = Goals.create(site, %{"page_path" => "/purchase", "currency" => "EUR"}) assert goal.event_name == nil assert goal.page_path == "/purchase" assert goal.currency == nil end test "for_site/1 returns trimmed input even if it was saved with trailing whitespace" do site = new_site() insert(:goal, %{site: site, event_name: " Signup "}) insert(:goal, %{site: site, page_path: " /Signup "}) goals = Goals.for_site(site) assert [%{page_path: "/Signup"}, %{event_name: "Signup"}] = goals end test "for_site/1 returns goals up to a limit" do site = new_site() for i <- 1..11, do: insert(:goal, %{site: site, event_name: "G#{i}"}) assert Goals.count(site) == 11 assert length(Goals.for_site(site)) == 10 end test "goals are present after domain change" do site = new_site() insert(:goal, %{site: site, event_name: " Signup "}) insert(:goal, %{site: site, page_path: " /Signup "}) {:ok, site} = Plausible.Site.Domain.change(site, "goals.example.com") assert [_, _] = Goals.for_site(site) end test "goals are removed when site is deleted" do site = new_site() insert(:goal, %{site: site, event_name: " Signup "}) insert(:goal, %{site: site, page_path: " /Signup "}) Plausible.Site.Removal.run(site) assert [] = Goals.for_site(site) end test "goals can be deleted" do site = new_site() goal = insert(:goal, %{site: site, event_name: " Signup "}) :ok = Goals.delete(goal.id, site) assert [] = Goals.for_site(site) end on_ee do test "goals can be fetched with funnel count preloaded" do site = new_site() goals = Enum.map(1..4, fn i -> {:ok, g} = Goals.create(site, %{"page_path" => "/#{i}"}) g end) {:ok, %{id: funnel_id1}} = Plausible.Funnels.create( site, "Funnel1", [ %{"goal_id" => Enum.at(goals, 1).id}, %{"goal_id" => Enum.at(goals, 2).id}, %{"goal_id" => Enum.at(goals, 3).id} ] ) {:ok, %{id: funnel_id2}} = Plausible.Funnels.create( site, "Funnel2", [ %{"goal_id" => Enum.at(goals, 1).id}, %{"goal_id" => Enum.at(goals, 3).id} ] ) assert [goal, _, _, _] = Goals.for_site(site, preload_funnels?: false) assert %Ecto.Association.NotLoaded{} = goal.funnels assert [goal, _, _, _] = Goals.for_site(site, preload_funnels?: true) assert [%{id: ^funnel_id1}, %{id: ^funnel_id2}] = goal.funnels end test "deleting goals with funnels triggers funnel reduction" do site = new_site() {:ok, g1} = Goals.create(site, %{"page_path" => "/1"}) {:ok, g2} = Goals.create(site, %{"page_path" => "/2"}) {:ok, g3} = Goals.create(site, %{"page_path" => "/3"}) {:ok, f1} = Plausible.Funnels.create( site, "Funnel 3 steps", [ %{"goal_id" => g1.id}, %{"goal_id" => g2.id}, %{"goal_id" => g3.id} ] ) {:ok, f2} = Plausible.Funnels.create( site, "Funnel 2 steps", [ %{"goal_id" => g1.id}, %{"goal_id" => g2.id} ] ) :ok = Goals.delete(g1.id, site) assert f1 = Plausible.Funnels.get(site.id, f1.id) assert Enum.count(f1.steps) == 2 refute Plausible.Funnels.get(site.id, f2.id) assert Repo.all(from(fs in Plausible.Funnel.Step, where: fs.funnel_id == ^f2.id)) == [] assert_matches [%{id: ^g3.id}, %{id: ^g2.id}] = Goals.for_site(site) end end test "must be either page_path or event_name" do site = new_site() assert {:error, changeset} = Goals.create(site, %{"page_path" => "/foo", "event_name" => "/foo"}) assert {"cannot co-exist with page_path", _} = changeset.errors[:event_name] end test "enforces goal limit per site" do site = new_site() for i <- 1..3 do assert {:ok, _goal} = Goals.create(site, %{"event_name" => "Event #{i}"}) end assert {:ok, _} = Goals.create(site, %{"event_name" => "Event 4"}) assert {:error, changeset} = Goals.create(site, %{"event_name" => "Event 5"}, max_goals_per_site: 4) assert {"Maximum number of goals reached", _} = changeset.errors[:event_name] assert {"Maximum number of goals reached", _} = changeset.errors[:page_path] end test "find_or_create with upsert bypasses limit check" do site = new_site() for i <- 1..3 do assert {:ok, _goal} = Goals.create(site, %{"event_name" => "Event #{i}"}) end assert {:ok, goal} = Goals.find_or_create(site, %{"goal_type" => "event", "event_name" => "Event 1"}, max_goals_per_site: 3 ) assert goal.event_name == "Event 1" end test "allows creating goals after deleting some" do site = new_site() for i <- 1..3 do assert {:ok, _goal} = Goals.create(site, %{"event_name" => "Event #{i}"}) end assert {:error, changeset} = Goals.create(site, %{"event_name" => "Event 4"}, max_goals_per_site: 3) assert {"Maximum number of goals reached", _} = changeset.errors[:event_name] assert {"Maximum number of goals reached", _} = changeset.errors[:page_path] [goal | _] = Goals.for_site(site) :ok = Goals.delete(goal.id, site) assert {:ok, _goal} = Goals.create(site, %{"event_name" => "Event 4"}, max_goals_per_site: 3) end test "batch_create_event_goals respects limit" do site = new_site() for i <- 1..6 do assert {:ok, _goal} = Goals.create(site, %{"event_name" => "Event #{i}"}) end event_names = for i <- 6..20, do: "Event #{i}" created_goals = Goals.batch_create_event_goals(event_names, site, max_goals_per_site: 10) assert length(created_goals) == 5 assert Enum.count(Goals.for_site(site)) == 10 end test "on Mix.env == :test, max goals per site is 10 and can be overridden" do assert Plausible.Goals.max_goals_per_site() == 10 assert Plausible.Goals.max_goals_per_site(max_goals_per_site: 5) == 5 end end