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