defmodule Plausible.Stats.Query.QueryParseAndBuildTest do use Plausible.DataCase import Plausible.AssertMatches alias Plausible.Stats.{Query, DateTimeRange, Filters, QueryError} @now DateTime.new!(~D[2021-05-05], ~T[12:30:00], "Etc/UTC") @date_range_today %DateTimeRange{ first: DateTime.new!(~D[2021-05-05], ~T[00:00:00], "Etc/UTC"), last: @now } @date_range_day %DateTimeRange{ first: DateTime.new!(~D[2021-05-05], ~T[00:00:00], "Etc/UTC"), last: DateTime.new!(~D[2021-05-05], ~T[23:59:59], "Etc/UTC") } @date_range_7d %DateTimeRange{ first: DateTime.new!(~D[2021-04-28], ~T[00:00:00], "Etc/UTC"), last: DateTime.new!(~D[2021-05-04], ~T[23:59:59], "Etc/UTC") } @date_range_10d %DateTimeRange{ first: DateTime.new!(~D[2021-04-25], ~T[00:00:00], "Etc/UTC"), last: DateTime.new!(~D[2021-05-04], ~T[23:59:59], "Etc/UTC") } @date_range_30d %DateTimeRange{ first: DateTime.new!(~D[2021-04-05], ~T[00:00:00], "Etc/UTC"), last: DateTime.new!(~D[2021-05-04], ~T[23:59:59], "Etc/UTC") } @date_range_month %DateTimeRange{ first: DateTime.new!(~D[2021-05-01], ~T[00:00:00], "Etc/UTC"), last: DateTime.new!(~D[2021-05-31], ~T[23:59:59], "Etc/UTC") } @date_range_3mo %DateTimeRange{ first: DateTime.new!(~D[2021-02-01], ~T[00:00:00], "Etc/UTC"), last: DateTime.new!(~D[2021-04-30], ~T[23:59:59], "Etc/UTC") } @date_range_6mo %DateTimeRange{ first: DateTime.new!(~D[2020-11-01], ~T[00:00:00], "Etc/UTC"), last: DateTime.new!(~D[2021-04-30], ~T[23:59:59], "Etc/UTC") } @date_range_year %DateTimeRange{ first: DateTime.new!(~D[2021-01-01], ~T[00:00:00], "Etc/UTC"), last: DateTime.new!(~D[2021-12-31], ~T[23:59:59], "Etc/UTC") } @date_range_12mo %DateTimeRange{ first: DateTime.new!(~D[2020-05-01], ~T[00:00:00], "Etc/UTC"), last: DateTime.new!(~D[2021-04-30], ~T[23:59:59], "Etc/UTC") } @default_include %Plausible.Stats.QueryInclude{ imports: false, imports_meta: false, time_labels: false, total_rows: false, compare: nil, compare_match_day_of_week: false, legacy_time_on_page_cutoff: nil, trim_relative_date_range: false } setup [:create_user, :create_site] def check_goals(query, opts) do assert %Query{ preloaded_goals: preloaded_goals, revenue_warning: revenue_warning, revenue_currencies: revenue_currencies } = query assert goal_names(preloaded_goals[:all]) == Enum.sort(Keyword.get(opts, :preloaded_goals)[:all]) assert goal_names(preloaded_goals[:matching_toplevel_filters]) == Enum.sort(Keyword.get(opts, :preloaded_goals)[:matching_toplevel_filters]) assert revenue_warning == Keyword.get(opts, :revenue_warning) assert revenue_currencies == Keyword.get(opts, :revenue_currencies) end defp goal_names(goals), do: Enum.map(goals, & &1.display_name) |> Enum.sort() describe "metrics" do test "public API does not recognize exit_rate metric", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["exit_rate"], "date_range" => "all" } assert {:error, %QueryError{message: error}} = Query.parse_and_build(site, params, now: @now) assert error =~ "Invalid metric" end test "public API does not recognize total_visitors metric", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["total_visitors"], "date_range" => "all" } assert {:error, %QueryError{message: error}} = Query.parse_and_build(site, params, now: @now) assert error =~ "Invalid metric" end test "valid metrics passed", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["visitors", "events"], "date_range" => "all" } assert {:ok, query} = Query.parse_and_build(site, params, now: @now) assert_matches %Query{ metrics: [:visitors, :events], utc_time_range: ^@date_range_day, filters: [], dimensions: [], order_by: nil, timezone: ^site.timezone, include: ^@default_include, pagination: %{limit: 10_000, offset: 0} } = query end test "fuller list of metrics", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => [ "visitors", "pageviews", "visits", "events", "bounce_rate", "visit_duration" ], "date_range" => "all" } assert {:ok, query} = Query.parse_and_build(site, params, now: @now) assert_matches %Query{ metrics: [ :visitors, :pageviews, :visits, :events, :bounce_rate, :visit_duration ], utc_time_range: ^@date_range_day, filters: [], dimensions: [], order_by: nil, timezone: ^site.timezone, include: ^@default_include, pagination: %{limit: 10_000, offset: 0} } = query end test "same metric queried multiple times", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["events", "visitors", "visitors"], "date_range" => "all" } assert {:error, %QueryError{ code: :failed_schema_validation, message: "#/metrics: Expected items to be unique but they were not." }} = Query.parse_and_build(site, params, now: @now) end test "no metrics passed", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => [], "date_range" => "all" } assert {:error, %QueryError{ code: :failed_schema_validation, message: "#/metrics: Expected a minimum of 1 items but got 0." }} = Query.parse_and_build(site, params, now: @now) end end describe "filters validation" do for operation <- [ :is, :is_not, :matches, :matches_not, :contains, :contains_not ] do test "#{operation} filter", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => "all", "filters" => [ [Atom.to_string(unquote(operation)), "event:name", ["foo"]] ] } assert {:ok, query} = Query.parse_and_build(site, params, now: @now) assert_matches %Query{ metrics: [:visitors], utc_time_range: ^@date_range_day, filters: [ [^unquote(operation), "event:name", ["foo"]] ], dimensions: [], order_by: nil, timezone: ^site.timezone, include: ^@default_include, pagination: %{limit: 10_000, offset: 0} } = query end test "#{operation} filter with invalid clause", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => "all", "filters" => [ [Atom.to_string(unquote(operation)), "event:name", "foo"] ] } assert {:error, %QueryError{message: error}} = Query.parse_and_build(site, params, now: @now) assert error == "#/filters/0: Invalid filter [\"#{unquote(operation)}\", \"event:name\", \"foo\"]" end end for operation <- [:matches_wildcard, :matches_wildcard_not] do test "#{operation} is not a valid filter operation in public API", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => "all", "filters" => [ [Atom.to_string(unquote(operation)), "event:name", ["foo"]] ] } assert {:error, %QueryError{message: error}} = Query.parse_and_build(site, params, now: @now) assert error == "#/filters/0: Invalid filter [\"#{unquote(operation)}\", \"event:name\", [\"foo\"]]" end end for too_short_filter <- [ [], ["and"], ["or"], ["and", []], ["or", []], ["not"], ["is_not"], ["is_not", "event:name"], ["has_done"], ["has_not_done"] ] do test "errors on too short filter #{inspect(too_short_filter)}", %{ site: site } do params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => "all", "filters" => [ unquote(too_short_filter) ] } assert {:error, %QueryError{message: error}} = Query.parse_and_build(site, params, now: @now) assert error == ~s(#/filters/0: Invalid filter #{inspect(unquote(too_short_filter))}) end end valid_filter = ["is", "event:props:foobar", ["value"]] for too_long_filter <- [ ["and", [valid_filter], "extra"], ["or", [valid_filter], []], ["not", valid_filter, 1], Enum.concat(valid_filter, [true]) ] do test "errors on too long filter #{inspect(too_long_filter)}", %{ site: site } do params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => "all", "filters" => [ unquote(too_long_filter) ] } assert {:error, %QueryError{message: error}} = Query.parse_and_build(site, params, now: @now) assert error == ~s(#/filters/0: Invalid filter #{inspect(unquote(too_long_filter))}) end end test "filtering by invalid operation", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => "all", "filters" => [ ["exists?", "event:name", ["foo"]] ] } assert {:error, %QueryError{message: error}} = Query.parse_and_build(site, params, now: @now) assert error == "#/filters/0: Invalid filter [\"exists?\", \"event:name\", [\"foo\"]]" end test "filtering by custom properties", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => "all", "filters" => [ ["is", "event:props:foobar", ["value"]] ] } assert {:ok, query} = Query.parse_and_build(site, params, now: @now) assert_matches %Query{ metrics: [:visitors], utc_time_range: ^@date_range_day, filters: [ [:is, "event:props:foobar", ["value"]] ], dimensions: [], order_by: nil, timezone: ^site.timezone, include: ^@default_include, pagination: %{limit: 10_000, offset: 0} } = query end for dimension <- Filters.event_props() do if dimension != "goal" do test "filtering by event:#{dimension} filter", %{site: site} do prefixed_dimension = "event:#{unquote(dimension)}" params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => "all", "filters" => [ ["is", prefixed_dimension, ["foo"]] ] } assert {:ok, query} = Query.parse_and_build(site, params, now: @now) assert_matches %Query{ metrics: [:visitors], utc_time_range: ^@date_range_day, filters: [ [:is, ^prefixed_dimension, ["foo"]] ], dimensions: [], order_by: nil, timezone: ^site.timezone, include: ^@default_include, pagination: %{limit: 10_000, offset: 0} } = query end end end for dimension <- Filters.visit_props() do test "filtering by visit:#{dimension} filter", %{site: site} do prefixed_dimension = "visit:#{unquote(dimension)}" params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => "all", "filters" => [ ["is", prefixed_dimension, ["ab"]] ] } assert {:ok, query} = Query.parse_and_build(site, params, now: @now) assert_matches %Query{ metrics: [:visitors], utc_time_range: ^@date_range_day, filters: [ [:is, ^prefixed_dimension, ["ab"]] ], dimensions: [], order_by: nil, timezone: ^site.timezone, include: ^@default_include, pagination: %{limit: 10_000, offset: 0} } = query end end test "invalid event filter", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => "all", "filters" => [ ["is", "event:device", ["foo"]] ] } assert {:error, %QueryError{message: error}} = Query.parse_and_build(site, params, now: @now) assert error == "#/filters/0: Invalid filter [\"is\", \"event:device\", [\"foo\"]]" end test "invalid visit filter", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => "all", "filters" => [ ["is", "visit:name", ["foo"]] ] } assert {:error, %QueryError{message: error}} = Query.parse_and_build(site, params, now: @now) assert error == "#/filters/0: Invalid filter [\"is\", \"visit:name\", [\"foo\"]]" end test "invalid filter", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => "all", "filters" => "foobar" } assert {:error, %QueryError{message: error}} = Query.parse_and_build(site, params, now: @now) assert error == "#/filters: Type mismatch. Expected Array but got String." end test "numeric filter is invalid", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => "all", "filters" => [["is", "visit:os_version", [123]]] } assert {:error, %QueryError{message: error}} = Query.parse_and_build(site, params, now: @now) assert error == "Invalid filter '[\"is\", \"visit:os_version\", [123]]'." end test "numbers are valid for visit:city", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => "all", "filters" => [["is", "visit:city", [123, 456]]] } assert {:ok, query} = Query.parse_and_build(site, params, now: @now) assert_matches %Query{ metrics: [:visitors], utc_time_range: ^@date_range_day, filters: [ [:is, "visit:city", [123, 456]] ], dimensions: [], order_by: nil, timezone: ^site.timezone, include: ^@default_include, pagination: %{limit: 10_000, offset: 0} } = query end test "strings are valid for visit:city", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => "all", "filters" => [["is", "visit:city", ["123", "456"]]] } assert {:ok, query} = Query.parse_and_build(site, params, now: @now) assert_matches %Query{ metrics: [:visitors], utc_time_range: ^@date_range_day, filters: [ [:is, "visit:city", ["123", "456"]] ], dimensions: [], order_by: nil, timezone: ^site.timezone, include: ^@default_include, pagination: %{limit: 10_000, offset: 0} } = query end test "invalid visit:country filter", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => "all", "filters" => [["is", "visit:country", ["USA"]]] } assert {:error, %QueryError{message: error}} = Query.parse_and_build(site, params, now: @now) assert error == "Invalid visit:country filter, visit:country needs to be a valid 2-letter country code." end test "valid nested `not`, `and` and `or`", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => "all", "filters" => [ [ "or", [ [ "and", [ ["is", "visit:city_name", ["Tallinn"]], ["is", "visit:country_name", ["Estonia"]] ] ], ["not", ["is", "visit:country_name", ["Estonia"]]] ] ] ] } assert {:ok, query} = Query.parse_and_build(site, params, now: @now) assert_matches %Query{ metrics: [:visitors], utc_time_range: ^@date_range_day, filters: [ [ :or, [ [ :and, [ [:is, "visit:city_name", ["Tallinn"]], [:is, "visit:country_name", ["Estonia"]] ] ], [:not, [:is, "visit:country_name", ["Estonia"]]] ] ] ], dimensions: [], order_by: nil, timezone: ^site.timezone, include: ^@default_include, pagination: %{limit: 10_000, offset: 0} } = query end test "valid has_done and has_not_done filters", %{site: site} do insert(:goal, %{site: site, event_name: "Signup"}) params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => "all", "filters" => [ ["has_done", ["is", "event:name", ["Signup"]]], [ "has_not_done", [ "or", [ ["is", "event:goal", ["Signup"]], ["is", "event:page", ["/signup"]] ] ] ] ] } assert {:ok, query} = Query.parse_and_build(site, params, now: @now) assert_matches %Query{ metrics: [:visitors], utc_time_range: ^@date_range_day, filters: [ [:has_done, [:is, "event:name", ["Signup"]]], [ :has_not_done, [ :or, [[:is, "event:goal", ["Signup"]], [:is, "event:page", ["/signup"]]] ] ] ], dimensions: [], order_by: nil, timezone: ^site.timezone, include: ^@default_include, pagination: %{limit: 10_000, offset: 0} } = query end test "fails when using visit filters within has_done filters", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => "all", "filters" => [ ["has_done", ["is", "visit:browser", ["Chrome"]]] ] } assert {:error, %QueryError{message: error}} = Query.parse_and_build(site, params, now: @now) assert error == "Invalid filters. Behavioral filters (has_done, has_not_done) can only be used with event dimension filters." end test "fails when nesting behavioral filters", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => "all", "filters" => [ ["has_done", ["has_not_done", ["is", "visit:browser", ["Chrome"]]]] ] } assert {:error, %QueryError{message: error}} = Query.parse_and_build(site, params, now: @now) assert error == "Invalid filters. Behavioral filters (has_done, has_not_done) cannot be nested." end for operator <- ["not", "or", "has_done", "has_not_done"] do test "invalid `#{operator}` clause", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => "all", "filters" => [[unquote(operator), []]] } assert {:error, %QueryError{message: error}} = Query.parse_and_build(site, params, now: @now) assert error == "#/filters/0: Invalid filter [\"#{unquote(operator)}\", []]" end end test "event:hostname filter", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => "all", "filters" => [["is", "event:hostname", ["a.plausible.io"]]] } assert {:ok, query} = Query.parse_and_build(site, params, now: @now) assert_matches %Query{ metrics: [:visitors], utc_time_range: ^@date_range_day, filters: [ [:is, "event:hostname", ["a.plausible.io"]] ], dimensions: [], order_by: nil, timezone: ^site.timezone, include: ^@default_include, pagination: %{limit: 10_000, offset: 0} } = query end test "event:hostname filter not at top level is invalid", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => "all", "filters" => [["not", ["is", "event:hostname", ["a.plausible.io"]]]] } assert {:error, %QueryError{message: error}} = Query.parse_and_build(site, params, now: @now) assert error == "Invalid filters. Dimension `event:hostname` can only be filtered at the top level." end for operation <- [:is, :contains, :is_not, :contains_not] do test "#{operation} allows case_sensitive modifier", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => "all", "filters" => [ [ Atom.to_string(unquote(operation)), "event:page", ["/foo"], %{"case_sensitive" => false} ], [ Atom.to_string(unquote(operation)), "event:name", ["/foo"], %{"case_sensitive" => true} ] ] } assert {:ok, query} = Query.parse_and_build(site, params, now: @now) assert_matches %Query{ metrics: [:visitors], utc_time_range: ^@date_range_day, filters: [ [ ^unquote(operation), "event:page", ["/foo"], %{case_sensitive: false} ], [^unquote(operation), "event:name", ["/foo"], %{case_sensitive: true}] ], dimensions: [], order_by: nil, timezone: ^site.timezone, include: ^@default_include, pagination: %{limit: 10_000, offset: 0} } = query end end for operation <- [:matches, :matches_not] do test "case_sensitive modifier is not valid for #{operation}", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => "all", "filters" => [ [ Atom.to_string(unquote(operation)), "event:hostname", ["a.plausible.io"], %{"case_sensitive" => false} ] ] } assert {:error, %QueryError{message: error}} = Query.parse_and_build(site, params, now: @now) assert error == "#/filters/0: Invalid filter [\"#{unquote(operation)}\", \"event:hostname\", [\"a.plausible.io\"], %{\"case_sensitive\" => false}]" end end end describe "preloading goals" do setup %{site: site} do insert(:goal, %{site: site, event_name: "Signup"}) insert(:goal, %{site: site, event_name: "Purchase"}) insert(:goal, %{site: site, event_name: "Contact"}) :ok end test "with exact match", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => "all", "filters" => [["is", "event:goal", ["Signup", "Purchase"]]] } assert {:ok, query} = Query.parse_and_build(site, params, now: @now) assert_matches %Query{ metrics: [:visitors], utc_time_range: ^@date_range_day, filters: [[:is, "event:goal", ["Signup", "Purchase"]]], dimensions: [], order_by: nil, timezone: ^site.timezone, include: ^@default_include, pagination: %{limit: 10_000, offset: 0} } = query check_goals(query, preloaded_goals: %{ all: ["Contact", "Purchase", "Signup"], matching_toplevel_filters: ["Purchase", "Signup"] }, revenue_currencies: %{} ) end test "with case insensitive match", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => "all", "filters" => [["is", "event:goal", ["signup", "purchase"], %{"case_sensitive" => false}]] } assert {:ok, query} = Query.parse_and_build(site, params, now: @now) assert_matches %Query{ metrics: [:visitors], utc_time_range: ^@date_range_day, filters: [ [:is, "event:goal", ["signup", "purchase"], %{case_sensitive: false}] ], dimensions: [], order_by: nil, timezone: ^site.timezone, include: ^@default_include, pagination: %{limit: 10_000, offset: 0} } = query check_goals(query, preloaded_goals: %{ all: ["Contact", "Purchase", "Signup"], matching_toplevel_filters: ["Purchase", "Signup"] }, revenue_currencies: %{} ) end test "with contains match", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => "all", "filters" => [["contains", "event:goal", ["Sign", "pur"]]] } assert {:ok, query} = Query.parse_and_build(site, params, now: @now) assert_matches %Query{ metrics: [:visitors], utc_time_range: ^@date_range_day, filters: [[:contains, "event:goal", ["Sign", "pur"]]], dimensions: [], order_by: nil, timezone: ^site.timezone, include: ^@default_include, pagination: %{limit: 10_000, offset: 0} } = query check_goals(query, preloaded_goals: %{ all: ["Contact", "Purchase", "Signup"], matching_toplevel_filters: ["Signup"] }, revenue_currencies: %{} ) end test "with case insensitive contains match", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => "all", "filters" => [["contains", "event:goal", ["sign", "CONT"], %{"case_sensitive" => false}]] } assert {:ok, query} = Query.parse_and_build(site, params, now: @now) assert_matches %Query{ metrics: [:visitors], utc_time_range: ^@date_range_day, filters: [ [:contains, "event:goal", ["sign", "CONT"], %{case_sensitive: false}] ], dimensions: [], order_by: nil, timezone: ^site.timezone, include: ^@default_include, pagination: %{limit: 10_000, offset: 0} } = query check_goals(query, preloaded_goals: %{ all: ["Contact", "Purchase", "Signup"], matching_toplevel_filters: ["Contact", "Signup"] }, revenue_currencies: %{} ) end end describe "include validation" do test "setting include values", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => "all", "dimensions" => ["time"], "include" => %{"imports" => true, "time_labels" => true, "total_rows" => true} } assert {:ok, query} = Query.parse_and_build(site, params, now: @now) assert_matches %Query{ metrics: [:visitors], utc_time_range: ^@date_range_day, filters: [], dimensions: ["time"], order_by: nil, timezone: ^site.timezone, include: %{ imports: true, imports_meta: false, time_labels: true, total_rows: true, compare: nil, compare_match_day_of_week: false, legacy_time_on_page_cutoff: nil, trim_relative_date_range: false }, pagination: %{limit: 10_000, offset: 0} } = query end test "setting invalid imports value", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => "all", "include" => "foobar" } assert {:error, %QueryError{message: error}} = Query.parse_and_build(site, params, now: @now) assert error == "#/include: Type mismatch. Expected Object but got String." end test "setting include.time_labels without time dimension", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => "all", "include" => %{"time_labels" => true} } assert {:error, %QueryError{message: error}} = Query.parse_and_build(site, params, now: @now) assert error == "Invalid include.time_labels: requires a time dimension." end end describe "pagination validation" do test "setting pagination values", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => "all", "dimensions" => ["time"], "pagination" => %{"limit" => 100, "offset" => 200} } assert {:ok, query} = Query.parse_and_build(site, params, now: @now) assert_matches %Query{ metrics: [:visitors], utc_time_range: ^@date_range_day, filters: [], dimensions: ["time"], order_by: nil, timezone: ^site.timezone, include: ^@default_include, pagination: %{limit: 100, offset: 200} } = query end test "out of range limit value", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => "all", "pagination" => %{"limit" => 100_000} } assert {:error, %QueryError{message: error}} = Query.parse_and_build(site, params, now: @now) assert error == "#/pagination/limit: Expected the value to be <= 10000" end test "out of range offset value", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => "all", "pagination" => %{"offset" => -5} } assert {:error, %QueryError{message: error}} = Query.parse_and_build(site, params, now: @now) assert error == "#/pagination/offset: Expected the value to be >= 0" end end describe "event:goal filter validation" do test "valid filters", %{site: site} do insert(:goal, %{site: site, event_name: "Signup"}) insert(:goal, %{site: site, page_path: "/thank-you"}) params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => "all", "filters" => [ ["is", "event:goal", ["Signup", "Visit /thank-you"]] ] } assert {:ok, query} = Query.parse_and_build(site, params, now: @now) assert_matches %Query{ metrics: [:visitors], utc_time_range: ^@date_range_day, filters: [ [:is, "event:goal", ["Signup", "Visit /thank-you"]] ], dimensions: [], order_by: nil, timezone: ^site.timezone, include: ^@default_include, pagination: %{limit: 10_000, offset: 0} } = query check_goals(query, preloaded_goals: %{ all: ["Signup", "Visit /thank-you"], matching_toplevel_filters: ["Signup", "Visit /thank-you"] }, revenue_warning: nil, revenue_currencies: %{} ) end test "invalid event filter", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => "all", "filters" => [ ["is", "event:goal", ["Signup"]] ] } assert {:error, %QueryError{message: error}} = Query.parse_and_build(site, params, now: @now) assert error == "Invalid filters. The goal `Signup` is not configured for this site. Find out how to configure goals here: https://plausible.io/docs/stats-api#filtering-by-goals" end test "invalid page filter", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => "all", "filters" => [ ["is", "event:goal", ["Visit /thank-you"]] ] } assert {:error, %QueryError{message: error}} = Query.parse_and_build(site, params, now: @now) assert error == "Invalid filters. The goal `Visit /thank-you` is not configured for this site. Find out how to configure goals here: https://plausible.io/docs/stats-api#filtering-by-goals" end test "unsupported filter", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => "all", "filters" => [ ["is_not", "event:goal", ["Signup"]] ] } assert {:error, %QueryError{message: error}} = Query.parse_and_build(site, params, now: @now) assert error == "#/filters/0: Invalid filter [\"is_not\", \"event:goal\", [\"Signup\"]]" end test "not top-level filter", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => "all", "filters" => [ [ "or", [ ["is", "event:goal", ["Signup"]], ["is", "event:name", ["pageview"]] ] ] ] } assert {:error, %QueryError{message: error}} = Query.parse_and_build(site, params, now: @now) assert error == "Invalid filters. Dimension `event:goal` can only be filtered at the top level." end test "allowed within behavioral filters has_done", %{site: site} do insert(:goal, %{site: site, event_name: "Signup"}) params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => "all", "filters" => [ [ "has_done", [ "or", [ ["is", "event:goal", ["Signup"]], ["is", "event:name", ["pageview"]] ] ] ] ] } assert {:ok, query} = Query.parse_and_build(site, params, now: @now) assert_matches %Query{ metrics: [:visitors], utc_time_range: ^@date_range_day, filters: [ [ :has_done, [ :or, [ [:is, "event:goal", ["Signup"]], [:is, "event:name", ["pageview"]] ] ] ] ], dimensions: [], order_by: nil, timezone: ^site.timezone, include: ^@default_include, pagination: %{limit: 10_000, offset: 0} } = query check_goals(query, preloaded_goals: %{all: ["Signup"], matching_toplevel_filters: ["Signup"]}, revenue_warning: nil, revenue_currencies: %{} ) end end describe "date range validation" do for shortcut <- ["realtime", "30m"] do test "public API does not recognize #{shortcut} date_range", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => unquote(shortcut) } assert {:error, %QueryError{message: error}} = Query.parse_and_build(site, params, now: @now) assert error =~ "Invalid date range" end end for {shortcut, expected_date_range} <- [ {"day", @date_range_today}, {"7d", @date_range_7d}, {"10d", @date_range_10d}, {"30d", @date_range_30d}, {"month", @date_range_month}, {"3mo", @date_range_3mo}, {"6mo", @date_range_6mo}, {"12mo", @date_range_12mo}, {"year", @date_range_year} ] do test "parses '#{shortcut}' date_range shortcut ", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["visitors", "events"], "date_range" => unquote(shortcut) } assert {:ok, query} = Query.parse_and_build(site, params, now: @now) assert_matches %Query{ metrics: [:visitors, :events], utc_time_range: ^unquote(Macro.escape(expected_date_range)), filters: [], dimensions: [], order_by: nil, timezone: ^site.timezone, include: ^@default_include, pagination: %{limit: 10_000, offset: 0} } = query end end test "parsing `all` with previous data", %{site: site} do site = Map.put(site, :stats_start_date, ~D[2020-01-01]) expected_date_range = DateTimeRange.new!(~D[2020-01-01], ~D[2021-05-05], "Etc/UTC") params = %{ "site_id" => site.domain, "metrics" => ["visitors", "events"], "date_range" => "all" } assert {:ok, query} = Query.parse_and_build(site, params, now: @now) assert_matches %Query{ metrics: [:visitors, :events], utc_time_range: ^expected_date_range, filters: [], dimensions: [], order_by: nil, timezone: ^site.timezone, include: ^@default_include, pagination: %{limit: 10_000, offset: 0} } = query end test "parsing `all` with no previous data", %{site: site} do site = Map.put(site, :stats_start_date, nil) params = %{ "site_id" => site.domain, "metrics" => ["visitors", "events"], "date_range" => "all" } assert {:ok, query} = Query.parse_and_build(site, params, now: @now) assert_matches %Query{ metrics: [:visitors, :events], utc_time_range: ^@date_range_day, filters: [], dimensions: [], order_by: nil, timezone: ^site.timezone, include: ^@default_include, pagination: %{limit: 10_000, offset: 0} } = query end test "parsing custom date range from simple date strings", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["visitors", "events"], "date_range" => ["2021-05-05", "2021-05-05"] } assert {:ok, query} = Query.parse_and_build(site, params, now: @now) assert_matches %Query{ metrics: [:visitors, :events], utc_time_range: ^@date_range_day, filters: [], dimensions: [], order_by: nil, timezone: ^site.timezone, include: ^@default_include, pagination: %{limit: 10_000, offset: 0} } = query end test "parsing custom date range from iso8601 timestamps", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["visitors", "events"], "date_range" => ["2024-01-01T00:00:00Z", "2024-01-02T23:59:59Z"] } expected_utc_time_range = DateTimeRange.new!( DateTime.new!(~D[2024-01-01], ~T[00:00:00], "Etc/UTC"), DateTime.new!(~D[2024-01-02], ~T[23:59:59], "Etc/UTC") ) assert {:ok, query} = Query.parse_and_build(site, params, now: @now) assert_matches %Query{ metrics: [:visitors, :events], utc_time_range: ^expected_utc_time_range, filters: [], dimensions: [], order_by: nil, timezone: ^site.timezone, include: ^@default_include, pagination: %{limit: 10_000, offset: 0} } = query end test "parsing custom date range from iso8601 timestamps with non-UTC timezone", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["visitors", "events"], "date_range" => ["2024-08-29T07:12:34-07:00", "2024-08-29T10:12:34-07:00"] } expected_utc_time_range = DateTimeRange.new!(~U[2024-08-29 14:12:34Z], ~U[2024-08-29 17:12:34Z]) assert {:ok, query} = Query.parse_and_build(site, params, now: @now) assert_matches %Query{ metrics: [:visitors, :events], utc_time_range: ^expected_utc_time_range, filters: [], dimensions: [], order_by: nil, timezone: ^site.timezone, include: ^@default_include, pagination: %{limit: 10_000, offset: 0} } = query end for invalid_value <- ["-1d", "foo", ["21415-00", "eee"]] do test "errors on invalid date range value (#{inspect(invalid_value)})", %{site: site} do params = %{ "site_id" => site.domain, "date_range" => unquote(invalid_value), "metrics" => ["visitors"] } assert {:error, %QueryError{message: error}} = Query.parse_and_build(site, params, now: @now) assert error == "#/date_range: Invalid date range #{inspect(unquote(invalid_value))}" end end test "999999999mo is invalid date range", %{site: site} do params = %{ "site_id" => site.domain, "date_range" => "999999999mo", "metrics" => ["visitors"] } assert {:error, %QueryError{message: error}} = Query.parse_and_build(site, params, now: @now) assert error == "Invalid date_range \"999999999mo\"" end test "custom date range is invalid when timestamps do not include timezone info", %{ site: site } do params = %{ "site_id" => site.domain, "date_range" => ["2021-02-03T00:00:00", "2021-02-03T23:59:59"], "metrics" => ["visitors"] } assert {:error, %QueryError{message: error}} = Query.parse_and_build(site, params, now: @now) assert error == "Invalid date_range '[\"2021-02-03T00:00:00\", \"2021-02-03T23:59:59\"]'." end test "custom date range is invalid when timestamp timezone is invalid", %{site: site} do params = %{ "site_id" => site.domain, "date_range" => ["2021-02-03T00:00:00-25:00", "2021-02-03T23:59:59-25:00"], "metrics" => ["visitors"] } assert {:error, %QueryError{message: error}} = Query.parse_and_build(site, params, now: @now) assert error == "#/date_range: Invalid date range [\"2021-02-03T00:00:00-25:00\", \"2021-02-03T23:59:59-25:00\"]" end test "custom date range is invalid when date and timestamp are combined", %{site: site} do params = %{ "site_id" => site.domain, "date_range" => ["2021-02-03T00:00:00Z", "2021-02-04"], "metrics" => ["visitors"] } assert {:error, %QueryError{message: error}} = Query.parse_and_build(site, params, now: @now) assert error == "#/date_range: Invalid date range [\"2021-02-03T00:00:00Z\", \"2021-02-04\"]" end test "parses date_range.first into a datetime right after the gap in site.timezone", %{ site: site } do site = %{site | timezone: "America/Santiago"} params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => ["2022-09-11", "2022-09-11"] } expected_utc_time_range = DateTimeRange.new!(~U[2022-09-11 04:00:00Z], ~U[2022-09-12 02:59:59Z]) assert {:ok, query} = Query.parse_and_build(site, params, now: @now) assert_matches %Query{ metrics: [:visitors], utc_time_range: ^expected_utc_time_range, filters: [], dimensions: [], order_by: nil, timezone: ^site.timezone, include: ^@default_include, pagination: %{limit: 10_000, offset: 0} } = query end test "parses date_range.first into the latest of ambiguous datetimes in site.timezone", %{ site: site } do site = %{site | timezone: "America/Havana"} params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => ["2023-11-05", "2023-11-05"] } expected_utc_time_range = DateTimeRange.new!(~U[2023-11-05 05:00:00Z], ~U[2023-11-06 04:59:59Z]) assert {:ok, query} = Query.parse_and_build(site, params, now: @now) assert_matches %Query{ metrics: [:visitors], utc_time_range: ^expected_utc_time_range, filters: [], dimensions: [], order_by: nil, timezone: ^site.timezone, include: ^@default_include, pagination: %{limit: 10_000, offset: 0} } = query end test "parses date_range.last into the earliest of ambiguous datetimes in site.timezone", %{ site: site } do site = %{site | timezone: "America/Asuncion"} params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => ["2024-03-23", "2024-03-23"] } expected_utc_time_range = DateTimeRange.new!(~U[2024-03-23 03:00:00Z], ~U[2024-03-24 02:59:59Z]) assert {:ok, query} = Query.parse_and_build(site, params, now: @now) assert_matches %Query{ metrics: [:visitors], utc_time_range: ^expected_utc_time_range, filters: [], dimensions: [], order_by: nil, timezone: ^site.timezone, include: ^@default_include, pagination: %{limit: 10_000, offset: 0} } = query end end describe "dimensions validation" do for dimension <- Filters.event_props() do test "event:#{dimension} dimension", %{site: site} do prefixed_dimension = "event:#{unquote(dimension)}" params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => "all", "dimensions" => [prefixed_dimension] } assert {:ok, query} = Query.parse_and_build(site, params, now: @now) assert_matches %Query{ metrics: [:visitors], utc_time_range: ^@date_range_day, filters: [], dimensions: [^prefixed_dimension], order_by: nil, timezone: ^site.timezone, include: ^@default_include, pagination: %{limit: 10_000, offset: 0} } = query end end for dimension <- Filters.visit_props() do test "visit:#{dimension} dimension", %{site: site} do prefixed_dimension = "visit:#{unquote(dimension)}" params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => "all", "dimensions" => [prefixed_dimension] } assert {:ok, query} = Query.parse_and_build(site, params, now: @now) assert_matches %Query{ metrics: [:visitors], utc_time_range: ^@date_range_day, filters: [], dimensions: [^prefixed_dimension], order_by: nil, timezone: ^site.timezone, include: ^@default_include, pagination: %{limit: 10_000, offset: 0} } = query end end test "time:minute dimension fails public schema validation", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => "all", "dimensions" => ["time:minute"] } assert {:error, %QueryError{message: error}} = Query.parse_and_build(site, params, now: @now) assert error == "#/dimensions/0: Invalid dimension \"time:minute\"" end test "custom properties dimension", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => "all", "dimensions" => ["event:props:foobar"] } assert {:ok, query} = Query.parse_and_build(site, params, now: @now) assert_matches %Query{ metrics: [:visitors], utc_time_range: ^@date_range_day, filters: [], dimensions: ["event:props:foobar"], order_by: nil, timezone: ^site.timezone, include: ^@default_include, pagination: %{limit: 10_000, offset: 0} } = query end test "invalid custom property dimension", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => "all", "dimensions" => ["event:props:"] } assert {:error, %QueryError{message: error}} = Query.parse_and_build(site, params, now: @now) assert error == "#/dimensions/0: Invalid dimension \"event:props:\"" end test "invalid dimension name passed", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => "all", "dimensions" => ["visitors"] } assert {:error, %QueryError{message: error}} = Query.parse_and_build(site, params, now: @now) assert error == "#/dimensions/0: Invalid dimension \"visitors\"" end test "invalid dimension", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => "all", "dimensions" => "foobar" } assert {:error, %QueryError{message: error}} = Query.parse_and_build(site, params, now: @now) assert error == "#/dimensions: Type mismatch. Expected Array but got String." end test "dimensions are not unique", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => "all", "dimensions" => ["event:name", "event:name"] } assert {:error, %QueryError{message: error}} = Query.parse_and_build(site, params, now: @now) assert error == "#/dimensions: Expected items to be unique but they were not." end end describe "order_by validation" do test "ordering by metric", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["visitors", "events"], "date_range" => "all", "order_by" => [["events", "desc"], ["visitors", "asc"]] } assert {:ok, query} = Query.parse_and_build(site, params, now: @now) assert_matches %Query{ metrics: [:visitors, :events], utc_time_range: ^@date_range_day, filters: [], dimensions: [], order_by: [{:events, :desc}, {:visitors, :asc}], timezone: ^site.timezone, include: ^@default_include, pagination: %{limit: 10_000, offset: 0} } = query end test "ordering by dimension", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => "all", "dimensions" => ["event:name"], "order_by" => [["event:name", "desc"]] } assert {:ok, query} = Query.parse_and_build(site, params, now: @now) assert_matches %Query{ metrics: [:visitors], utc_time_range: ^@date_range_day, filters: [], dimensions: ["event:name"], order_by: [{"event:name", :desc}], timezone: ^site.timezone, include: ^@default_include, pagination: %{limit: 10_000, offset: 0} } = query end test "ordering by invalid value", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => "all", "order_by" => [["visssss", "desc"]] } assert {:error, %QueryError{message: error}} = Query.parse_and_build(site, params, now: @now) assert error == "#/order_by/0/0: Invalid value in order_by \"visssss\"" end test "ordering by not queried metric", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => "all", "order_by" => [["events", "desc"]] } assert {:error, %QueryError{message: error}} = Query.parse_and_build(site, params, now: @now) assert error == "Invalid order_by entry '{:events, :desc}'. Entry is not a queried metric or dimension." end test "ordering by not queried dimension", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => "all", "order_by" => [["event:name", "desc"]] } assert {:error, %QueryError{message: error}} = Query.parse_and_build(site, params, now: @now) assert error == "Invalid order_by entry '{\"event:name\", :desc}'. Entry is not a queried metric or dimension." end end describe "custom props access" do test "filters - no access", %{site: site, user: user} do subscribe_to_enterprise_plan(user, features: [Plausible.Billing.Feature.StatsAPI]) params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => "all", "filters" => [["not", ["is", "event:props:foobar", ["foo"]]]] } assert {:error, %QueryError{message: error}} = Query.parse_and_build(site, params, now: @now) assert error == "The owner of this site does not have access to the custom properties feature." end test "dimensions - no access", %{site: site, user: user} do subscribe_to_enterprise_plan(user, features: [Plausible.Billing.Feature.StatsAPI]) params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => "all", "dimensions" => ["event:props:foobar"] } assert {:error, %QueryError{message: error}} = Query.parse_and_build(site, params, now: @now) assert error == "The owner of this site does not have access to the custom properties feature." end end describe "conversion_rate metric" do test "fails validation on its own", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["conversion_rate"], "date_range" => "all" } assert {:error, %QueryError{message: error}} = Query.parse_and_build(site, params, now: @now) assert error == "Metric `conversion_rate` can only be queried with event:goal filters or dimensions." end test "succeeds with event:goal filter", %{site: site} do insert(:goal, %{site: site, event_name: "Signup"}) insert(:goal, %{site: site, event_name: "Purchase", currency: "USD"}) params = %{ "site_id" => site.domain, "metrics" => ["conversion_rate"], "date_range" => "all", "filters" => [["is", "event:goal", ["Signup"]]] } assert {:ok, query} = Query.parse_and_build(site, params, now: @now) assert_matches %Query{ metrics: [:conversion_rate], utc_time_range: ^@date_range_day, filters: [[:is, "event:goal", ["Signup"]]], dimensions: [], order_by: nil, timezone: ^site.timezone, include: ^@default_include, pagination: %{limit: 10_000, offset: 0} } = query check_goals(query, preloaded_goals: %{ all: ["Purchase", "Signup"], matching_toplevel_filters: ["Signup"] }, revenue_currencies: %{} ) end test "succeeds with event:goal dimension", %{site: site} do insert(:goal, %{site: site, event_name: "Purchase", currency: "USD"}) insert(:goal, %{site: site, event_name: "Signup"}) params = %{ "site_id" => site.domain, "metrics" => ["conversion_rate"], "date_range" => "all", "dimensions" => ["event:goal"] } assert {:ok, query} = Query.parse_and_build(site, params, now: @now) assert_matches %Query{ metrics: [:conversion_rate], utc_time_range: ^@date_range_day, filters: [], dimensions: ["event:goal"], order_by: nil, timezone: ^site.timezone, include: ^@default_include, pagination: %{limit: 10_000, offset: 0} } = query check_goals(query, preloaded_goals: %{ all: ["Purchase", "Signup"], matching_toplevel_filters: ["Purchase", "Signup"] }, revenue_currencies: %{} ) end test "custom properties filter with special metric", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["conversion_rate", "group_conversion_rate"], "date_range" => "all", "filters" => [["is", "event:props:foo", ["bar"]]], "dimensions" => ["event:goal"] } assert {:ok, query} = Query.parse_and_build(site, params, now: @now) assert_matches %Query{ metrics: [:conversion_rate, :group_conversion_rate], utc_time_range: ^@date_range_day, filters: [ [:is, "event:props:foo", ["bar"]] ], dimensions: ["event:goal"], order_by: nil, timezone: ^site.timezone, include: ^@default_include, pagination: %{limit: 10_000, offset: 0} } = query end test "not top level custom properties filter with special metric is invalid", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["conversion_rate", "group_conversion_rate"], "date_range" => "all", "filters" => [["not", ["is", "event:props:foo", ["bar"]]]], "dimensions" => ["event:goal"] } assert {:error, %QueryError{message: error}} = Query.parse_and_build(site, params, now: @now) assert error == "Invalid filters. When `conversion_rate` or `group_conversion_rate` metrics are used, custom property filters can only be used on top level." end end describe "scroll_depth metric" do test "fails validation on its own", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["scroll_depth"], "date_range" => "all" } assert {:error, %QueryError{message: error}} = Query.parse_and_build(site, params, now: @now) assert error == "Metric `scroll_depth` can only be queried with event:page filters or dimensions." end test "fails with only a non-top-level event:page filter", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["scroll_depth"], "date_range" => "all", "filters" => [["not", ["is", "event:page", ["/"]]]] } assert {:error, %QueryError{message: error}} = Query.parse_and_build(site, params, now: @now) assert error == "Metric `scroll_depth` can only be queried with event:page filters or dimensions." end test "succeeds with top-level event:page filter", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["scroll_depth"], "date_range" => "all", "filters" => [["is", "event:page", ["/"]]] } assert {:ok, query} = Query.parse_and_build(site, params, now: @now) assert_matches %Query{ metrics: [:scroll_depth], utc_time_range: ^@date_range_day, filters: [[:is, "event:page", ["/"]]], dimensions: [], order_by: nil, timezone: ^site.timezone, include: ^@default_include, pagination: %{limit: 10_000, offset: 0} } = query end test "succeeds with event:page dimension", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["scroll_depth"], "date_range" => "all", "dimensions" => ["event:page"] } assert {:ok, query} = Query.parse_and_build(site, params, now: @now) assert_matches %Query{ metrics: [:scroll_depth], utc_time_range: ^@date_range_day, filters: [], dimensions: ["event:page"], order_by: nil, timezone: ^site.timezone, include: ^@default_include, pagination: %{limit: 10_000, offset: 0} } = query end end describe "views_per_visit metric" do test "succeeds with normal filters", %{site: site} do insert(:goal, %{site: site, event_name: "Signup"}) params = %{ "site_id" => site.domain, "metrics" => ["views_per_visit"], "date_range" => "all", "filters" => [["is", "event:goal", ["Signup"]]] } assert {:ok, query} = Query.parse_and_build(site, params, now: @now) assert_matches %Query{ metrics: [:views_per_visit], utc_time_range: ^@date_range_day, filters: [[:is, "event:goal", ["Signup"]]], dimensions: [], order_by: nil, timezone: ^site.timezone, include: ^@default_include, pagination: %{limit: 10_000, offset: 0} } = query check_goals(query, preloaded_goals: %{all: ["Signup"], matching_toplevel_filters: ["Signup"]}, revenue_currencies: %{} ) end test "fails validation if event:page filter specified", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["views_per_visit"], "date_range" => "all", "filters" => [["is", "event:page", ["/"]]] } assert {:error, %QueryError{message: error}} = Query.parse_and_build(site, params, now: @now) assert error == "Metric `views_per_visit` cannot be queried with a filter on `event:page`." end test "fails validation with non-time dimensions", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["views_per_visit"], "date_range" => "all", "dimensions" => ["event:name"] } assert {:error, %QueryError{message: error}} = Query.parse_and_build(site, params, now: @now) assert error == "Metric `views_per_visit` cannot be queried with non-time dimensions." end end describe "time_on_page metric" do test "fails validation on its own", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["time_on_page"], "date_range" => "all" } assert {:error, %QueryError{message: error}} = Query.parse_and_build(site, params, now: @now) assert error == "Metric `time_on_page` can only be queried with event:page filters or dimensions." end test "succeeds with event:page dimension", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["time_on_page"], "date_range" => "all", "dimensions" => ["time", "event:page"] } assert {:ok, query} = Query.parse_and_build(site, params, now: @now) assert_matches %Query{ metrics: [:time_on_page], utc_time_range: ^@date_range_day, filters: [], dimensions: ["time", "event:page"], order_by: nil, timezone: ^site.timezone, include: ^@default_include, pagination: %{limit: 10_000, offset: 0} } = query end test "succeeds with event:page filter", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["time_on_page"], "date_range" => "all", "filters" => [["is", "event:page", ["/"]]] } assert {:ok, query} = Query.parse_and_build(site, params, now: @now) assert_matches %Query{ metrics: [:time_on_page], utc_time_range: ^@date_range_day, filters: [[:is, "event:page", ["/"]]], dimensions: [], order_by: nil, timezone: ^site.timezone, include: ^@default_include, pagination: %{limit: 10_000, offset: 0} } = query end test "fails when using only a behavioral filter", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["time_on_page"], "date_range" => "all", "filters" => [ ["has_done", ["is", "event:page", ["/"]]] ] } assert {:error, %QueryError{message: error}} = Query.parse_and_build(site, params, now: @now) assert error == "Metric `time_on_page` can only be queried with event:page filters or dimensions." end end describe "revenue metrics" do @describetag :ee_only setup %{user: user} do subscribe_to_enterprise_plan(user, features: [Plausible.Billing.Feature.RevenueGoals]) :ok end test "can request", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["total_revenue", "average_revenue"], "date_range" => "all" } assert {:ok, query} = Query.parse_and_build(site, params, now: @now) assert_matches %Query{ metrics: [:total_revenue, :average_revenue], utc_time_range: ^@date_range_day, filters: [], dimensions: [], order_by: nil, timezone: ^site.timezone, include: ^@default_include, pagination: %{limit: 10_000, offset: 0} } = query check_goals(query, preloaded_goals: %{ all: [], matching_toplevel_filters: [] }, revenue_warning: :no_revenue_goals_matching, revenue_currencies: %{} ) end test "no access" do user = new_user() site = new_site(owner: user) subscribe_to_enterprise_plan(user, features: [Plausible.Billing.Feature.StatsAPI]) params = %{ "site_id" => site.domain, "metrics" => ["total_revenue", "average_revenue"], "date_range" => "all" } assert {:error, %QueryError{message: error}} = Query.parse_and_build(site, params, now: @now) assert error == "The owner of this site does not have access to the revenue metrics feature." end test "with event:goal filters with same currency", %{site: site} do insert(:goal, site: site, event_name: "Purchase", currency: "USD", display_name: "PurchaseUSD" ) insert(:goal, site: site, event_name: "Subscription", currency: "USD") insert(:goal, site: site, event_name: "Signup") insert(:goal, site: site, event_name: "Logout") params = %{ "site_id" => site.domain, "metrics" => ["total_revenue", "average_revenue"], "date_range" => "all", "filters" => [["is", "event:goal", ["PurchaseUSD", "Signup", "Subscription"]]] } assert {:ok, query} = Query.parse_and_build(site, params, now: @now) assert_matches %Query{ metrics: [:total_revenue, :average_revenue], utc_time_range: ^@date_range_day, filters: [[:is, "event:goal", ["PurchaseUSD", "Signup", "Subscription"]]], dimensions: [], order_by: nil, timezone: ^site.timezone, include: ^@default_include, pagination: %{limit: 10_000, offset: 0} } = query check_goals(query, preloaded_goals: %{ all: ["PurchaseUSD", "Signup", "Subscription", "Logout"], matching_toplevel_filters: ["PurchaseUSD", "Signup", "Subscription"] }, revenue_warning: nil, revenue_currencies: %{default: :USD} ) end test "with event:goal filters with different currencies", %{site: site} do insert(:goal, site: site, event_name: "Purchase", currency: "USD") insert(:goal, site: site, event_name: "Subscription", currency: "EUR") insert(:goal, site: site, event_name: "Signup") params = %{ "site_id" => site.domain, "metrics" => ["total_revenue", "average_revenue"], "date_range" => "all", "filters" => [["is", "event:goal", ["Purchase", "Signup", "Subscription"]]] } assert {:ok, query} = Query.parse_and_build(site, params, now: @now) assert_matches %Query{ metrics: [:total_revenue, :average_revenue], utc_time_range: ^@date_range_day, filters: [[:is, "event:goal", ["Purchase", "Signup", "Subscription"]]], dimensions: [], order_by: nil, timezone: ^site.timezone, include: ^@default_include, pagination: %{limit: 10_000, offset: 0} } = query check_goals(query, preloaded_goals: %{ all: ["Purchase", "Signup", "Subscription"], matching_toplevel_filters: ["Purchase", "Signup", "Subscription"] }, revenue_warning: :no_single_revenue_currency, revenue_currencies: %{} ) end test "with event:goal filters with no revenue currencies", %{site: site} do insert(:goal, site: site, event_name: "Purchase", currency: "USD") insert(:goal, site: site, event_name: "Subscription", currency: "EUR") insert(:goal, site: site, event_name: "Signup") params = %{ "site_id" => site.domain, "metrics" => ["total_revenue", "average_revenue"], "date_range" => "all", "filters" => [["is", "event:goal", ["Signup"]]] } assert {:ok, query} = Query.parse_and_build(site, params, now: @now) assert_matches %Query{ metrics: [:total_revenue, :average_revenue], utc_time_range: ^@date_range_day, filters: [[:is, "event:goal", ["Signup"]]], dimensions: [], order_by: nil, timezone: ^site.timezone, include: ^@default_include, pagination: %{limit: 10_000, offset: 0} } = query check_goals(query, preloaded_goals: %{ all: ["Purchase", "Subscription", "Signup"], matching_toplevel_filters: ["Signup"] }, revenue_warning: :no_revenue_goals_matching, revenue_currencies: %{} ) end test "with event:goal dimension, different currencies", %{site: site} do insert(:goal, site: site, event_name: "Purchase", currency: "USD") insert(:goal, site: site, event_name: "Donation", currency: "EUR") insert(:goal, site: site, event_name: "Signup") params = %{ "site_id" => site.domain, "metrics" => ["total_revenue", "average_revenue"], "date_range" => "all", "dimensions" => ["event:goal"] } assert {:ok, query} = Query.parse_and_build(site, params, now: @now) assert_matches %Query{ metrics: [:total_revenue, :average_revenue], utc_time_range: ^@date_range_day, filters: [], dimensions: ["event:goal"], order_by: nil, timezone: ^site.timezone, include: ^@default_include, pagination: %{limit: 10_000, offset: 0} } = query check_goals(query, preloaded_goals: %{ all: ["Donation", "Purchase", "Signup"], matching_toplevel_filters: ["Donation", "Purchase", "Signup"] }, revenue_warning: nil, revenue_currencies: %{"Donation" => :EUR, "Purchase" => :USD} ) end test "with event:goal dimension and filters", %{site: site} do insert(:goal, site: site, event_name: "Purchase", currency: "USD") insert(:goal, site: site, event_name: "Subscription", currency: "EUR") insert(:goal, site: site, event_name: "Signup") insert(:goal, site: site, event_name: "Logout") params = %{ "site_id" => site.domain, "metrics" => ["total_revenue", "average_revenue"], "date_range" => "all", "dimensions" => ["event:goal"], "filters" => [["is", "event:goal", ["Purchase", "Signup", "Subscription"]]] } assert {:ok, query} = Query.parse_and_build(site, params, now: @now) assert_matches %Query{ metrics: [:total_revenue, :average_revenue], utc_time_range: ^@date_range_day, filters: [[:is, "event:goal", ["Purchase", "Signup", "Subscription"]]], dimensions: ["event:goal"], order_by: nil, timezone: ^site.timezone, include: ^@default_include, pagination: %{limit: 10_000, offset: 0} } = query check_goals(query, preloaded_goals: %{ all: ["Logout", "Purchase", "Signup", "Subscription"], matching_toplevel_filters: ["Purchase", "Signup", "Subscription"] }, revenue_warning: nil, revenue_currencies: %{"Purchase" => :USD, "Subscription" => :EUR} ) end test "with event:goal dimension and filters with no revenue goals matching", %{ site: site } do insert(:goal, site: site, event_name: "Purchase", currency: "USD") insert(:goal, site: site, event_name: "Subscription", currency: "USD") insert(:goal, site: site, event_name: "Signup") insert(:goal, site: site, event_name: "Logout") params = %{ "site_id" => site.domain, "metrics" => ["total_revenue", "average_revenue"], "date_range" => "all", "dimensions" => ["event:goal"], "filters" => [["is", "event:goal", ["Signup"]]] } assert {:ok, query} = Query.parse_and_build(site, params, now: @now) assert_matches %Query{ metrics: [:total_revenue, :average_revenue], utc_time_range: ^@date_range_day, filters: [[:is, "event:goal", ["Signup"]]], dimensions: ["event:goal"], order_by: nil, timezone: ^site.timezone, include: ^@default_include, pagination: %{limit: 10_000, offset: 0} } = query check_goals(query, preloaded_goals: %{ all: ["Logout", "Signup", "Subscription", "Purchase"], matching_toplevel_filters: ["Signup"] }, revenue_warning: :no_revenue_goals_matching, revenue_currencies: %{} ) end end @tag :ce_build_only test "revenue metrics are not available on CE", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["total_revenue", "average_revenue"], "date_range" => "all" } assert {:error, %QueryError{message: error}} = Query.parse_and_build(site, params, now: @now) assert error == "#/metrics/0: Invalid metric \"total_revenue\"\n#/metrics/1: Invalid metric \"average_revenue\"" end describe "session metrics" do test "single session metric succeeds", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["bounce_rate"], "date_range" => "all", "dimensions" => ["visit:device"] } assert {:ok, query} = Query.parse_and_build(site, params, now: @now) assert_matches %Query{ metrics: [:bounce_rate], utc_time_range: ^@date_range_day, filters: [], dimensions: ["visit:device"], order_by: nil, timezone: ^site.timezone, include: ^@default_include, pagination: %{limit: 10_000, offset: 0} } = query end test "fails if using session metric with event dimension", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["bounce_rate"], "date_range" => "all", "dimensions" => ["event:props:foo"] } assert {:error, %QueryError{message: error}} = Query.parse_and_build(site, params, now: @now) assert error == "Session metric(s) `bounce_rate` cannot be queried along with event dimension(s) `event:props:foo`" end test "fails if using event metric with session-only dimension", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["events"], "date_range" => "all", "dimensions" => ["visit:exit_page"] } assert {:error, %QueryError{message: error}} = Query.parse_and_build(site, params, now: @now) assert error == "Event metric(s) `events` cannot be queried along with session dimension(s) `visit:exit_page`" end test "does not fail if using session metric with event:page dimension", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["bounce_rate"], "date_range" => "all", "dimensions" => ["event:page"] } assert {:ok, query} = Query.parse_and_build(site, params, now: @now) assert_matches %Query{ metrics: [:bounce_rate], utc_time_range: ^@date_range_day, filters: [], dimensions: ["event:page"], order_by: nil, timezone: ^site.timezone, include: ^@default_include, pagination: %{limit: 10_000, offset: 0} } = query end test "does not fail if using session metric with event filter", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["bounce_rate"], "date_range" => "all", "filters" => [["is", "event:props:foo", ["(none)"]]] } assert {:ok, query} = Query.parse_and_build(site, params, now: @now) assert_matches %Query{ metrics: [:bounce_rate], utc_time_range: ^@date_range_day, filters: [[:is, "event:props:foo", ["(none)"]]], dimensions: [], order_by: nil, timezone: ^site.timezone, include: ^@default_include, pagination: %{limit: 10_000, offset: 0} } = query end end describe "filtering with segments" do test "parsing fails when too many segments in query", %{ user: user, site: site } do segments = insert_list(11, :segment, type: :site, owner: user, site: site, name: "any" ) params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => "all", "filters" => [ ["and", segments |> Enum.map(fn segment -> ["is", "segment", [segment.id]] end)] ] } assert {:error, %QueryError{message: error}} = Query.parse_and_build(site, params, now: @now) assert error == "Invalid filters. You can only use up to 10 segment filters in a query." end test "parsing fails when segment filter is used, but segment is from another site", %{ site: site } do other_user = new_user() other_site = new_site(owner: other_user) segment = insert(:segment, type: :site, owner: other_user, site: other_site, name: "any" ) params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => "all", "filters" => [["is", "segment", [segment.id]]] } assert {:error, %QueryError{message: error}} = Query.parse_and_build(site, params, now: @now) assert error == "Invalid filters. Some segments don't exist or aren't accessible." end test "hiding custom properties filters in segments doesn't allow bypasssing feature check", %{ site: site, user: user } do subscribe_to_enterprise_plan(user, features: [Plausible.Billing.Feature.StatsAPI]) segment = insert(:segment, type: :site, owner: user, site: site, name: "segment with custom props filter", segment_data: %{"filters" => [["is", "event:props:foobar", ["foo"]]]} ) params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => "all", "filters" => [["is", "segment", [segment.id]]] } assert {:error, %QueryError{message: error}} = Query.parse_and_build(site, params, now: @now) assert error == "The owner of this site does not have access to the custom properties feature." end test "querying conversion rate is illegal if the complex event:goal filter is within a segment", %{ site: site, user: user } do segment = insert(:segment, type: :site, owner: user, site: site, name: "any", segment_data: %{ "filters" => [ [ "or", [ ["is", "event:goal", ["Signup"]], ["contains", "event:page", ["/"]] ] ] ] } ) params = %{ "site_id" => site.domain, "metrics" => ["visitors", "conversion_rate"], "date_range" => "all", "filters" => [["is", "segment", [segment.id]]] } assert {:error, %QueryError{message: error}} = Query.parse_and_build(site, params, now: @now) assert error == "Invalid filters. Dimension `event:goal` can only be filtered at the top level." end test "resolves segments correctly", %{site: site, user: user} do emea_segment = insert(:segment, type: :site, owner: user, site: site, name: "EMEA", segment_data: %{ "filters" => [["is", "visit:country", ["FR", "DE"]]], "labels" => %{"FR" => "France", "DE" => "Germany"} } ) apac_segment = insert(:segment, type: :site, owner: user, site: site, name: "APAC", segment_data: %{ "filters" => [["is", "visit:country", ["AU", "NZ"]]], "labels" => %{"AU" => "Australia", "NZ" => "New Zealand"} } ) firefox_segment = insert(:segment, type: :site, owner: user, site: site, name: "APAC", segment_data: %{ "filters" => [ ["is", "visit:browser", ["Firefox"]], ["is", "visit:os", ["Linux"]] ] } ) params = %{ "site_id" => site.domain, "metrics" => ["visitors", "events"], "date_range" => "all", "filters" => [ [ "and", [ ["is", "segment", [apac_segment.id, emea_segment.id]], ["is", "segment", [firefox_segment.id]] ] ] ] } assert {:ok, query} = Query.parse_and_build(site, params, now: @now) assert_matches %Query{ metrics: [:visitors, :events], utc_time_range: ^@date_range_day, filters: [ [ :or, [ [:and, [[:is, "visit:country", ["AU", "NZ"]]]], [:and, [[:is, "visit:country", ["FR", "DE"]]]] ] ], [ :and, [ [:is, "visit:browser", ["Firefox"]], [:is, "visit:os", ["Linux"]] ] ] ], dimensions: [], order_by: nil, timezone: ^site.timezone, include: ^@default_include, pagination: %{limit: 10_000, offset: 0} } = query end test "resolves segments containing otherwise internal features", %{site: site, user: user} do insert(:goal, %{site: site, event_name: "Signup"}) segment_from_dashboard = insert(:segment, name: "A segment that contains internal features", type: :site, owner: user, site: site, segment_data: %{ "filters" => [["matches_wildcard", "event:page", ["/blog/**"]]] } ) params = %{ "site_id" => site.domain, "metrics" => ["visitors", "events"], "date_range" => "all", "filters" => [ ["is", "segment", [segment_from_dashboard.id]] ] } assert {:ok, query} = Query.parse_and_build(site, params, now: @now) assert_matches %Query{ metrics: [:visitors, :events], utc_time_range: ^@date_range_day, filters: [ [:matches_wildcard, "event:page", ["/blog/**"]] ], dimensions: [], order_by: nil, timezone: ^site.timezone, include: ^@default_include, pagination: %{limit: 10_000, offset: 0} } = query end test "validation fails with string segment ids", %{site: site} do params = %{ "site_id" => site.domain, "metrics" => ["visitors"], "date_range" => "all", "filters" => [["is", "segment", ["123"]]] } assert {:error, %QueryError{message: error}} = Query.parse_and_build(site, params, now: @now) assert error == "Invalid filter '[\"is\", \"segment\", [\"123\"]]'." end end end