| defmodule Plausible.Stats.Query.QueryFromTest do |
| use Plausible.DataCase, async: true |
| alias Plausible.Stats.Query |
| alias Plausible.Stats.Legacy.QueryBuilder |
| alias Plausible.Stats.DateTimeRange |
|
|
| doctest Plausible.Stats.Legacy.QueryBuilder |
|
|
| setup do |
| user = new_user() |
|
|
| site = |
| new_site( |
| owner: user, |
| inserted_at: ~N[2020-01-01T00:00:00], |
| stats_start_date: ~D[2020-01-01], |
| timezone: "US/Eastern" |
| ) |
|
|
| {:ok, site: site, user: user} |
| end |
|
|
| @now ~U[2024-05-03 16:30:00Z] |
|
|
| @tag :slow |
| test "keeps current timestamp so that utc_boundaries don't depend on time passing by", %{ |
| site: site |
| } do |
| q1 = %{now: %DateTime{}} = Query.from(site, %{"period" => "realtime"}) |
| q2 = %{now: %DateTime{}} = Query.from(site, %{"period" => "30m"}) |
| boundaries1 = Plausible.Stats.Time.utc_boundaries(q1) |
| boundaries2 = Plausible.Stats.Time.utc_boundaries(q2) |
| :timer.sleep(1500) |
| assert ^boundaries1 = Plausible.Stats.Time.utc_boundaries(q1) |
| assert ^boundaries2 = Plausible.Stats.Time.utc_boundaries(q2) |
| end |
|
|
| test "parses day format", %{site: site} do |
| q = Query.from(site, %{"period" => "day", "date" => "2019-01-01"}, now: @now) |
|
|
| assert q.utc_time_range.first == ~U[2019-01-01 05:00:00Z] |
| assert q.utc_time_range.last == ~U[2019-01-02 04:59:59Z] |
| end |
|
|
| test "day format defaults to today", %{site: site} do |
| q = Query.from(site, %{"period" => "day"}, now: @now) |
|
|
| assert q.utc_time_range.first == ~U[2024-05-03 04:00:00Z] |
| assert q.utc_time_range.last == @now |
| end |
|
|
| test "parses realtime format", %{site: site} do |
| q = Query.from(site, %{"period" => "realtime"}, now: @now) |
|
|
| assert q.utc_time_range.first == ~U[2024-05-03 16:25:00Z] |
| assert q.utc_time_range.last == ~U[2024-05-03 16:30:05Z] |
| assert q.input_date_range == :realtime |
| end |
|
|
| test "parses month format", %{site: site} do |
| q = Query.from(site, %{"period" => "month", "date" => "2019-01-01"}, now: @now) |
|
|
| assert q.utc_time_range.first == ~U[2019-01-01 05:00:00Z] |
| assert q.utc_time_range.last == ~U[2019-02-01 04:59:59Z] |
| end |
|
|
| test "parses 6 month format", %{site: site} do |
| q = Query.from(site, %{"period" => "6mo"}, now: @now) |
|
|
| assert q.utc_time_range.first == ~U[2023-11-01 04:00:00Z] |
| assert q.utc_time_range.last == ~U[2024-05-01 03:59:59Z] |
| end |
|
|
| test "parses 12 month format", %{site: site} do |
| q = Query.from(site, %{"period" => "12mo"}, now: @now) |
|
|
| assert q.utc_time_range.first == ~U[2023-05-01 04:00:00Z] |
| assert q.utc_time_range.last == ~U[2024-05-01 03:59:59Z] |
| end |
|
|
| test "parses year to date format", %{site: site} do |
| q = Query.from(site, %{"period" => "year"}, now: @now) |
|
|
| assert q.utc_time_range.first == ~U[2024-01-01 05:00:00Z] |
| assert q.utc_time_range.last == ~U[2025-01-01 04:59:59Z] |
| end |
|
|
| test "parses all time", %{site: site} do |
| q = Query.from(site, %{"period" => "all"}, now: @now) |
|
|
| assert q.utc_time_range.first == ~U[2020-01-01 05:00:00Z] |
| assert q.utc_time_range.last == ~U[2024-05-04 03:59:59Z] |
| assert q.input_date_range == :all |
| end |
|
|
| test "parses all time in GMT+12 timezone", %{site: site} do |
| site = Map.put(site, :timezone, "Etc/GMT+12") |
| q = Query.from(site, %{"period" => "all"}, now: @now) |
|
|
| assert q.utc_time_range.first == ~U[2020-01-01 12:00:00Z] |
| assert q.utc_time_range.last == ~U[2024-05-04 11:59:59Z] |
| end |
|
|
| test "all time shows today if site has no start date", %{site: site} do |
| site = Map.put(site, :stats_start_date, nil) |
| q = Query.from(site, %{"period" => "all"}, now: @now) |
|
|
| assert q.utc_time_range.first == ~U[2024-05-03 04:00:00Z] |
| assert q.utc_time_range.last == ~U[2024-05-04 03:59:59Z] |
| assert q.input_date_range == :all |
| end |
|
|
| test "all time shows hourly if site is completely new", %{site: site} do |
| site = Map.put(site, :stats_start_date, @now |> DateTime.to_date()) |
| q = Query.from(site, %{"period" => "all"}, now: @now) |
|
|
| assert q.utc_time_range.first == ~U[2024-05-03 04:00:00Z] |
| assert q.utc_time_range.last == ~U[2024-05-04 03:59:59Z] |
| assert q.input_date_range == :all |
| end |
|
|
| test "all time shows daily if site is more than a day old", %{site: site} do |
| yesterday = @now |> DateTime.to_date() |> Date.shift(day: -1) |
| site = Map.put(site, :stats_start_date, yesterday) |
|
|
| q = Query.from(site, %{"period" => "all"}, now: @now) |
|
|
| assert q.utc_time_range.first == ~U[2024-05-02 04:00:00Z] |
| assert q.utc_time_range.last == ~U[2024-05-04 03:59:59Z] |
| assert q.input_date_range == :all |
| end |
|
|
| test "all time shows monthly if site is more than a month old", %{site: site} do |
| last_month = @now |> DateTime.to_date() |> Date.shift(month: -1) |
| site = Map.put(site, :stats_start_date, last_month) |
|
|
| q = Query.from(site, %{"period" => "all"}, now: @now) |
|
|
| assert q.utc_time_range.first == ~U[2024-04-03 04:00:00Z] |
| assert q.utc_time_range.last == ~U[2024-05-04 03:59:59Z] |
| assert q.input_date_range == :all |
| end |
|
|
| test "all time uses passed interval different from the default interval", %{site: site} do |
| last_month = @now |> DateTime.to_date() |> Date.shift(month: -1) |
| site = Map.put(site, :stats_start_date, last_month) |
|
|
| q = Query.from(site, %{"period" => "all", "interval" => "week"}, now: @now) |
|
|
| assert q.utc_time_range.first == ~U[2024-04-03 04:00:00Z] |
| assert q.utc_time_range.last == ~U[2024-05-04 03:59:59Z] |
| assert q.input_date_range == :all |
| end |
|
|
| test "defaults to 30 days format", %{site: site} do |
| query_default = Query.from(site, %{}) |> Map.delete(:now) |
| query_30d = Query.from(site, %{"period" => "30d"}) |> Map.delete(:now) |
|
|
| assert query_default == query_30d |
| end |
|
|
| test "parses custom format", %{site: site} do |
| q = |
| Query.from( |
| site, |
| %{"period" => "custom", "from" => "2019-01-01", "to" => "2019-01-15"}, |
| now: @now |
| ) |
|
|
| assert q.utc_time_range.first == ~U[2019-01-01 05:00:00Z] |
| assert q.utc_time_range.last == ~U[2019-01-16 04:59:59Z] |
| end |
|
|
| @tag :ee_only |
| test "adds sample_threshold :no_sampling to query struct", %{site: site} do |
| q = Query.from(site, %{"period" => "30d", "sample_threshold" => "infinite"}) |
| assert q.sample_threshold == :no_sampling |
| end |
|
|
| @tag :ee_only |
| test "casts sample_threshold to integer in query struct", %{site: site} do |
| q = Query.from(site, %{"period" => "30d", "sample_threshold" => "30000000"}) |
| assert q.sample_threshold == 30_000_000 |
| end |
|
|
| describe "&date_range/2" do |
| defp date_range({first, last}, timezone, now \\ nil, opts \\ []) do |
| %Query{ |
| utc_time_range: DateTimeRange.new!(first, last), |
| timezone: timezone, |
| now: now || last |
| } |
| |> Query.date_range(opts) |
| end |
|
|
| test "with no options" do |
| assert date_range({~U[2024-05-05 00:00:00Z], ~U[2024-05-07 23:59:59Z]}, "Etc/UTC") == |
| Date.range(~D[2024-05-05], ~D[2024-05-07]) |
|
|
| assert date_range({~U[2024-05-05 12:00:00Z], ~U[2024-05-08 11:59:59Z]}, "Etc/GMT+12") == |
| Date.range(~D[2024-05-05], ~D[2024-05-07]) |
|
|
| assert date_range({~U[2024-05-04 12:00:00Z], ~U[2024-05-07 11:59:59Z]}, "Etc/GMT-12") == |
| Date.range(~D[2024-05-05], ~D[2024-05-07]) |
| end |
|
|
| test "trim_trailing: true" do |
| assert date_range( |
| {~U[2024-05-05 00:00:00Z], ~U[2024-05-07 23:59:59Z]}, |
| "Etc/UTC", |
| ~U[2024-05-08 12:00:00Z], |
| trim_trailing: true |
| ) == Date.range(~D[2024-05-05], ~D[2024-05-07]) |
|
|
| assert date_range( |
| {~U[2024-05-05 00:00:00Z], ~U[2024-05-07 23:59:59Z]}, |
| "Etc/UTC", |
| ~U[2024-05-07 12:00:00Z], |
| trim_trailing: true |
| ) == Date.range(~D[2024-05-05], ~D[2024-05-07]) |
|
|
| assert date_range( |
| {~U[2024-05-05 00:00:00Z], ~U[2024-05-07 23:59:59Z]}, |
| "Etc/UTC", |
| ~U[2024-05-06 12:00:00Z], |
| trim_trailing: true |
| ) == Date.range(~D[2024-05-05], ~D[2024-05-06]) |
|
|
| assert date_range( |
| {~U[2024-05-05 12:00:00Z], ~U[2024-05-08 11:59:59Z]}, |
| "Etc/GMT+12", |
| ~U[2024-05-09 00:00:00Z], |
| trim_trailing: true |
| ) == Date.range(~D[2024-05-05], ~D[2024-05-07]) |
|
|
| assert date_range( |
| {~U[2024-05-05 12:00:00Z], ~U[2024-05-08 11:59:59Z]}, |
| "Etc/GMT+12", |
| ~U[2024-05-07 07:00:00Z], |
| trim_trailing: true |
| ) == Date.range(~D[2024-05-05], ~D[2024-05-06]) |
|
|
| assert date_range( |
| {~U[2024-05-05 12:00:00Z], ~U[2024-05-08 11:59:59Z]}, |
| "Etc/GMT+12", |
| ~U[2024-05-03 07:00:00Z], |
| trim_trailing: true |
| ) == Date.range(~D[2024-05-05], ~D[2024-05-05]) |
| end |
| end |
|
|
| describe "include_imported" do |
| setup [:create_site] |
|
|
| test "is true when requested via params and imported data exists", %{site: site} do |
| insert(:site_import, site: site) |
|
|
| assert %{include_imported: true} = |
| Query.from(site, %{"period" => "day", "with_imported" => "true"}) |
| end |
|
|
| test "is false when imported data does not exist", %{site: site} do |
| assert %{include_imported: false, skip_imported_reason: :no_imported_data} = |
| Query.from(site, %{"period" => "day", "with_imported" => "true"}) |
| end |
|
|
| test "is false when imported data exists but is out of the date range", %{site: site} do |
| insert(:site_import, site: site, start_date: ~D[2021-01-01], end_date: ~D[2022-01-01]) |
|
|
| assert %{include_imported: false, skip_imported_reason: :out_of_range} = |
| Query.from(site, %{"period" => "day", "with_imported" => "true"}) |
| end |
|
|
| test "is false in realtime even when imported data from today exists", %{site: site} do |
| insert(:site_import, site: site) |
|
|
| assert %{include_imported: false, skip_imported_reason: :out_of_range} = |
| Query.from(site, %{"period" => "realtime", "with_imported" => "true"}) |
| end |
|
|
| test "is false when an arbitrary custom property filter is used", %{site: site} do |
| insert(:site_import, site: site) |
|
|
| assert %{include_imported: false, skip_imported_reason: :unsupported_query} = |
| Query.from(site, %{ |
| "period" => "day", |
| "with_imported" => "true", |
| "property" => "event:props:url", |
| "filters" => Jason.encode!([[:is_not, "event:props:author", ["John Doe"]]]) |
| }) |
| end |
|
|
| test "is true when breaking down by url and filtering by outbound link or file download goal", |
| %{site: site} do |
| insert(:site_import, site: site) |
|
|
| Enum.each(["Outbound Link: Click", "File Download"], fn goal_name -> |
| insert(:goal, site: site, event_name: goal_name) |
|
|
| assert %{include_imported: true} = |
| Query.from(site, %{ |
| "period" => "day", |
| "with_imported" => "true", |
| "property" => "event:props:url", |
| "filters" => Jason.encode!([[:is, "event:goal", [goal_name]]]) |
| }) |
| end) |
| end |
|
|
| test "is false when breaking down by url but without a special goal filter", |
| %{site: site} do |
| insert(:site_import, site: site) |
|
|
| assert %{include_imported: false} = |
| Query.from(site, %{ |
| "period" => "day", |
| "with_imported" => "true", |
| "property" => "event:props:url" |
| }) |
| end |
|
|
| test "is false when breaking down by url but with a mismatched special goal filter", |
| %{site: site} do |
| insert(:site_import, site: site) |
| insert(:goal, site: site, event_name: "404") |
|
|
| assert %{include_imported: false} = |
| Query.from(site, %{ |
| "period" => "day", |
| "with_imported" => "true", |
| "property" => "event:props:url", |
| "filters" => Jason.encode!([[:is, "event:goal", ["404"]]]) |
| }) |
| end |
|
|
| test "is false when breaking down by url but with a special goal filter and an arbitrary filter", |
| %{site: site} do |
| insert(:site_import, site: site) |
| insert(:goal, site: site, event_name: "Outbound Link: Click") |
|
|
| assert %{include_imported: false} = |
| Query.from(site, %{ |
| "period" => "day", |
| "with_imported" => "true", |
| "property" => "event:props:url", |
| "filters" => |
| Jason.encode!([ |
| [:is, "event:goal", ["Outbound Link: Click"]], |
| [:is, "event:page", ["/example"]] |
| ]) |
| }) |
| end |
|
|
| for property <- [nil, "event:goal", "event:name"] do |
| test "is true when filtering by custom prop and special goal when breakdown prop is #{property}", |
| %{site: site} do |
| insert(:site_import, site: site) |
| insert(:goal, site: site, event_name: "Outbound Link: Click") |
|
|
| assert %{include_imported: true} = |
| Query.from(site, %{ |
| "period" => "day", |
| "with_imported" => "true", |
| "property" => unquote(property), |
| "filters" => |
| Jason.encode!([ |
| [:is, "event:goal", ["Outbound Link: Click"]], |
| [:is, "event:props:url", ["https://example.com"]] |
| ]) |
| }) |
| end |
| end |
|
|
| test "is true when filtering by matching multiple custom prop values and special goal", %{ |
| site: site |
| } do |
| insert(:site_import, site: site) |
| insert(:goal, site: site, event_name: "Outbound Link: Click") |
|
|
| assert %{include_imported: true} = |
| Query.from(site, %{ |
| "period" => "day", |
| "with_imported" => "true", |
| "property" => nil, |
| "filters" => |
| Jason.encode!([ |
| [:is, "event:goal", ["Outbound Link: Click"]], |
| [ |
| :is, |
| "event:props:url", |
| ["https://example.com", "https://another.example.com"] |
| ] |
| ]) |
| }) |
| end |
|
|
| test "is false when filtering by mismatched custom prop values and special goal", %{ |
| site: site |
| } do |
| insert(:site_import, site: site) |
| insert(:goal, site: site, event_name: "Outbound Link: Click") |
|
|
| assert %{include_imported: false} = |
| Query.from(site, %{ |
| "period" => "day", |
| "with_imported" => "true", |
| "property" => nil, |
| "filters" => |
| Jason.encode!([ |
| [:is, "event:goal", ["Outbound Link: Click"]], |
| [:is, "event:props:url", ["https://example.com"]], |
| [:is, "event:props:path", ["/whatever"]] |
| ]) |
| }) |
| end |
|
|
| test "is false with a custom prop + mismatched special goal filter", |
| %{site: site} do |
| insert(:site_import, site: site) |
| insert(:goal, site: site, event_name: "404") |
|
|
| assert %{include_imported: false} = |
| Query.from(site, %{ |
| "period" => "day", |
| "with_imported" => "true", |
| "property" => nil, |
| "filters" => |
| Jason.encode!([ |
| [:is, "event:goal", ["404"]], |
| [:is, "event:props:url", ["https://example.com"]] |
| ]) |
| }) |
| end |
|
|
| test "is false with a custom prop + required goal + arbitrary filter", |
| %{site: site} do |
| insert(:site_import, site: site) |
| insert(:goal, site: site, event_name: "Outbound Link: Click") |
|
|
| assert %{include_imported: false} = |
| Query.from(site, %{ |
| "period" => "day", |
| "with_imported" => "true", |
| "property" => nil, |
| "filters" => |
| Jason.encode!([ |
| [:is, "event:goal", ["Outbound Link: Click"]], |
| [:is, "event:page", ["/example"]], |
| [:is, "event:props:url", ["https://example.com"]] |
| ]) |
| }) |
| end |
|
|
| test "is false with a custom prop filter and non-matching property", %{site: site} do |
| insert(:site_import, site: site) |
| insert(:goal, site: site, event_name: "Outbound Link: Click") |
|
|
| assert %{include_imported: false} = |
| Query.from(site, %{ |
| "period" => "day", |
| "with_imported" => "true", |
| "property" => "visit:source", |
| "filters" => |
| Jason.encode!([ |
| [:is, "event:goal", ["Outbound Link: Click"]], |
| [:is, "event:props:url", ["https://example.com"]] |
| ]) |
| }) |
| end |
| end |
|
|
| on_ee do |
| describe "query.consolidated_site_ids" do |
| test "is set to nil when site is regular", %{site: site} do |
| assert %{consolidated_site_ids: nil} = Query.from(site, %{"period" => "day"}) |
| 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) |
|
|
| assert %{consolidated_site_ids: site_ids} = Query.from(cv, %{"period" => "day"}) |
| assert length(site_ids) == 2 |
| assert site.id in site_ids |
| end |
| end |
| end |
| end |
|
|