2741 lines
80 KiB
Elixir
2741 lines
80 KiB
Elixir
defmodule Plausible.Stats.Filters.QueryParserTest do
|
|
use Plausible
|
|
use Plausible.DataCase
|
|
use Plausible.Teams.Test
|
|
import Plausible.Stats.Filters.QueryParser
|
|
doctest Plausible.Stats.Filters.QueryParser
|
|
|
|
alias Plausible.Stats.DateTimeRange
|
|
alias Plausible.Stats.Filters
|
|
|
|
setup [:create_user, :create_site]
|
|
|
|
@now DateTime.new!(~D[2021-05-05], ~T[12:30:00], "Etc/UTC")
|
|
@date_range_realtime %DateTimeRange{
|
|
first: DateTime.new!(~D[2021-05-05], ~T[12:25:00], "Etc/UTC"),
|
|
last: DateTime.new!(~D[2021-05-05], ~T[12:30:05], "Etc/UTC")
|
|
}
|
|
@date_range_30m %DateTimeRange{
|
|
first: DateTime.new!(~D[2021-05-05], ~T[12:00:00], "Etc/UTC"),
|
|
last: DateTime.new!(~D[2021-05-05], ~T[12:30:05], "Etc/UTC")
|
|
}
|
|
@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 %{
|
|
imports: false,
|
|
imports_meta: false,
|
|
time_labels: false,
|
|
total_rows: false,
|
|
comparisons: nil,
|
|
legacy_time_on_page_cutoff: nil,
|
|
trim_relative_date_range: false
|
|
}
|
|
|
|
def check_success(params, site, expected_result, schema_type \\ :public) do
|
|
assert {:ok, result} = parse(site, schema_type, params, @now)
|
|
|
|
return_value = Map.take(result, [:preloaded_goals, :revenue_warning, :revenue_currencies])
|
|
|
|
result =
|
|
Map.drop(result, [
|
|
:now,
|
|
:input_date_range,
|
|
:preloaded_goals,
|
|
:revenue_warning,
|
|
:revenue_currencies,
|
|
:consolidated_site_ids
|
|
])
|
|
|
|
assert result == expected_result
|
|
|
|
return_value
|
|
end
|
|
|
|
def check_error(params, site, expected_error_message, schema_type \\ :public) do
|
|
{:error, message} = parse(site, schema_type, params, @now)
|
|
assert message == expected_error_message
|
|
end
|
|
|
|
def check_date_range(date_params, site, expected_date_range, schema_type \\ :public) do
|
|
params =
|
|
%{"site_id" => site.domain, "metrics" => ["visitors", "events"]}
|
|
|> Map.merge(date_params)
|
|
|
|
expected_parsed =
|
|
%{
|
|
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}
|
|
}
|
|
|
|
check_success(params, site, expected_parsed, schema_type)
|
|
end
|
|
|
|
def check_goals(actual, opts) do
|
|
assert goal_names(actual[:preloaded_goals][:all]) ==
|
|
Enum.sort(Keyword.get(opts, :preloaded_goals)[:all])
|
|
|
|
assert goal_names(actual[:preloaded_goals][:matching_toplevel_filters]) ==
|
|
Enum.sort(Keyword.get(opts, :preloaded_goals)[:matching_toplevel_filters])
|
|
|
|
assert actual[:revenue_warning] == Keyword.get(opts, :revenue_warning)
|
|
assert actual[:revenue_currencies] == Keyword.get(opts, :revenue_currencies)
|
|
end
|
|
|
|
defp goal_names(goals), do: Enum.map(goals, & &1.display_name) |> Enum.sort()
|
|
|
|
test "parsing empty map fails", %{site: site} do
|
|
%{}
|
|
|> check_error(site, "#: Required properties site_id, metrics, date_range were not present.")
|
|
end
|
|
|
|
describe "metrics validation" do
|
|
test "valid metrics passed", %{site: site} do
|
|
%{"site_id" => site.domain, "metrics" => ["visitors", "events"], "date_range" => "all"}
|
|
|> check_success(site, %{
|
|
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}
|
|
})
|
|
end
|
|
|
|
test "invalid metric passed", %{site: site} do
|
|
%{"site_id" => site.domain, "metrics" => ["visitors", "event:name"], "date_range" => "all"}
|
|
|> check_error(site, "#/metrics/1: Invalid metric \"event:name\"")
|
|
end
|
|
|
|
test "fuller list of metrics", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => [
|
|
"visitors",
|
|
"pageviews",
|
|
"visits",
|
|
"events",
|
|
"bounce_rate",
|
|
"visit_duration"
|
|
],
|
|
"date_range" => "all"
|
|
}
|
|
|> check_success(
|
|
site,
|
|
%{
|
|
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}
|
|
},
|
|
:internal
|
|
)
|
|
end
|
|
|
|
test "same metric queried multiple times", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["events", "visitors", "visitors"],
|
|
"date_range" => "all"
|
|
}
|
|
|> check_error(site, "#/metrics: Expected items to be unique but they were not.")
|
|
end
|
|
|
|
test "no metrics passed", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => [],
|
|
"date_range" => "all"
|
|
}
|
|
|> check_error(site, "#/metrics: Expected a minimum of 1 items but got 0.")
|
|
end
|
|
end
|
|
|
|
describe "filters validation" do
|
|
for operation <- [
|
|
:is,
|
|
:is_not,
|
|
:matches_wildcard,
|
|
:matches_wildcard_not,
|
|
:matches,
|
|
:matches_not,
|
|
:contains,
|
|
:contains_not
|
|
] do
|
|
test "#{operation} filter", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"filters" => [
|
|
[Atom.to_string(unquote(operation)), "event:name", ["foo"]]
|
|
]
|
|
}
|
|
|> check_success(
|
|
site,
|
|
%{
|
|
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}
|
|
},
|
|
:internal
|
|
)
|
|
end
|
|
|
|
test "#{operation} filter with invalid clause", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"filters" => [
|
|
[Atom.to_string(unquote(operation)), "event:name", "foo"]
|
|
]
|
|
}
|
|
|> check_error(
|
|
site,
|
|
"#/filters/0: Invalid filter [\"#{unquote(operation)}\", \"event:name\", \"foo\"]",
|
|
:internal
|
|
)
|
|
end
|
|
end
|
|
|
|
for operation <- [:matches_wildcard, :matches_wildcard_not] do
|
|
test "#{operation} is not a valid filter operation in public API", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"filters" => [
|
|
[Atom.to_string(unquote(operation)), "event:name", ["foo"]]
|
|
]
|
|
}
|
|
|> check_error(
|
|
site,
|
|
"#/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
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"filters" => [
|
|
unquote(too_short_filter)
|
|
]
|
|
}
|
|
|> check_error(
|
|
site,
|
|
~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
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"filters" => [
|
|
unquote(too_long_filter)
|
|
]
|
|
}
|
|
|> check_error(
|
|
site,
|
|
~s(#/filters/0: Invalid filter #{inspect(unquote(too_long_filter))})
|
|
)
|
|
end
|
|
end
|
|
|
|
test "filtering by invalid operation", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"filters" => [
|
|
["exists?", "event:name", ["foo"]]
|
|
]
|
|
}
|
|
|> check_error(site, "#/filters/0: Invalid filter [\"exists?\", \"event:name\", [\"foo\"]]")
|
|
end
|
|
|
|
test "filtering by custom properties", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"filters" => [
|
|
["is", "event:props:foobar", ["value"]]
|
|
]
|
|
}
|
|
|> check_success(site, %{
|
|
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}
|
|
})
|
|
end
|
|
|
|
for dimension <- Filters.event_props() do
|
|
if dimension != "goal" do
|
|
test "filtering by event:#{dimension} filter", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"filters" => [
|
|
["is", "event:#{unquote(dimension)}", ["foo"]]
|
|
]
|
|
}
|
|
|> check_success(site, %{
|
|
metrics: [:visitors],
|
|
utc_time_range: @date_range_day,
|
|
filters: [
|
|
[:is, "event:#{unquote(dimension)}", ["foo"]]
|
|
],
|
|
dimensions: [],
|
|
order_by: nil,
|
|
timezone: site.timezone,
|
|
include: @default_include,
|
|
pagination: %{limit: 10_000, offset: 0}
|
|
})
|
|
end
|
|
end
|
|
end
|
|
|
|
for dimension <- Filters.visit_props() do
|
|
test "filtering by visit:#{dimension} filter", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"filters" => [
|
|
["is", "visit:#{unquote(dimension)}", ["ab"]]
|
|
]
|
|
}
|
|
|> check_success(site, %{
|
|
metrics: [:visitors],
|
|
utc_time_range: @date_range_day,
|
|
filters: [
|
|
[:is, "visit:#{unquote(dimension)}", ["ab"]]
|
|
],
|
|
dimensions: [],
|
|
order_by: nil,
|
|
timezone: site.timezone,
|
|
include: @default_include,
|
|
pagination: %{limit: 10_000, offset: 0}
|
|
})
|
|
end
|
|
end
|
|
|
|
test "invalid event filter", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"filters" => [
|
|
["is", "event:device", ["foo"]]
|
|
]
|
|
}
|
|
|> check_error(site, "#/filters/0: Invalid filter [\"is\", \"event:device\", [\"foo\"]]")
|
|
end
|
|
|
|
test "invalid visit filter", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"filters" => [
|
|
["is", "visit:name", ["foo"]]
|
|
]
|
|
}
|
|
|> check_error(site, "#/filters/0: Invalid filter [\"is\", \"visit:name\", [\"foo\"]]")
|
|
end
|
|
|
|
test "invalid filter", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"filters" => "foobar"
|
|
}
|
|
|> check_error(site, "#/filters: Type mismatch. Expected Array but got String.")
|
|
end
|
|
|
|
test "numeric filter is invalid", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"filters" => [["is", "visit:os_version", [123]]]
|
|
}
|
|
|> check_error(site, "Invalid filter '[\"is\", \"visit:os_version\", [123]]'.")
|
|
end
|
|
|
|
test "numbers and strings are valid for visit:city", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"filters" => [["is", "visit:city", [123, 456]]]
|
|
}
|
|
|> check_success(site, %{
|
|
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}
|
|
})
|
|
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"filters" => [["is", "visit:city", ["123", "456"]]]
|
|
}
|
|
|> check_success(site, %{
|
|
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}
|
|
})
|
|
end
|
|
|
|
test "invalid visit:country filter", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"filters" => [["is", "visit:country", ["USA"]]]
|
|
}
|
|
|> check_error(
|
|
site,
|
|
"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
|
|
%{
|
|
"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"]]]
|
|
]
|
|
]
|
|
]
|
|
}
|
|
|> check_success(site, %{
|
|
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}
|
|
})
|
|
end
|
|
|
|
test "valid has_done and has_not_done filters", %{site: site} do
|
|
insert(:goal, %{site: site, event_name: "Signup"})
|
|
|
|
%{
|
|
"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"]]
|
|
]
|
|
]
|
|
]
|
|
]
|
|
}
|
|
|> check_success(
|
|
site,
|
|
%{
|
|
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}
|
|
}
|
|
)
|
|
end
|
|
|
|
test "fails when using visit filters within has_done filters", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"filters" => [
|
|
["has_done", ["is", "visit:browser", ["Chrome"]]]
|
|
]
|
|
}
|
|
|> check_error(
|
|
site,
|
|
"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
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"filters" => [
|
|
["has_done", ["has_not_done", ["is", "visit:browser", ["Chrome"]]]]
|
|
]
|
|
}
|
|
|> check_error(
|
|
site,
|
|
"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
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"filters" => [[unquote(operator), []]]
|
|
}
|
|
|> check_error(
|
|
site,
|
|
"#/filters/0: Invalid filter [\"#{unquote(operator)}\", []]",
|
|
:internal
|
|
)
|
|
end
|
|
end
|
|
|
|
test "event:hostname filter", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"filters" => [["is", "event:hostname", ["a.plausible.io"]]]
|
|
}
|
|
|> check_success(site, %{
|
|
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}
|
|
})
|
|
end
|
|
|
|
test "event:hostname filter not at top level is invalid", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"filters" => [["not", ["is", "event:hostname", ["a.plausible.io"]]]]
|
|
}
|
|
|> check_error(
|
|
site,
|
|
"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
|
|
%{
|
|
"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}
|
|
]
|
|
]
|
|
}
|
|
|> check_success(site, %{
|
|
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}
|
|
})
|
|
end
|
|
end
|
|
|
|
for operation <- [:matches, :matches_not, :matches_wildcard, :matches_wildcard_not] do
|
|
test "case_sensitive modifier is not valid for #{operation}", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"filters" => [
|
|
[
|
|
Atom.to_string(unquote(operation)),
|
|
"event:hostname",
|
|
["a.plausible.io"],
|
|
%{"case_sensitive" => false}
|
|
]
|
|
]
|
|
}
|
|
|> check_error(
|
|
site,
|
|
"#/filters/0: Invalid filter [\"#{unquote(operation)}\", \"event:hostname\", [\"a.plausible.io\"], %{\"case_sensitive\" => false}]",
|
|
:internal
|
|
)
|
|
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
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"filters" => [["is", "event:goal", ["Signup", "Purchase"]]]
|
|
}
|
|
|> check_success(site, %{
|
|
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}
|
|
})
|
|
|> check_goals(
|
|
preloaded_goals: %{
|
|
all: ["Contact", "Purchase", "Signup"],
|
|
matching_toplevel_filters: ["Purchase", "Signup"]
|
|
},
|
|
revenue_currencies: %{}
|
|
)
|
|
end
|
|
|
|
test "with case insensitive match", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"filters" => [["is", "event:goal", ["signup", "purchase"], %{"case_sensitive" => false}]]
|
|
}
|
|
|> check_success(site, %{
|
|
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}
|
|
})
|
|
|> check_goals(
|
|
preloaded_goals: %{
|
|
all: ["Contact", "Purchase", "Signup"],
|
|
matching_toplevel_filters: ["Purchase", "Signup"]
|
|
},
|
|
revenue_currencies: %{}
|
|
)
|
|
end
|
|
|
|
test "with contains match", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"filters" => [["contains", "event:goal", ["Sign", "pur"]]]
|
|
}
|
|
|> check_success(site, %{
|
|
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}
|
|
})
|
|
|> check_goals(
|
|
preloaded_goals: %{
|
|
all: ["Contact", "Purchase", "Signup"],
|
|
matching_toplevel_filters: ["Signup"]
|
|
},
|
|
revenue_currencies: %{}
|
|
)
|
|
end
|
|
|
|
test "with case insensitive contains match", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"filters" => [["contains", "event:goal", ["sign", "CONT"], %{"case_sensitive" => false}]]
|
|
}
|
|
|> check_success(site, %{
|
|
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}
|
|
})
|
|
|> check_goals(
|
|
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
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"dimensions" => ["time"],
|
|
"include" => %{"imports" => true, "time_labels" => true, "total_rows" => true}
|
|
}
|
|
|> check_success(site, %{
|
|
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,
|
|
comparisons: nil,
|
|
legacy_time_on_page_cutoff: nil,
|
|
trim_relative_date_range: false
|
|
},
|
|
pagination: %{limit: 10_000, offset: 0}
|
|
})
|
|
end
|
|
|
|
test "setting invalid imports value", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"include" => "foobar"
|
|
}
|
|
|> check_error(site, "#/include: Type mismatch. Expected Object but got String.")
|
|
end
|
|
|
|
test "setting include.time_labels without time dimension", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"include" => %{"time_labels" => true}
|
|
}
|
|
|> check_error(site, "Invalid include.time_labels: requires a time dimension.")
|
|
end
|
|
end
|
|
|
|
describe "include.comparisons" do
|
|
test "not allowed in public API", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"include" => %{"comparisons" => %{"mode" => "previous_period"}}
|
|
}
|
|
|> check_error(
|
|
site,
|
|
"#/include/comparisons: Schema does not allow additional properties."
|
|
)
|
|
end
|
|
|
|
test "mode=previous_period", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"include" => %{"comparisons" => %{"mode" => "previous_period"}}
|
|
}
|
|
|> check_success(
|
|
site,
|
|
%{
|
|
metrics: [:visitors],
|
|
utc_time_range: @date_range_day,
|
|
filters: [],
|
|
dimensions: [],
|
|
order_by: nil,
|
|
timezone: site.timezone,
|
|
include: %{
|
|
comparisons: %{
|
|
mode: "previous_period"
|
|
},
|
|
imports: false,
|
|
imports_meta: false,
|
|
time_labels: false,
|
|
total_rows: false,
|
|
legacy_time_on_page_cutoff: nil,
|
|
trim_relative_date_range: false
|
|
},
|
|
pagination: %{limit: 10_000, offset: 0}
|
|
},
|
|
:internal
|
|
)
|
|
end
|
|
|
|
test "mode=year_over_year", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"include" => %{"comparisons" => %{"mode" => "year_over_year"}}
|
|
}
|
|
|> check_success(
|
|
site,
|
|
%{
|
|
metrics: [:visitors],
|
|
utc_time_range: @date_range_day,
|
|
filters: [],
|
|
dimensions: [],
|
|
order_by: nil,
|
|
timezone: site.timezone,
|
|
include: %{
|
|
comparisons: %{
|
|
mode: "year_over_year"
|
|
},
|
|
imports: false,
|
|
imports_meta: false,
|
|
time_labels: false,
|
|
total_rows: false,
|
|
legacy_time_on_page_cutoff: nil,
|
|
trim_relative_date_range: false
|
|
},
|
|
pagination: %{limit: 10_000, offset: 0}
|
|
},
|
|
:internal
|
|
)
|
|
end
|
|
|
|
test "mode=custom", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"include" => %{
|
|
"comparisons" => %{"mode" => "custom", "date_range" => ["2021-04-05", "2021-05-04"]}
|
|
}
|
|
}
|
|
|> check_success(
|
|
site,
|
|
%{
|
|
metrics: [:visitors],
|
|
utc_time_range: @date_range_day,
|
|
filters: [],
|
|
dimensions: [],
|
|
order_by: nil,
|
|
timezone: site.timezone,
|
|
include: %{
|
|
comparisons: %{
|
|
mode: "custom",
|
|
date_range: @date_range_30d
|
|
},
|
|
imports_meta: false,
|
|
imports: false,
|
|
time_labels: false,
|
|
total_rows: false,
|
|
legacy_time_on_page_cutoff: nil,
|
|
trim_relative_date_range: false
|
|
},
|
|
pagination: %{limit: 10_000, offset: 0}
|
|
},
|
|
:internal
|
|
)
|
|
end
|
|
|
|
test "mode=custom without date_range is invalid", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"include" => %{"comparisons" => %{"mode" => "custom"}}
|
|
}
|
|
|> check_error(
|
|
site,
|
|
"#/include/comparisons: Expected exactly one of the schemata to match, but none of them did.",
|
|
:internal
|
|
)
|
|
end
|
|
|
|
test "mode=previous_period with date_range is invalid", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"include" => %{
|
|
"comparisons" => %{
|
|
"mode" => "previous_period",
|
|
"date_range" => ["2024-01-01", "2024-01-31"]
|
|
}
|
|
}
|
|
}
|
|
|> check_error(
|
|
site,
|
|
"#/include/comparisons: Expected exactly one of the schemata to match, but none of them did.",
|
|
:internal
|
|
)
|
|
end
|
|
end
|
|
|
|
describe "pagination validation" do
|
|
test "setting pagination values", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"dimensions" => ["time"],
|
|
"pagination" => %{"limit" => 100, "offset" => 200}
|
|
}
|
|
|> check_success(site, %{
|
|
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}
|
|
})
|
|
end
|
|
|
|
test "out of range limit value", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"pagination" => %{"limit" => 100_000}
|
|
}
|
|
|> check_error(site, "#/pagination/limit: Expected the value to be <= 10000")
|
|
end
|
|
|
|
test "out of range offset value", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"pagination" => %{"offset" => -5}
|
|
}
|
|
|> check_error(site, "#/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"})
|
|
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"filters" => [
|
|
["is", "event:goal", ["Signup", "Visit /thank-you"]]
|
|
]
|
|
}
|
|
|> check_success(
|
|
site,
|
|
%{
|
|
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}
|
|
}
|
|
)
|
|
|> check_goals(
|
|
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
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"filters" => [
|
|
["is", "event:goal", ["Signup"]]
|
|
]
|
|
}
|
|
|> check_error(
|
|
site,
|
|
"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
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"filters" => [
|
|
["is", "event:goal", ["Visit /thank-you"]]
|
|
]
|
|
}
|
|
|> check_error(
|
|
site,
|
|
"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
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"filters" => [
|
|
["is_not", "event:goal", ["Signup"]]
|
|
]
|
|
}
|
|
|> check_error(
|
|
site,
|
|
"#/filters/0: Invalid filter [\"is_not\", \"event:goal\", [\"Signup\"]]"
|
|
)
|
|
end
|
|
|
|
test "not top-level filter", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"filters" => [
|
|
[
|
|
"or",
|
|
[
|
|
["is", "event:goal", ["Signup"]],
|
|
["is", "event:name", ["pageview"]]
|
|
]
|
|
]
|
|
]
|
|
}
|
|
|> check_error(
|
|
site,
|
|
"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"})
|
|
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"filters" => [
|
|
[
|
|
"has_done",
|
|
[
|
|
"or",
|
|
[
|
|
["is", "event:goal", ["Signup"]],
|
|
["is", "event:name", ["pageview"]]
|
|
]
|
|
]
|
|
]
|
|
]
|
|
}
|
|
|> check_success(
|
|
site,
|
|
%{
|
|
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}
|
|
}
|
|
)
|
|
|> check_goals(
|
|
preloaded_goals: %{all: ["Signup"], matching_toplevel_filters: ["Signup"]},
|
|
revenue_warning: nil,
|
|
revenue_currencies: %{}
|
|
)
|
|
end
|
|
|
|
test "name is checked even within behavioral filters", %{site: site} do
|
|
insert(:goal, %{site: site, event_name: "Signup"})
|
|
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"filters" => [["has_done", ["is", "event:goal", ["Unknown"]]]]
|
|
}
|
|
|> check_error(
|
|
site,
|
|
"Invalid filters. The goal `Unknown` is not configured for this site. Find out how to configure goals here: https://plausible.io/docs/stats-api#filtering-by-goals",
|
|
:internal
|
|
)
|
|
end
|
|
end
|
|
|
|
describe "date range validation" do
|
|
test "parsing shortcut options", %{site: site} do
|
|
check_date_range(%{"date_range" => "day"}, site, @date_range_day)
|
|
check_date_range(%{"date_range" => "7d"}, site, @date_range_7d)
|
|
check_date_range(%{"date_range" => "10d"}, site, @date_range_10d)
|
|
check_date_range(%{"date_range" => "30d"}, site, @date_range_30d)
|
|
check_date_range(%{"date_range" => "month"}, site, @date_range_month)
|
|
check_date_range(%{"date_range" => "3mo"}, site, @date_range_3mo)
|
|
check_date_range(%{"date_range" => "6mo"}, site, @date_range_6mo)
|
|
check_date_range(%{"date_range" => "12mo"}, site, @date_range_12mo)
|
|
check_date_range(%{"date_range" => "year"}, site, @date_range_year)
|
|
end
|
|
|
|
test "30m and realtime are available in internal API", %{site: site} do
|
|
check_date_range(%{"date_range" => "30m"}, site, @date_range_30m, :internal)
|
|
|
|
check_date_range(
|
|
%{"date_range" => "realtime"},
|
|
site,
|
|
@date_range_realtime,
|
|
:internal
|
|
)
|
|
end
|
|
|
|
test "30m and realtime date_ranges are unavailable in public API", %{
|
|
site: site
|
|
} do
|
|
for date_range <- ["realtime", "30m"] do
|
|
%{"site_id" => site.domain, "metrics" => ["visitors"], "date_range" => date_range}
|
|
|> check_error(site, "#/date_range: Invalid date range \"#{date_range}\"")
|
|
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")
|
|
check_date_range(%{"date_range" => "all"}, site, expected_date_range)
|
|
end
|
|
|
|
test "parsing `all` with no previous data", %{site: site} do
|
|
site = Map.put(site, :stats_start_date, nil)
|
|
check_date_range(%{"date_range" => "all"}, site, @date_range_day)
|
|
end
|
|
|
|
test "parsing custom date range from simple date strings", %{site: site} do
|
|
check_date_range(%{"date_range" => ["2021-05-05", "2021-05-05"]}, site, @date_range_day)
|
|
end
|
|
|
|
test "parsing custom date range from iso8601 timestamps", %{site: site} do
|
|
check_date_range(
|
|
%{"date_range" => ["2024-01-01T00:00:00Z", "2024-01-02T23:59:59Z"]},
|
|
site,
|
|
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")
|
|
)
|
|
)
|
|
|
|
check_date_range(
|
|
%{
|
|
"date_range" => [
|
|
"2024-08-29T07:12:34-07:00",
|
|
"2024-08-29T10:12:34-07:00"
|
|
]
|
|
},
|
|
site,
|
|
DateTimeRange.new!(
|
|
~U[2024-08-29 14:12:34Z],
|
|
~U[2024-08-29 17:12:34Z]
|
|
)
|
|
)
|
|
end
|
|
|
|
test "parsing invalid custom date range with invalid dates", %{site: site} do
|
|
%{"site_id" => site.domain, "date_range" => "-1d", "metrics" => ["visitors"]}
|
|
|> check_error(site, "#/date_range: Invalid date range \"-1d\"")
|
|
|
|
%{"site_id" => site.domain, "date_range" => "foo", "metrics" => ["visitors"]}
|
|
|> check_error(site, "#/date_range: Invalid date range \"foo\"")
|
|
|
|
%{"site_id" => site.domain, "date_range" => ["21415-00", "eee"], "metrics" => ["visitors"]}
|
|
|> check_error(site, "#/date_range: Invalid date range [\"21415-00\", \"eee\"]")
|
|
|
|
%{"site_id" => site.domain, "date_range" => "999999999mo", "metrics" => ["visitors"]}
|
|
|> check_error(site, "Invalid date_range \"999999999mo\"")
|
|
end
|
|
|
|
test "custom date range is invalid when timestamps do not include timezone info", %{
|
|
site: site
|
|
} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"date_range" => ["2021-02-03T00:00:00", "2021-02-03T23:59:59"],
|
|
"metrics" => ["visitors"]
|
|
}
|
|
|> check_error(
|
|
site,
|
|
"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
|
|
%{
|
|
"site_id" => site.domain,
|
|
"date_range" => ["2021-02-03T00:00:00-25:00", "2021-02-03T23:59:59-25:00"],
|
|
"metrics" => ["visitors"]
|
|
}
|
|
|> check_error(
|
|
site,
|
|
"#/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
|
|
%{
|
|
"site_id" => site.domain,
|
|
"date_range" => ["2021-02-03T00:00:00Z", "2021-02-04"],
|
|
"metrics" => ["visitors"]
|
|
}
|
|
|> check_error(
|
|
site,
|
|
"#/date_range: Invalid date range [\"2021-02-03T00:00:00Z\", \"2021-02-04\"]"
|
|
)
|
|
end
|
|
|
|
test "parses date_range relative to date param", %{site: site} do
|
|
date = @now |> DateTime.to_date() |> Date.to_string()
|
|
|
|
for {date_range_shortcut, expected_date_range} <- [
|
|
{"day", @date_range_day},
|
|
{"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
|
|
%{"date_range" => date_range_shortcut, "date" => date}
|
|
|> check_date_range(site, expected_date_range, :internal)
|
|
end
|
|
end
|
|
|
|
test "date parameter is not available in the public API", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors", "events"],
|
|
"date_range" => "month",
|
|
"date" => "2021-05-05"
|
|
}
|
|
|> check_error(site, "#/date: Schema does not allow additional properties.")
|
|
end
|
|
|
|
test "parses date_range.first into a datetime right after the gap in site.timezone", %{
|
|
site: site
|
|
} do
|
|
site = %{site | timezone: "America/Santiago"}
|
|
|
|
%{"date_range" => ["2022-09-11", "2022-09-11"]}
|
|
|> check_date_range(
|
|
site,
|
|
DateTimeRange.new!(~U[2022-09-11 04:00:00Z], ~U[2022-09-12 02:59:59Z])
|
|
)
|
|
end
|
|
|
|
test "parses date_range.first into the latest of ambiguous datetimes in site.timezone", %{
|
|
site: site
|
|
} do
|
|
site = %{site | timezone: "America/Havana"}
|
|
|
|
%{"date_range" => ["2023-11-05", "2023-11-05"]}
|
|
|> check_date_range(
|
|
site,
|
|
DateTimeRange.new!(~U[2023-11-05 05:00:00Z], ~U[2023-11-06 04:59:59Z])
|
|
)
|
|
end
|
|
|
|
test "parses date_range.last into the earliest of ambiguous datetimes in site.timezone", %{
|
|
site: site
|
|
} do
|
|
site = %{site | timezone: "America/Asuncion"}
|
|
|
|
%{"date_range" => ["2024-03-23", "2024-03-23"]}
|
|
|> check_date_range(
|
|
site,
|
|
DateTimeRange.new!(~U[2024-03-23 03:00:00Z], ~U[2024-03-24 02:59:59Z])
|
|
)
|
|
end
|
|
end
|
|
|
|
describe "dimensions validation" do
|
|
for dimension <- Filters.event_props() do
|
|
test "event:#{dimension} dimension", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"dimensions" => ["event:#{unquote(dimension)}"]
|
|
}
|
|
|> check_success(site, %{
|
|
metrics: [:visitors],
|
|
utc_time_range: @date_range_day,
|
|
filters: [],
|
|
dimensions: ["event:#{unquote(dimension)}"],
|
|
order_by: nil,
|
|
timezone: site.timezone,
|
|
include: @default_include,
|
|
pagination: %{limit: 10_000, offset: 0}
|
|
})
|
|
end
|
|
end
|
|
|
|
for dimension <- Filters.visit_props() do
|
|
test "visit:#{dimension} dimension", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"dimensions" => ["visit:#{unquote(dimension)}"]
|
|
}
|
|
|> check_success(site, %{
|
|
metrics: [:visitors],
|
|
utc_time_range: @date_range_day,
|
|
filters: [],
|
|
dimensions: ["visit:#{unquote(dimension)}"],
|
|
order_by: nil,
|
|
timezone: site.timezone,
|
|
include: @default_include,
|
|
pagination: %{limit: 10_000, offset: 0}
|
|
})
|
|
end
|
|
end
|
|
|
|
test "time:minute dimension fails public schema validation", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"dimensions" => ["time:minute"]
|
|
}
|
|
|> check_error(site, "#/dimensions/0: Invalid dimension \"time:minute\"")
|
|
end
|
|
|
|
test "time:minute dimension passes internal schema validation", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"dimensions" => ["time:minute"]
|
|
}
|
|
|> check_success(
|
|
site,
|
|
%{
|
|
metrics: [:visitors],
|
|
utc_time_range: @date_range_day,
|
|
filters: [],
|
|
dimensions: ["time:minute"],
|
|
order_by: nil,
|
|
timezone: site.timezone,
|
|
include: @default_include,
|
|
pagination: %{limit: 10_000, offset: 0}
|
|
},
|
|
:internal
|
|
)
|
|
end
|
|
|
|
test "custom properties dimension", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"dimensions" => ["event:props:foobar"]
|
|
}
|
|
|> check_success(site, %{
|
|
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}
|
|
})
|
|
end
|
|
|
|
test "invalid custom property dimension", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"dimensions" => ["event:props:"]
|
|
}
|
|
|> check_error(site, "#/dimensions/0: Invalid dimension \"event:props:\"")
|
|
end
|
|
|
|
test "invalid dimension name passed", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"dimensions" => ["visitors"]
|
|
}
|
|
|> check_error(site, "#/dimensions/0: Invalid dimension \"visitors\"")
|
|
end
|
|
|
|
test "invalid dimension", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"dimensions" => "foobar"
|
|
}
|
|
|> check_error(site, "#/dimensions: Type mismatch. Expected Array but got String.")
|
|
end
|
|
|
|
test "dimensions are not unique", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"dimensions" => ["event:name", "event:name"]
|
|
}
|
|
|> check_error(site, "#/dimensions: Expected items to be unique but they were not.")
|
|
end
|
|
end
|
|
|
|
describe "order_by validation" do
|
|
test "ordering by metric", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors", "events"],
|
|
"date_range" => "all",
|
|
"order_by" => [["events", "desc"], ["visitors", "asc"]]
|
|
}
|
|
|> check_success(site, %{
|
|
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}
|
|
})
|
|
end
|
|
|
|
test "ordering by dimension", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"dimensions" => ["event:name"],
|
|
"order_by" => [["event:name", "desc"]]
|
|
}
|
|
|> check_success(site, %{
|
|
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}
|
|
})
|
|
end
|
|
|
|
test "ordering by invalid value", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"order_by" => [["visssss", "desc"]]
|
|
}
|
|
|> check_error(site, "#/order_by/0/0: Invalid value in order_by \"visssss\"")
|
|
end
|
|
|
|
test "ordering by not queried metric", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"order_by" => [["events", "desc"]]
|
|
}
|
|
|> check_error(
|
|
site,
|
|
"Invalid order_by entry '{:events, :desc}'. Entry is not a queried metric or dimension."
|
|
)
|
|
end
|
|
|
|
test "ordering by not queried dimension", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"order_by" => [["event:name", "desc"]]
|
|
}
|
|
|> check_error(
|
|
site,
|
|
"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])
|
|
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"filters" => [["not", ["is", "event:props:foobar", ["foo"]]]]
|
|
}
|
|
|> check_error(
|
|
site,
|
|
"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])
|
|
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"dimensions" => ["event:props:foobar"]
|
|
}
|
|
|> check_error(
|
|
site,
|
|
"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
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["conversion_rate"],
|
|
"date_range" => "all"
|
|
}
|
|
|> check_error(
|
|
site,
|
|
"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"})
|
|
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["conversion_rate"],
|
|
"date_range" => "all",
|
|
"filters" => [["is", "event:goal", ["Signup"]]]
|
|
}
|
|
|> check_success(site, %{
|
|
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}
|
|
})
|
|
|> check_goals(
|
|
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"})
|
|
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["conversion_rate"],
|
|
"date_range" => "all",
|
|
"dimensions" => ["event:goal"]
|
|
}
|
|
|> check_success(site, %{
|
|
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}
|
|
})
|
|
|> check_goals(
|
|
preloaded_goals: %{
|
|
all: ["Purchase", "Signup"],
|
|
matching_toplevel_filters: ["Purchase", "Signup"]
|
|
},
|
|
revenue_currencies: %{}
|
|
)
|
|
end
|
|
|
|
test "custom properties filter with special metric", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["conversion_rate", "group_conversion_rate"],
|
|
"date_range" => "all",
|
|
"filters" => [["is", "event:props:foo", ["bar"]]],
|
|
"dimensions" => ["event:goal"]
|
|
}
|
|
|> check_success(site, %{
|
|
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}
|
|
})
|
|
end
|
|
|
|
test "not top level custom properties filter with special metric is invalid", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["conversion_rate", "group_conversion_rate"],
|
|
"date_range" => "all",
|
|
"filters" => [["not", ["is", "event:props:foo", ["bar"]]]],
|
|
"dimensions" => ["event:goal"]
|
|
}
|
|
|> check_error(
|
|
site,
|
|
"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 "exit_rate metric" do
|
|
test "fails validation without visit:exit_page dimension", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["exit_rate"],
|
|
"date_range" => "all"
|
|
}
|
|
|> check_error(
|
|
site,
|
|
"Metric `exit_rate` requires a `\"visit:exit_page\"` dimension. No other dimensions are allowed.",
|
|
:internal
|
|
)
|
|
end
|
|
|
|
test "fails validation with event only filters", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["exit_rate"],
|
|
"dimensions" => ["visit:exit_page"],
|
|
"filters" => [["is", "event:page", ["/"]]],
|
|
"date_range" => "all"
|
|
}
|
|
|> check_error(
|
|
site,
|
|
"Metric `exit_rate` cannot be queried when filtering on event dimensions.",
|
|
:internal
|
|
)
|
|
end
|
|
|
|
test "fails validation with event metrics", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["exit_rate", "pageviews"],
|
|
"dimensions" => ["visit:exit_page"],
|
|
"date_range" => "all"
|
|
}
|
|
|> check_error(
|
|
site,
|
|
"Event metric(s) `pageviews` cannot be queried along with session dimension(s) `visit:exit_page`",
|
|
:internal
|
|
)
|
|
end
|
|
|
|
test "passes validation", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["exit_rate"],
|
|
"dimensions" => ["visit:exit_page"],
|
|
"date_range" => "all"
|
|
}
|
|
|> check_success(
|
|
site,
|
|
%{
|
|
metrics: [:exit_rate],
|
|
utc_time_range: @date_range_day,
|
|
filters: [],
|
|
dimensions: ["visit:exit_page"],
|
|
order_by: nil,
|
|
timezone: site.timezone,
|
|
include: @default_include,
|
|
pagination: %{limit: 10_000, offset: 0}
|
|
},
|
|
:internal
|
|
)
|
|
end
|
|
end
|
|
|
|
describe "scroll_depth metric" do
|
|
test "fails validation on its own", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["scroll_depth"],
|
|
"date_range" => "all"
|
|
}
|
|
|> check_error(
|
|
site,
|
|
"Metric `scroll_depth` can only be queried with event:page filters or dimensions.",
|
|
:internal
|
|
)
|
|
end
|
|
|
|
test "fails with only a non-top-level event:page filter", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["scroll_depth"],
|
|
"date_range" => "all",
|
|
"filters" => [["not", ["is", "event:page", ["/"]]]]
|
|
}
|
|
|> check_error(
|
|
site,
|
|
"Metric `scroll_depth` can only be queried with event:page filters or dimensions.",
|
|
:internal
|
|
)
|
|
end
|
|
|
|
test "succeeds with top-level event:page filter", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["scroll_depth"],
|
|
"date_range" => "all",
|
|
"filters" => [["is", "event:page", ["/"]]]
|
|
}
|
|
|> check_success(
|
|
site,
|
|
%{
|
|
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}
|
|
},
|
|
:internal
|
|
)
|
|
end
|
|
|
|
test "succeeds with event:page dimension", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["scroll_depth"],
|
|
"date_range" => "all",
|
|
"dimensions" => ["event:page"]
|
|
}
|
|
|> check_success(
|
|
site,
|
|
%{
|
|
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}
|
|
},
|
|
:internal
|
|
)
|
|
end
|
|
end
|
|
|
|
describe "views_per_visit metric" do
|
|
test "succeeds with normal filters", %{site: site} do
|
|
insert(:goal, %{site: site, event_name: "Signup"})
|
|
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["views_per_visit"],
|
|
"date_range" => "all",
|
|
"filters" => [["is", "event:goal", ["Signup"]]]
|
|
}
|
|
|> check_success(site, %{
|
|
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}
|
|
})
|
|
|> check_goals(
|
|
preloaded_goals: %{all: ["Signup"], matching_toplevel_filters: ["Signup"]},
|
|
revenue_currencies: %{}
|
|
)
|
|
end
|
|
|
|
test "fails validation if event:page filter specified", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["views_per_visit"],
|
|
"date_range" => "all",
|
|
"filters" => [["is", "event:page", ["/"]]]
|
|
}
|
|
|> check_error(
|
|
site,
|
|
"Metric `views_per_visit` cannot be queried with a filter on `event:page`."
|
|
)
|
|
end
|
|
|
|
test "fails validation with dimensions", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["views_per_visit"],
|
|
"date_range" => "all",
|
|
"dimensions" => ["event:name"]
|
|
}
|
|
|> check_error(
|
|
site,
|
|
"Metric `views_per_visit` cannot be queried with `dimensions`."
|
|
)
|
|
end
|
|
end
|
|
|
|
describe "time_on_page metric" do
|
|
test "fails validation on its own", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["time_on_page"],
|
|
"date_range" => "all"
|
|
}
|
|
|> check_error(
|
|
site,
|
|
"Metric `time_on_page` can only be queried with event:page filters or dimensions."
|
|
)
|
|
end
|
|
|
|
test "succeeds with event:page dimension", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["time_on_page"],
|
|
"date_range" => "all",
|
|
"dimensions" => ["time", "event:page"]
|
|
}
|
|
|> check_success(site, %{
|
|
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}
|
|
})
|
|
end
|
|
|
|
test "succeeds with event:page filter", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["time_on_page"],
|
|
"date_range" => "all",
|
|
"filters" => [["is", "event:page", ["/"]]]
|
|
}
|
|
|> check_success(site, %{
|
|
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}
|
|
})
|
|
end
|
|
|
|
test "fails when using only a behavioral filter", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["time_on_page"],
|
|
"date_range" => "all",
|
|
"filters" => [
|
|
["has_done", ["is", "event:page", ["/"]]]
|
|
]
|
|
}
|
|
|> check_error(
|
|
site,
|
|
"Metric `time_on_page` can only be queried with event:page filters or dimensions.",
|
|
:internal
|
|
)
|
|
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
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["total_revenue", "average_revenue"],
|
|
"date_range" => "all"
|
|
}
|
|
|> check_success(
|
|
site,
|
|
%{
|
|
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}
|
|
}
|
|
)
|
|
|> check_goals(
|
|
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])
|
|
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["total_revenue", "average_revenue"],
|
|
"date_range" => "all"
|
|
}
|
|
|> check_error(
|
|
site,
|
|
"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")
|
|
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["total_revenue", "average_revenue"],
|
|
"date_range" => "all",
|
|
"filters" => [["is", "event:goal", ["PurchaseUSD", "Signup", "Subscription"]]]
|
|
}
|
|
|> check_success(
|
|
site,
|
|
%{
|
|
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}
|
|
}
|
|
)
|
|
|> check_goals(
|
|
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")
|
|
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["total_revenue", "average_revenue"],
|
|
"date_range" => "all",
|
|
"filters" => [["is", "event:goal", ["Purchase", "Signup", "Subscription"]]]
|
|
}
|
|
|> check_success(
|
|
site,
|
|
%{
|
|
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}
|
|
}
|
|
)
|
|
|> check_goals(
|
|
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")
|
|
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["total_revenue", "average_revenue"],
|
|
"date_range" => "all",
|
|
"filters" => [["is", "event:goal", ["Signup"]]]
|
|
}
|
|
|> check_success(
|
|
site,
|
|
%{
|
|
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}
|
|
}
|
|
)
|
|
|> check_goals(
|
|
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")
|
|
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["total_revenue", "average_revenue"],
|
|
"date_range" => "all",
|
|
"dimensions" => ["event:goal"]
|
|
}
|
|
|> check_success(
|
|
site,
|
|
%{
|
|
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}
|
|
}
|
|
)
|
|
|> check_goals(
|
|
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")
|
|
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["total_revenue", "average_revenue"],
|
|
"date_range" => "all",
|
|
"dimensions" => ["event:goal"],
|
|
"filters" => [["is", "event:goal", ["Purchase", "Signup", "Subscription"]]]
|
|
}
|
|
|> check_success(
|
|
site,
|
|
%{
|
|
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}
|
|
}
|
|
)
|
|
|> check_goals(
|
|
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")
|
|
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["total_revenue", "average_revenue"],
|
|
"date_range" => "all",
|
|
"dimensions" => ["event:goal"],
|
|
"filters" => [["is", "event:goal", ["Signup"]]]
|
|
}
|
|
|> check_success(
|
|
site,
|
|
%{
|
|
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}
|
|
}
|
|
)
|
|
|> check_goals(
|
|
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
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["total_revenue", "average_revenue"],
|
|
"date_range" => "all"
|
|
}
|
|
|> check_error(
|
|
site,
|
|
"#/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
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["bounce_rate"],
|
|
"date_range" => "all",
|
|
"dimensions" => ["visit:device"]
|
|
}
|
|
|> check_success(site, %{
|
|
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}
|
|
})
|
|
end
|
|
|
|
test "fails if using session metric with event dimension", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["bounce_rate"],
|
|
"date_range" => "all",
|
|
"dimensions" => ["event:props:foo"]
|
|
}
|
|
|> check_error(
|
|
site,
|
|
"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
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["events"],
|
|
"date_range" => "all",
|
|
"dimensions" => ["visit:exit_page"]
|
|
}
|
|
|> check_error(
|
|
site,
|
|
"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
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["bounce_rate"],
|
|
"date_range" => "all",
|
|
"dimensions" => ["event:page"]
|
|
}
|
|
|> check_success(site, %{
|
|
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}
|
|
})
|
|
end
|
|
|
|
test "does not fail if using session metric with event filter", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["bounce_rate"],
|
|
"date_range" => "all",
|
|
"filters" => [["is", "event:props:foo", ["(none)"]]]
|
|
}
|
|
|> check_success(site, %{
|
|
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}
|
|
})
|
|
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"
|
|
)
|
|
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"filters" => [
|
|
["and", segments |> Enum.map(fn segment -> ["is", "segment", [segment.id]] end)]
|
|
]
|
|
}
|
|
|> check_error(
|
|
site,
|
|
"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"
|
|
)
|
|
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"filters" => [["is", "segment", [segment.id]]]
|
|
}
|
|
|> check_error(
|
|
site,
|
|
"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"]]]}
|
|
)
|
|
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"filters" => [["is", "segment", [segment.id]]]
|
|
}
|
|
|> check_error(
|
|
site,
|
|
"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", ["/"]]
|
|
]
|
|
]
|
|
]
|
|
}
|
|
)
|
|
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors", "conversion_rate"],
|
|
"date_range" => "all",
|
|
"filters" => [["is", "segment", [segment.id]]]
|
|
}
|
|
|> check_error(
|
|
site,
|
|
"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"]]
|
|
]
|
|
}
|
|
)
|
|
|
|
%{
|
|
"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]]
|
|
]
|
|
]
|
|
]
|
|
}
|
|
|> check_success(
|
|
site,
|
|
%{
|
|
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}
|
|
}
|
|
)
|
|
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" => [["has_not_done", ["is", "event:goal", ["Signup"]]]]
|
|
}
|
|
)
|
|
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors", "events"],
|
|
"date_range" => "all",
|
|
"filters" => [
|
|
["is", "segment", [segment_from_dashboard.id]]
|
|
]
|
|
}
|
|
|> check_success(
|
|
site,
|
|
%{
|
|
metrics: [:visitors, :events],
|
|
utc_time_range: @date_range_day,
|
|
filters: [
|
|
[:has_not_done, [:is, "event:goal", ["Signup"]]]
|
|
],
|
|
dimensions: [],
|
|
order_by: nil,
|
|
timezone: site.timezone,
|
|
include: @default_include,
|
|
pagination: %{limit: 10_000, offset: 0}
|
|
}
|
|
)
|
|
end
|
|
|
|
test "validation fails with string segment ids", %{site: site} do
|
|
%{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all",
|
|
"filters" => [["is", "segment", ["123"]]]
|
|
}
|
|
|> check_error(
|
|
site,
|
|
"Invalid filter '[\"is\", \"segment\", [\"123\"]]'."
|
|
)
|
|
end
|
|
end
|
|
|
|
on_ee do
|
|
describe "query.consolidated_site_ids" do
|
|
test "is set to nil when site is regular", %{site: site} do
|
|
params = %{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all"
|
|
}
|
|
|
|
{:ok, %{consolidated_site_ids: nil}} = parse(site, :public, params)
|
|
{:ok, %{consolidated_site_ids: nil}} = parse(site, :internal, params)
|
|
end
|
|
|
|
test "is set to a list of site_ids when site is consolidated", %{site: site} do
|
|
new_site(team: site.team)
|
|
cv = new_consolidated_view(site.team)
|
|
|
|
params = %{
|
|
"site_id" => site.domain,
|
|
"metrics" => ["visitors"],
|
|
"date_range" => "all"
|
|
}
|
|
|
|
assert {:ok, %{consolidated_site_ids: site_ids}} = parse(cv, :public, params)
|
|
assert length(site_ids) == 2
|
|
assert site.id in site_ids
|
|
|
|
assert {:ok, %{consolidated_site_ids: site_ids}} = parse(cv, :internal, params)
|
|
assert length(site_ids) == 2
|
|
assert site.id in site_ids
|
|
end
|
|
end
|
|
end
|
|
end
|