diff --git a/test/plausible_web/controllers/CSVs/30d-filter-goal/browser_versions.csv b/test/plausible_web/controllers/CSVs/30d-filter-goal/browser_versions.csv new file mode 100644 index 0000000000000000000000000000000000000000..a819274351e1cd92caa6e9c0694c536e61e21bf0 --- /dev/null +++ b/test/plausible_web/controllers/CSVs/30d-filter-goal/browser_versions.csv @@ -0,0 +1,2 @@ +name,version,conversions,conversion_rate +(not set),(not set),1,50.0 diff --git a/test/plausible_web/controllers/CSVs/30d-filter-goal/browsers.csv b/test/plausible_web/controllers/CSVs/30d-filter-goal/browsers.csv new file mode 100644 index 0000000000000000000000000000000000000000..89deb168d4703372d423927582ca1b810f558389 --- /dev/null +++ b/test/plausible_web/controllers/CSVs/30d-filter-goal/browsers.csv @@ -0,0 +1,2 @@ +name,conversions,conversion_rate +(not set),1,50.0 diff --git a/test/plausible_web/controllers/CSVs/30d-filter-goal/channels.csv b/test/plausible_web/controllers/CSVs/30d-filter-goal/channels.csv new file mode 100644 index 0000000000000000000000000000000000000000..89a3db835f66cca28a9511b73bd8a1032898b8a4 --- /dev/null +++ b/test/plausible_web/controllers/CSVs/30d-filter-goal/channels.csv @@ -0,0 +1,2 @@ +name,conversions,conversion_rate +Direct,1,33.33 diff --git a/test/plausible_web/controllers/CSVs/30d-filter-goal/cities.csv b/test/plausible_web/controllers/CSVs/30d-filter-goal/cities.csv new file mode 100644 index 0000000000000000000000000000000000000000..854a70d52fc5de746a3100299ed187da1d911e75 --- /dev/null +++ b/test/plausible_web/controllers/CSVs/30d-filter-goal/cities.csv @@ -0,0 +1 @@ +name,conversions,conversion_rate diff --git a/test/plausible_web/controllers/CSVs/30d-filter-goal/conversions.csv b/test/plausible_web/controllers/CSVs/30d-filter-goal/conversions.csv new file mode 100644 index 0000000000000000000000000000000000000000..5c3bf52284c34f6914f0b178860a44afb005e9a2 --- /dev/null +++ b/test/plausible_web/controllers/CSVs/30d-filter-goal/conversions.csv @@ -0,0 +1,2 @@ +name,unique_conversions,total_conversions +Signup,1,1 diff --git a/test/plausible_web/controllers/CSVs/30d-filter-goal/countries.csv b/test/plausible_web/controllers/CSVs/30d-filter-goal/countries.csv new file mode 100644 index 0000000000000000000000000000000000000000..854a70d52fc5de746a3100299ed187da1d911e75 --- /dev/null +++ b/test/plausible_web/controllers/CSVs/30d-filter-goal/countries.csv @@ -0,0 +1 @@ +name,conversions,conversion_rate diff --git a/test/plausible_web/controllers/CSVs/30d-filter-goal/custom_props.csv b/test/plausible_web/controllers/CSVs/30d-filter-goal/custom_props.csv new file mode 100644 index 0000000000000000000000000000000000000000..0a7292eeb900b0f0c4b2485e58fdc7779a27511c --- /dev/null +++ b/test/plausible_web/controllers/CSVs/30d-filter-goal/custom_props.csv @@ -0,0 +1,2 @@ +property,value,visitors,events,conversion_rate +variant,A,1,1,25.0 diff --git a/test/plausible_web/controllers/CSVs/30d-filter-goal/devices.csv b/test/plausible_web/controllers/CSVs/30d-filter-goal/devices.csv new file mode 100644 index 0000000000000000000000000000000000000000..cd761192e6cfa4c5cf1084baf7af0c3eb609703b --- /dev/null +++ b/test/plausible_web/controllers/CSVs/30d-filter-goal/devices.csv @@ -0,0 +1,2 @@ +name,conversions,conversion_rate +(not set),1,25.0 diff --git a/test/plausible_web/controllers/CSVs/30d-filter-goal/entry_pages.csv b/test/plausible_web/controllers/CSVs/30d-filter-goal/entry_pages.csv new file mode 100644 index 0000000000000000000000000000000000000000..0f6f2cfa5824932c34557d6dc06bc6996da9422a --- /dev/null +++ b/test/plausible_web/controllers/CSVs/30d-filter-goal/entry_pages.csv @@ -0,0 +1,2 @@ +name,conversions,conversion_rate +/signup,1,100.0 diff --git a/test/plausible_web/controllers/CSVs/30d-filter-goal/exit_pages.csv b/test/plausible_web/controllers/CSVs/30d-filter-goal/exit_pages.csv new file mode 100644 index 0000000000000000000000000000000000000000..0f6f2cfa5824932c34557d6dc06bc6996da9422a --- /dev/null +++ b/test/plausible_web/controllers/CSVs/30d-filter-goal/exit_pages.csv @@ -0,0 +1,2 @@ +name,conversions,conversion_rate +/signup,1,100.0 diff --git a/test/plausible_web/controllers/CSVs/30d-filter-goal/operating_system_versions.csv b/test/plausible_web/controllers/CSVs/30d-filter-goal/operating_system_versions.csv new file mode 100644 index 0000000000000000000000000000000000000000..a819274351e1cd92caa6e9c0694c536e61e21bf0 --- /dev/null +++ b/test/plausible_web/controllers/CSVs/30d-filter-goal/operating_system_versions.csv @@ -0,0 +1,2 @@ +name,version,conversions,conversion_rate +(not set),(not set),1,50.0 diff --git a/test/plausible_web/controllers/CSVs/30d-filter-goal/operating_systems.csv b/test/plausible_web/controllers/CSVs/30d-filter-goal/operating_systems.csv new file mode 100644 index 0000000000000000000000000000000000000000..89deb168d4703372d423927582ca1b810f558389 --- /dev/null +++ b/test/plausible_web/controllers/CSVs/30d-filter-goal/operating_systems.csv @@ -0,0 +1,2 @@ +name,conversions,conversion_rate +(not set),1,50.0 diff --git a/test/plausible_web/controllers/CSVs/30d-filter-goal/pages.csv b/test/plausible_web/controllers/CSVs/30d-filter-goal/pages.csv new file mode 100644 index 0000000000000000000000000000000000000000..f2471bfe9d83c5629721fc4c0fe73dfe0191ea9b --- /dev/null +++ b/test/plausible_web/controllers/CSVs/30d-filter-goal/pages.csv @@ -0,0 +1,2 @@ +name,conversions,conversion_rate +/,1,25.0 diff --git a/test/plausible_web/controllers/CSVs/30d-filter-goal/referrers.csv b/test/plausible_web/controllers/CSVs/30d-filter-goal/referrers.csv new file mode 100644 index 0000000000000000000000000000000000000000..fcc1dfa6808728bfeae8679b39c91c6307e72764 --- /dev/null +++ b/test/plausible_web/controllers/CSVs/30d-filter-goal/referrers.csv @@ -0,0 +1,2 @@ +name,conversions,conversion_rate +Direct / None,1,25.0 diff --git a/test/plausible_web/controllers/CSVs/30d-filter-goal/regions.csv b/test/plausible_web/controllers/CSVs/30d-filter-goal/regions.csv new file mode 100644 index 0000000000000000000000000000000000000000..854a70d52fc5de746a3100299ed187da1d911e75 --- /dev/null +++ b/test/plausible_web/controllers/CSVs/30d-filter-goal/regions.csv @@ -0,0 +1 @@ +name,conversions,conversion_rate diff --git a/test/plausible_web/controllers/CSVs/30d-filter-goal/sources.csv b/test/plausible_web/controllers/CSVs/30d-filter-goal/sources.csv new file mode 100644 index 0000000000000000000000000000000000000000..452c1c44ec1ba4898b5d640c361df0548412ec89 --- /dev/null +++ b/test/plausible_web/controllers/CSVs/30d-filter-goal/sources.csv @@ -0,0 +1,2 @@ +name,conversions,conversion_rate +Direct / None,1,33.33 diff --git a/test/plausible_web/controllers/CSVs/30d-filter-goal/utm_campaigns.csv b/test/plausible_web/controllers/CSVs/30d-filter-goal/utm_campaigns.csv new file mode 100644 index 0000000000000000000000000000000000000000..854a70d52fc5de746a3100299ed187da1d911e75 --- /dev/null +++ b/test/plausible_web/controllers/CSVs/30d-filter-goal/utm_campaigns.csv @@ -0,0 +1 @@ +name,conversions,conversion_rate diff --git a/test/plausible_web/controllers/CSVs/30d-filter-goal/utm_contents.csv b/test/plausible_web/controllers/CSVs/30d-filter-goal/utm_contents.csv new file mode 100644 index 0000000000000000000000000000000000000000..854a70d52fc5de746a3100299ed187da1d911e75 --- /dev/null +++ b/test/plausible_web/controllers/CSVs/30d-filter-goal/utm_contents.csv @@ -0,0 +1 @@ +name,conversions,conversion_rate diff --git a/test/plausible_web/controllers/CSVs/30d-filter-goal/utm_mediums.csv b/test/plausible_web/controllers/CSVs/30d-filter-goal/utm_mediums.csv new file mode 100644 index 0000000000000000000000000000000000000000..854a70d52fc5de746a3100299ed187da1d911e75 --- /dev/null +++ b/test/plausible_web/controllers/CSVs/30d-filter-goal/utm_mediums.csv @@ -0,0 +1 @@ +name,conversions,conversion_rate diff --git a/test/plausible_web/controllers/CSVs/30d-filter-goal/utm_sources.csv b/test/plausible_web/controllers/CSVs/30d-filter-goal/utm_sources.csv new file mode 100644 index 0000000000000000000000000000000000000000..854a70d52fc5de746a3100299ed187da1d911e75 --- /dev/null +++ b/test/plausible_web/controllers/CSVs/30d-filter-goal/utm_sources.csv @@ -0,0 +1 @@ +name,conversions,conversion_rate diff --git a/test/plausible_web/controllers/CSVs/30d-filter-goal/utm_terms.csv b/test/plausible_web/controllers/CSVs/30d-filter-goal/utm_terms.csv new file mode 100644 index 0000000000000000000000000000000000000000..854a70d52fc5de746a3100299ed187da1d911e75 --- /dev/null +++ b/test/plausible_web/controllers/CSVs/30d-filter-goal/utm_terms.csv @@ -0,0 +1 @@ +name,conversions,conversion_rate diff --git a/test/plausible_web/controllers/CSVs/30d-filter-goal/visitors.csv b/test/plausible_web/controllers/CSVs/30d-filter-goal/visitors.csv new file mode 100644 index 0000000000000000000000000000000000000000..aab21a073452ee1613ab0e8d394b774adc2ed33e --- /dev/null +++ b/test/plausible_web/controllers/CSVs/30d-filter-goal/visitors.csv @@ -0,0 +1,32 @@ +date,unique_conversions,total_conversions,conversion_rate +2021-09-20,0,0,0.0 +2021-09-21,0,0,0.0 +2021-09-22,0,0,0.0 +2021-09-23,0,0,0.0 +2021-09-24,0,0,0.0 +2021-09-25,0,0,0.0 +2021-09-26,0,0,0.0 +2021-09-27,0,0,0.0 +2021-09-28,0,0,0.0 +2021-09-29,0,0,0.0 +2021-09-30,0,0,0.0 +2021-10-01,0,0,0.0 +2021-10-02,0,0,0.0 +2021-10-03,0,0,0.0 +2021-10-04,0,0,0.0 +2021-10-05,0,0,0.0 +2021-10-06,0,0,0.0 +2021-10-07,0,0,0.0 +2021-10-08,0,0,0.0 +2021-10-09,0,0,0.0 +2021-10-10,0,0,0.0 +2021-10-11,0,0,0.0 +2021-10-12,0,0,0.0 +2021-10-13,0,0,0.0 +2021-10-14,0,0,0.0 +2021-10-15,0,0,0.0 +2021-10-16,0,0,0.0 +2021-10-17,0,0,0.0 +2021-10-18,0,0,0.0 +2021-10-19,1,1,50.0 +2021-10-20,0,0,0.0 diff --git a/test/plausible_web/controllers/CSVs/30d-filter-path/browser_versions.csv b/test/plausible_web/controllers/CSVs/30d-filter-path/browser_versions.csv new file mode 100644 index 0000000000000000000000000000000000000000..96f76c8b007b01bfbb3a6e3e3ea587bcffca3355 --- /dev/null +++ b/test/plausible_web/controllers/CSVs/30d-filter-path/browser_versions.csv @@ -0,0 +1,2 @@ +name,version,visitors +(not set),(not set),1 diff --git a/test/plausible_web/controllers/CSVs/30d-filter-path/browsers.csv b/test/plausible_web/controllers/CSVs/30d-filter-path/browsers.csv new file mode 100644 index 0000000000000000000000000000000000000000..d7aedd4cc9341738120006d8fa192a5f78608c80 --- /dev/null +++ b/test/plausible_web/controllers/CSVs/30d-filter-path/browsers.csv @@ -0,0 +1,2 @@ +name,visitors +(not set),1 diff --git a/test/plausible_web/controllers/CSVs/30d-filter-path/channels.csv b/test/plausible_web/controllers/CSVs/30d-filter-path/channels.csv new file mode 100644 index 0000000000000000000000000000000000000000..2a5bdece047226ab4f3f6e389566bb802c7fb9e9 --- /dev/null +++ b/test/plausible_web/controllers/CSVs/30d-filter-path/channels.csv @@ -0,0 +1,2 @@ +name,visitors,bounce_rate,visit_duration +Organic Search,1,0,60 diff --git a/test/plausible_web/controllers/CSVs/30d-filter-path/cities.csv b/test/plausible_web/controllers/CSVs/30d-filter-path/cities.csv new file mode 100644 index 0000000000000000000000000000000000000000..9816303bc6d03f33e4d8f6b5cef0f4899b68326a --- /dev/null +++ b/test/plausible_web/controllers/CSVs/30d-filter-path/cities.csv @@ -0,0 +1,2 @@ +name,visitors +Tallinn,1 diff --git a/test/plausible_web/controllers/CSVs/30d-filter-path/conversions.csv b/test/plausible_web/controllers/CSVs/30d-filter-path/conversions.csv new file mode 100644 index 0000000000000000000000000000000000000000..583f280ff6efc84b51d29d5b3505251a5943dee0 --- /dev/null +++ b/test/plausible_web/controllers/CSVs/30d-filter-path/conversions.csv @@ -0,0 +1 @@ +name,unique_conversions,total_conversions diff --git a/test/plausible_web/controllers/CSVs/30d-filter-path/countries.csv b/test/plausible_web/controllers/CSVs/30d-filter-path/countries.csv new file mode 100644 index 0000000000000000000000000000000000000000..7fd0fa6da7d41b5617370bc2e336be44fde15354 --- /dev/null +++ b/test/plausible_web/controllers/CSVs/30d-filter-path/countries.csv @@ -0,0 +1,2 @@ +name,visitors +Estonia,1 diff --git a/test/plausible_web/controllers/CSVs/30d-filter-path/custom_props.csv b/test/plausible_web/controllers/CSVs/30d-filter-path/custom_props.csv new file mode 100644 index 0000000000000000000000000000000000000000..ccda364455505f832b6947c666aee9a19ef4e625 --- /dev/null +++ b/test/plausible_web/controllers/CSVs/30d-filter-path/custom_props.csv @@ -0,0 +1 @@ +property,value,visitors,events,percentage diff --git a/test/plausible_web/controllers/CSVs/30d-filter-path/devices.csv b/test/plausible_web/controllers/CSVs/30d-filter-path/devices.csv new file mode 100644 index 0000000000000000000000000000000000000000..d7aedd4cc9341738120006d8fa192a5f78608c80 --- /dev/null +++ b/test/plausible_web/controllers/CSVs/30d-filter-path/devices.csv @@ -0,0 +1,2 @@ +name,visitors +(not set),1 diff --git a/test/plausible_web/controllers/CSVs/30d-filter-path/entry_pages.csv b/test/plausible_web/controllers/CSVs/30d-filter-path/entry_pages.csv new file mode 100644 index 0000000000000000000000000000000000000000..05da28508d63fa2cbd16e31fa5503381f9677c6a --- /dev/null +++ b/test/plausible_web/controllers/CSVs/30d-filter-path/entry_pages.csv @@ -0,0 +1,2 @@ +name,unique_entrances,total_entrances,bounce_rate,visit_duration +/,1,1,0,60 diff --git a/test/plausible_web/controllers/CSVs/30d-filter-path/exit_pages.csv b/test/plausible_web/controllers/CSVs/30d-filter-path/exit_pages.csv new file mode 100644 index 0000000000000000000000000000000000000000..fd838ca9eda68eeb92181870ac4f87f085f5ad49 --- /dev/null +++ b/test/plausible_web/controllers/CSVs/30d-filter-path/exit_pages.csv @@ -0,0 +1,2 @@ +name,unique_exits,total_exits +/some-other-page,1,1 diff --git a/test/plausible_web/controllers/CSVs/30d-filter-path/operating_system_versions.csv b/test/plausible_web/controllers/CSVs/30d-filter-path/operating_system_versions.csv new file mode 100644 index 0000000000000000000000000000000000000000..96f76c8b007b01bfbb3a6e3e3ea587bcffca3355 --- /dev/null +++ b/test/plausible_web/controllers/CSVs/30d-filter-path/operating_system_versions.csv @@ -0,0 +1,2 @@ +name,version,visitors +(not set),(not set),1 diff --git a/test/plausible_web/controllers/CSVs/30d-filter-path/operating_systems.csv b/test/plausible_web/controllers/CSVs/30d-filter-path/operating_systems.csv new file mode 100644 index 0000000000000000000000000000000000000000..d7aedd4cc9341738120006d8fa192a5f78608c80 --- /dev/null +++ b/test/plausible_web/controllers/CSVs/30d-filter-path/operating_systems.csv @@ -0,0 +1,2 @@ +name,visitors +(not set),1 diff --git a/test/plausible_web/controllers/CSVs/30d-filter-path/pages.csv b/test/plausible_web/controllers/CSVs/30d-filter-path/pages.csv new file mode 100644 index 0000000000000000000000000000000000000000..c998a05bb4aa76a1d3adcd002fc17af99679661b --- /dev/null +++ b/test/plausible_web/controllers/CSVs/30d-filter-path/pages.csv @@ -0,0 +1,2 @@ +name,visitors,pageviews,bounce_rate,time_on_page,scroll_depth +/some-other-page,1,1,0,60,30 diff --git a/test/plausible_web/controllers/CSVs/30d-filter-path/referrers.csv b/test/plausible_web/controllers/CSVs/30d-filter-path/referrers.csv new file mode 100644 index 0000000000000000000000000000000000000000..324c36e7f479920079c30f044df1e5ad3f9d56af --- /dev/null +++ b/test/plausible_web/controllers/CSVs/30d-filter-path/referrers.csv @@ -0,0 +1,2 @@ +name,visitors,bounce_rate,visit_duration +Direct / None,1,0,60 diff --git a/test/plausible_web/controllers/CSVs/30d-filter-path/regions.csv b/test/plausible_web/controllers/CSVs/30d-filter-path/regions.csv new file mode 100644 index 0000000000000000000000000000000000000000..94104deeb5298c689e91da9df3e15e8e1e8b0081 --- /dev/null +++ b/test/plausible_web/controllers/CSVs/30d-filter-path/regions.csv @@ -0,0 +1,2 @@ +name,visitors +Harjumaa,1 diff --git a/test/plausible_web/controllers/CSVs/30d-filter-path/sources.csv b/test/plausible_web/controllers/CSVs/30d-filter-path/sources.csv new file mode 100644 index 0000000000000000000000000000000000000000..3b22e30a010230452f49c48515f37f8c9317b4ef --- /dev/null +++ b/test/plausible_web/controllers/CSVs/30d-filter-path/sources.csv @@ -0,0 +1,2 @@ +name,visitors,bounce_rate,visit_duration +Google,1,0,60 diff --git a/test/plausible_web/controllers/CSVs/30d-filter-path/utm_campaigns.csv b/test/plausible_web/controllers/CSVs/30d-filter-path/utm_campaigns.csv new file mode 100644 index 0000000000000000000000000000000000000000..773a58cf75d2403876c6b94d946ab44d7476c3b4 --- /dev/null +++ b/test/plausible_web/controllers/CSVs/30d-filter-path/utm_campaigns.csv @@ -0,0 +1 @@ +name,visitors,bounce_rate,visit_duration diff --git a/test/plausible_web/controllers/CSVs/30d-filter-path/utm_contents.csv b/test/plausible_web/controllers/CSVs/30d-filter-path/utm_contents.csv new file mode 100644 index 0000000000000000000000000000000000000000..773a58cf75d2403876c6b94d946ab44d7476c3b4 --- /dev/null +++ b/test/plausible_web/controllers/CSVs/30d-filter-path/utm_contents.csv @@ -0,0 +1 @@ +name,visitors,bounce_rate,visit_duration diff --git a/test/plausible_web/controllers/CSVs/30d-filter-path/utm_mediums.csv b/test/plausible_web/controllers/CSVs/30d-filter-path/utm_mediums.csv new file mode 100644 index 0000000000000000000000000000000000000000..773a58cf75d2403876c6b94d946ab44d7476c3b4 --- /dev/null +++ b/test/plausible_web/controllers/CSVs/30d-filter-path/utm_mediums.csv @@ -0,0 +1 @@ +name,visitors,bounce_rate,visit_duration diff --git a/test/plausible_web/controllers/CSVs/30d-filter-path/utm_sources.csv b/test/plausible_web/controllers/CSVs/30d-filter-path/utm_sources.csv new file mode 100644 index 0000000000000000000000000000000000000000..773a58cf75d2403876c6b94d946ab44d7476c3b4 --- /dev/null +++ b/test/plausible_web/controllers/CSVs/30d-filter-path/utm_sources.csv @@ -0,0 +1 @@ +name,visitors,bounce_rate,visit_duration diff --git a/test/plausible_web/controllers/CSVs/30d-filter-path/utm_terms.csv b/test/plausible_web/controllers/CSVs/30d-filter-path/utm_terms.csv new file mode 100644 index 0000000000000000000000000000000000000000..773a58cf75d2403876c6b94d946ab44d7476c3b4 --- /dev/null +++ b/test/plausible_web/controllers/CSVs/30d-filter-path/utm_terms.csv @@ -0,0 +1 @@ +name,visitors,bounce_rate,visit_duration diff --git a/test/plausible_web/controllers/CSVs/30d-filter-path/visitors.csv b/test/plausible_web/controllers/CSVs/30d-filter-path/visitors.csv new file mode 100644 index 0000000000000000000000000000000000000000..e345da62c5dce361d2bb0218f6b2ad874de7b32a --- /dev/null +++ b/test/plausible_web/controllers/CSVs/30d-filter-path/visitors.csv @@ -0,0 +1,32 @@ +date,visitors,pageviews,visits,bounce_rate,time_on_page,scroll_depth +2021-09-20,0,0,0,0.0,, +2021-09-21,0,0,0,0.0,, +2021-09-22,0,0,0,0.0,, +2021-09-23,0,0,0,0.0,, +2021-09-24,0,0,0,0.0,, +2021-09-25,0,0,0,0.0,, +2021-09-26,0,0,0,0.0,, +2021-09-27,0,0,0,0.0,, +2021-09-28,0,0,0,0.0,, +2021-09-29,0,0,0,0.0,, +2021-09-30,0,0,0,0.0,, +2021-10-01,0,0,0,0.0,, +2021-10-02,0,0,0,0.0,, +2021-10-03,0,0,0,0.0,, +2021-10-04,0,0,0,0.0,, +2021-10-05,0,0,0,0.0,, +2021-10-06,0,0,0,0.0,, +2021-10-07,0,0,0,0.0,, +2021-10-08,0,0,0,0.0,, +2021-10-09,0,0,0,0.0,, +2021-10-10,0,0,0,0.0,, +2021-10-11,0,0,0,0.0,, +2021-10-12,0,0,0,0.0,, +2021-10-13,0,0,0,0.0,, +2021-10-14,0,0,0,0.0,, +2021-10-15,0,0,0,0.0,, +2021-10-16,0,0,0,0.0,, +2021-10-17,0,0,0,0.0,, +2021-10-18,0,0,0,0.0,, +2021-10-19,0,0,0,0.0,, +2021-10-20,1,1,1,0,60,30 diff --git a/test/plausible_web/controllers/CSVs/30d/referrers.csv b/test/plausible_web/controllers/CSVs/30d/referrers.csv new file mode 100644 index 0000000000000000000000000000000000000000..b8a074551038434055f8f6e57d3f9d3b64f96583 --- /dev/null +++ b/test/plausible_web/controllers/CSVs/30d/referrers.csv @@ -0,0 +1,2 @@ +name,visitors,bounce_rate,visit_duration +Direct / None,4,50,30 diff --git a/test/plausible_web/controllers/CSVs/30d/regions.csv b/test/plausible_web/controllers/CSVs/30d/regions.csv new file mode 100644 index 0000000000000000000000000000000000000000..94104deeb5298c689e91da9df3e15e8e1e8b0081 --- /dev/null +++ b/test/plausible_web/controllers/CSVs/30d/regions.csv @@ -0,0 +1,2 @@ +name,visitors +Harjumaa,1 diff --git a/test/plausible_web/controllers/CSVs/30d/sources.csv b/test/plausible_web/controllers/CSVs/30d/sources.csv new file mode 100644 index 0000000000000000000000000000000000000000..f9d0b8c92efb64d787844065f102404215a69486 --- /dev/null +++ b/test/plausible_web/controllers/CSVs/30d/sources.csv @@ -0,0 +1,3 @@ +name,visitors,bounce_rate,visit_duration +Direct / None,3,67,20 +Google,1,0,60 diff --git a/test/plausible_web/controllers/CSVs/30d/utm_campaigns.csv b/test/plausible_web/controllers/CSVs/30d/utm_campaigns.csv new file mode 100644 index 0000000000000000000000000000000000000000..934651b6b9f9d48e5dc8067761eadca7598c46b9 --- /dev/null +++ b/test/plausible_web/controllers/CSVs/30d/utm_campaigns.csv @@ -0,0 +1,2 @@ +name,visitors,bounce_rate,visit_duration +ads,1,100,0 diff --git a/test/plausible_web/controllers/CSVs/30d/utm_contents.csv b/test/plausible_web/controllers/CSVs/30d/utm_contents.csv new file mode 100644 index 0000000000000000000000000000000000000000..6f8d1ba25516033d0a54db2de2c8ff211dda2d4b --- /dev/null +++ b/test/plausible_web/controllers/CSVs/30d/utm_contents.csv @@ -0,0 +1,2 @@ +name,visitors,bounce_rate,visit_duration +content,1,100,0 diff --git a/test/plausible_web/controllers/CSVs/30d/utm_mediums.csv b/test/plausible_web/controllers/CSVs/30d/utm_mediums.csv new file mode 100644 index 0000000000000000000000000000000000000000..1fe1c21a2856803bf7d4cbb2d7f31c1e5fc40815 --- /dev/null +++ b/test/plausible_web/controllers/CSVs/30d/utm_mediums.csv @@ -0,0 +1,2 @@ +name,visitors,bounce_rate,visit_duration +search,1,100,0 diff --git a/test/plausible_web/controllers/CSVs/30d/utm_sources.csv b/test/plausible_web/controllers/CSVs/30d/utm_sources.csv new file mode 100644 index 0000000000000000000000000000000000000000..b32c9660a021d76334d5ac4845e9aec8aa242064 --- /dev/null +++ b/test/plausible_web/controllers/CSVs/30d/utm_sources.csv @@ -0,0 +1,2 @@ +name,visitors,bounce_rate,visit_duration +google,1,100,0 diff --git a/test/plausible_web/controllers/CSVs/30d/utm_terms.csv b/test/plausible_web/controllers/CSVs/30d/utm_terms.csv new file mode 100644 index 0000000000000000000000000000000000000000..cbd3a7ce404733f8418d4ba4bab5c8af6e930d56 --- /dev/null +++ b/test/plausible_web/controllers/CSVs/30d/utm_terms.csv @@ -0,0 +1,2 @@ +name,visitors,bounce_rate,visit_duration +term,1,100,0 diff --git a/test/plausible_web/controllers/CSVs/30d/visitors.csv b/test/plausible_web/controllers/CSVs/30d/visitors.csv new file mode 100644 index 0000000000000000000000000000000000000000..76dbc7a9c5d572616cbba9d1ae7cf13316e01c81 --- /dev/null +++ b/test/plausible_web/controllers/CSVs/30d/visitors.csv @@ -0,0 +1,32 @@ +date,visitors,pageviews,visits,views_per_visit,bounce_rate,visit_duration +2021-09-20,1,1,1,1.0,100,0 +2021-09-21,0,0,0,0.0,0.0, +2021-09-22,0,0,0,0.0,0.0, +2021-09-23,0,0,0,0.0,0.0, +2021-09-24,0,0,0,0.0,0.0, +2021-09-25,0,0,0,0.0,0.0, +2021-09-26,0,0,0,0.0,0.0, +2021-09-27,0,0,0,0.0,0.0, +2021-09-28,0,0,0,0.0,0.0, +2021-09-29,0,0,0,0.0,0.0, +2021-09-30,0,0,0,0.0,0.0, +2021-10-01,0,0,0,0.0,0.0, +2021-10-02,0,0,0,0.0,0.0, +2021-10-03,0,0,0,0.0,0.0, +2021-10-04,0,0,0,0.0,0.0, +2021-10-05,0,0,0,0.0,0.0, +2021-10-06,0,0,0,0.0,0.0, +2021-10-07,0,0,0,0.0,0.0, +2021-10-08,0,0,0,0.0,0.0, +2021-10-09,0,0,0,0.0,0.0, +2021-10-10,0,0,0,0.0,0.0, +2021-10-11,0,0,0,0.0,0.0, +2021-10-12,0,0,0,0.0,0.0, +2021-10-13,0,0,0,0.0,0.0, +2021-10-14,0,0,0,0.0,0.0, +2021-10-15,0,0,0,0.0,0.0, +2021-10-16,0,0,0,0.0,0.0, +2021-10-17,0,0,0,0.0,0.0, +2021-10-18,0,0,0,0.0,0.0, +2021-10-19,2,2,2,1.0,50,30 +2021-10-20,1,2,1,2.0,0,60 diff --git a/test/plausible_web/controllers/CSVs/6m/browser_versions.csv b/test/plausible_web/controllers/CSVs/6m/browser_versions.csv new file mode 100644 index 0000000000000000000000000000000000000000..2d11b2d3e89d0e90feeda69cb06b45bf7e9c49a0 --- /dev/null +++ b/test/plausible_web/controllers/CSVs/6m/browser_versions.csv @@ -0,0 +1,4 @@ +name,version,visitors +(not set),(not set),2 +Firefox,120,2 +FirefoxNoVersion,(not set),1 diff --git a/test/plausible_web/controllers/CSVs/6m/browsers.csv b/test/plausible_web/controllers/CSVs/6m/browsers.csv new file mode 100644 index 0000000000000000000000000000000000000000..1fb59812349c55f9c86329f79f41b7ecd70a43ac --- /dev/null +++ b/test/plausible_web/controllers/CSVs/6m/browsers.csv @@ -0,0 +1,4 @@ +name,visitors +(not set),2 +Firefox,2 +FirefoxNoVersion,1 diff --git a/test/plausible_web/controllers/CSVs/6m/channels.csv b/test/plausible_web/controllers/CSVs/6m/channels.csv new file mode 100644 index 0000000000000000000000000000000000000000..e7c51aeed68a4d9d7071ed5f86286ae446242282 --- /dev/null +++ b/test/plausible_web/controllers/CSVs/6m/channels.csv @@ -0,0 +1,4 @@ +name,visitors,bounce_rate,visit_duration +Direct,3,67,20 +Organic Search,1,0,60 +Paid Search,1,100,0 diff --git a/test/plausible_web/controllers/CSVs/6m/cities.csv b/test/plausible_web/controllers/CSVs/6m/cities.csv new file mode 100644 index 0000000000000000000000000000000000000000..9816303bc6d03f33e4d8f6b5cef0f4899b68326a --- /dev/null +++ b/test/plausible_web/controllers/CSVs/6m/cities.csv @@ -0,0 +1,2 @@ +name,visitors +Tallinn,1 diff --git a/test/plausible_web/controllers/CSVs/6m/conversions.csv b/test/plausible_web/controllers/CSVs/6m/conversions.csv new file mode 100644 index 0000000000000000000000000000000000000000..5c3bf52284c34f6914f0b178860a44afb005e9a2 --- /dev/null +++ b/test/plausible_web/controllers/CSVs/6m/conversions.csv @@ -0,0 +1,2 @@ +name,unique_conversions,total_conversions +Signup,1,1 diff --git a/test/plausible_web/controllers/CSVs/6m/countries.csv b/test/plausible_web/controllers/CSVs/6m/countries.csv new file mode 100644 index 0000000000000000000000000000000000000000..4daa0cc026f076032ef50a7c7b7baac6507b60ff --- /dev/null +++ b/test/plausible_web/controllers/CSVs/6m/countries.csv @@ -0,0 +1,2 @@ +name,visitors +Estonia,3 diff --git a/test/plausible_web/controllers/CSVs/6m/custom_props.csv b/test/plausible_web/controllers/CSVs/6m/custom_props.csv new file mode 100644 index 0000000000000000000000000000000000000000..8c6ddd707d666be1a309346412c18d7e13185396 --- /dev/null +++ b/test/plausible_web/controllers/CSVs/6m/custom_props.csv @@ -0,0 +1,3 @@ +property,value,visitors,events,percentage +variant,(none),4,5,80.0 +variant,A,1,2,20.0 diff --git a/test/plausible_web/controllers/CSVs/6m/devices.csv b/test/plausible_web/controllers/CSVs/6m/devices.csv new file mode 100644 index 0000000000000000000000000000000000000000..f574b0d4bab47ee20020a01772011218ea14e903 --- /dev/null +++ b/test/plausible_web/controllers/CSVs/6m/devices.csv @@ -0,0 +1,2 @@ +name,visitors +(not set),5 diff --git a/test/plausible_web/controllers/CSVs/6m/entry_pages.csv b/test/plausible_web/controllers/CSVs/6m/entry_pages.csv new file mode 100644 index 0000000000000000000000000000000000000000..61fd1962c715bbb614fc4632e9a784f6e411ed76 --- /dev/null +++ b/test/plausible_web/controllers/CSVs/6m/entry_pages.csv @@ -0,0 +1,3 @@ +name,unique_entrances,total_entrances,bounce_rate,visit_duration +/,4,4,75,15 +/signup,1,1,0,60 diff --git a/test/plausible_web/controllers/CSVs/6m/exit_pages.csv b/test/plausible_web/controllers/CSVs/6m/exit_pages.csv new file mode 100644 index 0000000000000000000000000000000000000000..bb235645af490ff0bb414656eb5452f26ce590fb --- /dev/null +++ b/test/plausible_web/controllers/CSVs/6m/exit_pages.csv @@ -0,0 +1,4 @@ +name,unique_exits,total_exits,exit_rate +/,3,3,75.0 +/signup,1,1,100.0 +/some-other-page,1,1,100.0 diff --git a/test/plausible_web/controllers/CSVs/6m/operating_system_versions.csv b/test/plausible_web/controllers/CSVs/6m/operating_system_versions.csv new file mode 100644 index 0000000000000000000000000000000000000000..2f10aaf693e255138b814cbe19da0acbea6c42dd --- /dev/null +++ b/test/plausible_web/controllers/CSVs/6m/operating_system_versions.csv @@ -0,0 +1,4 @@ +name,version,visitors +(not set),(not set),2 +Mac,14,2 +MacNoVersion,(not set),1 diff --git a/test/plausible_web/controllers/CSVs/6m/operating_systems.csv b/test/plausible_web/controllers/CSVs/6m/operating_systems.csv new file mode 100644 index 0000000000000000000000000000000000000000..86a01a8ede63a7445c5c07ca5845ca53399580ca --- /dev/null +++ b/test/plausible_web/controllers/CSVs/6m/operating_systems.csv @@ -0,0 +1,4 @@ +name,visitors +(not set),2 +Mac,2 +MacNoVersion,1 diff --git a/test/plausible_web/controllers/CSVs/6m/pages.csv b/test/plausible_web/controllers/CSVs/6m/pages.csv new file mode 100644 index 0000000000000000000000000000000000000000..31e6fd57835820e2c4c438199c183640bc01fe79 --- /dev/null +++ b/test/plausible_web/controllers/CSVs/6m/pages.csv @@ -0,0 +1,4 @@ +name,visitors,pageviews,bounce_rate,time_on_page,scroll_depth +/,5,4,75,30,25 +/signup,1,1,0,60,20 +/some-other-page,1,1,0,60,30 diff --git a/test/plausible_web/controllers/CSVs/6m/referrers.csv b/test/plausible_web/controllers/CSVs/6m/referrers.csv new file mode 100644 index 0000000000000000000000000000000000000000..fc901f7718f2446e6055723e2f8dfaff38098005 --- /dev/null +++ b/test/plausible_web/controllers/CSVs/6m/referrers.csv @@ -0,0 +1,2 @@ +name,visitors,bounce_rate,visit_duration +Direct / None,5,60,24 diff --git a/test/plausible_web/controllers/CSVs/6m/regions.csv b/test/plausible_web/controllers/CSVs/6m/regions.csv new file mode 100644 index 0000000000000000000000000000000000000000..94104deeb5298c689e91da9df3e15e8e1e8b0081 --- /dev/null +++ b/test/plausible_web/controllers/CSVs/6m/regions.csv @@ -0,0 +1,2 @@ +name,visitors +Harjumaa,1 diff --git a/test/plausible_web/controllers/CSVs/6m/sources.csv b/test/plausible_web/controllers/CSVs/6m/sources.csv new file mode 100644 index 0000000000000000000000000000000000000000..259c3a23face421ee7ac69234e28a2e0ceb94cd6 --- /dev/null +++ b/test/plausible_web/controllers/CSVs/6m/sources.csv @@ -0,0 +1,3 @@ +name,visitors,bounce_rate,visit_duration +Direct / None,3,67,20 +Google,2,50,30 diff --git a/test/plausible_web/controllers/CSVs/6m/utm_campaigns.csv b/test/plausible_web/controllers/CSVs/6m/utm_campaigns.csv new file mode 100644 index 0000000000000000000000000000000000000000..13779d3f0df2ff103dce651059b248802c66d57b --- /dev/null +++ b/test/plausible_web/controllers/CSVs/6m/utm_campaigns.csv @@ -0,0 +1,2 @@ +name,visitors,bounce_rate,visit_duration +ads,2,100,0 diff --git a/test/plausible_web/controllers/CSVs/6m/utm_contents.csv b/test/plausible_web/controllers/CSVs/6m/utm_contents.csv new file mode 100644 index 0000000000000000000000000000000000000000..6f8d1ba25516033d0a54db2de2c8ff211dda2d4b --- /dev/null +++ b/test/plausible_web/controllers/CSVs/6m/utm_contents.csv @@ -0,0 +1,2 @@ +name,visitors,bounce_rate,visit_duration +content,1,100,0 diff --git a/test/plausible_web/controllers/CSVs/6m/utm_mediums.csv b/test/plausible_web/controllers/CSVs/6m/utm_mediums.csv new file mode 100644 index 0000000000000000000000000000000000000000..1fe1c21a2856803bf7d4cbb2d7f31c1e5fc40815 --- /dev/null +++ b/test/plausible_web/controllers/CSVs/6m/utm_mediums.csv @@ -0,0 +1,2 @@ +name,visitors,bounce_rate,visit_duration +search,1,100,0 diff --git a/test/plausible_web/controllers/CSVs/6m/utm_sources.csv b/test/plausible_web/controllers/CSVs/6m/utm_sources.csv new file mode 100644 index 0000000000000000000000000000000000000000..b32c9660a021d76334d5ac4845e9aec8aa242064 --- /dev/null +++ b/test/plausible_web/controllers/CSVs/6m/utm_sources.csv @@ -0,0 +1,2 @@ +name,visitors,bounce_rate,visit_duration +google,1,100,0 diff --git a/test/plausible_web/controllers/CSVs/6m/utm_terms.csv b/test/plausible_web/controllers/CSVs/6m/utm_terms.csv new file mode 100644 index 0000000000000000000000000000000000000000..cbd3a7ce404733f8418d4ba4bab5c8af6e930d56 --- /dev/null +++ b/test/plausible_web/controllers/CSVs/6m/utm_terms.csv @@ -0,0 +1,2 @@ +name,visitors,bounce_rate,visit_duration +term,1,100,0 diff --git a/test/plausible_web/controllers/CSVs/6m/visitors.csv b/test/plausible_web/controllers/CSVs/6m/visitors.csv new file mode 100644 index 0000000000000000000000000000000000000000..6858d4466ef2ee4632871110573e7925ef729a72 --- /dev/null +++ b/test/plausible_web/controllers/CSVs/6m/visitors.csv @@ -0,0 +1,7 @@ +date,visitors,pageviews,visits,views_per_visit,bounce_rate,visit_duration +2021-05-01,1,1,1,1.0,100,0 +2021-06-01,0,0,0,0.0,0.0, +2021-07-01,0,0,0,0.0,0.0, +2021-08-01,0,0,0,0.0,0.0, +2021-09-01,1,1,1,1.0,100,0 +2021-10-01,3,4,3,1.33,33,40 diff --git a/test/plausible_web/controllers/admin_auth_controller_test.exs b/test/plausible_web/controllers/admin_auth_controller_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..dc1920be953b092a414254c8558bf896e030ec3a --- /dev/null +++ b/test/plausible_web/controllers/admin_auth_controller_test.exs @@ -0,0 +1,28 @@ +defmodule PlausibleWeb.AdminAuthControllerTest do + use PlausibleWeb.ConnCase + alias Plausible.Release + + describe "GET /" do + @describetag :ce_build_only + test "disable registration", %{conn: conn} do + insert(:user) + patch_config(disable_registration: true) + conn = get(conn, "/register") + assert redirected_to(conn) == "/login" + end + + test "disable registration + first launch", %{conn: conn} do + patch_config(disable_registration: true) + assert Release.should_be_first_launch?() + + # "first launch" takes precedence + conn = get(conn, "/register") + assert html_response(conn, 200) =~ "Create your Plausible CE account" + end + end + + def patch_config(config) do + updated_config = Keyword.merge([disable_registration: false], config) + patch_env(:selfhost, updated_config) + end +end diff --git a/test/plausible_web/controllers/api/external_controller_test.exs b/test/plausible_web/controllers/api/external_controller_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..d5726c5d158acd95d1ff1721cb1baeead14664f5 --- /dev/null +++ b/test/plausible_web/controllers/api/external_controller_test.exs @@ -0,0 +1,2605 @@ +defmodule PlausibleWeb.Api.ExternalControllerTest do + use PlausibleWeb.ConnCase + use Plausible.ClickhouseRepo + + @user_agent "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.110 Safari/537.36" + @user_agent_mobile "Mozilla/5.0 (Linux; Android 6.0; U007 Pro Build/MRA58K; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/44.0.2403.119 Mobile Safari/537.36" + @user_agent_tablet "Mozilla/5.0 (Linux; U; Android 4.2.2; it-it; Surfing TAB B 9.7 3G Build/JDQ39) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30" + + describe "POST /api/event" do + setup do + site = new_site() + {:ok, site: site} + end + + test "records the event and session", %{conn: conn, site: site} do + params = %{ + domain: site.domain, + name: "pageview", + url: "http://example.com/", + referrer: "http://m.facebook.com/" + } + + conn = + conn + |> put_req_header("user-agent", @user_agent) + |> post("/api/event", params) + + pageview = get_event(site) + [session] = get_sessions(site) + + assert response(conn, 202) == "ok" + assert pageview.hostname == "example.com" + + assert pageview.site_id == site.id + + assert pageview.pathname == "/" + assert pageview.session_id == session.session_id + end + + test "works with Content-Type: text/plain", %{conn: conn, site: site} do + params = %{ + domain: site.domain, + name: "pageview", + url: "http://example.com/" + } + + conn = + conn + |> put_req_header("content-type", "text/plain") + |> post("/api/event", Jason.encode!(params)) + + pageview = get_event(site) + + assert response(conn, 202) == "ok" + assert pageview.hostname == "example.com" + + assert pageview.site_id == site.id + + assert pageview.pathname == "/" + end + + test "returns error if JSON cannot be parsed", %{conn: conn} do + conn = + conn + |> put_req_header("content-type", "text/plain") + |> post("/api/event", "") + + assert conn.status == 400 + end + + test "can send to multiple dashboards by listing multiple domains - same timestamp", %{ + conn: conn, + site: site1 + } do + site2 = new_site() + + params = %{ + name: "pageview", + url: "http://example.com/", + referrer: "http://m.facebook.com/", + domain: "#{site1.domain},#{site2.domain}" + } + + conn = + conn + |> put_req_header("user-agent", @user_agent) + |> post("/api/event", params) + + assert response(conn, 202) == "ok" + assert e1 = get_event(site1) + assert e2 = get_event(site2) + + assert NaiveDateTime.compare(e1.timestamp, e2.timestamp) == :eq + end + + @tag :slow + test "timestamps differ when two events sent in a row", %{conn: conn, site: site} do + params = %{ + domain: site.domain, + name: "pageview", + url: "http://example.com/", + referrer: "http://m.facebook.com/" + } + + t1 = System.convert_time_unit(System.monotonic_time(), :native, :millisecond) + + conn + |> put_req_header("user-agent", @user_agent) + |> post("/api/event", params) + + t2 = System.convert_time_unit(System.monotonic_time(), :native, :millisecond) + + # timestamps are in second precision, so we need to wait for it to flip + Process.sleep(1000 - (t2 - t1)) + + conn + |> put_req_header("user-agent", @user_agent) + |> post("/api/event", params) + + [e1, e2] = get_events(site) + + assert NaiveDateTime.compare(e1.timestamp, e2.timestamp) == :gt + end + + test "www. is stripped from domain", %{conn: conn, site: site} do + params = %{ + name: "custom event", + url: "http://example.com/", + domain: site.domain + } + + conn + |> post("/api/event", params) + + pageview = get_event(site) + + assert pageview.site_id == site.id + end + + test "www. is stripped from hostname", %{conn: conn, site: site} do + params = %{ + name: "pageview", + url: "http://www.example.com/", + domain: site.domain + } + + conn + |> post("/api/event", params) + + pageview = get_event(site) + + assert pageview.hostname == "example.com" + end + + test "empty path defaults to /", %{conn: conn, site: site} do + params = %{ + name: "pageview", + url: "http://www.example.com", + domain: site.domain + } + + conn + |> post("/api/event", params) + + pageview = get_event(site) + + assert pageview.pathname == "/" + end + + test "trailing whitespace is removed", %{conn: conn, site: site} do + params = %{ + name: "pageview", + url: "http://www.example.com/path ", + domain: site.domain + } + + conn + |> post("/api/event", params) + + pageview = get_event(site) + + assert pageview.pathname == "/path" + end + + test "bots and crawlers are ignored", %{conn: conn, site: site} do + params = %{ + name: "pageview", + url: "http://www.example.com/", + domain: site.domain + } + + conn + |> put_req_header("user-agent", "generic crawler") + |> post("/api/event", params) + + assert get_event(site) == nil + end + + test "Headless Chrome is ignored", %{conn: conn, site: site} do + params = %{ + name: "pageview", + url: "http://www.example.com/", + domain: site.domain + } + + conn + |> put_req_header( + "user-agent", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/85.0.4183.83 Safari/537.36" + ) + |> post("/api/event", params) + + assert get_event(site) == nil + end + + test "parses user_agent", %{conn: conn, site: site} do + params = %{ + name: "pageview", + url: "http://example.com/", + domain: site.domain + } + + conn = + conn + |> put_req_header("user-agent", @user_agent) + |> post("/api/event", params) + + [session] = get_sessions(site) + event = get_event(site) + + assert response(conn, 202) == "ok" + assert session.operating_system == "Mac" + assert session.operating_system_version == "10.13" + assert session.browser == "Chrome" + assert session.browser_version == "70.0" + assert event.operating_system == "Mac" + assert event.operating_system_version == "10.13" + assert event.browser == "Chrome" + assert event.browser_version == "70.0" + end + + test "parses referrer source", %{conn: conn, site: site} do + params = %{ + name: "pageview", + url: "http://example.com/", + referrer: "https://facebook.com", + domain: site.domain + } + + conn = + conn + |> put_req_header("user-agent", @user_agent) + |> post("/api/event", params) + + [session] = get_sessions(site) + + assert response(conn, 202) == "ok" + assert session.referrer_source == "Facebook" + assert session.click_id_param == "" + end + + test "strips trailing slash from referrer", %{conn: conn, site: site} do + params = %{ + name: "pageview", + url: "http://example.com/", + referrer: "https://facebook.com/page/", + domain: site.domain + } + + conn = + conn + |> put_req_header("user-agent", @user_agent) + |> post("/api/event", params) + + [session] = get_sessions(site) + event = get_event(site) + + assert response(conn, 202) == "ok" + assert session.referrer == "facebook.com/page" + assert session.referrer_source == "Facebook" + assert event.referrer == session.referrer + assert event.referrer_source == session.referrer_source + end + + test "ignores event when referrer is a spammer", %{conn: conn, site: site} do + params = %{ + domain: site.domain, + name: "pageview", + url: "http://example.com/", + referrer: "https://www.1-best-seo.com" + } + + conn = + conn + |> put_req_header("user-agent", @user_agent) + |> post("/api/event", params) + + assert response(conn, 202) == "ok" + assert !get_event(site) + end + + test "blocks traffic from a domain when it's blocked", %{ + conn: conn + } do + site = new_site(ingest_rate_limit_threshold: 0) + + params = %{ + domain: site.domain, + name: "pageview", + url: "https://feature-flag-test.com" + } + + conn = + conn + |> put_req_header("user-agent", @user_agent) + |> post("/api/event", params) + + assert response(conn, 202) == "ok" + refute get_event(site) + end + + test "ignores when referrer is internal", %{conn: conn, site: site} do + params = %{ + name: "pageview", + url: "http://example.com/", + referrer: "https://example.com", + domain: site.domain + } + + conn = + conn + |> put_req_header("user-agent", @user_agent) + |> post("/api/event", params) + + [session] = get_sessions(site) + + assert response(conn, 202) == "ok" + assert session.referrer_source == "" + assert session.referrer == "" + end + + test "ignores localhost referrer", %{conn: conn, site: site} do + params = %{ + name: "pageview", + url: "http://example.com/", + referrer: "http://localhost:4000/", + domain: site.domain + } + + conn = + conn + |> put_req_header("user-agent", @user_agent) + |> post("/api/event", params) + + [session] = get_sessions(site) + + assert response(conn, 202) == "ok" + assert session.referrer_source == "" + end + + test "parses subdomain referrer", %{conn: conn, site: site} do + params = %{ + name: "pageview", + url: "http://example.com/", + referrer: "https://blog.example.com", + domain: site.domain + } + + conn = + conn + |> put_req_header("user-agent", @user_agent) + |> post("/api/event", params) + + [session] = get_sessions(site) + + assert response(conn, 202) == "ok" + assert session.referrer_source == "blog.example.com" + end + + test "referrer is cleaned", %{conn: conn, site: site} do + params = %{ + name: "pageview", + url: "http://www.example.com/", + referrer: "https://www.indiehackers.com/page?query=param#hash", + domain: site.domain + } + + conn + |> post("/api/event", params) + + [session] = get_sessions(site) + + assert session.referrer == "indiehackers.com/page" + end + + test "utm_source overrides referrer source", %{conn: conn, site: site} do + params = %{ + name: "pageview", + url: "http://www.example.com/?utm_source=betalist", + referrer: "https://betalist.com/my-produxct", + domain: site.domain + } + + conn + |> post("/api/event", params) + + [session] = get_sessions(site) + + assert session.referrer_source == "betalist" + assert session.utm_source == "betalist" + end + + test "?ref param behaves like ?utm_source", %{conn: conn, site: site} do + params = %{ + name: "pageview", + url: "http://www.example.com/?ref=betalist", + domain: site.domain + } + + conn + |> post("/api/event", params) + + [session] = get_sessions(site) + + assert session.referrer_source == "betalist" + assert session.utm_source == "betalist" + end + + test "?source param behaves like ?utm_source", %{conn: conn, site: site} do + params = %{ + name: "pageview", + url: "http://www.example.com/?source=betalist", + domain: site.domain + } + + conn + |> post("/api/event", params) + + [session] = get_sessions(site) + + assert session.referrer_source == "betalist" + assert session.utm_source == "betalist" + end + + test "if utm_source matches a capitalized form from ref_inspector, the capitalized form is recorded", + %{conn: conn, site: site} do + params = %{ + name: "pageview", + url: "http://www.example.com/?utm_source=facebook", + domain: site.domain + } + + conn + |> post("/api/event", params) + + [session] = get_sessions(site) + + assert session.referrer_source == "Facebook" + end + + test "utm tags are stored", %{conn: conn, site: site} do + params = %{ + name: "pageview", + url: + "http://www.example.com/?utm_medium=ads&utm_source=instagram&utm_campaign=video_story", + domain: site.domain + } + + conn + |> post("/api/event", params) + + [session] = get_sessions(site) + + assert session.utm_medium == "ads" + assert session.utm_source == "instagram" + assert session.utm_campaign == "video_story" + end + + test "if it's an :unknown referrer, just the domain is used", %{conn: conn, site: site} do + params = %{ + name: "pageview", + url: "http://example.com/", + referrer: "https://www.indiehackers.com/landing-page-feedback", + domain: site.domain + } + + conn = + conn + |> put_req_header("user-agent", @user_agent) + |> post("/api/event", params) + + [session] = get_sessions(site) + + assert response(conn, 202) == "ok" + assert session.referrer_source == "indiehackers.com" + end + + test "if the referrer is not http, https, or android it is ignored", %{conn: conn, site: site} do + params = %{ + name: "pageview", + url: "http://example.com/", + referrer: "ftp://wat", + domain: site.domain + } + + conn = + conn + |> put_req_header("user-agent", @user_agent) + |> post("/api/event", params) + + pageview = get_event(site) + + assert response(conn, 202) == "ok" + assert pageview.referrer_source == "" + assert pageview.referrer == "" + end + + test "stores referrer from android app", %{conn: conn, site: site} do + params = %{ + name: "pageview", + url: "http://example.com/", + referrer: "android-app://some.android.app", + domain: site.domain + } + + conn = + conn + |> put_req_header("user-agent", @user_agent) + |> post("/api/event", params) + + [session] = get_sessions(site) + + assert response(conn, 202) == "ok" + assert session.referrer == "android-app://some.android.app" + assert session.referrer_source == "android-app://some.android.app" + end + + test "screen size is calculated from user agent", %{conn: conn, site: site} do + params = %{ + name: "pageview", + url: "http://example.com/", + domain: site.domain + } + + conn = + conn + |> put_req_header("user-agent", @user_agent_mobile) + |> post("/api/event", params) + + [session] = get_sessions(site) + + assert response(conn, 202) == "ok" + assert session.screen_size == "Mobile" + end + + test "screen size is nil if user agent is unknown", %{conn: conn, site: site} do + params = %{ + name: "pageview", + url: "http://example.com/", + domain: site.domain + } + + conn = + conn + |> put_req_header("user-agent", "unknown UA") + |> post("/api/event", params) + + [session] = get_sessions(site) + + assert response(conn, 202) == "ok" + assert session.screen_size == "" + end + + test "screen size is calculated from user_agent when is tablet", %{conn: conn, site: site} do + params = %{ + name: "pageview", + url: "http://example.com/", + domain: site.domain + } + + conn = + conn + |> put_req_header("user-agent", @user_agent_tablet) + |> post("/api/event", params) + + [session] = get_sessions(site) + + assert response(conn, 202) == "ok" + assert session.screen_size == "Tablet" + end + + test "screen size is calculated from user_agent when is desktop", %{ + conn: conn, + site: site + } do + params = %{ + name: "pageview", + url: "http://example.com/", + domain: site.domain + } + + conn = + conn + |> put_req_header("user-agent", @user_agent) + |> post("/api/event", params) + + [session] = get_sessions(site) + + assert response(conn, 202) == "ok" + assert session.screen_size == "Desktop" + end + + test "can trigger a custom event", %{conn: conn, site: site} do + params = %{ + name: "custom event", + url: "http://example.com/", + domain: site.domain + } + + conn = + conn + |> put_req_header("user-agent", @user_agent) + |> post("/api/event", params) + + event = get_event(site) + + assert response(conn, 202) == "ok" + assert event.name == "custom event" + end + + test "casts custom props to string", %{conn: conn, site: site} do + params = %{ + name: "Signup", + url: "http://example.com/", + domain: site.domain, + props: %{ + bool_test: true, + number_test: 12 + } + } + + conn + |> post("/api/event", params) + + event = get_event(site) + + assert Map.get(event, :"meta.key") == ["bool_test", "number_test"] + assert Map.get(event, :"meta.value") == ["true", "12"] + end + + test "records custom props for a engagement event", %{conn: conn, site: site} do + post(conn, "/api/event", %{ + n: "pageview", + u: "https://ab.cd", + d: site.domain + }) + + post(conn, "/api/event", %{ + name: "engagement", + url: "http://ab.cd/", + domain: site.domain, + sd: 50, + e: 1000, + props: %{ + bool_test: true, + number_test: 12 + } + }) + + engagement = get_events(site) |> Enum.find(&(&1.name == "engagement")) + + assert Map.get(engagement, :"meta.key") == ["bool_test", "number_test"] + assert Map.get(engagement, :"meta.value") == ["true", "12"] + end + + test "filters out bad props", %{conn: conn, site: site} do + params = %{ + name: "Signup", + url: "http://example.com/", + domain: site.domain, + props: %{ + false: nil, + nil: false, + good: true, + " ": " ", + " ": "value", + key: " " + } + } + + conn + |> post("/api/event", params) + + event = get_event(site) + + assert Map.get(event, :"meta.key") == ["good"] + assert Map.get(event, :"meta.value") == ["true"] + end + + test "ignores malformed custom props", %{conn: conn, site: site} do + params = %{ + name: "Signup", + url: "http://example.com/", + domain: site.domain, + props: "\"show-more:button\"" + } + + conn + |> post("/api/event", params) + + event = get_event(site) + + assert Map.get(event, :"meta.key") == [] + assert Map.get(event, :"meta.value") == [] + end + + test "can send props stringified", %{conn: conn, site: site} do + params = %{ + name: "Signup", + url: "http://example.com/", + domain: site.domain, + props: Jason.encode!(%{number_test: 12}) + } + + conn + |> post("/api/event", params) + + event = get_event(site) + + assert Map.get(event, :"meta.key") == ["number_test"] + assert Map.get(event, :"meta.value") == ["12"] + end + + test "ignores custom prop with array value", %{conn: conn, site: site} do + params = %{ + name: "Signup", + url: "http://example.com/", + domain: site.domain, + props: Jason.encode!(%{wat: ["some-thing"], other: "key"}) + } + + conn = post(conn, "/api/event", params) + + assert conn.status == 202 + + event = get_event(site) + + assert Map.get(event, :"meta.key") == ["other"] + assert Map.get(event, :"meta.value") == ["key"] + end + + test "ignores custom prop with map value", %{conn: conn, site: site} do + params = %{ + name: "Signup", + url: "http://example.com/", + domain: site.domain, + props: Jason.encode!(%{foo: %{bar: "baz"}, other_key: 1}) + } + + conn = post(conn, "/api/event", params) + + assert conn.status == 202 + + event = get_event(site) + + assert Map.get(event, :"meta.key") == ["other_key"] + assert Map.get(event, :"meta.value") == ["1"] + end + + test "ignores custom prop with empty string value", %{conn: conn, site: site} do + params = %{ + name: "Signup", + url: "http://example.com/", + domain: site.domain, + props: Jason.encode!(%{foo: "", other_key: true}) + } + + conn = post(conn, "/api/event", params) + + assert conn.status == 202 + + event = get_event(site) + + assert Map.get(event, :"meta.key") == ["other_key"] + assert Map.get(event, :"meta.value") == ["true"] + end + + test "ignores custom prop with nil value", %{conn: conn, site: site} do + params = %{ + name: "Signup", + url: "http://example.com/", + domain: site.domain, + props: Jason.encode!(%{foo: nil, other_key: true}) + } + + conn = post(conn, "/api/event", params) + + assert conn.status == 202 + + event = get_event(site) + + assert Map.get(event, :"meta.key") == ["other_key"] + assert Map.get(event, :"meta.value") == ["true"] + end + + @tag :ee_only + test "converts revenue values into the goal currency", %{conn: conn, site: site} do + params = %{ + name: "Payment", + url: "http://example.com/", + domain: site.domain, + revenue: %{amount: 10.2, currency: "USD"} + } + + insert(:goal, event_name: "Payment", currency: "BRL", site: site) + + assert %{status: 202} = post(conn, "/api/event", params) + assert %{revenue_reporting_amount: amount} = get_event(site) + + assert Decimal.equal?(Decimal.new("7.14"), amount) + end + + @tag :ee_only + test "revenue values can be sent with minified keys", %{conn: conn, site: site} do + params = %{ + "n" => "Payment", + "u" => "http://example.com/", + "d" => site.domain, + "$" => Jason.encode!(%{amount: 10.2, currency: "USD"}) + } + + insert(:goal, event_name: "Payment", currency: "BRL", site: site) + + assert %{status: 202} = post(conn, "/api/event", params) + assert %{revenue_reporting_amount: amount} = get_event(site) + + assert Decimal.equal?(Decimal.new("7.14"), amount) + end + + @tag :ee_only + test "saves the exact same amount when goal currency is the same as the event", %{ + conn: conn, + site: site + } do + params = %{ + name: "Payment", + url: "http://example.com/", + domain: site.domain, + revenue: %{amount: 10, currency: "BRL"} + } + + insert(:goal, event_name: "Payment", currency: "BRL", site: site) + + assert %{status: 202} = post(conn, "/api/event", params) + assert %{revenue_reporting_amount: amount} = get_event(site) + + assert Decimal.equal?(Decimal.new("10.0"), amount) + end + + test "does not fail when revenue value is invalid", %{conn: conn, site: site} do + params = %{ + name: "Payment", + url: "http://example.com/", + domain: site.domain, + revenue: %{amount: "1831d", currency: "ADSIE"} + } + + insert(:goal, event_name: "Payment", currency: "BRL", site: site) + + assert %{status: 202} = post(conn, "/api/event", params) + assert %Plausible.ClickhouseEventV2{} = get_event(site) + end + + test "does not fail when sending revenue without a matching goal", %{conn: conn, site: site} do + params = %{ + name: "Add to Cart", + url: "http://example.com/", + domain: site.domain, + revenue: %{amount: 10.2, currency: "USD"} + } + + insert(:goal, event_name: "Checkout", currency: "BRL", site: site) + insert(:goal, event_name: "Payment", currency: "USD", site: site) + + assert %{status: 202} = post(conn, "/api/event", params) + assert %Plausible.ClickhouseEventV2{revenue_reporting_amount: nil} = get_event(site) + end + + test "ignores a malformed referrer URL", %{conn: conn, site: site} do + params = %{ + name: "pageview", + url: "http://example.com/", + referrer: "https:://twitter.com", + domain: site.domain + } + + conn = + conn + |> put_req_header("user-agent", @user_agent) + |> post("/api/event", params) + + [session] = get_sessions(site) + + assert response(conn, 202) == "ok" + assert session.referrer == "" + end + + # Fake geo is loaded from test/priv/GeoLite2-City-Test.mmdb + test "looks up location data from the ip address", %{conn: conn, site: site} do + params = %{ + name: "pageview", + domain: site.domain, + url: "http://example.com/" + } + + conn + |> put_req_header("x-forwarded-for", "2.125.160.216") + |> post("/api/event", params) + + [session] = get_sessions(site) + event = get_event(site) + + assert session.country_code == "GB" + assert session.subdivision1_code == "GB-ENG" + assert session.subdivision2_code == "GB-WBK" + assert session.city_geoname_id == 2_655_045 + assert event.country_code == session.country_code + assert event.subdivision1_code == session.subdivision1_code + assert event.subdivision2_code == session.subdivision2_code + assert event.city_geoname_id == session.city_geoname_id + end + + test "ignores unknown country code ZZ", %{conn: conn, site: site} do + params = %{ + name: "pageview", + domain: site.domain, + url: "http://example.com/" + } + + conn + |> put_req_header("x-forwarded-for", "0.0.0.0") + |> post("/api/event", params) + + [session] = get_sessions(site) + + assert session.country_code == <<0, 0>> + assert session.subdivision1_code == "" + assert session.subdivision2_code == "" + assert session.city_geoname_id == 0 + end + + test "ignores disputed territory code XX", %{conn: conn, site: site} do + params = %{ + name: "pageview", + domain: site.domain, + url: "http://example.com/" + } + + conn + |> put_req_header("x-forwarded-for", "0.0.0.1") + |> post("/api/event", params) + + [session] = get_sessions(site) + + assert session.country_code == <<0, 0>> + assert session.subdivision1_code == "" + assert session.subdivision2_code == "" + assert session.city_geoname_id == 0 + end + + test "ignores TOR exit node country code T1", %{conn: conn, site: site} do + params = %{ + name: "pageview", + domain: site.domain, + url: "http://example.com/" + } + + conn + |> put_req_header("x-forwarded-for", "0.0.0.2") + |> post("/api/event", params) + + [session] = get_sessions(site) + + assert session.country_code == <<0, 0>> + assert session.subdivision1_code == "" + assert session.subdivision2_code == "" + assert session.city_geoname_id == 0 + end + + test "scrubs port from x-forwarded-for", %{conn: conn, site: site} do + params = %{ + name: "pageview", + domain: site.domain, + url: "http://example.com/" + } + + conn + |> put_req_header("x-forwarded-for", "216.160.83.56:123") + |> post("/api/event", params) + + [session] = get_sessions(site) + + assert session.country_code == "US" + end + + test "works with ipv6 without port in x-forwarded-for", %{conn: conn, site: site} do + params = %{ + name: "pageview", + domain: site.domain, + url: "http://example.com/" + } + + conn + |> put_req_header("x-forwarded-for", "2001:218:1:1:1:1:1:1") + |> post("/api/event", params) + + [session] = get_sessions(site) + + assert session.country_code == "JP" + end + + test "works with ipv6 with a port number in x-forwarded-for", %{conn: conn, site: site} do + params = %{ + name: "pageview", + domain: site.domain, + url: "http://example.com/" + } + + conn + |> put_req_header("x-forwarded-for", "[2001:218:1:1:1:1:1:1]:123") + |> post("/api/event", params) + + [session] = get_sessions(site) + + assert session.country_code == "JP" + end + + test "uses cloudflare's special header for client IP address if present", %{ + conn: conn, + site: site + } do + params = %{ + name: "pageview", + domain: site.domain, + url: "http://example.com/" + } + + conn + |> put_req_header("x-forwarded-for", "0.0.0.0") + |> put_req_header("cf-connecting-ip", "216.160.83.56") + |> post("/api/event", params) + + [session] = get_sessions(site) + + assert session.country_code == "US" + end + + test "uses BunnyCDN's custom header for client IP address if present", %{ + conn: conn, + site: site + } do + params = %{ + name: "pageview", + domain: site.domain, + url: "http://example.com/" + } + + conn + |> put_req_header("x-forwarded-for", "0.0.0.0") + |> put_req_header("b-forwarded-for", "216.160.83.56,9.9.9.9") + |> post("/api/event", params) + + [session] = get_sessions(site) + + assert session.country_code == "US" + end + + test "prioritizes x-plausible-ip header over everything else", %{ + conn: conn, + site: site + } do + params = %{ + name: "pageview", + domain: site.domain, + url: "http://example.com/" + } + + conn + |> put_req_header("cf-connecting-ip", "0.0.0.0") + |> put_req_header("b-forwarded-for", "0.0.0.0") + |> put_req_header("x-forwarded-for", "0.0.0.0") + |> put_req_header("forwarded", "for=0.0.0.0;host=dashboard.site.com;proto=https") + |> put_req_header("x-plausible-ip", "216.160.83.56") + |> post("/api/event", params) + + [session] = get_sessions(site) + + assert session.country_code == "US" + end + + test "Uses the Forwarded header when cf-connecting-ip and x-forwarded-for are missing", %{ + site: site + } do + params = %{ + name: "pageview", + domain: site.domain, + url: "http://example.com/" + } + + build_conn() + |> put_req_header("forwarded", "by=0.0.0.0;for=216.160.83.56;host=somehost.com;proto=https") + |> post("/api/event", params) + + [session] = get_sessions(site) + + assert session.country_code == "US" + end + + test "Forwarded header can parse ipv6", %{site: site} do + params = %{ + name: "pageview", + domain: site.domain, + url: "http://example.com/" + } + + build_conn() + |> put_req_header( + "forwarded", + "by=0.0.0.0;for=\"[2001:218:1:1:1:1:1:1]\",for=0.0.0.0;host=somehost.com;proto=https" + ) + |> post("/api/event", params) + + [session] = get_sessions(site) + + assert session.country_code == "JP" + end + + test "URL is decoded", %{conn: conn, site: site} do + params = %{ + name: "pageview", + url: + "http://www.example.com/opportunity/category/%D8%AC%D9%88%D8%A7%D8%A6%D8%B2-%D9%88%D9%85%D8%B3%D8%A7%D8%A8%D9%82%D8%A7%D8%AA", + domain: site.domain + } + + conn + |> post("/api/event", params) + + pageview = get_event(site) + + assert pageview.pathname == "/opportunity/category/جوائز-ومسابقات" + end + + test "accepts shorthand map keys", %{conn: conn, site: site} do + params = %{ + n: "pageview", + u: "http://www.example.com/opportunity", + d: site.domain, + r: "https://facebook.com/page" + } + + conn + |> post("/api/event", params) + + pageview = get_event(site) + [session] = get_sessions(site) + + assert pageview.pathname == "/opportunity" + assert session.referrer_source == "Facebook" + assert session.referrer == "facebook.com/page" + end + + test "records hash when in hash mode", %{conn: conn, site: site} do + params = %{ + n: "pageview", + u: "http://www.example.com/#page-a", + d: site.domain, + h: 1 + } + + conn + |> post("/api/event", params) + + pageview = get_event(site) + + assert pageview.pathname == "/#page-a" + end + + test "does not record hash when hash mode is 0", %{conn: conn, site: site} do + params = %{ + n: "pageview", + u: "http://www.example.com/#page-a", + d: site.domain, + h: 0 + } + + conn + |> post("/api/event", params) + + pageview = get_event(site) + + assert pageview.pathname == "/" + end + + test "decodes URL pathname, fragment and search", %{conn: conn, site: site} do + params = %{ + n: "pageview", + u: + "https://test.com/%EF%BA%9D%EF%BB%AD%EF%BA%8E%EF%BA%8B%EF%BA%AF-%EF%BB%AE%EF%BB%A4%EF%BA%B3%EF%BA%8E%EF%BA%92%EF%BB%97%EF%BA%8E%EF%BA%97?utm_source=%25balle%25", + d: site.domain, + h: 1 + } + + conn + |> post("/api/event", params) + + pageview = get_event(site) + [session] = get_sessions(site) + + assert pageview.hostname == "test.com" + assert pageview.pathname == "/ﺝﻭﺎﺋﺯ-ﻮﻤﺳﺎﺒﻗﺎﺗ" + assert session.utm_source == "%balle%" + assert pageview.utm_source == session.utm_source + end + + test "can use double quotes in query params", %{conn: conn, site: site} do + q = URI.encode_query(%{"utm_source" => "Something \"quoted\""}) + + params = %{ + n: "pageview", + u: "https://test.com/?" <> q, + d: site.domain, + h: 1 + } + + conn + |> post("/api/event", params) + + [session] = get_sessions(site) + + assert session.utm_source == "Something \"quoted\"" + end + + test "responds 400 when required fields are missing", %{conn: conn, site: site} do + params = %{ + domain: site.domain, + name: "pageview" + } + + conn = + conn + |> put_req_header("user-agent", @user_agent) + |> post("/api/event", params) + + assert json_response(conn, 400) == %{ + "errors" => %{ + "url" => ["is required"] + } + } + end + + test "responds 400 when event name is blank", %{conn: conn, site: site} do + params = %{ + domain: site.domain, + name: "", + url: "http://example.com" + } + + conn = + conn + |> put_req_header("user-agent", @user_agent) + |> post("/api/event", params) + + assert json_response(conn, 400) == %{ + "errors" => %{ + "event_name" => ["can't be blank"] + } + } + end + + test "salts rotating once does not", %{conn: conn, site: site} do + post(conn, "/api/event", %{n: "pageview", u: "https://test.com", d: site.domain}) + Plausible.Session.WriteBuffer.flush() + Plausible.Session.Salts.rotate() + + post(conn, "/api/event", %{n: "pageview", u: "https://test.com", d: site.domain}) + + [event1, event2] = get_events(site) + [session1, session2] = get_sessions(site) + + records = [event1, event2, session1, session2] + + assert records |> Enum.map(& &1.user_id) |> Enum.uniq() |> Enum.count() == 1 + assert records |> Enum.map(& &1.session_id) |> Enum.uniq() |> Enum.count() == 1 + end + + test "responds 400 with errors when domain is missing", %{conn: conn} do + params = %{ + domain: nil, + url: "about:config", + name: "pageview" + } + + conn = + conn + |> put_req_header("user-agent", @user_agent) + |> post("/api/event", params) + + assert json_response(conn, 400) == %{ + "errors" => %{ + "domain" => ["can't be blank"] + } + } + end + end + + describe "engagement event tests" do + setup do + site = new_site() + {:ok, site: site} + end + + test "rejects engagement when both sd and e fields are missing", %{conn: conn, site: site} do + post(conn, "/api/event", %{n: "pageview", u: "https://test.com", d: site.domain}) + + conn = post(conn, "/api/event", %{n: "engagement", u: "https://test.com", d: site.domain}) + + assert %{"errors" => %{"event_name" => [error_msg]}} = json_response(conn, 400) + + assert error_msg =~ + "engagement event requires a valid integer value for at least one of 'sd' or 'e' fields" + end + + test "ingests scroll_depth as 255 when not in params", %{conn: conn, site: site} do + post(conn, "/api/event", %{n: "pageview", u: "https://test.com", d: site.domain}) + post(conn, "/api/event", %{n: "engagement", u: "https://test.com", d: site.domain, e: 200}) + + engagement = get_events(site) |> Enum.find(&(&1.name == "engagement")) + + assert engagement.scroll_depth == 255 + end + + test "ingests engagement_time as 0 when not in params", %{conn: conn, site: site} do + post(conn, "/api/event", %{n: "pageview", u: "https://test.com", d: site.domain}) + post(conn, "/api/event", %{n: "engagement", u: "https://test.com", d: site.domain, sd: 50}) + + engagement = get_events(site) |> Enum.find(&(&1.name == "engagement")) + + assert engagement.engagement_time == 0 + end + + test "ingests engagement_time as 0 when tracker is sending invalid high values", %{ + conn: conn, + site: site + } do + post(conn, "/api/event", %{n: "pageview", u: "https://test.com", d: site.domain}) + + post(conn, "/api/event", %{ + n: "engagement", + u: "https://test.com", + d: site.domain, + sd: 50, + e: 1_741_850_224_785 + }) + + engagement = get_events(site) |> Enum.find(&(&1.name == "engagement")) + + assert engagement.engagement_time == 0 + assert engagement.scroll_depth == 50 + end + + test "sd and e fields are ignored if name is not engagement", %{conn: conn, site: site} do + post(conn, "/api/event", %{ + n: "pageview", + u: "https://test.com", + d: site.domain, + sd: 10, + e: 789 + }) + + post(conn, "/api/event", %{ + n: "custom_e", + u: "https://test.com", + d: site.domain, + sd: 10, + e: 789 + }) + + assert [%{scroll_depth: 0, engagement_time: 0}, %{scroll_depth: 0, engagement_time: 0}] = + get_events(site) + end + + test "ingests valid scroll_depth and engagement_time for a engagement", %{ + conn: conn, + site: site + } do + post(conn, "/api/event", %{n: "pageview", u: "https://test.com", d: site.domain}) + + post(conn, "/api/event", %{ + n: "engagement", + u: "https://test.com", + d: site.domain, + sd: 25, + e: 789 + }) + + engagement = get_events(site) |> Enum.find(&(&1.name == "engagement")) + + assert engagement.scroll_depth == 25 + assert engagement.engagement_time == 789 + end + + test "ingests scroll_depth as 100 when sd > 100", %{conn: conn, site: site} do + post(conn, "/api/event", %{n: "pageview", u: "https://test.com", d: site.domain}) + post(conn, "/api/event", %{n: "engagement", u: "https://test.com", d: site.domain, sd: 101}) + + engagement = get_events(site) |> Enum.find(&(&1.name == "engagement")) + + assert engagement.scroll_depth == 100 + end + + test "parses scroll_depth from a string", %{conn: conn, site: site} do + post(conn, "/api/event", %{n: "pageview", u: "https://test.com", d: site.domain}) + post(conn, "/api/event", %{n: "engagement", u: "https://test.com", d: site.domain, sd: "1"}) + + engagement = get_events(site) |> Enum.find(&(&1.name == "engagement")) + + assert engagement.scroll_depth == 1 + end + + test "ingests scroll_depth as 255 when sd is a negative integer", %{conn: conn, site: site} do + post(conn, "/api/event", %{n: "pageview", u: "https://test.com", d: site.domain}) + + post(conn, "/api/event", %{ + n: "engagement", + u: "https://test.com", + d: site.domain, + sd: -1, + e: 100 + }) + + engagement = get_events(site) |> Enum.find(&(&1.name == "engagement")) + + assert engagement.scroll_depth == 255 + end + + test "ingests scroll_depth as 255 when sd is a non-number string", %{conn: conn, site: site} do + post(conn, "/api/event", %{n: "pageview", u: "https://test.com", d: site.domain}) + + post(conn, "/api/event", %{ + n: "engagement", + u: "https://test.com", + d: site.domain, + sd: "12asd", + e: 100 + }) + + engagement = get_events(site) |> Enum.find(&(&1.name == "engagement")) + + assert engagement.scroll_depth == 255 + end + + test "ingests engagement_time from a string", %{conn: conn, site: site} do + post(conn, "/api/event", %{n: "pageview", u: "https://test.com", d: site.domain}) + + post(conn, "/api/event", %{ + n: "engagement", + u: "https://test.com", + d: site.domain, + e: "789", + sd: 40 + }) + + engagement = get_events(site) |> Enum.find(&(&1.name == "engagement")) + + assert engagement.engagement_time == 789 + end + + test "ingests engagement_time as 0 when e is a negative integer", %{conn: conn, site: site} do + post(conn, "/api/event", %{n: "pageview", u: "https://test.com", d: site.domain}) + + post(conn, "/api/event", %{ + n: "engagement", + u: "https://test.com", + d: site.domain, + e: -100, + sd: 50 + }) + + engagement = get_events(site) |> Enum.find(&(&1.name == "engagement")) + + assert engagement.engagement_time == 0 + end + end + + describe "acquisition channel tests" do + setup do + site = new_site() + {:ok, site: site} + end + + test "parses cross network channel", %{site: site} do + site + |> submit_event(%{url: "http://example.com/?utm_campaign=cross-network"}) + |> assert_acquisition_channel("Cross-network") + end + + test "parses paid shopping channel based on campaign/medium", %{site: site} do + site + |> submit_event(%{url: "http://example.com/?utm_campaign=shopping&utm_medium=paid"}) + |> assert_acquisition_channel("Paid Shopping") + end + + test "parses paid shopping channel based on referrer source and medium", %{site: site} do + site + |> submit_event(%{ + url: "http://example.com?utm_medium=paid", + referrer: "https://shopify.com" + }) + |> assert_acquisition_channel("Paid Shopping") + end + + test "parses paid shopping channel based on referrer utm_source and medium", %{site: site} do + site + |> submit_event(%{url: "http://example.com?utm_source=shopify&utm_medium=paid"}) + |> assert_acquisition_channel("Paid Shopping") + end + + test "parses paid search channel based on referrer and medium", %{site: site} do + site + |> submit_event(%{ + url: "http://example.com?utm_medium=paid", + referrer: "https://duckduckgo.com" + }) + |> assert_acquisition_channel("Paid Search") + end + + test "parses paid search channel based on gclid", %{site: site} do + site + |> submit_event(%{ + url: "http://example.com?gclid=123identifier", + referrer: "https://google.com" + }) + |> assert_acquisition_channel("Paid Search") + |> assert_utm_medium("(gclid)") + |> assert_click_id_param("gclid") + end + + test "is not paid search when gclid is present on non-google referrer", %{site: site} do + site + |> submit_event(%{ + url: "http://example.com?gclid=123identifier", + referrer: "https://duckduckgo.com" + }) + |> assert_acquisition_channel("Organic Search") + |> assert_utm_medium("") + |> assert_click_id_param("gclid") + end + + test "does not override utm_medium with (gclid) if link is already tagged", %{site: site} do + site + |> submit_event(%{ + url: "http://example.com?gclid=123identifier&utm_medium=paidads", + referrer: "https://google.com" + }) + |> assert_acquisition_channel("Paid Search") + |> assert_utm_medium("paidads") + |> assert_click_id_param("gclid") + end + + test "parses paid search channel based on msclkid", %{site: site} do + site + |> submit_event(%{ + url: "http://example.com?msclkid=123identifier", + referrer: "https://bing.com" + }) + |> assert_acquisition_channel("Paid Search") + |> assert_utm_medium("(msclkid)") + |> assert_click_id_param("msclkid") + end + + test "is not paid search when msclkid is present on non-bing referrer", %{site: site} do + site + |> submit_event(%{ + url: "http://example.com?msclkid=123identifier&utm_medium=cpc", + referrer: "https://bing.com" + }) + |> assert_acquisition_channel("Paid Search") + |> assert_utm_medium("cpc") + |> assert_click_id_param("msclkid") + end + + test "does not override utm_medium with (msclkid) if link is already tagged", %{site: site} do + site + |> submit_event(%{ + url: "http://example.com?gclid=123identifier&utm_medium=paidads", + referrer: "https://google.com" + }) + |> assert_acquisition_channel("Paid Search") + |> assert_utm_medium("paidads") + |> assert_click_id_param("gclid") + end + + test "parses paid search channel based on utm_source and medium", %{site: site} do + site + |> submit_event(%{url: "http://example.com?utm_source=google&utm_medium=paid"}) + |> assert_acquisition_channel("Paid Search") + |> assert_click_id_param("") + end + + test "parses paid social channel based on referrer and medium", %{site: site} do + site + |> submit_event(%{ + url: "http://example.com?utm_medium=paid", + referrer: "https://tiktok.com" + }) + |> assert_acquisition_channel("Paid Social") + end + + test "parses paid social channel based on utm_source and medium", %{site: site} do + site + |> submit_event(%{url: "http://example.com?utm_source=tiktok&utm_medium=paid"}) + |> assert_acquisition_channel("Paid Social") + end + + test "parses paid video channel based on referrer and medium", %{site: site} do + site + |> submit_event(%{ + url: "http://example.com?utm_medium=paid", + referrer: "https://youtube.com" + }) + |> assert_acquisition_channel("Paid Video") + end + + test "parses paid video channel based on utm_source and medium", %{site: site} do + site + |> submit_event(%{url: "http://example.com?utm_source=youtube&utm_medium=paid"}) + |> assert_acquisition_channel("Paid Video") + end + + test "parses display channel", %{site: site} do + site + |> submit_event(%{url: "http://example.com?utm_medium=banner"}) + |> assert_acquisition_channel("Display") + end + + test "display channel with gclid", %{site: site} do + site + |> submit_event(%{ + url: "http://example.com?utm_medium=display&utm_source=google&gclid=123identifier" + }) + |> assert_acquisition_channel("Display") + end + + test "parses paid other channel", %{site: site} do + site + |> submit_event(%{url: "http://example.com?utm_medium=cpc"}) + |> assert_acquisition_channel("Paid Other") + end + + test "parses organic shopping channel from referrer", %{site: site} do + site + |> submit_event(%{url: "http://example.com", referrer: "https://walmart.com"}) + |> assert_acquisition_channel("Organic Shopping") + end + + test "parses organic shopping channel from utm_source", %{site: site} do + site + |> submit_event(%{url: "http://example.com?utm_source=walmart"}) + |> assert_acquisition_channel("Organic Shopping") + end + + test "parses organic shopping channel from utm_campaign", %{site: site} do + site + |> submit_event(%{url: "http://example.com?utm_campaign=shop"}) + |> assert_acquisition_channel("Organic Shopping") + end + + test "parses organic social channel from referrer", %{site: site} do + site + |> submit_event(%{url: "http://example.com", referrer: "http://facebook.com"}) + |> assert_acquisition_channel("Organic Social") + end + + test "parses organic social channel from utm_source", %{site: site} do + site + |> submit_event(%{url: "http://example.com?utm_source=twitter"}) + |> assert_acquisition_channel("Organic Social") + end + + test "parses organic social channel from utm_medium", %{site: site} do + site + |> submit_event(%{url: "http://example.com?utm_medium=social"}) + |> assert_acquisition_channel("Organic Social") + end + + test "parses organic video channel from referrer", %{site: site} do + site + |> submit_event(%{url: "http://example.com", referrer: "https://vimeo.com"}) + |> assert_acquisition_channel("Organic Video") + end + + test "parses organic video channel from utm_source", %{site: site} do + site + |> submit_event(%{url: "http://example.com?utm_source=vimeo"}) + |> assert_acquisition_channel("Organic Video") + end + + test "parses organic video channel from utm_medium", %{site: site} do + site + |> submit_event(%{url: "http://example.com?utm_medium=video"}) + |> assert_acquisition_channel("Organic Video") + end + + test "parses organic search channel from referrer", %{site: site} do + site + |> submit_event(%{url: "http://example.com", referrer: "http://duckduckgo.com"}) + |> assert_acquisition_channel("Organic Search") + end + + test "parses organic search channel from utm_source", %{site: site} do + site + |> submit_event(%{url: "http://example.com?utm_source=duckduckgo"}) + |> assert_acquisition_channel("Organic Search") + end + + test "parses referral channel from utm_medium", %{site: site} do + site + |> submit_event(%{url: "http://example.com?utm_medium=referral"}) + |> assert_acquisition_channel("Referral") + end + + test "parses email channel from utm_source", %{site: site} do + site + |> submit_event(%{url: "http://example.com?utm_source=email"}) + |> assert_acquisition_channel("Email") + end + + test "parses email channel from utm_medium", %{site: site} do + site + |> submit_event(%{url: "http://example.com?utm_medium=email"}) + |> assert_acquisition_channel("Email") + end + + test "parses affiliates channel from utm_medium", %{site: site} do + site + |> submit_event(%{url: "http://example.com?utm_medium=affiliate"}) + |> assert_acquisition_channel("Affiliates") + end + + test "parses audio channel from utm_medium", %{site: site} do + site + |> submit_event(%{url: "http://example.com?utm_medium=audio"}) + |> assert_acquisition_channel("Audio") + end + + test "parses sms channel from utm_source", %{site: site} do + site + |> submit_event(%{url: "http://example.com?utm_source=sms"}) + |> assert_acquisition_channel("SMS") + end + + test "parses sms channel from utm_medium", %{site: site} do + site + |> submit_event(%{url: "http://example.com?utm_medium=sms"}) + |> assert_acquisition_channel("SMS") + end + + test "parses mobile push notifications channel from utm_medium with push", %{site: site} do + site + |> submit_event(%{url: "http://example.com?utm_medium=app-push"}) + |> assert_acquisition_channel("Mobile Push Notifications") + end + + test "parses mobile push notifications channel from utm_medium", %{site: site} do + site + |> submit_event(%{url: "http://example.com?utm_medium=example-mobile"}) + |> assert_acquisition_channel("Mobile Push Notifications") + end + + test "parses referral channel if session starts with a simple referral", %{site: site} do + site + |> submit_event(%{url: "http://example.com", referrer: "https://othersite.com"}) + |> assert_acquisition_channel("Referral") + end + + test "parses direct channel if session starts without referrer or utm tags", %{ + site: site + } do + site + |> submit_event(%{ + name: "pageview", + url: "http://example.com", + domain: site.domain + }) + |> assert_acquisition_channel("Direct") + end + end + + describe "custom source parsing rules" do + setup do + site = new_site() + {:ok, site: site} + end + + defp submit_event(site, params_overrides) do + params = + Map.merge( + %{name: "pageview", url: "http://example.com", domain: site.domain}, + params_overrides + ) + + conn = + build_conn() + |> put_req_header("user-agent", @user_agent) + |> post("/api/event", params) + + assert response(conn, 202) == "ok" + + [session] = get_sessions(site) + session + end + + defp event_with_referrer(site, referrer) do + submit_event(site, %{referrer: referrer}) + end + + defp event_with_utm_source(site, utm_source) do + submit_event(site, %{url: "http://example.com?utm_source=#{utm_source}"}) + end + + defp assert_source(session, expected) do + assert session.referrer_source == expected + session + end + + defp assert_utm_source(session, expected) do + assert session.utm_source == expected + session + end + + defp assert_utm_medium(session, expected) do + assert session.utm_medium == expected + session + end + + defp assert_click_id_param(session, expected) do + assert session.click_id_param == expected + session + end + + defp assert_acquisition_channel(session, expected) do + assert session.acquisition_channel == expected + session + end + + test "threads is Threads", %{site: site} do + site + |> event_with_utm_source("threads") + |> assert_source("Threads") + |> assert_utm_source("threads") + |> assert_acquisition_channel("Organic Social") + end + + test "ig is Instagram", %{site: site} do + site + |> event_with_utm_source("ig") + |> assert_source("Instagram") + |> assert_utm_source("ig") + |> assert_acquisition_channel("Organic Social") + end + + test "yt is Youtube", %{site: site} do + site + |> event_with_utm_source("yt") + |> assert_source("Youtube") + |> assert_utm_source("yt") + |> assert_acquisition_channel("Organic Video") + end + + test "yt-ads is Youtube paid", %{site: site} do + site + |> event_with_utm_source("yt-ads") + |> assert_source("Youtube") + |> assert_utm_source("yt-ads") + |> assert_acquisition_channel("Paid Video") + end + + test "fb is Facebook", %{site: site} do + site + |> event_with_utm_source("fb") + |> assert_source("Facebook") + |> assert_utm_source("fb") + |> assert_acquisition_channel("Organic Social") + end + + test "fb-ads is Facebook", %{site: site} do + site + |> event_with_utm_source("fb-ads") + |> assert_source("Facebook") + |> assert_utm_source("fb-ads") + |> assert_acquisition_channel("Paid Social") + end + + test "fbad is Facebook", %{site: site} do + site + |> event_with_utm_source("fbad") + |> assert_source("Facebook") + |> assert_utm_source("fbad") + |> assert_acquisition_channel("Paid Social") + end + + test "facebook-ads is Facebook", %{site: site} do + site + |> event_with_utm_source("facebook-ads") + |> assert_source("Facebook") + |> assert_utm_source("facebook-ads") + |> assert_acquisition_channel("Paid Social") + end + + test "Reddit-ads is Reddit", %{site: site} do + site + |> event_with_utm_source("Reddit-ads") + |> assert_source("Reddit") + |> assert_utm_source("Reddit-ads") + |> assert_acquisition_channel("Paid Social") + end + + test "google_ads is Google", %{site: site} do + site + |> event_with_utm_source("google_ads") + |> assert_source("Google") + |> assert_utm_source("google_ads") + |> assert_acquisition_channel("Paid Search") + end + + test "Google-ads is Google", %{ + site: site + } do + site + |> submit_event(%{url: "http://example.com?source=Google-ads"}) + |> assert_source("Google") + |> assert_utm_source("Google-ads") + |> assert_acquisition_channel("Paid Search") + end + + test "utm_source=Adwords is Google paid search", %{site: site} do + site + |> event_with_utm_source("Adwords") + |> assert_source("Google") + |> assert_utm_source("Adwords") + |> assert_acquisition_channel("Paid Search") + end + + test "twitter-ads is X (Twitter)", %{site: site} do + site + |> event_with_utm_source("twitter-ads") + |> assert_source("X (Twitter)") + |> assert_utm_source("twitter-ads") + |> assert_acquisition_channel("Paid Social") + end + + test "android-app://com.reddit.frontpage is Reddit", %{site: site} do + site + |> event_with_referrer("android-app://com.reddit.frontpage") + |> assert_source("Reddit") + |> assert_acquisition_channel("Organic Social") + end + + test "perplexity.ai is Perplexity", %{site: site} do + site + |> event_with_referrer("https://perplexity.ai") + |> assert_source("Perplexity") + |> assert_acquisition_channel("AI Assistants") + end + + test "pplx.ai is Perplexity", %{site: site} do + site + |> event_with_referrer("https://pplx.ai") + |> assert_source("Perplexity") + |> assert_acquisition_channel("AI Assistants") + end + + test "utm_source=perplexity is Perplexity", %{site: site} do + site + |> event_with_utm_source("perplexity") + |> assert_source("Perplexity") + |> assert_acquisition_channel("AI Assistants") + end + + test "statics.teams.cdn.office.net is Microsoft Teams", %{site: site} do + site + |> event_with_referrer("https://statics.teams.cdn.office.net") + |> assert_source("Microsoft Teams") + |> assert_acquisition_channel("Organic Social") + end + + test "wikipedia domain is resolved as Wikipedia", %{site: site} do + site + |> event_with_referrer("https://en.wikipedia.org") + |> assert_source("Wikipedia") + |> assert_acquisition_channel("Referral") + end + + test "ntp.msn.com is Bing", %{site: site} do + site + |> event_with_referrer("https://ntp.msn.com") + |> assert_source("Bing") + |> assert_acquisition_channel("Organic Search") + end + + test "search.brave.com is Brave", %{site: site} do + site + |> event_with_referrer("https://search.brave.com") + |> assert_source("Brave") + |> assert_acquisition_channel("Organic Search") + end + + test "yandex.com.tr is Yandex", %{site: site} do + site + |> event_with_referrer("https://yandex.com.tr") + |> assert_source("Yandex") + |> assert_acquisition_channel("Organic Search") + end + + test "yandex.kz is Yandex", %{site: site} do + site + |> event_with_referrer("https://yandex.kz") + |> assert_source("Yandex") + |> assert_acquisition_channel("Organic Search") + end + + test "ya.ru is Yandex", %{site: site} do + site + |> event_with_referrer("https://ya.ru") + |> assert_source("Yandex") + |> assert_acquisition_channel("Organic Search") + end + + test "yandex.uz is Yandex", %{site: site} do + site + |> event_with_referrer("https://yandex.uz") + |> assert_source("Yandex") + |> assert_acquisition_channel("Organic Search") + end + + test "yandex.fr is Yandex", %{site: site} do + site + |> event_with_referrer("https://yandex.fr") + |> assert_source("Yandex") + |> assert_acquisition_channel("Organic Search") + end + + test "yandex.eu is Yandex", %{site: site} do + site + |> event_with_referrer("https://yandex.eu") + |> assert_source("Yandex") + |> assert_acquisition_channel("Organic Search") + end + + test "yandex.tm is Yandex", %{site: site} do + site + |> event_with_referrer("https://yandex.tm") + |> assert_source("Yandex") + |> assert_acquisition_channel("Organic Search") + end + + test "discord.com is Discord", %{site: site} do + site + |> event_with_referrer("https://discord.com") + |> assert_source("Discord") + |> assert_acquisition_channel("Organic Social") + end + + test "discordapp.com is Discord", %{site: site} do + site + |> event_with_referrer("https://discordapp.com") + |> assert_source("Discord") + |> assert_acquisition_channel("Organic Social") + end + + test "canary.discord.com is Discord", %{site: site} do + site + |> event_with_referrer("https://canary.discord.com") + |> assert_source("Discord") + |> assert_acquisition_channel("Organic Social") + end + + test "ptb.discord.com is Discord", %{site: site} do + site + |> event_with_referrer("https://ptb.discord.com") + |> assert_source("Discord") + |> assert_acquisition_channel("Organic Social") + end + + test "www.baidu.com is Baidu", %{site: site} do + site + |> event_with_referrer("https://baidu.com") + |> assert_source("Baidu") + |> assert_acquisition_channel("Organic Search") + end + + test "t.me is Telegram", %{site: site} do + site + |> event_with_referrer("https://t.me") + |> assert_source("Telegram") + |> assert_acquisition_channel("Organic Social") + end + + test "webk.telegram.org is Telegram", %{site: site} do + site + |> event_with_referrer("https://webk.telegram.org") + |> assert_source("Telegram") + |> assert_acquisition_channel("Organic Social") + end + + test "sogou.com is Sogou", %{site: site} do + site + |> event_with_referrer("https://sogou.com") + |> assert_source("Sogou") + |> assert_acquisition_channel("Organic Search") + end + + test "m.sogou.com is Sogou", %{site: site} do + site + |> event_with_referrer("https://m.sogou.com") + |> assert_source("Sogou") + |> assert_acquisition_channel("Organic Search") + end + + test "wap.sogou.com is Sogou", %{site: site} do + site + |> event_with_referrer("https://wap.sogou.com") + |> assert_source("Sogou") + |> assert_acquisition_channel("Organic Search") + end + + test "linktr.ee is Linktree", %{site: site} do + site + |> event_with_referrer("https://linktr.ee") + |> assert_source("Linktree") + |> assert_acquisition_channel("Referral") + end + + test "linktree is Linktree", %{site: site} do + site + |> event_with_utm_source("linktree") + |> assert_source("Linktree") + |> assert_acquisition_channel("Referral") + end + end + + describe "custom channel parsing rules" do + setup do + site = new_site() + {:ok, site: site} + end + + test "hacker news is social channel", %{site: site} do + site + |> event_with_referrer("https://news.ycombinator.com") + |> assert_source("Hacker News") + |> assert_acquisition_channel("Organic Social") + end + + test "yahoo is organic search", %{site: site} do + site + |> event_with_referrer("https://search.yahoo.com") + |> assert_source("Yahoo!") + |> assert_acquisition_channel("Organic Search") + end + + test "gmail is email channel", %{site: site} do + site + |> event_with_referrer("https://mail.google.com") + |> assert_source("Gmail") + |> assert_acquisition_channel("Email") + end + + test "utm_source=newsletter is email channel", %{site: site} do + site + |> event_with_utm_source("Newsletter-UK") + |> assert_source("Newsletter-UK") + |> assert_acquisition_channel("Email") + end + + test "temu.com is shopping channel", %{site: site} do + site + |> event_with_referrer("https://temu.com") + |> assert_source("temu.com") + |> assert_acquisition_channel("Organic Shopping") + end + + test "utm_source=Telegram is social channel", %{site: site} do + site + |> event_with_utm_source("Telegram") + |> assert_source("Telegram") + |> assert_acquisition_channel("Organic Social") + end + + test "Slack is social channel", %{site: site} do + site + |> event_with_referrer("https://app.slack.com") + |> assert_source("Slack") + |> assert_acquisition_channel("Organic Social") + end + + test "producthunt is social", %{ + conn: conn, + site: site + } do + params = %{ + name: "pageview", + url: "http://example.com?ref=producthunt", + domain: site.domain + } + + conn = + conn + |> put_req_header("user-agent", @user_agent) + |> post("/api/event", params) + + [session] = get_sessions(site) + + assert response(conn, 202) == "ok" + assert session.referrer_source == "producthunt" + assert session.acquisition_channel == "Organic Social" + end + + test "github is social", %{site: site} do + site + |> event_with_referrer("https://github.com") + |> assert_source("GitHub") + |> assert_acquisition_channel("Organic Social") + end + + test "steamcommunity.com is social", %{site: site} do + site + |> event_with_referrer("https://steamcommunity.com") + |> assert_source("steamcommunity.com") + |> assert_acquisition_channel("Organic Social") + end + + test "Vkontakte is social", %{site: site} do + site + |> event_with_referrer("https://vkontakte.ru") + |> assert_source("Vkontakte") + |> assert_acquisition_channel("Organic Social") + end + + test "Threads is social", %{site: site} do + site + |> event_with_referrer("https://threads.net") + |> assert_source("Threads") + |> assert_acquisition_channel("Organic Social") + end + + test "Ecosia is search", %{site: site} do + site + |> event_with_referrer("https://ecosia.org") + |> assert_source("Ecosia") + |> assert_acquisition_channel("Organic Search") + end + + test "bsky.app is Bluesky and Organic Social", %{site: site} do + site + |> event_with_referrer("https://bsky.app") + |> assert_source("Bluesky") + |> assert_acquisition_channel("Organic Social") + end + + test "go.bsky.app is Bluesky and Organic Social", %{site: site} do + site + |> event_with_referrer("https://go.bsky.app") + |> assert_source("Bluesky") + |> assert_acquisition_channel("Organic Social") + end + + test "mastodon.social is Mastodon and Organic Social", %{site: site} do + site + |> event_with_referrer("https://mastodon.social") + |> assert_source("Mastodon") + |> assert_acquisition_channel("Organic Social") + end + + test "fosstodon.org is Mastodon and Organic Social", %{site: site} do + site + |> event_with_referrer("https://fosstodon.org") + |> assert_source("Mastodon") + |> assert_acquisition_channel("Organic Social") + end + + test "gemini.google.com is Google Gemini and AI Assistants", %{site: site} do + site + |> event_with_referrer("https://gemini.google.com") + |> assert_source("Google Gemini") + |> assert_acquisition_channel("AI Assistants") + end + + test "chatgpt.com is ChatGPT and AI Assistants", %{site: site} do + site + |> event_with_referrer("https://chatgpt.com") + |> assert_source("ChatGPT") + |> assert_acquisition_channel("AI Assistants") + end + + test "chat.openai.com is ChatGPT and AI Assistants", %{site: site} do + site + |> event_with_referrer("https://chat.openai.com") + |> assert_source("ChatGPT") + |> assert_acquisition_channel("AI Assistants") + end + + test "claude.ai is Claude and AI Assistants", %{site: site} do + site + |> event_with_referrer("https://claude.ai") + |> assert_source("Claude") + |> assert_acquisition_channel("AI Assistants") + end + + test "phind.com is Phind and AI Assistants", %{site: site} do + site + |> event_with_referrer("https://phind.com") + |> assert_source("Phind") + |> assert_acquisition_channel("AI Assistants") + end + + test "copilot.microsoft.com is Microsoft Copilot and AI Assistants", %{site: site} do + site + |> event_with_referrer("https://copilot.microsoft.com") + |> assert_source("Microsoft Copilot") + |> assert_acquisition_channel("AI Assistants") + end + + test "copilot.com is Microsoft Copilot and AI Assistants", %{site: site} do + site + |> event_with_referrer("https://copilot.com") + |> assert_source("Microsoft Copilot") + |> assert_acquisition_channel("AI Assistants") + end + + test "x.com is X (Twitter) and Organic Social", %{site: site} do + site + |> event_with_referrer("https://x.com") + |> assert_source("X (Twitter)") + |> assert_acquisition_channel("Organic Social") + end + + test "l.threads.com is Threads and Organic Social", %{site: site} do + site + |> event_with_referrer("https://l.threads.com") + |> assert_source("Threads") + |> assert_acquisition_channel("Organic Social") + end + + test "kagi.com is Kagi and Organic Search", %{site: site} do + site + |> event_with_referrer("https://kagi.com") + |> assert_source("Kagi") + |> assert_acquisition_channel("Organic Search") + end + + test "officeapps.live.com subdomains are Microsoft 365 and Referral", %{site: site} do + site + |> event_with_referrer("https://cac-excel.officeapps.live.com") + |> assert_source("Microsoft 365") + |> assert_acquisition_channel("Referral") + end + end + + describe "user_id generation" do + setup do + site = new_site() + {:ok, site: site} + end + + test "with same IP address and user agent, the same user ID is generated", %{ + conn: conn, + site: site + } do + params = %{ + url: "https://user-id-test-domain.com/", + domain: site.domain, + name: "pageview" + } + + conn + |> put_req_header("user-agent", @user_agent) + |> put_req_header("x-forwarded-for", "127.0.0.1") + |> post("/api/event", params) + + conn + |> put_req_header("user-agent", @user_agent) + |> put_req_header("x-forwarded-for", "127.0.0.1") + |> post("/api/event", params) + + [one, two] = get_events(site) + + assert one.user_id == two.user_id + end + + test "different IP address results in different user ID", %{conn: conn, site: site} do + params = %{ + url: "https://user-id-test-domain.com/", + domain: site.domain, + name: "pageview" + } + + conn + |> put_req_header("user-agent", @user_agent) + |> put_req_header("x-forwarded-for", "127.0.0.1") + |> post("/api/event", params) + + conn + |> put_req_header("user-agent", @user_agent) + |> put_req_header("x-forwarded-for", "82.32.12.1") + |> post("/api/event", params) + + [one, two] = get_events(site) + + assert one.user_id != two.user_id + end + + test "different user agent results in different user ID", %{conn: conn, site: site} do + params = %{ + url: "https://user-id-test-domain.com/", + domain: site.domain, + name: "pageview" + } + + conn + |> put_req_header("user-agent", @user_agent) + |> put_req_header("x-forwarded-for", "127.0.0.1") + |> post("/api/event", params) + + conn + |> put_req_header("user-agent", @user_agent <> "!!") + |> put_req_header("x-forwarded-for", "127.0.0.1") + |> post("/api/event", params) + + [one, two] = get_events(site) + + assert one.user_id != two.user_id + end + + test "different domain value results in different user ID", %{conn: conn, site: site1} do + site2 = new_site() + + params = %{ + url: "https://user-id-test-domain.com/", + domain: site1.domain, + name: "pageview" + } + + conn + |> put_req_header("user-agent", @user_agent) + |> put_req_header("x-forwarded-for", "127.0.0.1") + |> post("/api/event", params) + + conn + |> put_req_header("user-agent", @user_agent) + |> put_req_header("x-forwarded-for", "127.0.0.1") + |> post("/api/event", Map.put(params, :domain, site2.domain)) + + one = get_event(site1) + two = get_event(site2) + + assert one.user_id != two.user_id + end + + test "different hostname results in different user ID", %{conn: conn, site: site} do + params = %{ + url: "https://user-id-test-domain.com/", + domain: site.domain, + name: "pageview" + } + + conn + |> put_req_header("user-agent", @user_agent) + |> put_req_header("x-forwarded-for", "127.0.0.1") + |> post("/api/event", params) + + conn + |> put_req_header("user-agent", @user_agent) + |> put_req_header("x-forwarded-for", "127.0.0.1") + |> post("/api/event", Map.put(params, :url, "https://other-domain.com/")) + + [one, two] = get_events(site) + + assert one.user_id != two.user_id + end + + test "different hostname results in the same user ID when the root domain in the same", %{ + conn: conn, + site: site + } do + params = %{ + url: "https://user-id-test-domain.com/", + domain: site.domain, + name: "pageview" + } + + conn + |> put_req_header("user-agent", @user_agent) + |> put_req_header("x-forwarded-for", "127.0.0.1") + |> post("/api/event", params) + + conn + |> put_req_header("user-agent", @user_agent) + |> put_req_header("x-forwarded-for", "127.0.0.1") + |> post("/api/event", Map.put(params, :url, "https://app.user-id-test-domain.com/")) + + [one, two] = get_events(site) + + assert one.user_id == two.user_id + end + end + + describe "remaining" do + setup do + site = new_site() + {:ok, site: site} + end + + test "defaults hostname to (none) when missing", %{conn: conn, site: site} do + params = %{ + domain: site.domain, + name: "pageview", + url: "file:///android_asset/www/index.html" + } + + conn = + conn + |> put_req_header("content-type", "text/plain") + |> post("/api/event", Jason.encode!(params)) + + pageview = get_event(site) + + assert response(conn, 202) == "ok" + assert pageview.hostname == "(none)" + end + + test "accepts chrome extension URLs", %{conn: conn, site: site} do + params = %{ + domain: site.domain, + name: "pageview", + url: "chrome-extension://liipgellkffekalgefpjolodblggkmjg/popup.html" + } + + conn = + conn + |> put_req_header("content-type", "text/plain") + |> post("/api/event", Jason.encode!(params)) + + pageview = get_event(site) + + assert response(conn, 202) == "ok" + assert pageview.hostname == "liipgellkffekalgefpjolodblggkmjg" + end + end + + describe "GET /api/health" do + test "returns 200 OK", %{conn: conn} do + conn = get(conn, "/api/health") + + assert payload = json_response(conn, 200) + assert payload["postgres"] == "ok" + assert payload["clickhouse"] == "ok" + assert payload["sites_cache"] == "ok" + end + end + + defp get_event(site) do + Plausible.Event.WriteBuffer.flush() + + ClickhouseRepo.one( + from(e in Plausible.ClickhouseEventV2, + where: e.site_id == ^site.id, + order_by: [desc: e.timestamp] + ) + ) + end + + defp get_sessions(site) do + Plausible.Session.WriteBuffer.flush() + + ClickhouseRepo.all( + from(s in Plausible.ClickhouseSessionV2, + where: s.site_id == ^site.id and s.sign == 1, + order_by: [desc: s.timestamp] + ) + ) + end + + defp get_events(site) do + Plausible.Event.WriteBuffer.flush() + + ClickhouseRepo.all( + from(e in Plausible.ClickhouseEventV2, + where: e.site_id == ^site.id, + order_by: [desc: e.timestamp] + ) + ) + end +end diff --git a/test/plausible_web/controllers/api/external_sites_controller_sites_crud_api_test.exs b/test/plausible_web/controllers/api/external_sites_controller_sites_crud_api_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..458804f3b0db3034e6ef658b0a4510051536602e --- /dev/null +++ b/test/plausible_web/controllers/api/external_sites_controller_sites_crud_api_test.exs @@ -0,0 +1,982 @@ +defmodule PlausibleWeb.Api.ExternalSitesControllerSitesCrudApiTest do + @moduledoc """ + Tests for Sites create/read/update/delete API. + """ + use PlausibleWeb.ConnCase + use Plausible.Repo + use Bamboo.Test + + on_ee do + setup :create_user + + setup %{conn: conn, user: user} do + api_key = insert(:api_key, user: user, scopes: ["sites:provision:*"]) + conn = Plug.Conn.put_req_header(conn, "authorization", "Bearer #{api_key.key}") + {:ok, api_key: api_key, conn: conn} + end + + describe "POST /api/v1/sites" do + setup %{user: user} do + subscribe_to_enterprise_plan(user, + features: [ + Plausible.Billing.Feature.StatsAPI, + Plausible.Billing.Feature.SitesAPI + ] + ) + + :ok + end + + test "can create a site", %{conn: conn} do + conn = + post(conn, "/api/v1/sites", %{ + "domain" => "some-site.domain", + "timezone" => "Europe/Tallinn" + }) + + response = json_response(conn, 200) + + assert_matches ^strict_map(%{ + "domain" => "some-site.domain", + "timezone" => "Europe/Tallinn", + "custom_properties" => [], + "tracker_script_configuration" => + ^strict_map(%{ + "id" => ^any(:string), + "installation_type" => nil, + "track_404_pages" => false, + "hash_based_routing" => false, + "outbound_links" => false, + "file_downloads" => false, + "revenue_tracking" => false, + "tagged_events" => false, + "form_submissions" => false, + "pageview_props" => false + }) + }) = response + end + + test "can create more sites than their Enterprise plans is limited to (we sort the bills post-fact)", + %{conn: conn} do + user = + new_user() + |> subscribe_to_enterprise_plan( + features: [ + Plausible.Billing.Feature.StatsAPI, + Plausible.Billing.Feature.SitesAPI + ], + site_limit: 10 + ) + + sites = for _ <- 1..10, do: new_site(owner: user) + assert 10 == length(sites) + + conn = + post(conn, "/api/v1/sites", %{ + "domain" => "some-site.domain", + "timezone" => "Europe/Tallinn" + }) + + response = json_response(conn, 200) + + assert_matches ^strict_map(%{ + "domain" => "some-site.domain", + "timezone" => "Europe/Tallinn", + "custom_properties" => [], + "tracker_script_configuration" => + ^strict_map(%{ + "id" => ^any(:string), + "installation_type" => nil, + "track_404_pages" => false, + "hash_based_routing" => false, + "outbound_links" => false, + "file_downloads" => false, + "revenue_tracking" => false, + "tagged_events" => false, + "form_submissions" => false, + "pageview_props" => false + }) + }) = response + end + + test "can create a site with a specific tracker script configuration", %{conn: conn} do + payload = %{ + "domain" => "some-site.domain", + "timezone" => "Europe/Tallinn", + "tracker_script_configuration" => %{ + "installation_type" => "wordpress", + "track_404_pages" => false, + "hash_based_routing" => false, + "outbound_links" => false, + "file_downloads" => true, + "revenue_tracking" => false, + "tagged_events" => false, + "form_submissions" => true, + "pageview_props" => false + } + } + + conn = + post(conn, "/api/v1/sites", payload) + + response = json_response(conn, 200) + + assert_matches ^strict_map(%{ + "domain" => ^payload["domain"], + "timezone" => ^payload["timezone"], + "custom_properties" => [], + "tracker_script_configuration" => + ^strict_map(%{ + "id" => ^any(:string), + "installation_type" => + ^payload["tracker_script_configuration"]["installation_type"], + "track_404_pages" => + ^payload["tracker_script_configuration"]["track_404_pages"], + "hash_based_routing" => + ^payload["tracker_script_configuration"]["hash_based_routing"], + "outbound_links" => + ^payload["tracker_script_configuration"]["outbound_links"], + "file_downloads" => + ^payload["tracker_script_configuration"]["file_downloads"], + "revenue_tracking" => + ^payload["tracker_script_configuration"]["revenue_tracking"], + "tagged_events" => + ^payload["tracker_script_configuration"]["tagged_events"], + "form_submissions" => + ^payload["tracker_script_configuration"]["form_submissions"], + "pageview_props" => + ^payload["tracker_script_configuration"]["pageview_props"] + }) + }) = response + end + + test "can't create site in a team where not permitted to", %{conn: conn, user: user} do + owner = new_user() |> subscribe_to_growth_plan() + team = owner |> team_of() |> Plausible.Teams.complete_setup() + add_member(team, user: user, role: :viewer) + + conn = + post(conn, "/api/v1/sites", %{ + "team_id" => team.identifier, + "domain" => "some-site.domain", + "timezone" => "Europe/Tallinn" + }) + + assert %{"error" => error} = json_response(conn, 402) + assert error =~ "API key does not have access to Sites API" + end + + test "can create a site under a specific team if permitted", %{conn: conn, user: user} do + _site = new_site(owner: user) + + owner = + new_user() + |> subscribe_to_enterprise_plan( + features: [Plausible.Billing.Feature.StatsAPI, Plausible.Billing.Feature.SitesAPI] + ) + + team = owner |> team_of() |> Plausible.Teams.complete_setup() + add_member(team, user: user, role: :owner) + + conn = + post(conn, "/api/v1/sites", %{ + "team_id" => team.identifier, + "domain" => "some-site.domain", + "timezone" => "Europe/Tallinn" + }) + + response = json_response(conn, 200) + + assert_matches ^strict_map(%{ + "domain" => "some-site.domain", + "timezone" => "Europe/Tallinn", + "custom_properties" => [], + "tracker_script_configuration" => + ^strict_map(%{ + "id" => ^any(:string), + "installation_type" => nil, + "track_404_pages" => false, + "hash_based_routing" => false, + "outbound_links" => false, + "file_downloads" => false, + "revenue_tracking" => false, + "tagged_events" => false, + "form_submissions" => false, + "pageview_props" => false + }) + }) = response + + assert Repo.get_by(Plausible.Site, domain: "some-site.domain").team_id == team.id + end + + test "creates under a particular team when team-scoped key used", %{conn: conn, user: user} do + personal_team = user |> subscribe_to_business_plan() |> team_of() + + another_team = + new_user() + |> subscribe_to_enterprise_plan( + features: [Plausible.Billing.Feature.StatsAPI, Plausible.Billing.Feature.SitesAPI] + ) + |> team_of() + + add_member(another_team, user: user, role: :admin) + + api_key = insert(:api_key, user: user, team: another_team, scopes: ["sites:provision:*"]) + conn = Plug.Conn.put_req_header(conn, "authorization", "Bearer #{api_key.key}") + + conn = + post(conn, "/api/v1/sites", %{ + # is ignored + "team_id" => personal_team.identifier, + "domain" => "some-site.domain", + "timezone" => "Europe/Tallinn" + }) + + response = json_response(conn, 200) + + assert_matches ^strict_map(%{ + "domain" => "some-site.domain", + "timezone" => "Europe/Tallinn", + "custom_properties" => [], + "tracker_script_configuration" => + ^strict_map(%{ + "id" => ^any(:string), + "installation_type" => nil, + "track_404_pages" => false, + "hash_based_routing" => false, + "outbound_links" => false, + "file_downloads" => false, + "revenue_tracking" => false, + "tagged_events" => false, + "form_submissions" => false, + "pageview_props" => false + }) + }) = response + + assert Repo.get_by(Plausible.Site, domain: "some-site.domain").team_id == another_team.id + end + + test "timezone is validated", %{conn: conn} do + conn = + post(conn, "/api/v1/sites", %{ + "domain" => "some-site.domain", + "timezone" => "d" + }) + + assert json_response(conn, 400) == %{ + "error" => "timezone: is invalid" + } + end + + test "timezone defaults to Etc/UTC", %{conn: conn} do + conn = + post(conn, "/api/v1/sites", %{ + "domain" => "some-site.domain" + }) + + response = json_response(conn, 200) + + assert_matches ^strict_map(%{ + "domain" => "some-site.domain", + "timezone" => "Etc/UTC", + "custom_properties" => [], + "tracker_script_configuration" => %{} + }) = response + end + + test "domain is required", %{conn: conn} do + conn = post(conn, "/api/v1/sites", %{}) + + assert json_response(conn, 400) == %{ + "error" => "domain: can't be blank" + } + end + + test "accepts international domain names", %{conn: conn} do + ["müllers-café.test", "音乐.cn", "до.101домен.рф/pages"] + |> Enum.each(fn idn_domain -> + conn = post(conn, "/api/v1/sites", %{"domain" => idn_domain}) + assert %{"domain" => ^idn_domain} = json_response(conn, 200) + end) + end + + test "validates uri breaking domains", %{conn: conn} do + ["quero:café.test", "h&llo.test", "iamnotsur&about?this.com"] + |> Enum.each(fn bad_domain -> + conn = post(conn, "/api/v1/sites", %{"domain" => bad_domain}) + + assert %{"error" => error} = json_response(conn, 400) + assert error =~ "domain: must not contain URI reserved characters" + end) + end + + test "validates tracker script configuration if it's not nil, serializing the first error", + %{conn: conn} do + conn = + post(conn, "/api/v1/sites", %{ + "domain" => "some-site.domain", + "timezone" => "Europe/Tallinn", + "tracker_script_configuration" => %{ + "installation_type" => "an invalid value", + "track_404_pages" => "an invalid value" + } + }) + + assert json_response(conn, 400) == %{ + "error" => "tracker_script_configuration.installation_type: is invalid" + } + + conn2 = + post(conn, "/api/v1/sites", %{ + "domain" => "some-site.domain", + "timezone" => "Europe/Tallinn", + "tracker_script_configuration" => %{ + "installation_type" => "wordpress", + "track_404_pages" => "an invalid value" + } + }) + + assert json_response(conn2, 400) == %{ + "error" => "tracker_script_configuration.track_404_pages: is invalid" + } + end + + test "creating the site and creating the tracker script configuration are run as a transaction: if creating the tracker script configuration fails, the site is not inserted", + %{conn: conn} do + conn = + post(conn, "/api/v1/sites", %{ + "domain" => "some-site.domain", + "timezone" => "Europe/Tallinn", + "tracker_script_configuration" => %{ + "installation_type" => "an invalid value" + } + }) + + assert json_response(conn, 400) == %{ + "error" => "tracker_script_configuration.installation_type: is invalid" + } + + assert Repo.get_by(Plausible.Site, domain: "some-site.domain") == nil + + # try again with a valid tracker script configuration + conn2 = + post(conn, "/api/v1/sites", %{ + "domain" => "some-site.domain", + "timezone" => "Europe/Tallinn", + "tracker_script_configuration" => %{ + "installation_type" => "manual" + } + }) + + response = json_response(conn2, 200) + + assert_matches ^strict_map(%{ + "domain" => "some-site.domain", + "timezone" => "Europe/Tallinn", + "custom_properties" => [], + "tracker_script_configuration" => + ^strict_map(%{ + "id" => ^any(:string), + "installation_type" => "manual", + "track_404_pages" => false, + "hash_based_routing" => false, + "outbound_links" => false, + "file_downloads" => false, + "revenue_tracking" => false, + "tagged_events" => false, + "form_submissions" => false, + "pageview_props" => false + }) + }) = response + end + + test "cannot access with a bad API key scope", %{conn: conn, user: user} do + api_key = insert(:api_key, user: user, scopes: ["stats:read:*"]) + + conn = + conn + |> Plug.Conn.put_req_header("authorization", "Bearer #{api_key.key}") + |> post("/api/v1/sites", %{"site" => %{"domain" => "domain.com"}}) + + assert json_response(conn, 401) == %{ + "error" => + "Invalid API key. Please make sure you're using a valid API key with access to the resource you've requested." + } + end + end + + describe "DELETE /api/v1/sites/:site_id" do + setup :create_site + + setup %{user: user} do + subscribe_to_enterprise_plan(user, + features: [ + Plausible.Billing.Feature.StatsAPI, + Plausible.Billing.Feature.SitesAPI + ] + ) + + :ok + end + + test "delete a site by its domain", %{conn: conn, site: site} do + conn = delete(conn, "/api/v1/sites/" <> site.domain) + + assert json_response(conn, 200) == %{"deleted" => true} + end + + test "delete a site by its old domain after domain change", %{conn: conn, site: site} do + old_domain = site.domain + new_domain = "new.example.com" + + Plausible.Site.Domain.change(site, new_domain) + + conn = delete(conn, "/api/v1/sites/" <> old_domain) + + assert json_response(conn, 200) == %{"deleted" => true} + end + + test "is 404 when site cannot be found", %{conn: conn} do + conn = delete(conn, "/api/v1/sites/foobar.baz") + + assert json_response(conn, 404) == %{"error" => "Site could not be found"} + end + + @tag :capture_log + test "cannot delete a site that the user does not own", %{conn: conn, user: user} do + site = new_site() + add_guest(site, user: user, role: :editor) + conn = delete(conn, "/api/v1/sites/" <> site.domain) + + assert %{"error" => error} = json_response(conn, 402) + assert error =~ "API key does not have access to Sites API" + end + + test "cannot delete if team not matching team-scoped API key", %{ + conn: conn, + user: user, + site: site + } do + another_team = new_user() |> subscribe_to_business_plan() |> team_of() + add_member(another_team, user: user, role: :admin) + api_key = insert(:api_key, user: user, team: another_team, scopes: ["sites:provision:*"]) + conn = Plug.Conn.put_req_header(conn, "authorization", "Bearer #{api_key.key}") + + conn = delete(conn, "/api/v1/sites/" <> site.domain) + + assert %{"error" => error} = json_response(conn, 401) + assert error =~ "Invalid API key" + end + + test "cannot access with a bad API key scope", %{conn: conn, site: site, user: user} do + api_key = insert(:api_key, user: user, scopes: ["stats:read:*"]) + + conn = + conn + |> Plug.Conn.put_req_header("authorization", "Bearer #{api_key.key}") + |> delete("/api/v1/sites/" <> site.domain) + + assert json_response(conn, 401) == %{ + "error" => + "Invalid API key. Please make sure you're using a valid API key with access to the resource you've requested." + } + end + end + + describe "GET /api/v1/sites" do + test "returns empty when there are no sites for user", %{conn: conn} do + conn = get(conn, "/api/v1/sites") + + assert json_response(conn, 200) == %{ + "sites" => [], + "meta" => %{ + "before" => nil, + "after" => nil, + "limit" => 100 + } + } + end + + test "returns sites when present", %{conn: conn, user: user} do + site1 = new_site(owner: user) + site2 = new_site(owner: user) + + _unrelated_site = new_site() + _consolidated_excluded = new_site(owner: user, consolidated: true) + + conn = get(conn, "/api/v1/sites") + + assert json_response(conn, 200) == %{ + "sites" => [ + %{"domain" => site2.domain, "timezone" => site2.timezone}, + %{"domain" => site1.domain, "timezone" => site1.timezone} + ], + "meta" => %{ + "before" => nil, + "after" => nil, + "limit" => 100 + } + } + end + + test "returns sites where user is only a viewer", %{conn: conn, user: user} do + %{domain: owned_site_domain} = new_site(owner: user) + other_site = %{domain: other_site_domain} = new_site() + add_guest(other_site, user: user, role: :viewer) + + conn = get(conn, "/api/v1/sites") + + assert %{ + "sites" => [ + %{"domain" => ^other_site_domain}, + %{"domain" => ^owned_site_domain} + ] + } = json_response(conn, 200) + end + + test "returns sites scoped to a given team for full memberships", %{conn: conn, user: user} do + _owned_site = new_site(owner: user) + other_site = new_site() + add_guest(other_site, user: user, role: :viewer) + other_team_site = new_site() + add_member(other_team_site.team, user: user, role: :viewer) + + conn = get(conn, "/api/v1/sites?team_id=" <> other_team_site.team.identifier) + + assert_matches %{ + "sites" => [ + %{"domain" => ^other_team_site.domain} + ] + } = json_response(conn, 200) + end + + test "implicitly scopes to a team for a team-scoped key", %{ + conn: conn, + user: user + } do + another_team = new_user() |> subscribe_to_business_plan() |> team_of() + add_member(another_team, user: user, role: :admin) + site = new_site(team: another_team) + api_key = insert(:api_key, user: user, team: another_team, scopes: ["sites:provision:*"]) + conn = Plug.Conn.put_req_header(conn, "authorization", "Bearer #{api_key.key}") + + _owned_site = new_site(owner: user) + other_site = new_site() + add_guest(other_site, user: user, role: :viewer) + other_team_site = new_site() + add_member(other_team_site.team, user: user, role: :viewer) + + # `team_id` paramaeter is ignored + conn = get(conn, "/api/v1/sites?team_id=" <> other_team_site.team.identifier) + + assert_matches %{ + "sites" => [ + %{"domain" => ^site.domain} + ] + } = json_response(conn, 200) + end + + test "handles pagination correctly", %{conn: conn, user: user} do + [ + %{domain: site1_domain}, + %{domain: site2_domain}, + %{domain: site3_domain} + ] = for _ <- 1..3, do: new_site(owner: user) + + conn1 = get(conn, "/api/v1/sites?limit=2") + + assert %{ + "sites" => [ + %{"domain" => ^site3_domain}, + %{"domain" => ^site2_domain} + ], + "meta" => %{ + "before" => nil, + "after" => after_cursor, + "limit" => 2 + } + } = json_response(conn1, 200) + + conn2 = get(conn, "/api/v1/sites?limit=2&after=" <> after_cursor) + + assert %{ + "sites" => [ + %{"domain" => ^site1_domain} + ], + "meta" => %{ + "before" => before_cursor, + "after" => nil, + "limit" => 2 + } + } = json_response(conn2, 200) + + assert is_binary(before_cursor) + end + + test "lists sites for user with read-only scope", %{conn: conn, user: user} do + %{domain: site_domain} = new_site(owner: user) + api_key = insert(:api_key, user: user, scopes: ["stats:read:*"]) + + conn = + conn + |> Plug.Conn.put_req_header("authorization", "Bearer #{api_key.key}") + |> get("/api/v1/sites") + + assert %{"sites" => [%{"domain" => ^site_domain}]} = json_response(conn, 200) + end + end + + describe "GET /api/v1/sites/:site_id" do + setup :create_site + + test "get a site by its domain", %{conn: conn, site: site} do + site = + site + |> Ecto.Changeset.change(allowed_event_props: ["logged_in", "author"]) + |> Repo.update!() + + conn = get(conn, "/api/v1/sites/" <> site.domain) + + response = json_response(conn, 200) + + assert_matches ^strict_map(%{ + "domain" => ^site.domain, + "timezone" => ^site.timezone, + "custom_properties" => ["logged_in", "author"], + "tracker_script_configuration" => + ^strict_map(%{ + "id" => ^any(:string), + "installation_type" => nil, + "track_404_pages" => false, + "hash_based_routing" => false, + "outbound_links" => false, + "file_downloads" => false, + "revenue_tracking" => false, + "tagged_events" => false, + "form_submissions" => false, + "pageview_props" => false + }) + }) = response + end + + test "get a site by old site_id after domain change", %{conn: conn, site: site} do + old_domain = site.domain + new_domain = "new.example.com" + + Plausible.Site.Domain.change(site, new_domain) + + conn = get(conn, "/api/v1/sites/" <> old_domain) + + response = json_response(conn, 200) + + assert_matches ^strict_map(%{ + "domain" => ^new_domain, + "timezone" => ^site.timezone, + "custom_properties" => [], + "tracker_script_configuration" => + ^strict_map(%{ + "id" => ^any(:string), + "installation_type" => nil, + "track_404_pages" => false, + "hash_based_routing" => false, + "outbound_links" => false, + "file_downloads" => false, + "revenue_tracking" => false, + "tagged_events" => false, + "form_submissions" => false, + "pageview_props" => false + }) + }) = response + end + + test "get a site for user with read-only scope", %{conn: conn, user: user, site: site} do + api_key = insert(:api_key, user: user, scopes: ["stats:read:*"]) + + conn = + conn + |> Plug.Conn.put_req_header("authorization", "Bearer #{api_key.key}") + |> get("/api/v1/sites/" <> site.domain) + + response = json_response(conn, 200) + + assert_matches ^strict_map(%{ + "domain" => ^site.domain, + "timezone" => ^site.timezone, + "custom_properties" => [], + "tracker_script_configuration" => + ^strict_map(%{ + "id" => ^any(:string), + "installation_type" => nil, + "track_404_pages" => false, + "hash_based_routing" => false, + "outbound_links" => false, + "file_downloads" => false, + "revenue_tracking" => false, + "tagged_events" => false, + "form_submissions" => false, + "pageview_props" => false + }) + }) = response + end + + test "fails when team does not match team-scoped key", %{conn: conn, user: user, site: site} do + another_team = new_user() |> subscribe_to_business_plan() |> team_of() + add_member(another_team, user: user, role: :admin) + api_key = insert(:api_key, user: user, team: another_team, scopes: ["sites:provision:*"]) + conn = Plug.Conn.put_req_header(conn, "authorization", "Bearer #{api_key.key}") + + conn = get(conn, "/api/v1/sites/" <> site.domain) + + res = json_response(conn, 401) + assert res["error"] =~ "Invalid API key" + end + + test "is 404 when site cannot be found", %{conn: conn} do + conn = get(conn, "/api/v1/sites/foobar.baz") + + assert json_response(conn, 404) == %{"error" => "Site could not be found"} + end + + @tag :capture_log + test "is 401 when user is not a member of the site", %{conn: conn} do + site = new_site() + + conn = get(conn, "/api/v1/sites/" <> site.domain) + + assert %{"error" => error} = json_response(conn, 401) + assert error =~ "Invalid API key" + end + end + + describe "PUT /api/v1/sites/:site_id" do + setup :create_site + + setup %{user: user} do + subscribe_to_enterprise_plan(user, + features: [ + Plausible.Billing.Feature.StatsAPI, + Plausible.Billing.Feature.SitesAPI, + Plausible.Billing.Feature.ConsolidatedView + ] + ) + + :ok + end + + test "can change domain name", %{conn: conn, site: site} do + old_domain = site.domain + assert old_domain != "new.example.com" + + conn = + put(conn, "/api/v1/sites/#{old_domain}", %{ + "domain" => "new.example.com" + }) + + response = json_response(conn, 200) + + assert_matches ^strict_map(%{ + "domain" => "new.example.com", + "timezone" => "Etc/UTC", + "custom_properties" => [], + "tracker_script_configuration" => + ^strict_map(%{ + "id" => ^any(:string), + "installation_type" => nil, + "track_404_pages" => false, + "hash_based_routing" => false, + "outbound_links" => false, + "file_downloads" => false, + "revenue_tracking" => false, + "tagged_events" => false, + "form_submissions" => false, + "pageview_props" => false + }) + }) = response + + site = Repo.reload!(site) + + assert site.domain == "new.example.com" + assert site.domain_changed_from == old_domain + end + + test "can change tracker script configuration (merging updated keys with previous configuration)", + %{conn: conn, site: site} do + conn = + put(conn, "/api/v1/sites/#{site.domain}", %{ + "tracker_script_configuration" => %{ + "form_submissions" => true + } + }) + + response = json_response(conn, 200) + + assert_matches ^strict_map(%{ + "domain" => ^site.domain, + "timezone" => "Etc/UTC", + "custom_properties" => [], + "tracker_script_configuration" => + ^strict_map(%{ + "id" => ^any(:string), + "installation_type" => nil, + "track_404_pages" => false, + "hash_based_routing" => false, + "outbound_links" => false, + "file_downloads" => false, + "revenue_tracking" => false, + "tagged_events" => false, + "form_submissions" => true, + "pageview_props" => false + }) + }) = response + end + + test "can change domain name and tracker script configuration together (merging updated keys with previous configuration)", + %{conn: conn, site: site} do + old_domain = site.domain + assert old_domain != "new.example.com" + + conn = + put(conn, "/api/v1/sites/#{site.domain}", %{ + "domain" => "new.example.com", + "tracker_script_configuration" => %{ + "form_submissions" => true + } + }) + + response = json_response(conn, 200) + + assert_matches ^strict_map(%{ + "domain" => "new.example.com", + "timezone" => "Etc/UTC", + "custom_properties" => [], + "tracker_script_configuration" => + ^strict_map(%{ + "id" => ^any(:string), + "installation_type" => nil, + "track_404_pages" => false, + "hash_based_routing" => false, + "outbound_links" => false, + "file_downloads" => false, + "revenue_tracking" => false, + "tagged_events" => false, + "form_submissions" => true, + "pageview_props" => false + }) + }) = response + + site = Repo.reload!(site) + + assert site.domain == "new.example.com" + assert site.domain_changed_from == old_domain + end + + test "domain name change and tracker script configuration update are run as a transaction: if updating the configuration fails, the domain change is rolled back", + %{conn: conn, site: site} do + old_domain = site.domain + assert old_domain != "new.example.com" + + conn = + put(conn, "/api/v1/sites/#{site.domain}", %{ + "domain" => "new.example.com", + "tracker_script_configuration" => %{ + "installation_type" => "an invalid value", + "form_submissions" => true + } + }) + + assert json_response(conn, 400) == %{ + "error" => "tracker_script_configuration.installation_type: is invalid" + } + + site = Repo.reload!(site) + + assert site.domain == old_domain + assert site.domain_changed_from == nil + end + + test "cannot update consolidated view", %{conn: conn, user: user} do + new_site(owner: user) + {:ok, team} = Plausible.Teams.get_or_create(user) + consolidated_view = new_consolidated_view(team) + + old_domain = consolidated_view.domain + + conn = + put(conn, "/api/v1/sites/#{consolidated_view.domain}", %{ + "domain" => "updated.domain.com" + }) + + assert json_response(conn, 400)["error"] =~ + "This operation is unavailable for a consolidated view" + + assert Repo.reload!(consolidated_view).domain == old_domain + end + + test "fails when team does not match team-scoped key", %{conn: conn, user: user, site: site} do + another_team = new_user() |> subscribe_to_business_plan() |> team_of() + add_member(another_team, user: user, role: :admin) + api_key = insert(:api_key, user: user, team: another_team, scopes: ["sites:provision:*"]) + conn = Plug.Conn.put_req_header(conn, "authorization", "Bearer #{api_key.key}") + + old_domain = site.domain + assert old_domain != "new.example.com" + + conn = + put(conn, "/api/v1/sites/#{old_domain}", %{ + "domain" => "new.example.com" + }) + + assert %{"error" => error} = json_response(conn, 401) + assert error =~ "Invalid API key" + end + + test "fails when neither 'domain' nor 'tracker_script_configuration' is provided", %{ + conn: conn, + site: site + } do + conn = + put(conn, "/api/v1/sites/#{site.domain}", %{"foo" => "bar"}) + + assert json_response(conn, 400) == %{ + "error" => + "Payload must contain at least one of the parameters 'domain', 'tracker_script_configuration'" + } + end + + test "fails when domain parameter is invalid", %{conn: conn, site: site} do + conn = put(conn, "/api/v1/sites/#{site.domain}", %{"domain" => 123}) + + assert json_response(conn, 400) == %{ + "error" => "domain: is invalid" + } + end + + test "can't make a no-op domain change", %{conn: conn, site: site} do + conn = + put(conn, "/api/v1/sites/#{site.domain}", %{ + "domain" => site.domain + }) + + assert json_response(conn, 400) == %{ + "error" => "domain: New domain must be different than the current one" + } + end + + test "fails when tracker script configuration parameter is invalid", %{ + conn: conn, + site: site + } do + conn = + put(conn, "/api/v1/sites/#{site.domain}", %{ + "tracker_script_configuration" => %{ + "form_submissions" => "an invalid value" + } + }) + + assert json_response(conn, 400) == %{ + "error" => "tracker_script_configuration.form_submissions: is invalid" + } + end + end + end +end diff --git a/test/plausible_web/controllers/api/external_sites_controller_test.exs b/test/plausible_web/controllers/api/external_sites_controller_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..49b1b61a6bdfaf0bdb51c2418574db855aa6a78f --- /dev/null +++ b/test/plausible_web/controllers/api/external_sites_controller_test.exs @@ -0,0 +1,1643 @@ +defmodule PlausibleWeb.Api.ExternalSitesControllerTest do + @moduledoc """ + Tests for the following endpoints + + GET /api/v1/sites/teams + + GET /api/v1/sites/guests + PUT /api/v1/sites/guests + DELETE /api/v1/sites/guests + + PUT /api/v1/sites/shared-links + + GET /api/v1/custom-props + PUT /api/v1/sites/custom-props + DELETE /api/v1/sites/custom-props/:property + + GET /api/v1/goals + PUT /api/v1/sites/goals + DELETE /api/v1/sites/goals/:goal_id + + Site CRUD endpoints tests are in ExternalSitesControllerSitesCrudApiTest + """ + use PlausibleWeb.ConnCase, async: false + use Plausible.Repo + use Bamboo.Test + + on_ee do + setup :create_user + + setup %{conn: conn, user: user} do + api_key = insert(:api_key, user: user, scopes: ["sites:provision:*"]) + conn = Plug.Conn.put_req_header(conn, "authorization", "Bearer #{api_key.key}") + {:ok, api_key: api_key, conn: conn} + end + + describe "GET /api/v1/sites/teams" do + test "shows empty list when user is not a member of any team", %{conn: conn} do + conn = get(conn, "/api/v1/sites/teams") + + assert json_response(conn, 200) == %{ + "teams" => [], + "meta" => %{ + "before" => nil, + "after" => nil, + "limit" => 100 + } + } + end + + test "shows list of teams user is a member of with api availability reflecting team state", + %{conn: conn, user: user} do + user |> subscribe_to_growth_plan() + + personal_team = team_of(user) + + owner1 = + new_user( + trial_expiry_date: Date.add(Date.utc_today(), -1), + team: [name: "Team Without Stats API"] + ) + |> subscribe_to_enterprise_plan(features: []) + + team_without_stats = owner1 |> team_of() |> Plausible.Teams.complete_setup() + add_member(team_without_stats, user: user, role: :editor) + owner2 = new_user(team: [name: "Team With Stats API"]) + team_with_stats = owner2 |> team_of() |> Plausible.Teams.complete_setup() + add_member(team_with_stats, user: user, role: :owner) + + conn = get(conn, "/api/v1/sites/teams") + + assert json_response(conn, 200) == %{ + "teams" => [ + %{ + "id" => team_with_stats.identifier, + "name" => "Team With Stats API", + "api_available" => true + }, + %{ + "id" => team_without_stats.identifier, + "name" => "Team Without Stats API", + "api_available" => false + }, + %{ + "id" => personal_team.identifier, + "name" => "My personal sites", + "api_available" => false + } + ], + "meta" => %{ + "before" => nil, + "after" => nil, + "limit" => 100 + } + } + end + + test "shows only one team for team scoped key", %{conn: conn, user: user} do + user |> subscribe_to_business_plan() + + personal_team = team_of(user) + + another_team = new_site().team |> Plausible.Teams.complete_setup() + add_member(another_team, user: user, role: :admin) + + api_key = insert(:api_key, user: user, team: personal_team, scopes: ["sites:provision:*"]) + conn = Plug.Conn.put_req_header(conn, "authorization", "Bearer #{api_key.key}") + + conn = get(conn, "/api/v1/sites/teams") + + assert json_response(conn, 200) == %{ + "teams" => [ + %{ + "id" => personal_team.identifier, + "name" => "My personal sites", + "api_available" => true + } + ], + "meta" => %{ + "before" => nil, + "after" => nil, + "limit" => 100 + } + } + end + end + + describe "PUT /api/v1/sites/shared-links" do + setup :create_site + + setup %{user: user} do + subscribe_to_enterprise_plan(user, + features: [ + Plausible.Billing.Feature.SharedLinks, + Plausible.Billing.Feature.SitesAPI + ] + ) + + :ok + end + + test "can add a shared link to a site", %{conn: conn, site: site} do + conn = + put(conn, "/api/v1/sites/shared-links", %{ + site_id: site.domain, + name: "WordPress" + }) + + res = json_response(conn, 200) + assert res["name"] == "WordPress" + assert String.starts_with?(res["url"], "http://") + end + + test "can add a shared link to a site using the old site id after domain change", %{ + conn: conn, + site: site + } do + old_domain = site.domain + new_domain = "new.example.com" + + Plausible.Site.Domain.change(site, new_domain) + + conn = + put(conn, "/api/v1/sites/shared-links", %{ + site_id: old_domain, + name: "WordPress" + }) + + res = json_response(conn, 200) + assert res["name"] == "WordPress" + assert String.starts_with?(res["url"], "http://") + end + + test "is idempotent find or create op", %{conn: conn, site: site} do + conn = + put(conn, "/api/v1/sites/shared-links", %{ + site_id: site.domain, + name: "WordPress" + }) + + %{"url" => url} = json_response(conn, 200) + + conn = + put(conn, "/api/v1/sites/shared-links", %{ + site_id: site.domain, + name: "WordPress" + }) + + assert %{"url" => ^url} = json_response(conn, 200) + end + + test "fails when team does not match team-scoped key", %{conn: conn, user: user, site: site} do + another_team = new_user() |> subscribe_to_business_plan() |> team_of() + add_member(another_team, user: user, role: :admin) + api_key = insert(:api_key, user: user, team: another_team, scopes: ["sites:provision:*"]) + conn = Plug.Conn.put_req_header(conn, "authorization", "Bearer #{api_key.key}") + + conn = + put(conn, "/api/v1/sites/shared-links", %{ + site_id: site.domain, + name: "WordPress" + }) + + res = json_response(conn, 401) + assert res["error"] =~ "Invalid API key" + end + + test "returns 400 when site id missing", %{conn: conn} do + conn = + put(conn, "/api/v1/sites/shared-links", %{ + name: "WordPress" + }) + + res = json_response(conn, 400) + + assert res["error"] == + "Missing site ID. Please provide the required site_id parameter with your request." + end + + test "returns 404 when site id is non existent", %{conn: conn} do + conn = + put(conn, "/api/v1/sites/shared-links", %{ + name: "WordPress", + site_id: "bad" + }) + + res = json_response(conn, 404) + assert res["error"] == "Site could not be found" + end + + @tag :capture_log + test "returns 404 when api key owner does not have permissions to create a shared link", %{ + conn: conn, + user: user + } do + site = new_site() + + add_guest(site, user: user, role: :viewer) + + conn = + put(conn, "/api/v1/sites/shared-links", %{ + site_id: site.domain, + name: "WordPress" + }) + + res = json_response(conn, 402) + assert res["error"] =~ "API key does not have access to Sites API" + end + + test "fails to create without access to SharedLinks feature", %{ + conn: conn, + site: site, + user: user + } do + subscribe_to_enterprise_plan(user, features: [Plausible.Billing.Feature.SitesAPI]) + + conn = + put(conn, "/api/v1/sites/shared-links", %{ + site_id: site.domain, + name: "My Link" + }) + + res = json_response(conn, 402) + + assert res["error"] == "Your current subscription plan does not include Shared Links" + end + + for special_name <- Plausible.Sites.shared_link_special_names() do + test "fails to create with the special '#{special_name}' name intended for Plugins API", + %{conn: conn, site: site} do + conn = + put(conn, "/api/v1/sites/shared-links", %{ + site_id: site.domain, + name: unquote(special_name) + }) + + res = json_response(conn, 400) + + assert res["error"] == "This name is reserved. Please choose another one" + end + end + end + + describe "PUT /api/v1/sites/custom-props" do + setup :create_site + + setup %{user: user} do + subscribe_to_enterprise_plan(user, + features: [ + Plausible.Billing.Feature.Props, + Plausible.Billing.Feature.SitesAPI + ] + ) + + :ok + end + + test "can add a custom property to a site", %{conn: conn, site: site} do + conn = + put(conn, "/api/v1/sites/custom-props", %{ + site_id: site.domain, + property: "prop1" + }) + + res = json_response(conn, 200) + + assert res["created"] == true + + assert Repo.reload!(site).allowed_event_props == ["prop1"] + end + + test "can add a custom prop using old site_id after domain change", %{ + conn: conn, + site: site + } do + old_domain = site.domain + new_domain = "new.example.com" + + Plausible.Site.Domain.change(site, new_domain) + + conn = + put(conn, "/api/v1/sites/custom-props", %{ + site_id: old_domain, + property: "prop1" + }) + + res = json_response(conn, 200) + + assert res["created"] == true + + assert Repo.reload!(site).allowed_event_props == ["prop1"] + end + + test "is idempotent", %{conn: conn, site: site} do + conn = + put(conn, "/api/v1/sites/custom-props", %{ + site_id: site.domain, + property: "prop1" + }) + + assert %{"created" => true} = json_response(conn, 200) + + conn = + put(conn, "/api/v1/sites/custom-props", %{ + site_id: site.domain, + property: "prop1" + }) + + assert %{"created" => true} = json_response(conn, 200) + + assert Repo.reload!(site).allowed_event_props == ["prop1"] + end + + test "fails to add a custom prop with too long name", %{conn: conn, site: site} do + conn = + put(conn, "/api/v1/sites/custom-props", %{ + site_id: site.domain, + property: String.duplicate("a", Plausible.Props.max_prop_key_length() + 1) + }) + + assert %{"error" => "Parameter `property` is too long"} = json_response(conn, 400) + end + + test "fails to add a custom prop when props list is too long", %{conn: conn, site: site} do + max_props = Plausible.Props.max_props() + + Enum.reduce(1..max_props, site, fn idx, site -> + {:ok, site} = Plausible.Props.allow(site, "prop#{idx}") + site + end) + + conn = + put(conn, "/api/v1/sites/custom-props", %{ + site_id: site.domain, + property: "prop#{max_props + 1}" + }) + + assert %{"error" => "Can't add any more custom properties"} = json_response(conn, 400) + end + + test "fails when team does not match team-scoped key", %{conn: conn, user: user, site: site} do + another_team = new_user() |> subscribe_to_business_plan() |> team_of() + add_member(another_team, user: user, role: :admin) + api_key = insert(:api_key, user: user, team: another_team, scopes: ["sites:provision:*"]) + conn = Plug.Conn.put_req_header(conn, "authorization", "Bearer #{api_key.key}") + + conn = + put(conn, "/api/v1/sites/custom-props", %{ + site_id: site.domain, + property: "prop1" + }) + + res = json_response(conn, 401) + assert res["error"] =~ "Invalid API key" + end + + test "returns 400 when site id missing", %{conn: conn} do + conn = + put(conn, "/api/v1/sites/custom-props", %{ + property: "prop1" + }) + + res = json_response(conn, 400) + + assert res["error"] == + "Missing site ID. Please provide the required site_id parameter with your request." + end + + test "returns 404 when site id is non existent", %{conn: conn} do + conn = + put(conn, "/api/v1/sites/custom-props", %{ + property: "prop1", + site_id: "bad" + }) + + res = json_response(conn, 404) + assert res["error"] == "Site could not be found" + end + + @tag :capture_log + test "returns 404 when api key owner does not have permissions to create a goal", %{ + conn: conn, + user: user + } do + site = new_site() + + add_guest(site, user: user, role: :viewer) + + conn = + put(conn, "/api/v1/sites/custom-props", %{ + site_id: site.domain, + property: "prop1" + }) + + res = json_response(conn, 402) + assert res["error"] =~ "API key does not have access to Sites API" + end + + test "returns 400 when property missing", %{conn: conn, site: site} do + conn = + put(conn, "/api/v1/sites/custom-props", %{ + site_id: site.domain + }) + + res = json_response(conn, 400) + assert res["error"] == "Parameter `property` is required to create a custom property" + end + end + + describe "PUT /api/v1/sites/goals" do + setup :create_site + + setup %{user: user} do + subscribe_to_enterprise_plan(user, + features: [Plausible.Billing.Feature.SitesAPI] + ) + + :ok + end + + test "can add a goal as event to a site", %{conn: conn, site: site} do + conn = + put(conn, "/api/v1/sites/goals", %{ + site_id: site.domain, + goal_type: "event", + event_name: "Signup" + }) + + resp = json_response(conn, 200) + + assert_matches ^strict_map(%{ + "custom_props" => ^strict_map(%{}), + "display_name" => "Signup", + "domain" => ^site.domain, + "event_name" => "Signup", + "goal_type" => "event", + "id" => ^any(:integer), + "page_path" => nil + }) = resp + end + + test "can add a goal as page to a site", %{conn: conn, site: site} do + conn = + put(conn, "/api/v1/sites/goals", %{ + site_id: site.domain, + goal_type: "page", + page_path: "/signup" + }) + + resp = json_response(conn, 200) + + assert_matches ^strict_map(%{ + "custom_props" => ^strict_map(%{}), + "display_name" => "Visit /signup", + "domain" => ^site.domain, + "event_name" => nil, + "goal_type" => "page", + "id" => ^any(:integer), + "page_path" => "/signup" + }) = resp + end + + test "can add a goal using old site_id after domain change", %{conn: conn, site: site} do + old_domain = site.domain + new_domain = "new.example.com" + + Plausible.Site.Domain.change(site, new_domain) + + conn = + put(conn, "/api/v1/sites/goals", %{ + site_id: old_domain, + goal_type: "event", + event_name: "Signup" + }) + + res = json_response(conn, 200) + assert res["goal_type"] == "event" + assert res["event_name"] == "Signup" + assert res["display_name"] == "Signup" + assert res["domain"] == new_domain + end + + test "can add a goal as event with display name", %{conn: conn, site: site} do + conn = + put(conn, "/api/v1/sites/goals", %{ + site_id: site.domain, + goal_type: "event", + event_name: "Signup", + display_name: "Customer Acquired" + }) + + res = json_response(conn, 200) + assert res["goal_type"] == "event" + assert res["event_name"] == "Signup" + assert res["display_name"] == "Customer Acquired" + assert res["domain"] == site.domain + end + + test "can add a goal as page with display name", %{conn: conn, site: site} do + conn = + put(conn, "/api/v1/sites/goals", %{ + site_id: site.domain, + goal_type: "page", + page_path: "/foo", + display_name: "Visit the foo page" + }) + + res = json_response(conn, 200) + assert res["goal_type"] == "page" + assert res["display_name"] == "Visit the foo page" + assert res["page_path"] == "/foo" + assert res["domain"] == site.domain + end + + test "is idempotent find or create op", %{conn: conn, site: site} do + conn = + put(conn, "/api/v1/sites/goals", %{ + site_id: site.domain, + goal_type: "event", + event_name: "Signup" + }) + + %{"id" => goal_id} = json_response(conn, 200) + + conn = + put(conn, "/api/v1/sites/goals", %{ + site_id: site.domain, + goal_type: "event", + event_name: "Signup" + }) + + assert %{"id" => ^goal_id} = json_response(conn, 200) + end + + test "fails when team does not match team-scoped key", %{conn: conn, user: user, site: site} do + another_team = new_user() |> subscribe_to_business_plan() |> team_of() + add_member(another_team, user: user, role: :admin) + api_key = insert(:api_key, user: user, team: another_team, scopes: ["sites:provision:*"]) + conn = Plug.Conn.put_req_header(conn, "authorization", "Bearer #{api_key.key}") + + conn = + put(conn, "/api/v1/sites/goals", %{ + site_id: site.domain, + goal_type: "event", + event_name: "Signup" + }) + + res = json_response(conn, 401) + assert res["error"] =~ "Invalid API key" + end + + test "returns 400 when site id missing", %{conn: conn} do + conn = + put(conn, "/api/v1/sites/goals", %{ + goal_type: "event", + event_name: "Signup" + }) + + res = json_response(conn, 400) + + assert res["error"] == + "Missing site ID. Please provide the required site_id parameter with your request." + end + + test "returns 404 when site id is non existent", %{conn: conn} do + conn = + put(conn, "/api/v1/sites/goals", %{ + goal_type: "event", + event_name: "Signup", + site_id: "bad" + }) + + res = json_response(conn, 404) + assert res["error"] == "Site could not be found" + end + + @tag :capture_log + test "returns 404 when api key owner does not have permissions to create a goal", %{ + conn: conn, + user: user + } do + site = new_site() + + add_guest(site, user: user, role: :viewer) + + conn = + put(conn, "/api/v1/sites/goals", %{ + site_id: site.domain, + goal_type: "event", + event_name: "Signup" + }) + + res = json_response(conn, 402) + assert res["error"] =~ "API key does not have access to Sites API" + end + + test "returns 400 when goal type missing", %{conn: conn, site: site} do + conn = + put(conn, "/api/v1/sites/goals", %{ + site_id: site.domain, + event_name: "Signup" + }) + + res = json_response(conn, 400) + assert res["error"] == "Parameter `goal_type` is required to create a goal" + end + + test "returns 400 when goal event name missing", %{conn: conn, site: site} do + conn = + put(conn, "/api/v1/sites/goals", %{ + site_id: site.domain, + goal_type: "event" + }) + + res = json_response(conn, 400) + assert res["error"] == "Parameter `event_name` is required to create a goal" + end + + test "returns 400 when goal page path missing", %{conn: conn, site: site} do + conn = + put(conn, "/api/v1/sites/goals", %{ + site_id: site.domain, + goal_type: "page" + }) + + res = json_response(conn, 400) + assert res["error"] == "Parameter `page_path` is required to create a goal" + end + + test "fails when max goals per site limit reached", %{conn: conn, site: site} do + for i <- 1..10, do: {:ok, _} = Plausible.Goals.create(site, %{"event_name" => "G#{i}"}) + + conn = + put(conn, "/api/v1/sites/goals", %{ + site_id: site.domain, + goal_type: "event", + event_name: "Signup" + }) + + res = json_response(conn, 400) + + assert res["error"] =~ "Maximum number of goals reached" + end + + test "can create a goal with custom_props", %{conn: conn, site: site, user: user} do + subscribe_to_enterprise_plan(user, + features: [Plausible.Billing.Feature.SitesAPI, Plausible.Billing.Feature.Props] + ) + + conn = + put(conn, "/api/v1/sites/goals", %{ + site_id: site.domain, + goal_type: "event", + event_name: "Purchase", + custom_props: %{"product" => "enterprise", "tier" => "premium"} + }) + + res = json_response(conn, 200) + assert res["goal_type"] == "event" + assert res["event_name"] == "Purchase" + assert res["custom_props"] == %{"product" => "enterprise", "tier" => "premium"} + end + + test "custom_props defaults to empty map if not provided", %{conn: conn, site: site} do + conn = + put(conn, "/api/v1/sites/goals", %{ + site_id: site.domain, + goal_type: "event", + event_name: "Signup" + }) + + res = json_response(conn, 200) + assert res["custom_props"] == %{} + end + + test "fails when custom_props exceeds max of 3", %{conn: conn, site: site, user: user} do + subscribe_to_enterprise_plan(user, + features: [Plausible.Billing.Feature.SitesAPI, Plausible.Billing.Feature.Props] + ) + + conn = + put(conn, "/api/v1/sites/goals", %{ + site_id: site.domain, + goal_type: "event", + event_name: "Purchase", + custom_props: %{"a" => "1", "b" => "2", "c" => "3", "d" => "4"} + }) + + res = json_response(conn, 400) + assert res["error"] =~ "use at most 3 properties per goal" + end + + test "fails when custom_props has null values", %{conn: conn, site: site, user: user} do + subscribe_to_enterprise_plan(user, + features: [Plausible.Billing.Feature.SitesAPI, Plausible.Billing.Feature.Props] + ) + + conn = + put(conn, "/api/v1/sites/goals", %{ + site_id: site.domain, + goal_type: "event", + event_name: "Purchase", + custom_props: %{"product" => nil} + }) + + res = json_response(conn, 400) + assert res["error"] =~ "must be a map with string keys and string values" + end + + test "fails when custom_props has non-string values", %{conn: conn, site: site, user: user} do + subscribe_to_enterprise_plan(user, + features: [Plausible.Billing.Feature.SitesAPI, Plausible.Billing.Feature.Props] + ) + + conn = + put(conn, "/api/v1/sites/goals", %{ + site_id: site.domain, + goal_type: "event", + event_name: "Purchase", + custom_props: %{"count" => 42} + }) + + res = json_response(conn, 400) + assert res["error"] =~ "must be a map with string keys and string values" + end + + @tag :ee_only + test "fails without Props feature when custom_props is non-empty", %{conn: conn, site: site} do + conn = + put(conn, "/api/v1/sites/goals", %{ + site_id: site.domain, + goal_type: "event", + event_name: "Purchase", + custom_props: %{"product" => "enterprise"} + }) + + res = json_response(conn, 402) + assert res["error"] == "Your current subscription plan does not include Custom Properties" + end + end + + describe "DELETE /api/v1/sites/custom-props/:property" do + setup :create_site + + setup %{user: user} do + subscribe_to_enterprise_plan(user, + features: [ + Plausible.Billing.Feature.Props, + Plausible.Billing.Feature.SitesAPI + ] + ) + + :ok + end + + test "deletes a custom property", %{conn: conn, site: site} do + conn = + put(conn, "/api/v1/sites/custom-props", %{ + site_id: site.domain, + property: "prop1" + }) + + assert %{"created" => true} = json_response(conn, 200) + + conn = + delete(conn, "/api/v1/sites/custom-props/prop1", %{ + site_id: site.domain + }) + + assert json_response(conn, 200) == %{"deleted" => true} + + assert Repo.reload!(site).allowed_event_props == [] + end + + test "deletes a custom property with slash in name", %{conn: conn, site: site} do + conn = + put(conn, "/api/v1/sites/custom-props", %{ + site_id: site.domain, + property: "prop1/key/with/slashes" + }) + + assert %{"created" => true} = json_response(conn, 200) + + conn = + delete(conn, "/api/v1/sites/custom-props/prop1/key/with/slashes", %{ + site_id: site.domain + }) + + assert json_response(conn, 200) == %{"deleted" => true} + + assert Repo.reload!(site).allowed_event_props == [] + end + + test "deletes a custom prop using old site_id after domain change", %{ + conn: conn, + site: site + } do + old_domain = site.domain + new_domain = "new.example.com" + + Plausible.Site.Domain.change(site, new_domain) + + conn = + put(conn, "/api/v1/sites/custom-props", %{ + site_id: new_domain, + property: "prop1" + }) + + assert %{"created" => true} = json_response(conn, 200) + + conn = + delete(conn, "/api/v1/sites/custom-props/prop1", %{ + site_id: old_domain + }) + + assert json_response(conn, 200) == %{"deleted" => true} + assert Repo.reload!(site).allowed_event_props == [] + end + + test "fails when team does not match team-scoped key", %{conn: conn, user: user, site: site} do + conn1 = + put(conn, "/api/v1/sites/custom-props", %{ + site_id: site.domain, + property: "prop1" + }) + + assert %{"created" => true} = json_response(conn1, 200) + + another_team = new_user() |> subscribe_to_business_plan() |> team_of() + add_member(another_team, user: user, role: :admin) + api_key = insert(:api_key, user: user, team: another_team, scopes: ["sites:provision:*"]) + conn = Plug.Conn.put_req_header(conn, "authorization", "Bearer #{api_key.key}") + + conn = + delete(conn, "/api/v1/sites/custom-props/prop1", %{ + site_id: site.domain + }) + + res = json_response(conn, 401) + assert res["error"] =~ "Invalid API key" + end + + test "handles non-existent custom prop gracefully", %{conn: conn, site: site} do + conn = + delete(conn, "/api/v1/sites/custom-props/none", %{ + site_id: site.domain + }) + + assert json_response(conn, 200) == %{"deleted" => true} + end + + @tag :capture_log + test "cannot delete a custom prop belongs to a site that the user does not own", %{ + conn: conn, + user: user + } do + site = new_site() + add_guest(site, user: user, role: :viewer) + + conn = + delete(conn, "/api/v1/sites/custom-props/prop1", %{ + site_id: site.domain + }) + + assert %{"error" => error} = json_response(conn, 402) + assert error =~ "API key does not have access to Sites API" + end + + test "cannot access with a bad API key scope", %{conn: conn, site: site, user: user} do + {:ok, site} = Plausible.Props.allow(site, "prop1") + api_key = insert(:api_key, user: user, scopes: ["stats:read:*"]) + + conn = + conn + |> Plug.Conn.put_req_header("authorization", "Bearer #{api_key.key}") + + conn = + delete(conn, "/api/v1/sites/custom-props/prop1", %{ + site_id: site.domain + }) + + assert json_response(conn, 401) == %{ + "error" => + "Invalid API key. Please make sure you're using a valid API key with access to the resource you've requested." + } + end + end + + describe "DELETE /api/v1/sites/goals/:goal_id" do + setup :create_site + + setup %{user: user} do + subscribe_to_enterprise_plan(user, + features: [ + Plausible.Billing.Feature.SitesAPI + ] + ) + + :ok + end + + test "delete a goal by its id", %{conn: conn, site: site} do + conn = + put(conn, "/api/v1/sites/goals", %{ + site_id: site.domain, + goal_type: "event", + event_name: "Signup" + }) + + %{"id" => goal_id} = json_response(conn, 200) + + conn = + delete(conn, "/api/v1/sites/goals/#{goal_id}", %{ + site_id: site.domain + }) + + assert json_response(conn, 200) == %{"deleted" => true} + end + + test "delete a goal using old site_id after domain change", %{conn: conn, site: site} do + old_domain = site.domain + new_domain = "new.example.com" + + Plausible.Site.Domain.change(site, new_domain) + + conn = + put(conn, "/api/v1/sites/goals", %{ + site_id: new_domain, + goal_type: "event", + event_name: "Signup" + }) + + %{"id" => goal_id} = json_response(conn, 200) + + conn = + delete(conn, "/api/v1/sites/goals/#{goal_id}", %{ + site_id: old_domain + }) + + assert json_response(conn, 200) == %{"deleted" => true} + end + + test "fails when team does not match team-scoped key", %{conn: conn, user: user, site: site} do + conn1 = + put(conn, "/api/v1/sites/goals", %{ + site_id: site.domain, + goal_type: "event", + event_name: "Signup" + }) + + %{"id" => goal_id} = json_response(conn1, 200) + + another_team = new_user() |> subscribe_to_business_plan() |> team_of() + add_member(another_team, user: user, role: :admin) + api_key = insert(:api_key, user: user, team: another_team, scopes: ["sites:provision:*"]) + conn = Plug.Conn.put_req_header(conn, "authorization", "Bearer #{api_key.key}") + + conn = + delete(conn, "/api/v1/sites/goals/#{goal_id}", %{ + site_id: site.domain + }) + + res = json_response(conn, 401) + assert res["error"] =~ "Invalid API key" + end + + test "is 404 when goal cannot be found", %{conn: conn, site: site} do + conn = + delete(conn, "/api/v1/sites/goals/0", %{ + site_id: site.domain + }) + + assert json_response(conn, 404) == %{"error" => "Goal could not be found"} + end + + @tag :capture_log + test "cannot delete a goal belongs to a site that the user does not own", %{ + conn: conn, + user: user + } do + site = new_site() + add_guest(site, user: user, role: :viewer) + + conn = + delete(conn, "/api/v1/sites/goals/1", %{ + site_id: site.domain + }) + + assert %{"error" => error} = json_response(conn, 402) + assert error =~ "API key does not have access to Sites API" + end + + test "cannot access with a bad API key scope", %{conn: conn, site: site, user: user} do + api_key = insert(:api_key, user: user, scopes: ["stats:read:*"]) + + conn = + conn + |> Plug.Conn.put_req_header("authorization", "Bearer #{api_key.key}") + + conn = + delete(conn, "/api/v1/sites/goals/1", %{ + site_id: site.domain + }) + + assert json_response(conn, 401) == %{ + "error" => + "Invalid API key. Please make sure you're using a valid API key with access to the resource you've requested." + } + end + end + + describe "GET /api/v1/sites/guests" do + test "returns empty when there are no guests for site", %{conn: conn, user: user} do + site = new_site(owner: user) + conn = get(conn, "/api/v1/sites/guests?site_id=#{site.domain}") + + assert json_response(conn, 200) == %{ + "guests" => [], + "meta" => %{ + "before" => nil, + "after" => nil, + "limit" => 100 + } + } + end + + test "returns guests when present", %{conn: conn, user: user} do + site = new_site(owner: user) + + guest1 = add_guest(site, site: site, role: :editor) + guest2 = add_guest(site, site: site, role: :viewer) + guest3 = invite_guest(site, "third@example.com", inviter: user, role: :viewer) + + conn = get(conn, "/api/v1/sites/guests?site_id=#{site.domain}") + + assert json_response(conn, 200) == %{ + "guests" => [ + %{"email" => guest2.email, "status" => "accepted", "role" => "viewer"}, + %{"email" => guest1.email, "status" => "accepted", "role" => "editor"}, + %{ + "email" => guest3.team_invitation.email, + "status" => "invited", + "role" => "viewer" + } + ], + "meta" => %{ + "before" => nil, + "after" => nil, + "limit" => 100 + } + } + end + + test "returns guests paginated", %{conn: conn, user: user} do + site = new_site(owner: user) + + %{email: guest1_email} = add_guest(site, site: site, role: :editor) + %{email: guest2_email} = add_guest(site, site: site, role: :viewer) + invite_guest(site, "third@example.com", inviter: user, role: :viewer) + + conn1 = get(conn, "/api/v1/sites/guests?site_id=#{site.domain}&limit=2") + + assert %{ + "guests" => [ + %{"email" => ^guest2_email, "status" => "accepted", "role" => "viewer"}, + %{"email" => ^guest1_email, "status" => "accepted", "role" => "editor"} + ], + "meta" => %{ + "before" => nil, + "after" => after_cursor, + "limit" => 2 + } + } = json_response(conn1, 200) + + conn2 = + get( + conn, + "/api/v1/sites/guests?site_id=#{site.domain}&limit=2&after=#{after_cursor}" + ) + + assert %{ + "guests" => [ + %{ + "email" => "third@example.com", + "status" => "invited", + "role" => "viewer" + } + ], + "meta" => %{ + "before" => before_cursor, + "after" => nil, + "limit" => 2 + } + } = + json_response(conn2, 200) + + assert is_binary(before_cursor) + end + + test "fails when team does not match team-scoped key", %{conn: conn, user: user} do + another_team = new_user() |> subscribe_to_business_plan() |> team_of() + add_member(another_team, user: user, role: :admin) + api_key = insert(:api_key, user: user, team: another_team, scopes: ["sites:provision:*"]) + conn = Plug.Conn.put_req_header(conn, "authorization", "Bearer #{api_key.key}") + + site = new_site(owner: user) + + _guest = add_guest(site, site: site, role: :editor) + + conn = get(conn, "/api/v1/sites/guests?site_id=#{site.domain}") + + res = json_response(conn, 401) + assert res["error"] =~ "Invalid API key" + end + end + + describe "PUT /api/v1/sites/guests" do + setup %{user: user} do + subscribe_to_enterprise_plan(user, + features: [ + Plausible.Billing.Feature.SitesAPI + ] + ) + + :ok + end + + test "creates new invitation", %{conn: conn, user: user} do + site = new_site(owner: user) + + conn = + put(conn, "/api/v1/sites/guests?site_id=#{site.domain}", %{ + "role" => "viewer", + "email" => "test@example.com" + }) + + assert json_response(conn, 200) == %{ + "status" => "invited", + "email" => "test@example.com", + "role" => "viewer" + } + + assert_email_delivered_with( + to: [nil: "test@example.com"], + subject: ~r/You've been invited to #{site.domain}/ + ) + end + + test "is idempotent", %{conn: conn, user: user} do + site = new_site(owner: user) + + conn1 = + put(conn, "/api/v1/sites/guests?site_id=#{site.domain}", %{ + "role" => "viewer", + "email" => "test@example.com" + }) + + assert_email_delivered_with(to: [nil: "test@example.com"]) + assert json_response(conn1, 200) + + conn2 = + put(conn, "/api/v1/sites/guests?site_id=#{site.domain}", %{ + "role" => "editor", + "email" => "test@example.com" + }) + + assert %{"role" => "viewer", "status" => "invited"} = json_response(conn2, 200) + + assert %{memberships: [_], invitations: [%{role: :viewer}]} = + Plausible.Sites.list_people(site) + + assert_no_emails_delivered() + end + + test "is idempotent when membership already present", %{conn: conn, user: user} do + site = new_site(owner: user) + guest = new_user(email: "guest@example.com") + + add_guest(site, role: :viewer, user: guest) + + conn = + put(conn, "/api/v1/sites/guests?site_id=#{site.domain}", %{ + "role" => "editor", + "email" => "guest@example.com" + }) + + assert %{"role" => "viewer", "status" => "accepted"} = json_response(conn, 200) + + assert %{ + memberships: [%{user: _}, %{user: %{email: "guest@example.com"}}], + invitations: [] + } = + Plausible.Sites.list_people(site) + + assert_no_emails_delivered() + end + + test "fails when team does not match team-scoped key", %{conn: conn, user: user} do + site = new_site(owner: user) + + another_team = new_user() |> subscribe_to_business_plan() |> team_of() + add_member(another_team, user: user, role: :admin) + api_key = insert(:api_key, user: user, team: another_team, scopes: ["sites:provision:*"]) + conn = Plug.Conn.put_req_header(conn, "authorization", "Bearer #{api_key.key}") + + conn = + put(conn, "/api/v1/sites/guests?site_id=#{site.domain}", %{ + "role" => "viewer", + "email" => "test@example.com" + }) + + res = json_response(conn, 401) + assert res["error"] =~ "Invalid API key" + end + + test "fails for unknown role", %{conn: conn, user: user} do + site = new_site(owner: user) + + conn = + put(conn, "/api/v1/sites/guests?site_id=#{site.domain}", %{ + "role" => "owner", + "email" => "test@example.com" + }) + + assert %{"error" => error} = json_response(conn, 400) + + assert error =~ + "Parameter `role` is required to create guest. Possible values: `viewer` or `editor`" + + assert_no_emails_delivered() + end + end + + describe "DELETE /api/v1/sites/guests" do + setup %{user: user} do + subscribe_to_enterprise_plan(user, + features: [Plausible.Billing.Feature.SitesAPI] + ) + + :ok + end + + test "no-op when nothing to delete", %{conn: conn, user: user} do + site = new_site(owner: user) + + conn = delete(conn, "/api/v1/sites/guests/test@example.com?site_id=#{site.domain}") + + assert json_response(conn, 200) == %{"deleted" => true} + end + + test "deletes invitation", %{conn: conn, user: user} do + site = new_site(owner: user) + + invite_guest(site, "invite@example.com", inviter: user, role: :viewer) + + assert %{invitations: [_]} = Plausible.Sites.list_people(site) + + conn = delete(conn, "/api/v1/sites/guests/invite@example.com?site_id=#{site.domain}") + + assert json_response(conn, 200) == %{"deleted" => true} + + assert %{invitations: []} = Plausible.Sites.list_people(site) + end + + test "deletes guest membership", %{conn: conn, user: user} do + site = new_site(owner: user) + + guest = new_user(email: "guest@example.com") + add_guest(site, role: :viewer, user: guest) + + assert %{memberships: [_, _]} = Plausible.Sites.list_people(site) + + conn = delete(conn, "/api/v1/sites/guests/#{guest.email}?site_id=#{site.domain}") + + assert json_response(conn, 200) == %{"deleted" => true} + + assert %{memberships: [_]} = Plausible.Sites.list_people(site) + end + + test "is idempotent", %{conn: conn, user: user} do + site = new_site(owner: user) + + invite_guest(site, "third@example.com", inviter: user, role: :viewer) + + assert %{invitations: [_]} = Plausible.Sites.list_people(site) + + conn1 = delete(conn, "/api/v1/sites/guests/third@example.com?site_id=#{site.domain}") + assert json_response(conn1, 200) == %{"deleted" => true} + + conn2 = delete(conn, "/api/v1/sites/guests/third@example.com?site_id=#{site.domain}") + assert json_response(conn2, 200) == %{"deleted" => true} + end + + test "fails when team does not match team-scoped key", %{conn: conn, user: user} do + site = new_site(owner: user) + + another_team = new_user() |> subscribe_to_business_plan() |> team_of() + add_member(another_team, user: user, role: :admin) + api_key = insert(:api_key, user: user, team: another_team, scopes: ["sites:provision:*"]) + conn = Plug.Conn.put_req_header(conn, "authorization", "Bearer #{api_key.key}") + + conn = delete(conn, "/api/v1/sites/guests/test@example.com?site_id=#{site.domain}") + + res = json_response(conn, 401) + assert res["error"] =~ "Invalid API key" + end + + test "won't delete non-guest membership", %{conn: conn, user: user} do + site = new_site(owner: user) + + assert %{memberships: [_]} = Plausible.Sites.list_people(site) + + conn = delete(conn, "/api/v1/sites/guests/#{user.email}?site_id=#{site.domain}") + + assert json_response(conn, 200) == %{"deleted" => true} + + assert %{memberships: [_]} = Plausible.Sites.list_people(site) + end + end + + describe "GET /api/v1/custom-props" do + setup :create_site + + test "returns empty when there are no custom props for site", %{conn: conn, site: site} do + conn = get(conn, "/api/v1/sites/custom-props?site_id=" <> site.domain) + + assert json_response(conn, 200) == %{"custom_properties" => []} + end + + test "returns custom props when present", %{conn: conn, site: site} do + {:ok, site} = Plausible.Props.allow(site, "prop1") + {:ok, site} = Plausible.Props.allow(site, "prop2") + Plausible.Props.allow(site, "prop3") + + conn = get(conn, "/api/v1/sites/custom-props?site_id=" <> site.domain) + + assert json_response(conn, 200) == %{ + "custom_properties" => [ + %{"property" => "prop1"}, + %{"property" => "prop2"}, + %{"property" => "prop3"} + ] + } + end + + @tag :capture_log + test "returns custom props for site where user is viewer", %{site: site} do + viewer = new_user() + add_guest(site, user: viewer, role: :viewer) + + {:ok, site} = Plausible.Props.allow(site, "prop1") + + api_key = insert(:api_key, user: viewer, scopes: ["sites:provision:*"]) + conn = Plug.Conn.put_req_header(build_conn(), "authorization", "Bearer #{api_key.key}") + + conn = get(conn, "/api/v1/sites/custom-props?site_id=" <> site.domain) + + assert %{"custom_properties" => [%{"property" => "prop1"}]} = json_response(conn, 200) + end + + test "lists custom props for user with read-only scope", %{ + conn: conn, + user: user, + site: site + } do + {:ok, site} = Plausible.Props.allow(site, "prop1") + api_key = insert(:api_key, user: user, scopes: ["stats:read:*"]) + + conn = + conn + |> Plug.Conn.put_req_header("authorization", "Bearer #{api_key.key}") + |> get("/api/v1/sites/custom-props?site_id=" <> site.domain) + + assert %{"custom_properties" => [%{"property" => "prop1"}]} = json_response(conn, 200) + end + + test "fails when team does not match team-scoped key", %{conn: conn, user: user, site: site} do + another_team = new_user() |> subscribe_to_business_plan() |> team_of() + add_member(another_team, user: user, role: :admin) + api_key = insert(:api_key, user: user, team: another_team, scopes: ["sites:provision:*"]) + conn = Plug.Conn.put_req_header(conn, "authorization", "Bearer #{api_key.key}") + + _goal = insert(:goal, %{site: site, page_path: "/login"}) + + conn = get(conn, "/api/v1/sites/custom-props?site_id=" <> site.domain) + + res = json_response(conn, 401) + assert res["error"] =~ "Invalid API key" + end + + test "returns error when `site_id` parameter is missing", %{conn: conn} do + conn = get(conn, "/api/v1/sites/custom-props") + + assert json_response(conn, 400) == %{ + "error" => + "Missing site ID. Please provide the required site_id parameter with your request." + } + end + + test "returns error when `site_id` parameter is invalid", %{conn: conn} do + conn = get(conn, "/api/v1/sites/custom-props?site_id=does.not.exist") + + assert json_response(conn, 404) == %{ + "error" => "Site could not be found" + } + end + + @tag :capture_log + test "returns error when user is not a member of the site", %{conn: conn} do + site = new_site() + + conn = get(conn, "/api/v1/sites/custom-props?site_id=" <> site.domain) + + assert %{"error" => error} = json_response(conn, 401) + assert(error =~ "Invalid API key") + end + end + + describe "GET /api/v1/goals" do + setup :create_site + + test "returns empty when there are no goals for site", %{conn: conn, site: site} do + conn = get(conn, "/api/v1/sites/goals?site_id=" <> site.domain) + + assert json_response(conn, 200) == %{ + "goals" => [], + "meta" => %{ + "before" => nil, + "after" => nil, + "limit" => 100 + } + } + end + + test "returns goals when present", %{conn: conn, site: site} do + goal1 = insert(:goal, %{site: site, page_path: "/login"}) + goal2 = insert(:goal, %{site: site, event_name: "Signup"}) + goal3 = insert(:goal, %{site: site, event_name: "Purchase", currency: "USD"}) + + conn = get(conn, "/api/v1/sites/goals?site_id=" <> site.domain) + + assert json_response(conn, 200) == %{ + "goals" => [ + %{ + "id" => goal3.id, + "display_name" => "Purchase", + "goal_type" => "event", + "event_name" => "Purchase", + "page_path" => nil, + "custom_props" => %{} + }, + %{ + "id" => goal2.id, + "display_name" => "Signup", + "goal_type" => "event", + "event_name" => "Signup", + "page_path" => nil, + "custom_props" => %{} + }, + %{ + "id" => goal1.id, + "display_name" => "Visit /login", + "goal_type" => "page", + "event_name" => nil, + "page_path" => "/login", + "custom_props" => %{} + } + ], + "meta" => %{ + "before" => nil, + "after" => nil, + "limit" => 100 + } + } + end + + test "returns goals with custom_props", %{conn: conn, site: site} do + goal = + insert(:goal, %{ + site: site, + event_name: "Purchase", + custom_props: %{"product" => "enterprise", "tier" => "premium"} + }) + + conn = get(conn, "/api/v1/sites/goals?site_id=" <> site.domain) + + goal_id = goal.id + + assert %{ + "goals" => [ + %{ + "id" => ^goal_id, + "custom_props" => %{"product" => "enterprise", "tier" => "premium"} + } + ] + } = json_response(conn, 200) + end + + @tag :capture_log + test "returns goals for site where user is viewer", %{site: site} do + viewer = new_user() + add_guest(site, user: viewer, role: :viewer) + + %{id: goal_id} = insert(:goal, %{site: site, event_name: "Signup"}) + + api_key = insert(:api_key, user: viewer, scopes: ["sites:provision:*"]) + conn = Plug.Conn.put_req_header(build_conn(), "authorization", "Bearer #{api_key.key}") + + conn = get(conn, "/api/v1/sites/goals?site_id=" <> site.domain) + + assert %{"goals" => [%{"id" => ^goal_id}]} = json_response(conn, 200) + end + + test "handles pagination correctly", %{conn: conn, site: site} do + %{id: goal1_id} = insert(:goal, %{site: site, page_path: "/login"}) + %{id: goal2_id} = insert(:goal, %{site: site, event_name: "Signup"}) + %{id: goal3_id} = insert(:goal, %{site: site, event_name: "Purchase", currency: "USD"}) + + conn1 = get(conn, "/api/v1/sites/goals?limit=2&site_id=" <> site.domain) + + assert %{ + "goals" => [ + %{"id" => ^goal3_id}, + %{"id" => ^goal2_id} + ], + "meta" => %{ + "before" => nil, + "after" => after_cursor, + "limit" => 2 + } + } = json_response(conn1, 200) + + conn2 = + get(conn, "/api/v1/sites/goals?limit=2&after=#{after_cursor}&site_id=" <> site.domain) + + assert %{ + "goals" => [ + %{"id" => ^goal1_id} + ], + "meta" => %{ + "before" => before_cursor, + "after" => nil, + "limit" => 2 + } + } = json_response(conn2, 200) + + assert is_binary(before_cursor) + end + + test "lists goals for user with read-only scope", %{conn: conn, user: user, site: site} do + %{id: goal_id} = insert(:goal, %{site: site, page_path: "/login"}) + api_key = insert(:api_key, user: user, scopes: ["stats:read:*"]) + + conn = + conn + |> Plug.Conn.put_req_header("authorization", "Bearer #{api_key.key}") + |> get("/api/v1/sites/goals?site_id=" <> site.domain) + + assert %{"goals" => [%{"id" => ^goal_id}]} = json_response(conn, 200) + end + + test "fails when team does not match team-scoped key", %{conn: conn, user: user, site: site} do + another_team = new_user() |> subscribe_to_business_plan() |> team_of() + add_member(another_team, user: user, role: :admin) + api_key = insert(:api_key, user: user, team: another_team, scopes: ["sites:provision:*"]) + conn = Plug.Conn.put_req_header(conn, "authorization", "Bearer #{api_key.key}") + + _goal = insert(:goal, %{site: site, page_path: "/login"}) + + conn = get(conn, "/api/v1/sites/goals?site_id=" <> site.domain) + + res = json_response(conn, 401) + assert res["error"] =~ "Invalid API key" + end + + test "returns error when `site_id` parameter is missing", %{conn: conn} do + conn = get(conn, "/api/v1/sites/goals") + + assert json_response(conn, 400) == %{ + "error" => + "Missing site ID. Please provide the required site_id parameter with your request." + } + end + + test "returns error when `site_id` parameter is invalid", %{conn: conn} do + conn = get(conn, "/api/v1/sites/goals?site_id=does.not.exist") + + assert json_response(conn, 404) == %{ + "error" => "Site could not be found" + } + end + + @tag :capture_log + test "returns error when user is not a member of the site", %{conn: conn} do + site = new_site() + + conn = get(conn, "/api/v1/sites/goals?site_id=" <> site.domain) + + assert %{"error" => error} = json_response(conn, 401) + assert error =~ "Invalid API key" + end + end + end +end diff --git a/test/plausible_web/controllers/api/external_stats_controller/aggregate_test.exs b/test/plausible_web/controllers/api/external_stats_controller/aggregate_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..08647abeb6cd782f9628efbfe43294b6c0a3e102 --- /dev/null +++ b/test/plausible_web/controllers/api/external_stats_controller/aggregate_test.exs @@ -0,0 +1,2036 @@ +defmodule PlausibleWeb.Api.ExternalStatsController.AggregateTest do + use PlausibleWeb.ConnCase + alias Plausible.Billing.Feature + + setup [:create_user, :create_site, :create_api_key, :use_api_key] + @user_id Enum.random(1000..9999) + + describe "feature access" do + test "cannot filter by a custom prop without access to the props feature", %{ + conn: conn, + user: user, + site: site + } do + subscribe_to_enterprise_plan(user, features: [Feature.StatsAPI]) + + conn = + get(conn, "/api/v1/stats/aggregate", %{ + "site_id" => site.domain, + "filters" => "event:props:author==Uku" + }) + + assert json_response(conn, 402)["error"] == + "The owner of this site does not have access to the custom properties feature" + end + + test "can filter by an internal prop key without access to the props feature", %{ + conn: conn, + user: user, + site: site + } do + subscribe_to_enterprise_plan(user, features: [Feature.StatsAPI]) + + conn = + get(conn, "/api/v1/stats/aggregate", %{ + "site_id" => site.domain, + "filters" => "event:props:url==https://site.com" + }) + + assert json_response(conn, 200)["results"] + end + end + + describe "param validation" do + test "validates that date can be parsed", %{conn: conn, site: site} do + conn = + get(conn, "/api/v1/stats/aggregate", %{ + "site_id" => site.domain, + "period" => "month", + "date" => "2021-dkjbAS", + "metrics" => "pageviews" + }) + + assert json_response(conn, 400) == %{ + "error" => + "Error parsing `date` parameter: invalid_format. Please specify a valid date in ISO-8601 format." + } + end + + test "ignores a given property parameter", %{conn: conn, site: site} do + conn = + get(conn, "/api/v1/stats/aggregate", %{ + "site_id" => site.domain, + "property" => "event:props:author", + "metrics" => "visit_duration" + }) + + assert json_response(conn, 200) + end + + test "validates that period can be parsed", %{conn: conn, site: site} do + conn = + get(conn, "/api/v1/stats/aggregate", %{ + "site_id" => site.domain, + "period" => "aosuhsacp", + "metrics" => "pageviews" + }) + + assert json_response(conn, 400) == %{ + "error" => + "Error parsing `period` parameter: invalid period `aosuhsacp`. Please find accepted values in our docs: https://plausible.io/docs/stats-api#time-periods" + } + end + + test "custom period is not valid without a date", %{conn: conn, site: site} do + conn = + get(conn, "/api/v1/stats/aggregate", %{ + "site_id" => site.domain, + "period" => "custom", + "metrics" => "pageviews" + }) + + assert json_response(conn, 400) == %{ + "error" => + "The `date` parameter is required when using a custom period. See https://plausible.io/docs/stats-api#time-periods" + } + end + + test "validates date format in custom period", %{conn: conn, site: site} do + conn = + get(conn, "/api/v1/stats/aggregate", %{ + "site_id" => site.domain, + "period" => "custom", + "date" => "2020-131-2piaskj,s,a90uac", + "metrics" => "pageviews" + }) + + assert json_response(conn, 400) == %{ + "error" => + "Invalid format for `date` parameter. When using a custom period, please include two ISO-8601 formatted dates joined by a comma. See https://plausible.io/docs/stats-api#time-periods" + } + end + + test "validates that metrics are all recognized", %{conn: conn, site: site} do + conn = + get(conn, "/api/v1/stats/aggregate", %{ + "site_id" => site.domain, + "period" => "30d", + "metrics" => "pageviews,led_zeppelin" + }) + + assert json_response(conn, 400) == %{ + "error" => + "The metric `led_zeppelin` is not recognized. Find valid metrics from the documentation: https://plausible.io/docs/stats-api#metrics" + } + end + + test "scroll depth metric is not recognized in the legacy API v1", %{conn: conn, site: site} do + conn = + get(conn, "/api/v1/stats/aggregate", %{ + "site_id" => site.domain, + "period" => "30d", + "metrics" => "scroll_depth" + }) + + assert json_response(conn, 400) == %{ + "error" => + "The metric `scroll_depth` is not recognized. Find valid metrics from the documentation: https://plausible.io/docs/stats-api#metrics" + } + end + + for property <- ["event:name", "event:goal", "event:props:custom_prop"] do + test "validates that session metrics cannot be used with #{property} filter", %{ + conn: conn, + site: site + } do + prop = unquote(property) + + if prop == "event:goal", do: insert(:goal, %{site: site, event_name: "some_value"}) + + conn = + get(conn, "/api/v1/stats/aggregate", %{ + "site_id" => site.domain, + "period" => "30d", + "metrics" => "pageviews,visit_duration", + "filters" => "#{prop}==some_value" + }) + + assert json_response(conn, 400) == %{ + "error" => + "Session metric `visit_duration` cannot be queried when using a filter on `#{prop}`." + } + end + end + + test "validates that conversion_rate cannot be queried without a goal filter", %{ + conn: conn, + site: site + } do + conn = + get(conn, "/api/v1/stats/aggregate", %{ + "site_id" => site.domain, + "metrics" => "conversion_rate" + }) + + assert %{"error" => msg} = json_response(conn, 400) + + assert msg =~ + "Metric `conversion_rate` can only be queried in a goal breakdown or with a goal filter" + end + + test "validates that views_per_visit cannot be used with event:page filter", %{ + conn: conn, + site: site + } do + conn = + get(conn, "/api/v1/stats/aggregate", %{ + "site_id" => site.domain, + "filters" => "event:page==/something", + "metrics" => "views_per_visit" + }) + + assert json_response(conn, 400) == %{ + "error" => + "Metric `views_per_visit` cannot be queried with a filter on `event:page`." + } + end + + test "validates that views_per_visit cannot be used with an event only filter", %{ + conn: conn, + site: site + } do + conn = + get(conn, "/api/v1/stats/aggregate", %{ + "site_id" => site.domain, + "filters" => "event:name==Something", + "metrics" => "views_per_visit" + }) + + assert json_response(conn, 400) == %{ + "error" => + "Session metric `views_per_visit` cannot be queried when using a filter on `event:name`." + } + end + + test "validates a metric isn't asked multiple times", %{ + conn: conn, + site: site + } do + conn = + get(conn, "/api/v1/stats/aggregate", %{ + "site_id" => site.domain, + "metrics" => "visitors,visitors" + }) + + assert json_response(conn, 400) == %{ + "error" => "Metrics cannot be queried multiple times." + } + end + + test "validates that time_on_page cannot be queried without a page filter", %{ + conn: conn, + site: site + } do + conn = + get(conn, "/api/v1/stats/aggregate", %{ + "site_id" => site.domain, + "metrics" => "time_on_page" + }) + + assert json_response(conn, 400) == %{ + "error" => + "Metric `time_on_page` can only be queried in a page breakdown or with a page filter." + } + end + + test "validates that time_on_page cannot be queried with a goal filter", %{ + conn: conn, + site: site + } do + insert(:goal, %{site: site, event_name: "Signup"}) + + conn = + get(conn, "/api/v1/stats/aggregate", %{ + "site_id" => site.domain, + "metrics" => "time_on_page", + "filters" => "event:page==/A;event:goal==Signup" + }) + + assert json_response(conn, 400) == %{ + "error" => "Metric `time_on_page` cannot be queried when filtering by `event:goal`" + } + end + + test "validates that time_on_page cannot be queried with an event:name filter", %{ + conn: conn, + site: site + } do + conn = + get(conn, "/api/v1/stats/aggregate", %{ + "site_id" => site.domain, + "metrics" => "time_on_page", + "filters" => "event:page==/A;event:name==Signup" + }) + + assert json_response(conn, 400) == %{ + "error" => "Metric `time_on_page` cannot be queried when filtering by `event:name`" + } + end + end + + test "aggregates a single metric", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, user_id: @user_id, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, user_id: @user_id, timestamp: ~N[2021-01-01 00:25:00]), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + get(conn, "/api/v1/stats/aggregate", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "metrics" => "pageviews" + }) + + assert json_response(conn, 200)["results"] == %{ + "pageviews" => %{"value" => 3} + } + end + + test "rounds views_per_visit to two decimal places", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, user_id: @user_id, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, user_id: @user_id, timestamp: ~N[2021-01-01 00:25:00]), + build(:pageview, user_id: 456, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, user_id: 456, timestamp: ~N[2021-01-01 00:25:00]), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + get(conn, "/api/v1/stats/aggregate", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "metrics" => "views_per_visit" + }) + + assert json_response(conn, 200)["results"] == %{ + "views_per_visit" => %{"value" => 1.67} + } + end + + test "aggregates all metrics in a single query", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, user_id: @user_id, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, user_id: @user_id, timestamp: ~N[2021-01-01 00:25:00]), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + get(conn, "/api/v1/stats/aggregate", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "metrics" => "pageviews,visits,views_per_visit,visitors,bounce_rate,visit_duration" + }) + + assert json_response(conn, 200)["results"] == %{ + "pageviews" => %{"value" => 3}, + "visitors" => %{"value" => 2}, + "visits" => %{"value" => 2}, + "views_per_visit" => %{"value" => 1.5}, + "bounce_rate" => %{"value" => 50}, + "visit_duration" => %{"value" => 750} + } + end + + describe "comparisons" do + test "compare period=day with previous period", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, timestamp: ~N[2020-12-31 00:00:00]), + build(:pageview, + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: @user_id, + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + get(conn, "/api/v1/stats/aggregate", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "metrics" => "pageviews,visitors,bounce_rate,visit_duration", + "compare" => "previous_period" + }) + + assert json_response(conn, 200)["results"] == %{ + "pageviews" => %{"value" => 3, "change" => 200, "comparison_value" => 1}, + "visitors" => %{"value" => 2, "change" => 100, "comparison_value" => 1}, + "bounce_rate" => %{"value" => 50, "change" => -50, "comparison_value" => 100}, + "visit_duration" => %{"value" => 750, "change" => 100, "comparison_value" => 0} + } + end + + test "compare period=6mo with previous period", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, timestamp: ~N[2020-12-31 00:00:00]), + build(:pageview, + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: @user_id, + timestamp: ~N[2021-02-01 00:25:00] + ), + build(:pageview, timestamp: ~N[2021-03-01 00:00:00]) + ]) + + conn = + get(conn, "/api/v1/stats/aggregate", %{ + "site_id" => site.domain, + "period" => "6mo", + "date" => "2021-04-11", + "metrics" => "pageviews,visitors,bounce_rate,visit_duration", + "compare" => "previous_period" + }) + + assert json_response(conn, 200)["results"] == %{ + "pageviews" => %{"value" => 4, "change" => 100, "comparison_value" => 0}, + "visitors" => %{"value" => 3, "change" => 100, "comparison_value" => 0}, + "bounce_rate" => %{"value" => 100, "change" => nil, "comparison_value" => 0}, + "visit_duration" => %{"value" => 0, "change" => 0, "comparison_value" => 0} + } + end + + test "can compare conversion_rate with previous period", %{conn: conn, site: site} do + today = ~N[2023-05-05 12:00:00] + yesterday = NaiveDateTime.shift(today, day: -1) + + populate_stats(site, [ + build(:event, name: "Signup", timestamp: yesterday), + build(:pageview, timestamp: yesterday), + build(:pageview, timestamp: yesterday), + build(:event, name: "Signup", timestamp: today), + build(:pageview, timestamp: today) + ]) + + insert(:goal, %{site: site, event_name: "Signup", display_name: "Signup Display Name"}) + + conn = + get(conn, "/api/v1/stats/aggregate", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2023-05-05", + "metrics" => "conversion_rate", + "filters" => "event:goal==Signup Display Name", + "compare" => "previous_period" + }) + + assert json_response(conn, 200)["results"] == %{ + "conversion_rate" => %{ + "value" => 50.0, + "change" => 16.7, + "comparison_value" => 33.33 + } + } + end + + test "can compare time_on_page with previous period", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/A", user_id: 111, timestamp: ~N[2021-01-01 00:00:00]), + build(:engagement, + pathname: "/A", + user_id: 111, + timestamp: ~N[2021-01-01 00:01:00], + engagement_time: 60_000 + ), + build(:pageview, pathname: "/B", user_id: 111, timestamp: ~N[2021-01-01 00:01:00]), + build(:pageview, pathname: "/A", user_id: 999, timestamp: ~N[2021-01-02 00:00:00]), + build(:engagement, + pathname: "/A", + user_id: 999, + timestamp: ~N[2021-01-02 00:01:30], + engagement_time: 90_000 + ), + build(:pageview, pathname: "/B", user_id: 999, timestamp: ~N[2021-01-02 00:01:30]) + ]) + + insert(:goal, %{site: site, event_name: "Signup"}) + + conn = + get(conn, "/api/v1/stats/aggregate", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-02", + "metrics" => "time_on_page", + "filters" => "event:page==/A", + "compare" => "previous_period" + }) + + assert json_response(conn, 200)["results"] == %{ + "time_on_page" => %{"value" => 90, "change" => 50.0, "comparison_value" => 60} + } + end + + test "time_on_page change is nil if previous period returns a number but current period is nil", + %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/A", user_id: 123, timestamp: ~N[2021-01-01 00:00:00]), + build(:engagement, + pathname: "/A", + user_id: 123, + timestamp: ~N[2021-01-01 00:01:00], + engagement_time: 60_000 + ), + build(:pageview, pathname: "/B", user_id: 123, timestamp: ~N[2021-01-01 00:01:00]), + build(:pageview, pathname: "/A", timestamp: ~N[2021-01-02 00:00:00]) + ]) + + insert(:goal, %{site: site, event_name: "Signup"}) + + conn = + get(conn, "/api/v1/stats/aggregate", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-02", + "metrics" => "time_on_page", + "filters" => "event:page==/A", + "compare" => "previous_period" + }) + + assert json_response(conn, 200)["results"] == %{ + "time_on_page" => %{"value" => nil, "change" => nil, "comparison_value" => 60} + } + end + end + + describe "with imported data" do + setup :create_site_import + + test "does not count imported stats unless specified", %{ + conn: conn, + site: site, + site_import: site_import + } do + populate_stats(site, site_import.id, [ + build(:imported_visitors, date: ~D[2023-01-01]), + build(:pageview, timestamp: ~N[2023-01-01 00:00:00]) + ]) + + query_params = %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2023-01-01", + "metrics" => "pageviews" + } + + conn1 = get(conn, "/api/v1/stats/aggregate", query_params) + + assert json_response(conn1, 200)["results"] == %{ + "pageviews" => %{"value" => 1} + } + + conn2 = get(conn, "/api/v1/stats/aggregate", Map.put(query_params, "with_imported", "true")) + + assert json_response(conn2, 200)["results"] == %{ + "pageviews" => %{"value" => 2} + } + + refute json_response(conn2, 200)["warning"] + end + + test "counts imported stats when comparing with previous period", %{ + conn: conn, + site: site, + site_import: site_import + } do + populate_stats(site, site_import.id, [ + build(:imported_visitors, + visits: 2, + bounces: 1, + visit_duration: 200, + pageviews: 10, + date: ~D[2023-01-01] + ), + build(:imported_visitors, + visits: 4, + bounces: 1, + visit_duration: 100, + pageviews: 8, + date: ~D[2023-01-02] + ), + build(:pageview, timestamp: ~N[2023-01-02 00:10:00]) + ]) + + conn = + get(conn, "/api/v1/stats/aggregate", %{ + "site_id" => site.domain, + "period" => "custom", + "date" => "2023-01-02,2023-01-02", + "metrics" => "visitors,visits,pageviews,views_per_visit,bounce_rate,visit_duration", + "compare" => "previous_period", + "with_imported" => "true" + }) + + assert json_response(conn, 200)["results"] == %{ + "visitors" => %{"value" => 2, "change" => 100, "comparison_value" => 1}, + "visits" => %{"value" => 5, "change" => 150, "comparison_value" => 2}, + "pageviews" => %{"value" => 9, "change" => -10, "comparison_value" => 10}, + "bounce_rate" => %{"value" => 40, "change" => -10, "comparison_value" => 50}, + "views_per_visit" => %{"value" => 1.8, "change" => -64, "comparison_value" => 5.0}, + "visit_duration" => %{"value" => 20, "change" => -80, "comparison_value" => 100} + } + end + + test "includes imported data in comparison when filter applied", %{ + conn: conn, + site: site, + site_import: site_import + } do + populate_stats(site, site_import.id, [ + build(:imported_visitors, date: ~D[2023-01-01]), + build(:imported_sources, source: "Google", date: ~D[2023-01-01], visitors: 3), + build(:pageview, + referrer_source: "Google", + timestamp: ~N[2023-01-02 00:10:00] + ) + ]) + + conn = + get(conn, "/api/v1/stats/aggregate", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2023-01-02", + "metrics" => "visitors", + "filters" => "visit:source==Google", + "compare" => "previous_period", + "with_imported" => "true" + }) + + assert json_response(conn, 200)["results"] == %{ + "visitors" => %{"value" => 1, "change" => -67, "comparison_value" => 3} + } + end + + test "adds a warning when query params are not supported for imported data", %{ + conn: conn, + site: site, + site_import: site_import + } do + populate_stats(site, site_import.id, [ + build(:event, + name: "Signup", + "meta.key": ["package"], + "meta.value": ["large"] + ), + build(:imported_visitors, visitors: 9) + ]) + + conn = + get(conn, "/api/v1/stats/aggregate", %{ + "site_id" => site.domain, + "period" => "day", + "metrics" => "visitors", + "filters" => "event:props:package==large", + "compare" => "previous_period", + "with_imported" => "true" + }) + + assert json_response(conn, 200)["results"] == %{ + "visitors" => %{"change" => 100, "value" => 1, "comparison_value" => 0} + } + + assert json_response(conn, 200)["warning"] =~ + "Imported stats are not included in the results because query parameters are not supported." + end + + test "does not add a warning when there are no site imports", %{ + conn: conn, + site: site, + site_import: site_import + } do + Plausible.Repo.delete!(site_import) + + populate_stats(site, [ + build(:event, + name: "Signup", + "meta.key": ["package"], + "meta.value": ["large"] + ) + ]) + + conn = + get(conn, "/api/v1/stats/aggregate", %{ + "site_id" => site.domain, + "period" => "day", + "metrics" => "visitors", + "filters" => "event:props:package==large", + "compare" => "previous_period", + "with_imported" => "true" + }) + + refute json_response(conn, 200)["warning"] + end + + test "does not add a warning when import is out of queried date range", %{ + conn: conn, + site: site, + site_import: site_import + } do + site_import + |> Ecto.Changeset.change(end_date: Date.add(Date.utc_today(), -3)) + |> Plausible.Repo.update!() + + populate_stats(site, site_import.id, [ + build(:event, + name: "Signup", + "meta.key": ["package"], + "meta.value": ["large"] + ), + build(:imported_visitors, visitors: 9) + ]) + + conn = + get(conn, "/api/v1/stats/aggregate", %{ + "site_id" => site.domain, + "period" => "day", + "metrics" => "visitors", + "filters" => "event:props:package==large", + "compare" => "previous_period", + "with_imported" => "true" + }) + + refute json_response(conn, 200)["warning"] + end + + test "excludes imported data from conversion rate computation when query filters by non-imported props", + %{conn: conn, site: site, site_import: site_import} do + insert(:goal, site: site, event_name: "Purchase") + + populate_stats(site, site_import.id, [ + build(:event, + name: "Purchase", + "meta.key": ["package"], + "meta.value": ["large"] + ), + build(:imported_visitors, visitors: 9) + ]) + + conn = + get(conn, "/api/v1/stats/aggregate", %{ + "site_id" => site.domain, + "period" => "day", + "metrics" => "visitors,conversion_rate", + "filters" => "event:goal==Purchase;event:props:package==large", + "with_imported" => "true" + }) + + assert json_response(conn, 200)["results"] == %{ + "visitors" => %{"value" => 1}, + "conversion_rate" => %{"value" => 100.0} + } + end + + test "returns stats with page + pageview goal filter", + %{conn: conn, site: site, site_import: site_import} do + insert(:goal, site: site, page_path: "/blog/**", display_name: "Blog Visit") + + populate_stats(site, site_import.id, [ + build(:imported_pages, page: "/blog/1", visitors: 1, pageviews: 1), + build(:imported_pages, page: "/blog/2", visitors: 2, pageviews: 2), + build(:imported_pages, visitors: 3) + ]) + + conn = + get(conn, "/api/v1/stats/aggregate", %{ + "site_id" => site.domain, + "period" => "day", + "metrics" => "visitors,events,conversion_rate", + "filters" => "event:page==/blog/2;event:goal==Blog Visit", + "with_imported" => "true" + }) + + assert json_response(conn, 200)["results"] == %{ + "visitors" => %{"value" => 2}, + "events" => %{"value" => 2}, + "conversion_rate" => %{"value" => 100.0} + } + end + end + + describe "filters" do + test "event:goal filter returns 400 when goal not configured", %{conn: conn, site: site} do + conn = + get(conn, "/api/v1/stats/aggregate", %{ + "site_id" => site.domain, + "filters" => "event:goal==Register|Visit /register" + }) + + assert %{"error" => msg} = json_response(conn, 400) + assert msg =~ "The goal `Register` is not configured for this site. Find out how" + end + + test "validates that filters are valid", %{conn: conn, site: site} do + conn = + get(conn, "/api/v1/stats/aggregate", %{ + "site_id" => site.domain, + "property" => "event:page", + "filters" => "badproperty==bar" + }) + + assert json_response(conn, 400) == %{ + "error" => + "Invalid filter property 'badproperty'. Please provide a valid filter property: https://plausible.io/docs/stats-api#properties" + } + end + + test "can filter by source", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + referrer_source: "Google", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: @user_id, + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + get(conn, "/api/v1/stats/aggregate", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "metrics" => "pageviews,visitors,bounce_rate,visit_duration", + "filters" => "visit:source==Google" + }) + + assert json_response(conn, 200)["results"] == %{ + "pageviews" => %{"value" => 2}, + "visitors" => %{"value" => 1}, + "bounce_rate" => %{"value" => 0}, + "visit_duration" => %{"value" => 1500} + } + end + + test "can filter by no source/referrer", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: @user_id, + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, + referrer_source: "Google", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + conn = + get(conn, "/api/v1/stats/aggregate", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "metrics" => "pageviews,visitors,bounce_rate,visit_duration", + "filters" => "visit:source==Direct / None" + }) + + assert json_response(conn, 200)["results"] == %{ + "pageviews" => %{"value" => 2}, + "visitors" => %{"value" => 1}, + "bounce_rate" => %{"value" => 0}, + "visit_duration" => %{"value" => 1500} + } + end + + test "can filter by referrer", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + referrer: "https://facebook.com", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: @user_id, + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + get(conn, "/api/v1/stats/aggregate", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "metrics" => "pageviews,visitors,bounce_rate,visit_duration", + "filters" => "visit:referrer==https://facebook.com" + }) + + assert json_response(conn, 200)["results"] == %{ + "pageviews" => %{"value" => 2}, + "visitors" => %{"value" => 1}, + "bounce_rate" => %{"value" => 0}, + "visit_duration" => %{"value" => 1500} + } + end + + test "wildcard referrer filter with special regex characters", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, referrer: "https://a.com"), + build(:pageview, referrer: "https://a.com"), + build(:pageview, referrer: "https://ab.com") + ]) + + conn = + get(conn, "/api/v1/stats/aggregate?period=day", %{ + "site_id" => site.domain, + "metrics" => "visitors", + "filters" => "visit:referrer==**a.com**" + }) + + assert json_response(conn, 200)["results"] == %{"visitors" => %{"value" => 2}} + end + + test "can filter by utm_medium", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + utm_medium: "social", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: @user_id, + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + get(conn, "/api/v1/stats/aggregate", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "metrics" => "pageviews,visitors,bounce_rate,visit_duration", + "filters" => "visit:utm_medium==social" + }) + + assert json_response(conn, 200)["results"] == %{ + "pageviews" => %{"value" => 2}, + "visitors" => %{"value" => 1}, + "bounce_rate" => %{"value" => 0}, + "visit_duration" => %{"value" => 1500} + } + end + + test "can filter by utm_source", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + utm_source: "Twitter", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: @user_id, + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + get(conn, "/api/v1/stats/aggregate", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "metrics" => "pageviews,visitors,bounce_rate,visit_duration", + "filters" => "visit:utm_source==Twitter" + }) + + assert json_response(conn, 200)["results"] == %{ + "pageviews" => %{"value" => 2}, + "visitors" => %{"value" => 1}, + "bounce_rate" => %{"value" => 0}, + "visit_duration" => %{"value" => 1500} + } + end + + test "can filter by utm_campaign", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + utm_campaign: "profile", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: @user_id, + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + get(conn, "/api/v1/stats/aggregate", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "metrics" => "pageviews,visitors,bounce_rate,visit_duration", + "filters" => "visit:utm_campaign==profile" + }) + + assert json_response(conn, 200)["results"] == %{ + "pageviews" => %{"value" => 2}, + "visitors" => %{"value" => 1}, + "bounce_rate" => %{"value" => 0}, + "visit_duration" => %{"value" => 1500} + } + end + + test "can filter by device type", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + screen_size: "Desktop", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: @user_id, + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + get(conn, "/api/v1/stats/aggregate", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "metrics" => "pageviews,visitors,bounce_rate,visit_duration", + "filters" => "visit:device==Desktop" + }) + + assert json_response(conn, 200)["results"] == %{ + "pageviews" => %{"value" => 2}, + "visitors" => %{"value" => 1}, + "bounce_rate" => %{"value" => 0}, + "visit_duration" => %{"value" => 1500} + } + end + + test "can filter by browser", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + browser: "Chrome", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: @user_id, + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + get(conn, "/api/v1/stats/aggregate", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "metrics" => "pageviews,visitors,bounce_rate,visit_duration", + "filters" => "visit:browser==Chrome" + }) + + assert json_response(conn, 200)["results"] == %{ + "pageviews" => %{"value" => 2}, + "visitors" => %{"value" => 1}, + "bounce_rate" => %{"value" => 0}, + "visit_duration" => %{"value" => 1500} + } + end + + test "can filter by browser version", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + browser: "Chrome", + browser_version: "56", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: @user_id, + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + get(conn, "/api/v1/stats/aggregate", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "metrics" => "pageviews,visitors,bounce_rate,visit_duration", + "filters" => "visit:browser_version==56" + }) + + assert json_response(conn, 200)["results"] == %{ + "pageviews" => %{"value" => 2}, + "visitors" => %{"value" => 1}, + "bounce_rate" => %{"value" => 0}, + "visit_duration" => %{"value" => 1500} + } + end + + test "can filter by operating system", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + operating_system: "Mac", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: @user_id, + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + get(conn, "/api/v1/stats/aggregate", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "metrics" => "pageviews,visitors,bounce_rate,visit_duration", + "filters" => "visit:os==Mac" + }) + + assert json_response(conn, 200)["results"] == %{ + "pageviews" => %{"value" => 2}, + "visitors" => %{"value" => 1}, + "bounce_rate" => %{"value" => 0}, + "visit_duration" => %{"value" => 1500} + } + end + + test "can filter by operating system version", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + operating_system_version: "10.5", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: @user_id, + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + get(conn, "/api/v1/stats/aggregate", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "metrics" => "pageviews,visitors,bounce_rate,visit_duration", + "filters" => "visit:os_version==10.5" + }) + + assert json_response(conn, 200)["results"] == %{ + "pageviews" => %{"value" => 2}, + "visitors" => %{"value" => 1}, + "bounce_rate" => %{"value" => 0}, + "visit_duration" => %{"value" => 1500} + } + end + + test "can filter by country", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + country_code: "EE", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: @user_id, + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + get(conn, "/api/v1/stats/aggregate", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "metrics" => "pageviews,visitors,bounce_rate,visit_duration", + "filters" => "visit:country==EE" + }) + + assert json_response(conn, 200)["results"] == %{ + "pageviews" => %{"value" => 2}, + "visitors" => %{"value" => 1}, + "bounce_rate" => %{"value" => 0}, + "visit_duration" => %{"value" => 1500} + } + end + + test "can filter by page", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: @user_id, + pathname: "/blogpost", + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, + pathname: "/blogpost", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + conn = + get(conn, "/api/v1/stats/aggregate", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "metrics" => "pageviews,visitors,bounce_rate,visit_duration", + "filters" => "event:page==/blogpost" + }) + + assert json_response(conn, 200)["results"] == %{ + "pageviews" => %{"value" => 2}, + "visitors" => %{"value" => 2}, + "bounce_rate" => %{"value" => 100}, + "visit_duration" => %{"value" => 750} + } + end + + test "can filter by hostname", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + hostname: "one.example.com", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + hostname: "example.com", + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + get(conn, "/api/v1/stats/aggregate", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "metrics" => "pageviews,visitors,bounce_rate,visit_duration", + "filters" => "event:hostname==*.example.com|example.com" + }) + + assert json_response(conn, 200)["results"] == %{ + "pageviews" => %{"value" => 2}, + "visitors" => %{"value" => 2}, + "bounce_rate" => %{"value" => 100}, + "visit_duration" => %{"value" => 0} + } + end + + test "filtering by event:name", %{conn: conn, site: site} do + populate_stats(site, [ + build(:event, + name: "Signup", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Signup", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:event, + name: "Signup", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + get(conn, "/api/v1/stats/aggregate", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "metrics" => "visitors,pageviews", + "filters" => "event:name==Signup" + }) + + assert json_response(conn, 200)["results"] == %{ + "pageviews" => %{"value" => 0}, + "visitors" => %{"value" => 2} + } + end + + test "filtering by a custom event goal", %{conn: conn, site: site} do + populate_stats(site, [ + build(:event, + name: "Signup", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Signup", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:event, + name: "Signup", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:event, + name: "NotConfigured", + timestamp: ~N[2021-01-01 00:25:00] + ) + ]) + + insert(:goal, %{site: site, event_name: "Signup"}) + + conn = + get(conn, "/api/v1/stats/aggregate", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "metrics" => "visitors,events", + "filters" => "event:goal==Signup" + }) + + assert json_response(conn, 200)["results"] == %{ + "visitors" => %{"value" => 2}, + "events" => %{"value" => 3} + } + end + + test "filtering by a revenue goal", %{conn: conn, site: site} do + populate_stats(site, [ + build(:event, + name: "Purchase", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Purchase", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:event, + name: "Purchase", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:25:00] + ) + ]) + + insert(:goal, site: site, currency: :USD, event_name: "Purchase") + + conn = + get(conn, "/api/v1/stats/aggregate", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "metrics" => "visitors,events", + "filters" => "event:goal==Purchase" + }) + + assert json_response(conn, 200)["results"] == %{ + "visitors" => %{"value" => 2}, + "events" => %{"value" => 3} + } + end + + test "filtering by a simple pageview goal", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/register", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/register", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, + pathname: "/register", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, + pathname: "/", + timestamp: ~N[2021-01-01 00:25:00] + ) + ]) + + insert(:goal, %{site: site, page_path: "/register"}) + + conn = + get(conn, "/api/v1/stats/aggregate", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "metrics" => "visitors,pageviews", + "filters" => "event:goal==Visit /register" + }) + + assert json_response(conn, 200)["results"] == %{ + "visitors" => %{"value" => 2}, + "pageviews" => %{"value" => 3} + } + end + + test "filtering by a wildcard pageview goal", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/blog/post-1"), + build(:pageview, pathname: "/blog/post-2", user_id: @user_id), + build(:pageview, pathname: "/blog", user_id: @user_id), + build(:pageview, pathname: "/") + ]) + + insert(:goal, %{site: site, page_path: "/blog**"}) + + conn = + get(conn, "/api/v1/stats/aggregate", %{ + "site_id" => site.domain, + "period" => "day", + "metrics" => "visitors,pageviews", + "filters" => "event:goal==Visit /blog**" + }) + + assert json_response(conn, 200)["results"] == %{ + "visitors" => %{"value" => 2}, + "pageviews" => %{"value" => 3} + } + end + + test "filtering by multiple custom event goals", %{conn: conn, site: site} do + populate_stats(site, [ + build(:event, name: "Signup"), + build(:event, name: "Purchase", user_id: @user_id), + build(:event, name: "Purchase", user_id: @user_id), + build(:pageview) + ]) + + insert(:goal, %{site: site, event_name: "Signup"}) + insert(:goal, %{site: site, event_name: "Purchase"}) + + conn = + get(conn, "/api/v1/stats/aggregate", %{ + "site_id" => site.domain, + "period" => "day", + "metrics" => "visitors,events", + "filters" => "event:goal==Signup|Purchase" + }) + + assert json_response(conn, 200)["results"] == %{ + "visitors" => %{"value" => 2}, + "events" => %{"value" => 3} + } + end + + test "filtering by multiple mixed goals", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/account/register"), + build(:pageview, pathname: "/register", user_id: @user_id), + build(:event, name: "Signup", user_id: @user_id), + build(:pageview) + ]) + + insert(:goal, %{site: site, event_name: "Signup"}) + insert(:goal, %{site: site, page_path: "/**register"}) + + conn = + get(conn, "/api/v1/stats/aggregate", %{ + "site_id" => site.domain, + "period" => "day", + "metrics" => "visitors,events,pageviews", + "filters" => "event:goal==Signup|Visit /**register" + }) + + assert json_response(conn, 200)["results"] == %{ + "visitors" => %{"value" => 2}, + "events" => %{"value" => 3}, + "pageviews" => %{"value" => 2} + } + end + + test "combining filters", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/blogpost", + country_code: "EE", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: @user_id, + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, + pathname: "/blogpost", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + conn = + get(conn, "/api/v1/stats/aggregate", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "metrics" => "pageviews,visitors,bounce_rate,visit_duration", + "filters" => "event:page==/blogpost;visit:country==EE" + }) + + assert json_response(conn, 200)["results"] == %{ + "pageviews" => %{"value" => 1}, + "visitors" => %{"value" => 1}, + "bounce_rate" => %{"value" => 0}, + "visit_duration" => %{"value" => 1500} + } + end + + test "wildcard page filter", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/en/page1"), + build(:pageview, pathname: "/en/page2"), + build(:pageview, pathname: "/pl/page1") + ]) + + conn = + get(conn, "/api/v1/stats/aggregate?period=day", %{ + "site_id" => site.domain, + "metrics" => "visitors", + "filters" => "event:page==/en/**" + }) + + assert json_response(conn, 200)["results"] == %{"visitors" => %{"value" => 2}} + end + + test "negated wildcard page filter", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/en/page1"), + build(:pageview, pathname: "/en/page2"), + build(:pageview, pathname: "/pl/page1") + ]) + + conn = + get(conn, "/api/v1/stats/aggregate?period=day", %{ + "site_id" => site.domain, + "metrics" => "visitors", + "filters" => "event:page!=/en/**" + }) + + assert json_response(conn, 200)["results"] == %{"visitors" => %{"value" => 1}} + end + + test "wildcard and member filter combined", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/en/page1"), + build(:pageview, pathname: "/en/page2"), + build(:pageview, pathname: "/pl/page1"), + build(:pageview, pathname: "/ee/page1") + ]) + + conn = + get(conn, "/api/v1/stats/aggregate?period=day", %{ + "site_id" => site.domain, + "metrics" => "visitors", + "filters" => "event:page==/en/**|/pl/**" + }) + + assert json_response(conn, 200)["results"] == %{"visitors" => %{"value" => 3}} + end + + test "can escape pipe character in member + wildcard filter", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/blog/post|1"), + build(:pageview, pathname: "/otherpost|1"), + build(:pageview, pathname: "/blog/post|2"), + build(:pageview, pathname: "/something-else") + ]) + + conn = + get(conn, "/api/v1/stats/aggregate?period=day", %{ + "site_id" => site.domain, + "metrics" => "visitors", + "filters" => "event:page==**post\\|1|/something-else" + }) + + assert json_response(conn, 200)["results"] == %{"visitors" => %{"value" => 3}} + end + + test "handles filtering by visit country", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, country_code: "EE"), + build(:pageview, country_code: "EE"), + build(:pageview, country_code: "EE") + ]) + + conn = + get(conn, "/api/v1/stats/aggregate?period=day", %{ + "site_id" => site.domain, + "metrics" => "pageviews", + "filters" => "visit:country==EE" + }) + + assert json_response(conn, 200)["results"] == %{"pageviews" => %{"value" => 3}} + end + end + + describe "metrics" do + test "time_on_page with a page filter", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/A", + user_id: 123, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:engagement, + pathname: "/A", + user_id: 123, + timestamp: ~N[2021-01-01 00:01:00], + engagement_time: 60_000 + ), + build(:pageview, + pathname: "/another", + user_id: 123, + timestamp: ~N[2021-01-01 00:01:00] + ), + build(:pageview, + pathname: "/A", + user_id: 321, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:engagement, + pathname: "/A", + user_id: 321, + timestamp: ~N[2021-01-01 00:01:20], + engagement_time: 80_000 + ), + build(:pageview, + pathname: "/another", + user_id: 321, + timestamp: ~N[2021-01-01 00:01:20] + ), + build(:pageview, pathname: "/A", timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + get(conn, "/api/v1/stats/aggregate", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "metrics" => "time_on_page", + "filters" => "event:page==/A" + }) + + assert json_response(conn, 200)["results"] == %{"time_on_page" => %{"value" => 70}} + end + + test "time_on_page is returned as `nil` if it cannot be calculated", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/A") + ]) + + conn = + get(conn, "/api/v1/stats/aggregate", %{ + "site_id" => site.domain, + "metrics" => "time_on_page", + "filters" => "event:page==/A" + }) + + assert json_response(conn, 200)["results"] == %{"time_on_page" => %{"value" => nil}} + end + + test "conversion_rate when goal filter is applied", %{conn: conn, site: site} do + populate_stats(site, [ + build(:event, name: "Signup"), + build(:pageview) + ]) + + insert(:goal, %{site: site, event_name: "Signup"}) + + conn = + get(conn, "/api/v1/stats/aggregate?period=day", %{ + "site_id" => site.domain, + "metrics" => "conversion_rate", + "filters" => "event:goal==Signup" + }) + + assert json_response(conn, 200)["results"] == %{"conversion_rate" => %{"value" => 50}} + end + + test "conversion_rate when goal + custom prop filter applied", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:event, name: "Signup"), + build(:event, name: "Signup", "meta.key": ["author"], "meta.value": ["Uku"]), + build(:event, name: "Signup", "meta.key": ["author"], "meta.value": ["Marko"]), + build(:pageview) + ]) + + insert(:goal, %{site: site, event_name: "Signup"}) + + conn = + get(conn, "/api/v1/stats/aggregate?period=day", %{ + "site_id" => site.domain, + "metrics" => "conversion_rate,visitors,events", + "filters" => "event:goal==Signup;event:props:author==Uku" + }) + + assert %{ + "conversion_rate" => %{"value" => 25.0}, + "visitors" => %{"value" => 1}, + "events" => %{"value" => 1} + } = json_response(conn, 200)["results"] + end + + test "conversion_rate when goal + visit property filter applied", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:event, name: "Signup"), + build(:event, name: "Signup", browser: "Chrome"), + build(:event, name: "Signup", browser: "Firefox", user_id: 123), + build(:event, name: "Signup", browser: "Firefox", user_id: 123), + build(:pageview, browser: "Firefox"), + build(:pageview) + ]) + + insert(:goal, %{site: site, event_name: "Signup"}) + + conn = + get(conn, "/api/v1/stats/aggregate?period=day", %{ + "site_id" => site.domain, + "metrics" => "conversion_rate,visitors,events", + "filters" => "visit:browser==Firefox;event:goal==Signup" + }) + + assert %{ + "conversion_rate" => %{"value" => 50.0}, + "visitors" => %{"value" => 1}, + "events" => %{"value" => 2} + } = + json_response(conn, 200)["results"] + end + + test "conversion_rate when goal + page filter applied", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:event, name: "Signup"), + build(:event, name: "Signup", pathname: "/not-this"), + build(:event, name: "Signup", pathname: "/this", user_id: 123), + build(:event, name: "Signup", pathname: "/this", user_id: 123), + build(:pageview, pathname: "/this"), + build(:pageview) + ]) + + insert(:goal, %{site: site, event_name: "Signup"}) + + conn = + get(conn, "/api/v1/stats/aggregate?period=day", %{ + "site_id" => site.domain, + "metrics" => "conversion_rate,visitors,events", + "filters" => "event:page==/this;event:goal==Signup" + }) + + assert %{ + "conversion_rate" => %{"value" => 50.0}, + "visitors" => %{"value" => 1}, + "events" => %{"value" => 2} + } = + json_response(conn, 200)["results"] + end + + test "conversion_rate for the filtered goal is 0 when no stats exist", %{ + conn: conn, + site: site + } do + insert(:goal, %{site: site, event_name: "Signup"}) + + conn = + get(conn, "/api/v1/stats/aggregate", %{ + "site_id" => site.domain, + "metrics" => "conversion_rate", + "filters" => "event:goal==Signup" + }) + + assert json_response(conn, 200)["results"] == %{"conversion_rate" => %{"value" => 0}} + end + end + + describe "with json filters" do + test "filtering by exact string", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/en*"), + build(:pageview, pathname: "/en*/page1"), + build(:pageview, pathname: "/en*/page2"), + build(:pageview, pathname: "/ena/page2"), + build(:pageview, pathname: "/pll/page1") + ]) + + conn = + get(conn, "/api/v1/stats/aggregate?period=day", %{ + "site_id" => site.domain, + "metrics" => "visitors", + "filters" => [ + ["is", "event:page", ["/en*"]] + ] + }) + + assert json_response(conn, 200)["results"] == %{"visitors" => %{"value" => 1}} + end + + test "filtering by goal", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/blog/post-1"), + build(:pageview, pathname: "/blog/post-2", user_id: @user_id), + build(:pageview, pathname: "/blog", user_id: @user_id), + build(:pageview, pathname: "/") + ]) + + insert(:goal, %{site: site, page_path: "/blog"}) + + conn = + get(conn, "/api/v1/stats/aggregate", %{ + "site_id" => site.domain, + "period" => "day", + "metrics" => "visitors,pageviews", + "filters" => [["is", "event:goal", ["Visit /blog"]]] + }) + + assert json_response(conn, 200)["results"] == %{ + "visitors" => %{"value" => 1}, + "pageviews" => %{"value" => 1} + } + end + + test "filtering by wildcard goal", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/blog/post-1"), + build(:pageview, pathname: "/blog/post-2", user_id: @user_id), + build(:pageview, pathname: "/blog", user_id: @user_id), + build(:pageview, pathname: "/") + ]) + + insert(:goal, %{site: site, page_path: "/blog**"}) + + conn = + get(conn, "/api/v1/stats/aggregate", %{ + "site_id" => site.domain, + "period" => "day", + "metrics" => "visitors,pageviews", + "filters" => [["is", "event:goal", ["Visit /blog**"]]] + }) + + assert json_response(conn, 200)["results"] == %{ + "visitors" => %{"value" => 2}, + "pageviews" => %{"value" => 3} + } + end + + test "contains", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/en*"), + build(:pageview, pathname: "/en*/page1"), + build(:pageview, pathname: "/en*/page2"), + build(:pageview, pathname: "/ena/page2"), + build(:pageview, pathname: "/pll/page1") + ]) + + conn = + get(conn, "/api/v1/stats/aggregate?period=day", %{ + "site_id" => site.domain, + "metrics" => "visitors", + "filters" => [ + ["contains", "event:page", ["/en*"]] + ] + }) + + assert json_response(conn, 200)["results"] == %{"visitors" => %{"value" => 3}} + end + + test "does not contain", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/en*"), + build(:pageview, pathname: "/en*/page1"), + build(:pageview, pathname: "/en*/page2"), + build(:pageview, pathname: "/ena/page2"), + build(:pageview, pathname: "/pll/page1") + ]) + + conn = + get(conn, "/api/v1/stats/aggregate?period=day", %{ + "site_id" => site.domain, + "metrics" => "visitors", + "filters" => [ + ["contains_not", "event:page", ["/en*"]] + ] + }) + + assert json_response(conn, 200)["results"] == %{"visitors" => %{"value" => 2}} + end + + test "matches custom event property", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + "meta.key": ["tier"], + "meta.value": ["large-1"] + ), + build(:pageview, + "meta.key": ["tier"], + "meta.value": ["small-1"] + ), + build(:pageview, + "meta.key": ["tier"], + "meta.value": ["small-1"] + ), + build(:pageview, + "meta.key": ["tier"], + "meta.value": ["small-2"] + ) + ]) + + conn = + get(conn, "/api/v1/stats/aggregate?period=day", %{ + "site_id" => site.domain, + "metrics" => "visitors", + "filters" => [ + ["matches_wildcard", "event:props:tier", ["small*"]] + ] + }) + + assert json_response(conn, 200)["results"] == %{"visitors" => %{"value" => 3}} + end + + test "not matches_wildcard custom event property", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + "meta.key": ["tier"], + "meta.value": ["large-1"] + ), + build(:pageview, + "meta.key": ["tier"], + "meta.value": ["small-1"] + ), + build(:pageview, + "meta.key": ["tier"], + "meta.value": ["small-1"] + ), + build(:pageview, + "meta.key": ["tier"], + "meta.value": ["small-2"] + ), + build(:pageview) + ]) + + conn = + get(conn, "/api/v1/stats/aggregate?period=day", %{ + "site_id" => site.domain, + "metrics" => "visitors", + "filters" => [ + ["matches_wildcard_not", "event:props:tier", ["small*"]] + ] + }) + + assert json_response(conn, 200)["results"] == %{"visitors" => %{"value" => 1}} + end + + test "contains custom event property", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + "meta.key": ["tier"], + "meta.value": ["large-1"] + ), + build(:pageview, + "meta.key": ["tier"], + "meta.value": ["small-1"] + ), + build(:pageview, + "meta.key": ["tier"], + "meta.value": ["small-1"] + ), + build(:pageview, + "meta.key": ["tier"], + "meta.value": ["small-2"] + ) + ]) + + conn = + get(conn, "/api/v1/stats/aggregate?period=day", %{ + "site_id" => site.domain, + "metrics" => "visitors", + "filters" => [ + ["contains", "event:props:tier", ["small"]] + ] + }) + + assert json_response(conn, 200)["results"] == %{"visitors" => %{"value" => 3}} + end + + test "contains_not custom event property", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + "meta.key": ["tier"], + "meta.value": ["large-1"] + ), + build(:pageview, + "meta.key": ["tier"], + "meta.value": ["small-1"] + ), + build(:pageview, + "meta.key": ["tier"], + "meta.value": ["small-1"] + ), + build(:pageview, + "meta.key": ["tier"], + "meta.value": ["small-2"] + ) + ]) + + conn = + get(conn, "/api/v1/stats/aggregate?period=day", %{ + "site_id" => site.domain, + "metrics" => "visitors", + "filters" => [ + ["contains_not", "event:props:tier", ["small"]] + ] + }) + + assert json_response(conn, 200)["results"] == %{"visitors" => %{"value" => 1}} + end + end +end diff --git a/test/plausible_web/controllers/api/external_stats_controller/auth_test.exs b/test/plausible_web/controllers/api/external_stats_controller/auth_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..8feda9e94400a7d3d511656e1027c81ea6b4fb32 --- /dev/null +++ b/test/plausible_web/controllers/api/external_stats_controller/auth_test.exs @@ -0,0 +1,191 @@ +defmodule PlausibleWeb.Api.ExternalStatsController.AuthTest do + use PlausibleWeb.ConnCase + + alias Plausible.Repo + + setup [:create_user, :create_api_key] + + test "unauthenticated request - returns 401", %{conn: conn} do + conn + |> get("/api/v1/stats/aggregate", %{ + "site_id" => "some-site.com", + "metrics" => "pageviews" + }) + |> assert_error( + 401, + "Missing API key. Please use a valid Plausible API key as a Bearer Token." + ) + end + + test "bad API key - returns 401", %{conn: conn} do + conn + |> with_api_key("Bad key") + |> get("/api/v1/stats/aggregate", %{"site_id" => "some-site.com", "metrics" => "pageviews"}) + |> assert_error( + 401, + "Invalid API key or site ID. Please make sure you're using a valid API key with access to the site you've requested." + ) + end + + test "good API key but bad site id - returns 401", %{conn: conn, api_key: api_key} do + conn + |> with_api_key(api_key) + |> get("/api/v1/stats/aggregate", %{"site_id" => "some-site.com", "metrics" => "pageviews"}) + |> assert_error( + 401, + "Invalid API key or site ID. Please make sure you're using a valid API key with access to the site you've requested." + ) + end + + test "good API key but missing site id - returns 400", %{conn: conn, api_key: api_key} do + conn + |> with_api_key(api_key) + |> get("/api/v1/stats/aggregate", %{"metrics" => "pageviews"}) + |> assert_error( + 400, + "Missing site ID. Please provide the required site_id parameter with your request." + ) + end + + test "locked site - returns 402", %{conn: conn, api_key: api_key, user: user} do + site = new_site(owner: user) + site.team |> Ecto.Changeset.change(locked: true) |> Repo.update!() + + conn + |> with_api_key(api_key) + |> get("/api/v1/stats/aggregate", %{"site_id" => site.domain, "metrics" => "pageviews"}) + |> assert_error(402, "missing active subscription") + end + + test "can access with correct API key and site ID", %{conn: conn, user: user, api_key: api_key} do + site = new_site(owner: user) + + conn + |> with_api_key(api_key) + |> get("/api/v1/stats/aggregate", %{"site_id" => site.domain, "metrics" => "pageviews"}) + |> assert_ok(%{ + "results" => %{"pageviews" => %{"value" => 0}} + }) + end + + describe "super admin access" do + @describetag :ee_only + setup %{user: user} do + patch_env(:super_admin_user_ids, [user.id]) + end + + test "can access as a super admin", %{conn: conn, api_key: api_key} do + site = new_site() + + conn + |> with_api_key(api_key) + |> get("/api/v1/stats/aggregate", %{"site_id" => site.domain, "metrics" => "pageviews"}) + |> assert_ok(%{ + "results" => %{"pageviews" => %{"value" => 0}} + }) + end + + test "can access as a super admin even if site is locked", %{ + conn: conn, + api_key: api_key, + user: user + } do + site = new_site(owner: user) + site.team |> Ecto.Changeset.change(locked: true) |> Repo.update!() + + conn + |> with_api_key(api_key) + |> get("/api/v1/stats/aggregate", %{"site_id" => site.domain, "metrics" => "pageviews"}) + |> assert_ok(%{ + "results" => %{"pageviews" => %{"value" => 0}} + }) + end + end + + test "limits the rate of API requests", %{user: user} do + site = new_site(owner: user) + + user + |> team_of() + |> Ecto.Changeset.change(hourly_api_request_limit: 3) + |> Plausible.Repo.update!() + + api_key = insert(:api_key, user_id: user.id) + + build_conn() + |> with_api_key(api_key.key) + |> get("/api/v1/stats/aggregate", %{"site_id" => site.domain}) + + build_conn() + |> with_api_key(api_key.key) + |> get("/api/v1/stats/aggregate", %{"site_id" => site.domain}) + + build_conn() + |> with_api_key(api_key.key) + |> get("/api/v1/stats/aggregate", %{"site_id" => site.domain}) + + build_conn() + |> with_api_key(api_key.key) + |> get("/api/v1/stats/aggregate", %{"site_id" => site.domain}) + |> assert_error( + 429, + "Too many API requests. The limit is 3 per hour. Please contact us to request more capacity." + ) + end + + test "can access with either site_id after domain change", %{ + conn: conn, + user: user, + api_key: api_key + } do + old_domain = "old.example.com" + new_domain = "new.example.com" + site = new_site(domain: old_domain, owner: user) + + Plausible.Site.Domain.change(site, new_domain) + + conn + |> with_api_key(api_key) + |> get("/api/v1/stats/aggregate", %{"site_id" => new_domain, "metrics" => "pageviews"}) + |> assert_ok(%{ + "results" => %{"pageviews" => %{"value" => 0}} + }) + + conn + |> with_api_key(api_key) + |> get("/api/v1/stats/aggregate", %{"site_id" => old_domain, "metrics" => "pageviews"}) + |> assert_ok(%{ + "results" => %{"pageviews" => %{"value" => 0}} + }) + end + + @tag :ee_only + test "returns HTTP 402 when user is on a growth plan", %{ + conn: conn, + user: user, + api_key: api_key + } do + subscribe_to_growth_plan(user) + site = new_site(owner: user) + + conn + |> with_api_key(api_key) + |> get("/api/v1/stats/aggregate", %{"site_id" => site.domain, "metrics" => "pageviews"}) + |> assert_error( + 402, + "The account that owns this API key does not have access to Stats API." + ) + end + + defp with_api_key(conn, api_key) do + Plug.Conn.put_req_header(conn, "authorization", "Bearer #{api_key}") + end + + defp assert_error(conn, status, message) do + assert json_response(conn, status)["error"] =~ message + end + + defp assert_ok(conn, payload) do + assert json_response(conn, 200) == payload + end +end diff --git a/test/plausible_web/controllers/api/external_stats_controller/breakdown_test.exs b/test/plausible_web/controllers/api/external_stats_controller/breakdown_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..b13b94870e44ddb9b22fac21549f3abfe2973fab --- /dev/null +++ b/test/plausible_web/controllers/api/external_stats_controller/breakdown_test.exs @@ -0,0 +1,3645 @@ +defmodule PlausibleWeb.Api.ExternalStatsController.BreakdownTest do + use PlausibleWeb.ConnCase + + alias Plausible.Billing.Feature + + @user_id Enum.random(1000..9999) + + setup [:create_user, :create_site, :create_api_key, :use_api_key] + + describe "feature access" do + test "cannot break down by a custom prop without access to the props feature", %{ + conn: conn, + user: user, + site: site + } do + subscribe_to_enterprise_plan(user, features: [Feature.StatsAPI]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "property" => "event:props:author" + }) + + assert json_response(conn, 402)["error"] == + "The owner of this site does not have access to the custom properties feature" + end + + test "can break down by an internal prop key without access to the props feature", %{ + conn: conn, + user: user, + site: site + } do + subscribe_to_enterprise_plan(user, features: [Feature.StatsAPI]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "property" => "event:props:path" + }) + + assert json_response(conn, 200)["results"] + end + + test "cannot filter by a custom prop without access to the props feature", %{ + conn: conn, + user: user, + site: site + } do + subscribe_to_enterprise_plan(user, features: [Feature.StatsAPI]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "property" => "visit:source", + "filters" => "event:props:author==Uku" + }) + + assert json_response(conn, 402)["error"] == + "The owner of this site does not have access to the custom properties feature" + end + + test "can filter by an internal prop key without access to the props feature", %{ + conn: conn, + user: user, + site: site + } do + subscribe_to_enterprise_plan(user, features: [Feature.StatsAPI]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "property" => "visit:source", + "filters" => "event:props:url==whatever" + }) + + assert json_response(conn, 200)["results"] + end + end + + describe "param validation" do + test "time_on_page is not supported in breakdown queries other than by event:page", %{ + conn: conn, + site: site + } do + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "property" => "visit:source", + "filters" => "event:page==/A", + "metrics" => "time_on_page" + }) + + assert json_response(conn, 400) == %{ + "error" => + "Metric `time_on_page` is not supported in breakdown queries (except `event:page` breakdown)" + } + end + + test "does not allow querying conversion_rate without a goal filter", %{ + conn: conn, + site: site + } do + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "property" => "event:page", + "metrics" => "conversion_rate" + }) + + assert json_response(conn, 400) == %{ + "error" => + "Metric `conversion_rate` can only be queried in a goal breakdown or with a goal filter" + } + end + + test "validates that property is required", %{conn: conn, site: site} do + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain + }) + + assert json_response(conn, 400) == %{ + "error" => + "The `property` parameter is required. Please provide at least one property to show a breakdown by." + } + end + + test "validates that property is valid", %{conn: conn, site: site} do + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "property" => "badproperty" + }) + + assert json_response(conn, 400) == %{ + "error" => + "Invalid property 'badproperty'. Please provide a valid property for the breakdown endpoint: https://plausible.io/docs/stats-api#properties" + } + end + + test "empty custom prop is invalid", %{conn: conn, site: site} do + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "property" => "event:props:" + }) + + assert json_response(conn, 400) == %{ + "error" => + "Invalid property 'event:props:'. Please provide a valid property for the breakdown endpoint: https://plausible.io/docs/stats-api#properties" + } + end + + test "validates that correct period is used", %{conn: conn, site: site} do + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "bad_period" + }) + + assert json_response(conn, 400) == %{ + "error" => + "Error parsing `period` parameter: invalid period `bad_period`. Please find accepted values in our docs: https://plausible.io/docs/stats-api#time-periods" + } + end + + test "fails when an invalid metric is provided", %{conn: conn, site: site} do + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "property" => "event:page", + "metrics" => "visitors,baa", + "site_id" => site.domain + }) + + assert json_response(conn, 400) == %{ + "error" => + "The metric `baa` is not recognized. Find valid metrics from the documentation: https://plausible.io/docs/stats-api#metrics" + } + end + + test "session metrics cannot be used with event:name property", %{conn: conn, site: site} do + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "property" => "event:name", + "metrics" => "visitors,bounce_rate", + "site_id" => site.domain + }) + + assert json_response(conn, 400) == %{ + "error" => + "Session metric `bounce_rate` cannot be queried for breakdown by `event:name`." + } + end + + test "session metrics cannot be used with event:props:* property", %{conn: conn, site: site} do + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "property" => "event:props:url", + "metrics" => "visitors,bounce_rate", + "site_id" => site.domain + }) + + assert json_response(conn, 400) == %{ + "error" => + "Session metric `bounce_rate` cannot be queried for breakdown by `event:props:url`." + } + end + + test "session metrics cannot be used with event:name filter", %{conn: conn, site: site} do + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "property" => "event:page", + "filters" => "event:name==Signup", + "metrics" => "visitors,bounce_rate", + "site_id" => site.domain + }) + + assert json_response(conn, 400) == %{ + "error" => + "Session metric `bounce_rate` cannot be queried when using a filter on `event:name`." + } + end + + test "session metrics cannot be used with event:props:* filter", %{conn: conn, site: site} do + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "property" => "event:page", + "filters" => "event:props:url==google.com", + "metrics" => "visitors,bounce_rate", + "site_id" => site.domain + }) + + assert json_response(conn, 400) == %{ + "error" => + "Session metric `bounce_rate` cannot be queried when using a filter on `event:props:url`." + } + end + end + + test "breakdown by visit:source", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + referrer_source: "Google", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + referrer_source: "Google", + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, + referrer_source: "", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "property" => "visit:source" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{"source" => "Google", "visitors" => 2}, + %{"source" => "Direct / None", "visitors" => 1} + ] + } + end + + test "breakdown by visit:channel", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + referrer_source: "Bing", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + referrer_source: "Bing", + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "property" => "visit:channel" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{"channel" => "Organic Search", "visitors" => 2}, + %{"channel" => "Direct", "visitors" => 1} + ] + } + end + + test "breakdown by visit:country", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, country_code: "EE", timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, country_code: "EE", timestamp: ~N[2021-01-01 00:25:00]), + build(:pageview, country_code: "US", timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "property" => "visit:country" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{"country" => "EE", "visitors" => 2}, + %{"country" => "US", "visitors" => 1} + ] + } + end + + test "breakdown by visit:referrer", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + referrer: "https://ref.com", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + referrer: "https://ref.com", + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, + referrer: "", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "property" => "visit:referrer" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{"referrer" => "https://ref.com", "visitors" => 2}, + %{"referrer" => "Direct / None", "visitors" => 1} + ] + } + end + + test "breaks down all metrics by visit:referrer with imported data", %{conn: conn, site: site} do + site_import = + insert(:site_import, + site: site, + start_date: ~D[2005-01-01], + end_date: Date.utc_today(), + source: :universal_analytics + ) + + populate_stats(site, site_import.id, [ + build(:pageview, referrer: "site.com", timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, referrer: "site.com/1", timestamp: ~N[2021-01-01 00:00:00]), + build(:imported_sources, + referrer: "site.com", + date: ~D[2021-01-01], + visitors: 2, + visits: 2, + pageviews: 2, + bounces: 1, + visit_duration: 120 + ), + build(:imported_sources, + referrer: "site.com/2", + date: ~D[2021-01-01], + visitors: 2, + visits: 2, + pageviews: 2, + bounces: 2, + visit_duration: 0 + ), + build(:imported_sources, + date: ~D[2021-01-01], + visitors: 10, + visits: 11, + pageviews: 50, + bounces: 0, + visit_duration: 1100 + ) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "metrics" => "visitors,visits,pageviews,bounce_rate,visit_duration", + "date" => "2021-01-01", + "property" => "visit:referrer", + "with_imported" => "true" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{ + "referrer" => "Direct / None", + "visitors" => 10, + "visits" => 11, + "pageviews" => 50, + "bounce_rate" => 0, + "visit_duration" => 100 + }, + %{ + "referrer" => "site.com", + "visitors" => 3, + "visits" => 3, + "pageviews" => 3, + "bounce_rate" => 67.0, + "visit_duration" => 40 + }, + %{ + "referrer" => "site.com/2", + "visitors" => 2, + "visits" => 2, + "pageviews" => 2, + "bounce_rate" => 100.0, + "visit_duration" => 0 + }, + %{ + "referrer" => "site.com/1", + "visitors" => 1, + "visits" => 1, + "pageviews" => 1, + "bounce_rate" => 100.0, + "visit_duration" => 0 + } + ] + } + end + + for {property, attr} <- [ + {"visit:utm_campaign", :utm_campaign}, + {"visit:utm_source", :utm_source}, + {"visit:utm_term", :utm_term}, + {"visit:utm_content", :utm_content} + ] do + test "breakdown by #{property} when filtered by hostname", %{conn: conn, site: site} do + populate_stats(site, [ + # session starts at two.example.com with utm_param=ad + build( + :pageview, + [ + {unquote(attr), "ad"}, + {:user_id, @user_id}, + {:hostname, "two.example.com"}, + {:timestamp, ~N[2021-01-01 00:00:00]} + ] + ), + # session continues on one.example.com without any utm_params + build( + :pageview, + [ + {:user_id, @user_id}, + {:hostname, "one.example.com"}, + {:timestamp, ~N[2021-01-01 00:15:00]} + ] + ) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "filters" => "event:hostname==one.example.com", + "property" => unquote(property) + }) + + # nobody landed on one.example.com from utm_param=ad + assert json_response(conn, 200) == %{"results" => []} + end + end + + test "breakdown by visit:utm_medium", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + utm_medium: "Search", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + utm_medium: "Search", + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, + utm_medium: "", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "property" => "visit:utm_medium" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{"utm_medium" => "Search", "visitors" => 2} + ] + } + end + + test "breakdown by visit:utm_source", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + utm_source: "Google", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + utm_source: "Google", + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, + utm_source: "", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "property" => "visit:utm_source" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{"utm_source" => "Google", "visitors" => 2} + ] + } + end + + test "breaks down all metrics by visit:utm_source with imported data", %{conn: conn, site: site} do + site_import = + insert(:site_import, + site: site, + start_date: ~D[2005-01-01], + end_date: Date.utc_today(), + source: :universal_analytics + ) + + populate_stats(site, site_import.id, [ + build(:pageview, utm_source: "SomeUTMSource", timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, utm_source: "SomeUTMSource-1", timestamp: ~N[2021-01-01 00:00:00]), + build(:imported_sources, + utm_source: "SomeUTMSource", + date: ~D[2021-01-01], + visitors: 2, + visits: 2, + pageviews: 2, + bounces: 1, + visit_duration: 120 + ), + build(:imported_sources, + utm_source: "SomeUTMSource-2", + date: ~D[2021-01-01], + visitors: 2, + visits: 2, + pageviews: 2, + bounces: 2, + visit_duration: 0 + ), + build(:imported_sources, + date: ~D[2021-01-01], + visitors: 10, + visits: 11, + pageviews: 50, + bounces: 0, + visit_duration: 1100 + ) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "metrics" => "visitors,visits,pageviews,bounce_rate,visit_duration", + "date" => "2021-01-01", + "property" => "visit:utm_source", + "with_imported" => "true" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{ + "utm_source" => "SomeUTMSource", + "visitors" => 3, + "visits" => 3, + "pageviews" => 3, + "bounce_rate" => 67.0, + "visit_duration" => 40 + }, + %{ + "utm_source" => "SomeUTMSource-2", + "visitors" => 2, + "visits" => 2, + "pageviews" => 2, + "bounce_rate" => 100.0, + "visit_duration" => 0 + }, + %{ + "utm_source" => "SomeUTMSource-1", + "visitors" => 1, + "visits" => 1, + "pageviews" => 1, + "bounce_rate" => 100.0, + "visit_duration" => 0 + } + ] + } + end + + test "breakdown by visit:utm_campaign", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + utm_campaign: "ads", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + utm_campaign: "ads", + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, + utm_campaign: "", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "property" => "visit:utm_campaign" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{"utm_campaign" => "ads", "visitors" => 2} + ] + } + end + + test "breakdown by visit:utm_content", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + utm_content: "Content1", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + utm_content: "Content1", + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, + utm_content: "", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "property" => "visit:utm_content" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{"utm_content" => "Content1", "visitors" => 2} + ] + } + end + + test "breakdown by visit:utm_term", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + utm_term: "Term1", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + utm_term: "Term1", + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, + utm_term: "", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "property" => "visit:utm_term" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{"utm_term" => "Term1", "visitors" => 2} + ] + } + end + + test "breakdown by visit:device", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + screen_size: "Desktop", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + screen_size: "Desktop", + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, + screen_size: "Mobile", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "property" => "visit:device" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{"device" => "Desktop", "visitors" => 2}, + %{"device" => "Mobile", "visitors" => 1} + ] + } + end + + test "breakdown by visit:os", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + operating_system: "Mac", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + operating_system: "Mac", + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, + operating_system: "Windows", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "property" => "visit:os" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{"os" => "Mac", "visitors" => 2}, + %{"os" => "Windows", "visitors" => 1} + ] + } + end + + test "breakdown by visit:os_version", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, operating_system: "Mac", operating_system_version: "14"), + build(:pageview, operating_system: "Mac", operating_system_version: "14"), + build(:pageview, operating_system: "Mac", operating_system_version: "14"), + build(:pageview, operating_system_version: "14"), + build(:pageview, + operating_system: "Windows", + operating_system_version: "11" + ), + build(:pageview, + operating_system: "Windows", + operating_system_version: "11" + ) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "property" => "visit:os_version" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{"os_version" => "14", "visitors" => 3, "os" => "Mac"}, + %{"os_version" => "11", "visitors" => 2, "os" => "Windows"}, + %{"os_version" => "14", "visitors" => 1, "os" => "(not set)"} + ] + } + end + + test "breakdown by visit:browser", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, browser: "Firefox", timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, browser: "Firefox", timestamp: ~N[2021-01-01 00:25:00]), + build(:pageview, browser: "Safari", timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "property" => "visit:browser" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{"browser" => "Firefox", "visitors" => 2}, + %{"browser" => "Safari", "visitors" => 1} + ] + } + end + + test "breakdown by visit:browser_version", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + browser_version: "56", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + browser_version: "56", + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, + browser_version: "57", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "property" => "visit:browser_version" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{"browser_version" => "56", "visitors" => 2, "browser" => "(not set)"}, + %{"browser_version" => "57", "visitors" => 1, "browser" => "(not set)"} + ] + } + end + + test "pageviews breakdown by event:page - imported data having pageviews=0 and visitors=n should be bypassed", + %{conn: conn, site: site} do + site_import = + insert(:site_import, + site: site, + start_date: ~D[2005-01-01], + end_date: Date.utc_today(), + source: :universal_analytics + ) + + populate_stats(site, site_import.id, [ + build(:pageview, pathname: "/", timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, pathname: "/", timestamp: ~N[2021-01-01 00:25:00]), + build(:pageview, + pathname: "/plausible.io", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:imported_pages, + page: "/skip-me", + date: ~D[2021-01-01], + visitors: 1, + pageviews: 0 + ), + build(:imported_pages, + page: "/include-me", + date: ~D[2021-01-01], + visitors: 1, + pageviews: 1 + ) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "property" => "event:page", + "with_imported" => "true", + "metrics" => "pageviews" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{"page" => "/", "pageviews" => 2}, + %{"page" => "/include-me", "pageviews" => 1}, + %{"page" => "/plausible.io", "pageviews" => 1} + ] + } + end + + test "breakdown by event:page", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/", timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, pathname: "/", timestamp: ~N[2021-01-01 00:25:00]), + build(:pageview, + pathname: "/plausible.io", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "property" => "event:page" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{"page" => "/", "visitors" => 2}, + %{"page" => "/plausible.io", "visitors" => 1} + ] + } + end + + test "breakdown by event:page when there are no events in the second page", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, pathname: "/", timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, pathname: "/", timestamp: ~N[2021-01-01 00:25:00]), + build(:pageview, + pathname: "/plausible.io", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "property" => "event:page", + "metrics" => "visitors,bounce_rate", + "page" => 2, + "limit" => 2 + }) + + assert json_response(conn, 200) == %{"results" => []} + end + + test "attempting to breakdown by event:hostname returns an error", %{conn: conn, site: site} do + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "property" => "event:hostname", + "with_imported" => "true" + }) + + assert %{ + "error" => error + } = json_response(conn, 400) + + assert error =~ "Property 'event:hostname' is currently not supported for breakdowns." + end + + describe "breakdown by visit:exit_page" do + setup %{site: site} do + site_import = insert(:site_import, site: site) + + populate_stats(site, site_import.id, [ + build(:pageview, + pathname: "/a", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: @user_id, + pathname: "/a", + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, + user_id: @user_id, + pathname: "/b", + timestamp: ~N[2021-01-01 00:35:00] + ), + build(:imported_exit_pages, + exit_page: "/b", + exits: 3, + visitors: 2, + pageviews: 5, + date: ~D[2021-01-01] + ) + ]) + end + + test "can breakdown by visit:exit_page", %{conn: conn, site: site} do + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "property" => "visit:exit_page", + "with_imported" => "true" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{"exit_page" => "/b", "visitors" => 3}, + %{"exit_page" => "/a", "visitors" => 1} + ] + } + end + + test "handles all applicable metrics with graceful fallback for imported data when needed", %{ + conn: conn, + site: site + } do + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "property" => "visit:exit_page", + "metrics" => "visitors,visits,bounce_rate,visit_duration,events,pageviews", + "with_imported" => "true" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{ + "bounce_rate" => 0.0, + "exit_page" => "/b", + "visit_duration" => 150.0, + "visitors" => 3, + "visits" => 4, + "events" => 7, + "pageviews" => 7 + }, + %{ + "bounce_rate" => 100.0, + "exit_page" => "/a", + "visit_duration" => 0.0, + "visitors" => 1, + "visits" => 1, + "events" => 1, + "pageviews" => 1 + } + ] + } + end + end + + describe "custom events" do + test "can breakdown by event:name", %{conn: conn, site: site} do + populate_stats(site, [ + build(:event, + name: "Signup", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Signup", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + timestamp: ~N[2021-01-01 00:25:00] + ) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "property" => "event:name" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{"name" => "Signup", "visitors" => 2}, + %{"name" => "pageview", "visitors" => 1} + ] + } + end + + test "can breakdown by event:name with visitors and events metrics", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/non-existing", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "404", + pathname: "/non-existing", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/non-existing", + timestamp: ~N[2021-01-01 00:00:01] + ), + build(:pageview, + pathname: "/non-existing", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:02] + ), + build(:event, + name: "404", + pathname: "/non-existing", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:02] + ), + build(:pageview, + pathname: "/non-existing", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:03] + ) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "property" => "event:name", + "metrics" => "visitors,events" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{"name" => "pageview", "visitors" => 2, "events" => 4}, + %{"name" => "404", "visitors" => 1, "events" => 2} + ] + } + end + + test "can breakdown by event:name while filtering for something", %{conn: conn, site: site} do + populate_stats(site, [ + build(:event, + name: "Signup", + pathname: "/pageA", + browser: "Chrome", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Signup", + pathname: "/pageA", + browser: "Chrome", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Signup", + pathname: "/pageA", + browser: "Safari", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Signup", + pathname: "/pageB", + browser: "Chrome", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/pageA", + browser: "Chrome", + timestamp: ~N[2021-01-01 00:25:00] + ) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "property" => "event:name", + "filters" => "event:page==/pageA;visit:browser==Chrome" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{"name" => "Signup", "visitors" => 2}, + %{"name" => "pageview", "visitors" => 1} + ] + } + end + + test "can breakdown by a visit:property when filtering by event:name", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, + referrer_source: "Google", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Signup", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + referrer_source: "Twitter", + timestamp: ~N[2021-01-01 00:25:00] + ) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "property" => "visit:source", + "filters" => "event:name==Signup" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{"source" => "Google", "visitors" => 1} + ] + } + end + + test "can breakdown by event:name when filtering by event:page", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/pageA", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/pageA", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + pathname: "/pageA", + name: "Signup", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/pageB", + referrer_source: "Twitter", + timestamp: ~N[2021-01-01 00:25:00] + ) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "property" => "event:name", + "filters" => "event:page==/pageA" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{"name" => "pageview", "visitors" => 2}, + %{"name" => "Signup", "visitors" => 1} + ] + } + end + + test "can breakdown by event:page when filtering by event:name", %{conn: conn, site: site} do + populate_stats(site, [ + build(:event, + name: "Signup", + pathname: "/pageA", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Signup", + pathname: "/pageA", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Signup", + pathname: "/pageB", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/pageB", + referrer_source: "Twitter", + timestamp: ~N[2021-01-01 00:25:00] + ) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "property" => "event:page", + "filters" => "event:name==Signup" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{"page" => "/pageA", "visitors" => 2}, + %{"page" => "/pageB", "visitors" => 1} + ] + } + end + + test "can filter event:page with a wildcard", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, pathname: "/en/page1"), + build(:pageview, pathname: "/en/page2"), + build(:pageview, pathname: "/en/page2"), + build(:pageview, pathname: "/pl/page1") + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "property" => "event:page", + "filters" => "event:page==/en/**" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{"page" => "/en/page2", "visitors" => 2}, + %{"page" => "/en/page1", "visitors" => 1} + ] + } + end + + test "can filter event:hostname with a wildcard", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, hostname: "alice.example.com", pathname: "/a"), + build(:pageview, hostname: "anna.example.com", pathname: "/a"), + build(:pageview, hostname: "adam.example.com", pathname: "/a"), + build(:pageview, hostname: "bob.example.com", pathname: "/b") + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "property" => "event:page", + "filters" => "event:hostname==a*.example.com" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{"page" => "/a", "visitors" => 3} + ] + } + end + + test "breakdown by custom event property", %{conn: conn, site: site} do + populate_stats(site, [ + build(:event, + name: "Purchase", + "meta.key": ["package"], + "meta.value": ["business"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Purchase", + "meta.key": ["package"], + "meta.value": ["personal"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Purchase", + "meta.key": ["package"], + "meta.value": ["business"], + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:event, + name: "Some other event", + "meta.key": ["package"], + "meta.value": ["business"], + timestamp: ~N[2021-01-01 00:25:00] + ) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "property" => "event:props:package", + "filters" => "event:name==Purchase" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{"package" => "business", "visitors" => 2}, + %{"package" => "personal", "visitors" => 1} + ] + } + end + + test "breakdown by custom event property, with pageviews metric", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + "meta.key": ["package"], + "meta.value": ["business"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + "meta.key": ["package"], + "meta.value": ["personal"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + "meta.key": ["package"], + "meta.value": ["business"], + timestamp: ~N[2021-01-01 00:25:00] + ) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "property" => "event:props:package", + "metrics" => "pageviews" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{"package" => "business", "pageviews" => 2}, + %{"package" => "personal", "pageviews" => 1} + ] + } + end + + test "breakdown by custom event property, with (none)", %{conn: conn, site: site} do + populate_stats(site, [ + build(:event, + name: "Purchase", + "meta.key": ["cost"], + "meta.value": ["16"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Purchase", + "meta.key": ["cost"], + "meta.value": ["16"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Purchase", + "meta.key": ["cost"], + "meta.value": ["16"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Purchase", + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:event, + name: "Purchase", + "meta.key": ["cost"], + "meta.value": ["14"], + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:event, + name: "Purchase", + "meta.key": ["cost"], + "meta.value": ["14"], + timestamp: ~N[2021-01-01 00:26:00] + ) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "property" => "event:props:cost", + "filters" => "event:name==Purchase" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{"cost" => "16", "visitors" => 3}, + %{"cost" => "14", "visitors" => 2}, + %{"cost" => "(none)", "visitors" => 1} + ] + } + end + + test "breakdown by custom event property, limited", %{conn: conn, site: site} do + populate_stats(site, [ + build(:event, + name: "Purchase", + "meta.key": ["cost"], + "meta.value": ["16"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Purchase", + "meta.key": ["cost"], + "meta.value": ["18"], + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:event, + name: "Purchase", + "meta.key": ["cost"], + "meta.value": ["14"], + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:event, + name: "Purchase", + "meta.key": ["cost"], + "meta.value": ["14"], + timestamp: ~N[2021-01-01 00:26:00] + ) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "property" => "event:props:cost", + "filters" => "event:name==Purchase", + "limit" => 2 + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{"cost" => "14", "visitors" => 2}, + %{"cost" => "16", "visitors" => 1} + ] + } + end + + test "breakdown by custom event property, paginated", %{conn: conn, site: site} do + populate_stats(site, [ + build(:event, + name: "Purchase", + "meta.key": ["cost"], + "meta.value": ["16"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Purchase", + "meta.key": ["cost"], + "meta.value": ["16"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Purchase", + "meta.key": ["cost"], + "meta.value": ["18"], + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:event, + name: "Purchase", + "meta.key": ["cost"], + "meta.value": ["14"], + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:event, + name: "Purchase", + "meta.key": ["cost"], + "meta.value": ["14"], + timestamp: ~N[2021-01-01 00:26:00] + ) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "property" => "event:props:cost", + "filters" => "event:name==Purchase", + "limit" => 2, + "page" => 2 + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{"cost" => "18", "visitors" => 1} + ] + } + end + end + + describe "breakdown by event:goal" do + test "returns custom event goals and pageview goals", %{conn: conn, site: site} do + insert(:goal, %{site: site, event_name: "Purchase"}) + insert(:goal, %{site: site, page_path: "/test"}) + + populate_stats(site, [ + build(:pageview, + timestamp: ~N[2021-01-01 00:00:01], + pathname: "/test" + ), + build(:event, + name: "Purchase", + timestamp: ~N[2021-01-01 00:00:03] + ), + build(:event, + name: "Purchase", + timestamp: ~N[2021-01-01 00:00:03] + ) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "property" => "event:goal" + }) + + assert [ + %{"goal" => "Purchase", "visitors" => 2}, + %{"goal" => "Visit /test", "visitors" => 1} + ] = json_response(conn, 200)["results"] + end + + test "returns pageview goals containing wildcards", %{conn: conn, site: site} do + insert(:goal, %{site: site, page_path: "/**/post"}) + insert(:goal, %{site: site, page_path: "/blog**"}) + + populate_stats(site, [ + build(:pageview, pathname: "/blog", user_id: @user_id), + build(:pageview, pathname: "/blog/post-1", user_id: @user_id), + build(:pageview, pathname: "/blog/post-2", user_id: @user_id), + build(:pageview, pathname: "/blog/something/post"), + build(:pageview, pathname: "/different/page/post") + ]) + + conn = + get(conn, "/api/v1/stats/breakdown?period=day", %{ + "site_id" => site.domain, + "metrics" => "visitors,pageviews", + "property" => "event:goal" + }) + + assert [ + %{"goal" => "Visit /**/post", "visitors" => 2, "pageviews" => 2}, + %{"goal" => "Visit /blog**", "visitors" => 2, "pageviews" => 4} + ] = json_response(conn, 200)["results"] + end + + test "does not return goals that are not configured for the site", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/register"), + build(:event, name: "Signup") + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "metrics" => "visitors,pageviews", + "property" => "event:goal" + }) + + assert [] = json_response(conn, 200)["results"] + end + end + + describe "filtering" do + test "event:goal filter returns 400 when goal not configured", %{conn: conn, site: site} do + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "property" => "event:page", + "filters" => "event:goal==Register" + }) + + assert %{"error" => msg} = json_response(conn, 400) + assert msg =~ "The goal `Register` is not configured for this site. Find out how" + end + + test "validates that filters are valid", %{conn: conn, site: site} do + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "property" => "event:page", + "filters" => "badproperty==bar" + }) + + assert json_response(conn, 400) == %{ + "error" => + "Invalid filter property 'badproperty'. Please provide a valid filter property: https://plausible.io/docs/stats-api#properties" + } + end + + test "event:page filter for breakdown by session props", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/ignore", + browser: "Chrome", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/plausible.io", + browser: "Chrome", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/plausible.io", + browser: "Chrome", + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, + browser: "Safari", + pathname: "/plausible.io", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "property" => "visit:browser", + "filters" => "event:page==/plausible.io" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{"browser" => "Chrome", "visitors" => 2}, + %{"browser" => "Safari", "visitors" => 1} + ] + } + end + + test "event:page filter shows sources of sessions that have visited that page", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, + pathname: "/", + referrer_source: "Twitter", + utm_medium: "Twitter", + utm_source: "Twitter", + utm_campaign: "Twitter", + user_id: @user_id + ), + build(:pageview, + pathname: "/plausible.io", + user_id: @user_id + ), + build(:pageview, + pathname: "/plausible.io", + referrer_source: "Google", + utm_medium: "Google", + utm_source: "Google", + utm_campaign: "Google" + ), + build(:pageview, + pathname: "/plausible.io", + referrer_source: "Google", + utm_medium: "Google", + utm_source: "Google", + utm_campaign: "Google" + ) + ]) + + for property <- ["source", "utm_medium", "utm_source", "utm_campaign"] do + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "property" => "visit:" <> property, + "filters" => "event:page==/plausible.io" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{property => "Google", "visitors" => 2}, + %{property => "Twitter", "visitors" => 1} + ] + } + end + end + + test "top sources for a custom goal and filtered by hostname", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + hostname: "blog.example.com", + referrer_source: "Facebook", + user_id: @user_id + ), + build(:pageview, + hostname: "app.example.com", + pathname: "/register", + user_id: @user_id + ), + build(:event, + name: "Signup", + hostname: "app.example.com", + pathname: "/register", + user_id: @user_id + ) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "property" => "visit:source", + "filters" => "event:hostname==app.example.com" + }) + + assert json_response(conn, 200) == %{"results" => []} + end + + test "top sources for a custom goal and filtered by hostname (2)", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + hostname: "app.example.com", + referrer_source: "Facebook", + pathname: "/register", + user_id: @user_id + ), + build(:event, + name: "Signup", + hostname: "app.example.com", + pathname: "/register", + user_id: @user_id + ) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "property" => "visit:source", + "filters" => "event:hostname==app.example.com" + }) + + assert json_response(conn, 200) == %{ + "results" => [%{"source" => "Facebook", "visitors" => 1}] + } + end + + test "event:page filter is interpreted as entry_page filter only for bounce_rate", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, + browser: "Chrome", + user_id: @user_id, + pathname: "/ignore", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + browser: "Chrome", + user_id: @user_id, + pathname: "/plausible.io", + timestamp: ~N[2021-01-01 00:01:00] + ), + build(:pageview, + browser: "Chrome", + user_id: 456, + pathname: "/important-page", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + browser: "Chrome", + user_id: 456, + pathname: "/", + timestamp: ~N[2021-01-01 00:01:00] + ), + build(:pageview, + browser: "Chrome", + pathname: "/plausible.io", + timestamp: ~N[2021-01-01 00:01:00] + ) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "metrics" => "visitors,bounce_rate", + "property" => "visit:browser", + "filters" => "event:page == /plausible.io|/important-page" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{ + "browser" => "Chrome", + "bounce_rate" => 50, + "visitors" => 3 + } + ] + } + end + + test "event:goal pageview filter for breakdown by visit source", %{conn: conn, site: site} do + insert(:goal, %{site: site, page_path: "/plausible.io"}) + + populate_stats(site, [ + build(:pageview, + referrer_source: "Bing", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + referrer_source: "Google", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/plausible.io", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "property" => "visit:source", + "filters" => "event:goal == Visit /plausible.io" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{"source" => "Google", "visitors" => 1} + ] + } + end + + test "event:goal custom event filter for breakdown by visit source", %{conn: conn, site: site} do + insert(:goal, %{site: site, event_name: "Register"}) + + populate_stats(site, [ + build(:pageview, + referrer_source: "Bing", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + referrer_source: "Google", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Register", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "property" => "visit:source", + "filters" => "event:goal == Register" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{"source" => "Google", "visitors" => 1} + ] + } + end + + test "wildcard pageview goal filter for breakdown by event:page", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/en/register"), + build(:pageview, pathname: "/en/register", user_id: @user_id), + build(:pageview, pathname: "/en/register", user_id: @user_id), + build(:pageview, pathname: "/123/it/register"), + build(:pageview, pathname: "/should-not-appear") + ]) + + insert(:goal, %{site: site, page_path: "/**register"}) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "metrics" => "visitors,pageviews", + "property" => "event:page", + "filters" => "event:goal==Visit /**register" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{"page" => "/en/register", "visitors" => 2, "pageviews" => 3}, + %{"page" => "/123/it/register", "visitors" => 1, "pageviews" => 1} + ] + } + end + + test "mixed multi-goal filter for breakdown by visit:country", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, country_code: "EE", pathname: "/en/register"), + build(:event, country_code: "EE", name: "Signup", pathname: "/en/register"), + build(:pageview, country_code: "US", pathname: "/123/it/register"), + build(:pageview, country_code: "US", pathname: "/different") + ]) + + insert(:goal, %{site: site, page_path: "/**register"}) + insert(:goal, %{site: site, event_name: "Signup"}) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "metrics" => "visitors,pageviews,events", + "property" => "visit:country", + "filters" => "event:goal==Signup|Visit /**register" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{"country" => "EE", "visitors" => 2, "pageviews" => 1, "events" => 2}, + %{"country" => "US", "visitors" => 1, "pageviews" => 1, "events" => 1} + ] + } + end + + test "event:goal custom event filter for breakdown by event page", %{conn: conn, site: site} do + insert(:goal, %{site: site, event_name: "Register"}) + + populate_stats(site, [ + build(:event, + pathname: "/en/register", + name: "Register" + ), + build(:event, + pathname: "/en/register", + name: "Register" + ), + build(:event, + pathname: "/it/register", + name: "Register" + ) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "property" => "event:page", + "filters" => "event:goal == Register" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{"page" => "/en/register", "visitors" => 2}, + %{"page" => "/it/register", "visitors" => 1} + ] + } + end + + test "IN filter for event:page", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/ignore", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/plausible.io", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/plausible.io", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/important-page", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "property" => "event:page", + "filters" => "event:page == /plausible.io|/important-page" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{"page" => "/plausible.io", "visitors" => 2}, + %{"page" => "/important-page", "visitors" => 1} + ] + } + end + + test "IN filter for visit:browser", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/ignore", + browser: "Firefox", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/plausible.io", + browser: "Chrome", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/plausible.io", + browser: "Safari", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/important-page", + browser: "Safari", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "property" => "event:page", + "filters" => "visit:browser == Chrome|Safari" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{"page" => "/plausible.io", "visitors" => 2}, + %{"page" => "/important-page", "visitors" => 1} + ] + } + end + + test "IN filter for visit:entry_page", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/ignore", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/plausible.io", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/plausible.io", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/important-page", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "property" => "event:page", + "filters" => "event:page == /plausible.io|/important-page", + "metrics" => "bounce_rate" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{"page" => "/important-page", "bounce_rate" => 100}, + %{"page" => "/plausible.io", "bounce_rate" => 100} + ] + } + end + + test "IN filter for event:name", %{conn: conn, site: site} do + populate_stats(site, [ + build(:event, + name: "Signup", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Signup", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Login", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Irrelevant", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "property" => "event:name", + "filters" => "event:name == Signup|Login" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{"name" => "Signup", "visitors" => 2}, + %{"name" => "Login", "visitors" => 1} + ] + } + end + + test "IN filter for event:props:*", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + browser: "Chrome", + "meta.key": ["browser"], + "meta.value": ["Chrome"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + browser: "Chrome", + "meta.key": ["browser"], + "meta.value": ["Chrome"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + browser: "Safari", + "meta.key": ["browser"], + "meta.value": ["Safari"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + browser: "Firefox", + "meta.key": ["browser"], + "meta.value": ["Firefox"], + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "property" => "visit:browser", + "filters" => "event:props:browser == Chrome|Safari" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{"browser" => "Chrome", "visitors" => 2}, + %{"browser" => "Safari", "visitors" => 1} + ] + } + end + + test "Multiple event:props:* filters", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + browser: "Chrome", + "meta.key": ["browser"], + "meta.value": ["Chrome"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + browser: "Chrome", + "meta.key": ["browser", "prop"], + "meta.value": ["Chrome", "xyz"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + browser: "Safari", + "meta.key": ["browser", "prop"], + "meta.value": ["Safari", "target_value"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + browser: "Firefox", + "meta.key": ["browser", "prop"], + "meta.value": ["Firefox", "target_value"], + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "property" => "visit:browser", + "filters" => "event:props:browser == Chrome|Safari;event:props:prop == target_value" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{"browser" => "Safari", "visitors" => 1} + ] + } + end + + test "IN filter for event:props:* including (none) value", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + browser: "Chrome", + "meta.key": ["browser"], + "meta.value": ["Chrome"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + browser: "Chrome", + "meta.key": ["browser"], + "meta.value": ["Chrome"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + browser: "Safari", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + browser: "Firefox", + "meta.key": ["browser"], + "meta.value": ["Firefox"], + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "property" => "visit:browser", + "filters" => "event:props:browser == Chrome|(none)" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{"browser" => "Chrome", "visitors" => 2}, + %{"browser" => "Safari", "visitors" => 1} + ] + } + end + + test "can use a is_not filter", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, browser: "Chrome"), + build(:pageview, browser: "Safari"), + build(:pageview, browser: "Safari"), + build(:pageview, browser: "Edge") + ]) + + conn = + get(conn, "/api/v1/stats/breakdown?period=day", %{ + "site_id" => site.domain, + "filters" => "visit:browser != Chrome", + "property" => "visit:browser" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{"browser" => "Safari", "visitors" => 2}, + %{"browser" => "Edge", "visitors" => 1} + ] + } + end + end + + describe "pagination" do + test "can limit results", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/a", timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, pathname: "/b", timestamp: ~N[2021-01-01 00:25:00]), + build(:pageview, pathname: "/c", timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "property" => "event:page", + "limit" => 2 + }) + + res = json_response(conn, 200) + assert Enum.count(res["results"]) == 2 + end + + test "does not repeat results", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, %{"meta.key": ["item"], "meta.value": ["apple"]}), + build(:pageview, %{"meta.key": ["item"], "meta.value": ["kiwi"]}), + build(:pageview, %{"meta.key": ["item"], "meta.value": ["pineapple"]}), + build(:pageview, %{"meta.key": ["item"], "meta.value": ["grapes"]}) + ]) + + params = %{ + "site_id" => site.domain, + "metrics" => "visitors", + "property" => "event:props:item", + "limit" => 3, + "page" => nil + } + + first_page = + conn + |> get("/api/v1/stats/breakdown", %{params | "page" => 1}) + |> json_response(200) + |> Map.get("results") + |> Enum.map(& &1["item"]) + |> MapSet.new() + + second_page = + conn + |> get("/api/v1/stats/breakdown", %{params | "page" => 2}) + |> json_response(200) + |> Map.get("results") + |> Enum.map(& &1["item"]) + |> MapSet.new() + + assert first_page |> MapSet.intersection(second_page) |> Enum.empty?() + end + + @invalid_limit_message "Please provide limit as a number between 1 and 1000." + + test "returns error when limit too large", %{conn: conn, site: site} do + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "property" => "event:page", + "limit" => 1001 + }) + + assert json_response(conn, 400) == %{"error" => @invalid_limit_message} + end + + test "returns error with non-integer limit", %{conn: conn, site: site} do + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "property" => "event:page", + "limit" => "bad_limit" + }) + + assert json_response(conn, 400) == %{"error" => @invalid_limit_message} + end + + test "returns error with negative integer limit", %{conn: conn, site: site} do + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "property" => "event:page", + "limit" => -1 + }) + + assert json_response(conn, 400) == %{"error" => @invalid_limit_message} + end + + test "can paginate results", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/a", timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, pathname: "/b", timestamp: ~N[2021-01-01 00:25:00]), + build(:pageview, pathname: "/c", timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "property" => "event:page", + "limit" => 2, + "page" => 2 + }) + + res = json_response(conn, 200) + assert Enum.count(res["results"]) == 1 + end + end + + describe "metrics" do + test "returns time_on_page with imported data", %{conn: conn, site: site} do + site_import = + insert(:site_import, + site: site, + start_date: ~D[2005-01-01], + end_date: Date.utc_today(), + source: :universal_analytics + ) + + populate_stats(site, site_import.id, [ + build(:imported_pages, page: "/A", total_time_on_page: 40, date: ~D[2021-01-01]), + build(:imported_pages, page: "/A", total_time_on_page: 110, date: ~D[2021-01-01]), + build(:imported_pages, page: "/B", total_time_on_page: 499, date: ~D[2021-01-01]), + build(:pageview, pathname: "/A", user_id: 4, timestamp: ~N[2021-01-01 00:00:00]), + build(:engagement, + pathname: "/A", + user_id: 4, + timestamp: ~N[2021-01-01 00:01:00], + engagement_time: 60_000 + ), + build(:pageview, pathname: "/B", user_id: 4, timestamp: ~N[2021-01-01 00:01:00]), + build(:engagement, + pathname: "/B", + user_id: 4, + timestamp: ~N[2021-01-01 00:01:10], + engagement_time: 5_000 + ) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "property" => "event:page", + "metrics" => "visitors,time_on_page", + "with_imported" => "true" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{ + "page" => "/A", + "visitors" => 3, + "time_on_page" => 70 + }, + %{ + "page" => "/B", + "visitors" => 2, + "time_on_page" => 252 + } + ] + } + end + + test "returns time_on_page in an event:page breakdown", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/A", timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, pathname: "/A", timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, pathname: "/B", timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, pathname: "/A", user_id: 4, timestamp: ~N[2021-01-01 00:00:00]), + build(:engagement, + pathname: "/A", + user_id: 4, + timestamp: ~N[2021-01-01 00:01:00], + engagement_time: 60_000 + ), + build(:pageview, pathname: "/B", user_id: 4, timestamp: ~N[2021-01-01 00:01:00]), + build(:engagement, + pathname: "/B", + user_id: 4, + timestamp: ~N[2021-01-01 00:02:30], + engagement_time: 90_000 + ), + build(:pageview, pathname: "/C", user_id: 4, timestamp: ~N[2021-01-01 00:02:30]), + build(:engagement, + pathname: "/C", + user_id: 4, + timestamp: ~N[2021-01-01 00:02:30], + engagement_time: 3_000 + ) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "property" => "event:page", + "metrics" => "visitors,time_on_page" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{ + "page" => "/A", + "visitors" => 3, + "time_on_page" => 60 + }, + %{ + "page" => "/B", + "visitors" => 2, + "time_on_page" => 90 + }, + %{ + "page" => "/C", + "visitors" => 1, + "time_on_page" => 3 + } + ] + } + end + + test "returns time_on_page as the only metric in an event:page breakdown", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, pathname: "/A", timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, pathname: "/A", timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, pathname: "/B", timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, pathname: "/A", user_id: 4, timestamp: ~N[2021-01-01 00:00:00]), + build(:engagement, + pathname: "/A", + user_id: 4, + timestamp: ~N[2021-01-01 00:01:00], + engagement_time: 60_000 + ), + build(:pageview, pathname: "/B", user_id: 4, timestamp: ~N[2021-01-01 00:01:00]), + build(:engagement, + pathname: "/B", + user_id: 4, + timestamp: ~N[2021-01-01 00:02:30], + engagement_time: 90_000 + ), + build(:pageview, pathname: "/C", user_id: 4, timestamp: ~N[2021-01-01 00:02:30]), + build(:engagement, + pathname: "/C", + user_id: 4, + timestamp: ~N[2021-01-01 00:02:30], + engagement_time: 3_000 + ) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "property" => "event:page", + "metrics" => "time_on_page" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{ + "page" => "/A", + "time_on_page" => 60 + }, + %{ + "page" => "/B", + "time_on_page" => 90 + }, + %{ + "page" => "/C", + "time_on_page" => 3 + } + ] + } + end + + test "returns conversion_rate in an event:goal breakdown", %{conn: conn, site: site} do + populate_stats(site, [ + build(:event, name: "Signup", user_id: 1), + build(:event, name: "Signup", user_id: 1), + build(:pageview, pathname: "/blog"), + build(:pageview, pathname: "/blog/post"), + build(:pageview) + ]) + + insert(:goal, %{site: site, event_name: "Signup"}) + insert(:goal, %{site: site, page_path: "/blog**"}) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "property" => "event:goal", + "metrics" => "visitors,events,conversion_rate" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{ + "goal" => "Visit /blog**", + "visitors" => 2, + "events" => 2, + "conversion_rate" => 50 + }, + %{ + "goal" => "Signup", + "visitors" => 1, + "events" => 2, + "conversion_rate" => 25 + } + ] + } + end + + test "returns conversion_rate alone in an event:goal breakdown", %{conn: conn, site: site} do + populate_stats(site, [ + build(:event, name: "Signup", user_id: 1), + build(:pageview) + ]) + + insert(:goal, %{site: site, event_name: "Signup"}) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "property" => "event:goal", + "metrics" => "conversion_rate" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{ + "goal" => "Signup", + "conversion_rate" => 50 + } + ] + } + end + + test "returns conversion_rate in a goal filtered custom prop breakdown", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, pathname: "/blog/1", "meta.key": ["author"], "meta.value": ["Uku"]), + build(:pageview, pathname: "/blog/2", "meta.key": ["author"], "meta.value": ["Uku"]), + build(:pageview, pathname: "/blog/3", "meta.key": ["author"], "meta.value": ["Uku"]), + build(:pageview, pathname: "/blog/1", "meta.key": ["author"], "meta.value": ["Marko"]), + build(:pageview, + pathname: "/blog/2", + "meta.key": ["author"], + "meta.value": ["Marko"], + user_id: 1 + ), + build(:pageview, + pathname: "/blog/3", + "meta.key": ["author"], + "meta.value": ["Marko"], + user_id: 1 + ), + build(:pageview, pathname: "/blog"), + build(:pageview, "meta.key": ["author"], "meta.value": ["Marko"]), + build(:pageview) + ]) + + insert(:goal, %{site: site, page_path: "/blog**"}) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "property" => "event:props:author", + "filters" => "event:goal==Visit /blog**", + "metrics" => "visitors,events,conversion_rate" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{ + "author" => "Uku", + "visitors" => 3, + "events" => 3, + "conversion_rate" => 37.5 + }, + %{ + "author" => "Marko", + "visitors" => 2, + "events" => 3, + "conversion_rate" => 25 + }, + %{ + "author" => "(none)", + "visitors" => 1, + "events" => 1, + "conversion_rate" => 12.5 + } + ] + } + end + + test "returns conversion_rate alone in a goal filtered custom prop breakdown", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, pathname: "/blog/1", "meta.key": ["author"], "meta.value": ["Uku"]), + build(:pageview) + ]) + + insert(:goal, %{site: site, page_path: "/blog**"}) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "property" => "event:props:author", + "filters" => "event:goal==Visit /blog**", + "metrics" => "conversion_rate" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{ + "author" => "Uku", + "conversion_rate" => 50 + } + ] + } + end + + test "returns conversion_rate in a goal filtered event:page breakdown", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:event, pathname: "/en/register", name: "pageview"), + build(:event, pathname: "/en/register", name: "Signup"), + build(:event, pathname: "/en/register", name: "Signup"), + build(:event, pathname: "/it/register", name: "Signup", user_id: 1), + build(:event, pathname: "/it/register", name: "Signup", user_id: 1), + build(:event, pathname: "/it/register", name: "pageview") + ]) + + insert(:goal, %{site: site, event_name: "Signup"}) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "property" => "event:page", + "filters" => "event:goal==Signup", + "metrics" => "visitors,events,conversion_rate" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{ + "page" => "/en/register", + "visitors" => 2, + "events" => 2, + "conversion_rate" => 66.67 + }, + %{ + "page" => "/it/register", + "visitors" => 1, + "events" => 2, + "conversion_rate" => 50 + } + ] + } + end + + test "returns conversion_rate alone in a goal filtered event:page breakdown", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:event, pathname: "/en/register", name: "pageview"), + build(:event, pathname: "/en/register", name: "Signup") + ]) + + insert(:goal, %{site: site, event_name: "Signup"}) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "property" => "event:page", + "filters" => "event:goal==Signup", + "metrics" => "conversion_rate" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{ + "page" => "/en/register", + "conversion_rate" => 50 + } + ] + } + end + + test "returns conversion_rate in a multi-goal filtered visit:screen_size breakdown", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:event, screen_size: "Mobile", name: "pageview"), + build(:event, screen_size: "Mobile", name: "AddToCart"), + build(:event, screen_size: "Mobile", name: "AddToCart"), + build(:event, screen_size: "Desktop", name: "AddToCart", user_id: 1), + build(:event, screen_size: "Desktop", name: "Purchase", user_id: 1), + build(:event, screen_size: "Desktop", name: "pageview") + ]) + + # Make sure that revenue goals are treated the same + # way as regular custom event goals + insert(:goal, %{site: site, event_name: "Purchase", currency: :EUR}) + insert(:goal, %{site: site, event_name: "AddToCart"}) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "property" => "visit:device", + "filters" => "event:goal==AddToCart|Purchase", + "metrics" => "visitors,events,conversion_rate" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{ + "device" => "Mobile", + "visitors" => 2, + "events" => 2, + "conversion_rate" => 66.67 + }, + %{ + "device" => "Desktop", + "visitors" => 1, + "events" => 2, + "conversion_rate" => 50 + } + ] + } + end + + test "returns conversion_rate alone in a goal filtered visit:screen_size breakdown", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:event, screen_size: "Mobile", name: "pageview"), + build(:event, screen_size: "Mobile", name: "AddToCart") + ]) + + insert(:goal, %{site: site, event_name: "AddToCart"}) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "property" => "visit:device", + "filters" => "event:goal==AddToCart", + "metrics" => "conversion_rate" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{ + "device" => "Mobile", + "conversion_rate" => 50 + } + ] + } + end + + test "returns conversion_rate for a browser_version breakdown with pagination limit", %{ + site: site, + conn: conn + } do + populate_stats(site, [ + build(:pageview, browser: "Firefox", browser_version: "110"), + build(:pageview, browser: "Firefox", browser_version: "110"), + build(:pageview, browser: "Chrome", browser_version: "110"), + build(:pageview, browser: "Chrome", browser_version: "110"), + build(:pageview, browser: "Avast Secure Browser", browser_version: "110"), + build(:pageview, browser: "Avast Secure Browser", browser_version: "110"), + build(:event, name: "Signup", browser: "Edge", browser_version: "110") + ]) + + insert(:goal, site: site, event_name: "Signup") + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "property" => "visit:browser_version", + "filters" => "event:goal==Signup", + "metrics" => "visitors,conversion_rate", + "page" => 1, + "limit" => 1 + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{ + "browser" => "Edge", + "browser_version" => "110", + "visitors" => 1, + "conversion_rate" => 100.0 + } + ] + } + end + + test "all metrics for breakdown by visit prop", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + user_id: 1, + referrer_source: "Google", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "signup", + user_id: 1, + referrer_source: "Google", + timestamp: ~N[2021-01-01 00:05:00] + ), + build(:pageview, + user_id: 1, + referrer_source: "Google", + timestamp: ~N[2021-01-01 00:10:00] + ), + build(:pageview, + referrer_source: "Google", + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, + referrer_source: "Twitter", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "property" => "visit:source", + "metrics" => "visitors,visits,pageviews,events,bounce_rate,visit_duration" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{ + "source" => "Google", + "visitors" => 2, + "visits" => 2, + "bounce_rate" => 50, + "visit_duration" => 300, + "pageviews" => 3, + "events" => 4 + }, + %{ + "source" => "Twitter", + "visitors" => 1, + "visits" => 1, + "bounce_rate" => 100, + "visit_duration" => 0, + "pageviews" => 1, + "events" => 1 + } + ] + } + end + + test "metrics=bounce_rate does not add visits to the response", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + user_id: 1, + pathname: "/entry-page-1", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: 1, + pathname: "/some-page", + timestamp: ~N[2021-01-01 00:10:00] + ), + build(:pageview, + user_id: 2, + pathname: "/entry-page-2", + referrer_source: "Google", + timestamp: ~N[2021-01-01 00:05:00] + ) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "property" => "visit:entry_page", + "metrics" => "bounce_rate" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{ + "entry_page" => "/entry-page-2", + "bounce_rate" => 100 + }, + %{ + "entry_page" => "/entry-page-1", + "bounce_rate" => 0 + } + ] + } + end + + test "filter by custom event property", %{conn: conn, site: site} do + populate_stats(site, [ + build(:event, + name: "Purchase", + "meta.key": ["package"], + "meta.value": ["business"], + browser: "Chrome", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Purchase", + "meta.key": ["package"], + "meta.value": ["business"], + browser: "Safari", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Purchase", + "meta.key": ["package"], + "meta.value": ["business"], + browser: "Safari", + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:event, + name: "Purchase", + "meta.key": ["package"], + "meta.value": ["personal"], + browser: "IE", + timestamp: ~N[2021-01-01 00:25:00] + ) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "property" => "visit:browser", + "filters" => "event:name==Purchase;event:props:package==business" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{"browser" => "Safari", "visitors" => 2}, + %{"browser" => "Chrome", "visitors" => 1} + ] + } + end + + test "all metrics for breakdown by event prop", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + user_id: 1, + pathname: "/", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: 1, + pathname: "/plausible.io", + timestamp: ~N[2021-01-01 00:10:00] + ), + build(:pageview, pathname: "/", timestamp: ~N[2021-01-01 00:25:00]), + build(:pageview, + pathname: "/plausible.io", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "property" => "event:page", + "metrics" => "visitors,pageviews,events,bounce_rate,visit_duration" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{ + "page" => "/", + "visitors" => 2, + "bounce_rate" => 50, + "visit_duration" => 300, + "pageviews" => 2, + "events" => 2 + }, + %{ + "page" => "/plausible.io", + # Breaks for event:page breakdown since visitors is calculated based on entry_page :/ + "visitors" => 2, + "bounce_rate" => 100, + "visit_duration" => 0, + "pageviews" => 2, + "events" => 2 + } + ] + } + end + end + + describe "imported data" do + test "returns screen sizes breakdown when filtering by screen size", %{conn: conn, site: site} do + site_import = insert(:site_import, site: site) + + populate_stats(site, site_import.id, [ + build(:pageview, + timestamp: ~N[2021-01-01 00:00:01], + screen_size: "Mobile" + ), + build(:imported_devices, + device: "Mobile", + visitors: 3, + pageviews: 5, + date: ~D[2021-01-01] + ) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "property" => "visit:device", + "filters" => "visit:device==Mobile", + "metrics" => "visitors,pageviews", + "with_imported" => "true" + }) + + assert [%{"pageviews" => 6, "visitors" => 4, "device" => "Mobile"}] = + json_response(conn, 200)["results"] + end + + test "returns custom event goals and pageview goals", %{conn: conn, site: site} do + insert(:goal, site: site, event_name: "Purchase") + insert(:goal, site: site, page_path: "/test") + + site_import = insert(:site_import, site: site) + + populate_stats(site, site_import.id, [ + build(:pageview, + timestamp: ~N[2021-01-01 00:00:01], + pathname: "/test" + ), + build(:event, + name: "Purchase", + timestamp: ~N[2021-01-01 00:00:03] + ), + build(:event, + name: "Purchase", + timestamp: ~N[2021-01-01 00:00:03] + ), + build(:imported_custom_events, + name: "Purchase", + visitors: 3, + events: 5, + date: ~D[2021-01-01] + ), + build(:imported_pages, + page: "/test", + visitors: 2, + pageviews: 2, + date: ~D[2021-01-01] + ), + build(:imported_visitors, visitors: 5, date: ~D[2021-01-01]) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "property" => "event:goal", + "metrics" => "visitors,events,pageviews,conversion_rate", + "with_imported" => "true" + }) + + assert [ + %{ + "goal" => "Purchase", + "visitors" => 5, + "events" => 7, + "pageviews" => 0, + "conversion_rate" => 62.5 + }, + %{ + "goal" => "Visit /test", + "visitors" => 3, + "events" => 3, + "pageviews" => 3, + "conversion_rate" => 37.5 + } + ] = json_response(conn, 200)["results"] + end + + test "pageviews are returned as events for breakdown reports other than custom events", %{ + conn: conn, + site: site + } do + site_import = insert(:site_import, site: site) + + populate_stats(site, site_import.id, [ + build(:imported_browsers, browser: "Chrome", pageviews: 1, date: ~D[2021-01-01]), + build(:imported_devices, device: "Desktop", pageviews: 1, date: ~D[2021-01-01]), + build(:imported_entry_pages, entry_page: "/test", pageviews: 1, date: ~D[2021-01-01]), + build(:imported_exit_pages, exit_page: "/test", pageviews: 1, date: ~D[2021-01-01]), + build(:imported_locations, country: "EE", pageviews: 1, date: ~D[2021-01-01]), + build(:imported_operating_systems, + operating_system: "Mac", + pageviews: 1, + date: ~D[2021-01-01] + ), + build(:imported_pages, page: "/test", pageviews: 1, date: ~D[2021-01-01]), + build(:imported_sources, source: "Google", pageviews: 1, date: ~D[2021-01-01]) + ]) + + params = %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "metrics" => "events", + "with_imported" => "true" + } + + breakdown_and_first = fn property -> + conn + |> get("/api/v1/stats/breakdown", Map.put(params, "property", property)) + |> json_response(200) + |> Map.get("results") + |> List.first() + end + + assert %{"browser" => "Chrome", "events" => 1} = breakdown_and_first.("visit:browser") + assert %{"device" => "Desktop", "events" => 1} = breakdown_and_first.("visit:device") + assert %{"country" => "EE", "events" => 1} = breakdown_and_first.("visit:country") + assert %{"os" => "Mac", "events" => 1} = breakdown_and_first.("visit:os") + assert %{"page" => "/test", "events" => 1} = breakdown_and_first.("event:page") + assert %{"source" => "Google", "events" => 1} = breakdown_and_first.("visit:source") + end + + for event_name <- Plausible.Event.SystemEvents.events_with_url_prop() do + test "returns url breakdown for #{event_name} goal", %{conn: conn, site: site} do + insert(:goal, event_name: unquote(event_name), site: site) + site_import = insert(:site_import, site: site) + + populate_stats(site, site_import.id, [ + build(:event, + name: unquote(event_name), + "meta.key": ["url"], + "meta.value": ["https://one.com"] + ), + build(:imported_custom_events, + name: unquote(event_name), + visitors: 2, + events: 5, + link_url: "https://one.com" + ), + build(:imported_custom_events, + name: unquote(event_name), + visitors: 5, + events: 10, + link_url: "https://two.com" + ), + build(:imported_custom_events, + name: "some goal", + visitors: 5, + events: 10 + ), + build(:imported_visitors, visitors: 9) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "property" => "event:props:url", + "filters" => "event:goal==#{unquote(event_name)}", + "metrics" => "visitors,events,conversion_rate", + "with_imported" => "true" + }) + + assert json_response(conn, 200)["results"] == [ + %{ + "visitors" => 5, + "url" => "https://two.com", + "events" => 10, + "conversion_rate" => 50.0 + }, + %{ + "visitors" => 3, + "url" => "https://one.com", + "events" => 6, + "conversion_rate" => 30.0 + } + ] + + refute json_response(conn, 200)["warning"] + end + end + + for event_name <- Plausible.Event.SystemEvents.events_with_path_prop() do + test "returns path breakdown for #{event_name} goal", %{conn: conn, site: site} do + insert(:goal, event_name: unquote(event_name), site: site) + site_import = insert(:site_import, site: site) + + populate_stats(site, site_import.id, [ + build(:event, + name: unquote(event_name), + "meta.key": ["path"], + "meta.value": ["/one"] + ), + build(:imported_custom_events, + name: unquote(event_name), + visitors: 2, + events: 5, + path: "/one" + ), + build(:imported_custom_events, + name: unquote(event_name), + visitors: 5, + events: 10, + path: "/two" + ), + build(:imported_custom_events, + name: "some goal", + visitors: 5, + events: 10 + ), + build(:imported_visitors, visitors: 9) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "property" => "event:props:path", + "filters" => "event:goal==#{unquote(event_name)}", + "metrics" => "visitors,events,conversion_rate", + "with_imported" => "true" + }) + + assert json_response(conn, 200)["results"] == [ + %{ + "visitors" => 5, + "path" => "/two", + "events" => 10, + "conversion_rate" => 50.0 + }, + %{ + "visitors" => 3, + "path" => "/one", + "events" => 6, + "conversion_rate" => 30.0 + } + ] + + refute json_response(conn, 200)["warning"] + end + end + + test "adds a warning when query params are not supported for imported data", %{ + conn: conn, + site: site + } do + site_import = insert(:site_import, site: site) + + insert(:goal, event_name: "Signup", site: site) + + populate_stats(site, site_import.id, [ + build(:event, + name: "Signup", + "meta.key": ["package"], + "meta.value": ["large"] + ), + build(:imported_visitors, visitors: 9) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "property" => "event:props:package", + "filters" => "event:goal==Signup", + "metrics" => "visitors", + "with_imported" => "true" + }) + + assert json_response(conn, 200)["results"] == [ + %{ + "package" => "large", + "visitors" => 1 + } + ] + + assert json_response(conn, 200)["warning"] =~ + "Imported stats are not included in the results because query parameters are not supported." + end + + test "does not add a warning when there are no site imports", %{conn: conn, site: site} do + insert(:goal, event_name: "Signup", site: site) + + populate_stats(site, [ + build(:event, + name: "Signup", + "meta.key": ["package"], + "meta.value": ["large"] + ) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "property" => "event:props:package", + "filters" => "event:goal==Signup", + "metrics" => "visitors", + "with_imported" => "true" + }) + + refute json_response(conn, 200)["warning"] + end + + test "does not add a warning when import is out of queried date range", %{ + conn: conn, + site: site + } do + site_import = insert(:site_import, site: site, end_date: Date.add(Date.utc_today(), -3)) + + insert(:goal, event_name: "Signup", site: site) + + populate_stats(site, site_import.id, [ + build(:event, + name: "Signup", + "meta.key": ["package"], + "meta.value": ["large"] + ), + build(:imported_visitors, visitors: 9) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "property" => "event:props:package", + "filters" => "event:goal==Signup", + "metrics" => "visitors", + "with_imported" => "true" + }) + + refute json_response(conn, 200)["warning"] + end + + test "applies multiple filters if the properties belong to the same table", %{ + conn: conn, + site: site + } do + site_import = insert(:site_import, site: site) + + populate_stats(site, site_import.id, [ + build(:imported_sources, source: "Google", utm_medium: "organic", utm_term: "one"), + build(:imported_sources, source: "Twitter", utm_medium: "organic", utm_term: "two"), + build(:imported_sources, + source: "Facebook", + utm_medium: "something_else", + utm_term: "one" + ) + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "property" => "visit:source", + "filters" => "visit:utm_medium==organic;visit:utm_term==one", + "with_imported" => "true" + }) + + assert json_response(conn, 200) == %{ + "results" => [%{"source" => "Google", "visitors" => 1}] + } + end + + test "ignores imported data if filtered property belongs to a different table than the breakdown property", + %{ + conn: conn, + site: site + } do + site_import = insert(:site_import, site: site) + + populate_stats(site, site_import.id, [ + build(:imported_sources, source: "Google"), + build(:imported_devices, device: "Desktop") + ]) + + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "period" => "day", + "property" => "visit:source", + "filters" => "visit:device==Desktop", + "with_imported" => "true" + }) + + assert %{ + "results" => [], + "warning" => warning + } = json_response(conn, 200) + + assert warning =~ "Imported stats are not included in the results" + end + end +end diff --git a/test/plausible_web/controllers/api/external_stats_controller/debug_metadata_test.exs b/test/plausible_web/controllers/api/external_stats_controller/debug_metadata_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..eb76ad08586f59d12430905cbe2d1b54e86e1cf2 --- /dev/null +++ b/test/plausible_web/controllers/api/external_stats_controller/debug_metadata_test.exs @@ -0,0 +1,53 @@ +defmodule PlausibleWeb.Api.ExternalController.DebugMetadataTest do + alias Plausible.{IngestRepo, ClickhouseRepo} + use PlausibleWeb.ConnCase + + describe "Debug metadata" do + setup [:create_user, :create_api_key, :use_api_key] + + test "is saved correctly", %{conn: conn, user: user} do + domain = :rand.bytes(20) |> Base.url_encode64() + site = new_site(domain: domain, owner: user) + + query = %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors"] + } + + conn = + post(conn, "/api/v2/query", query) + + assert json_response(conn, 200) + + IngestRepo.query!("SYSTEM FLUSH LOGS") + + %{rows: [r1, r2]} = + ClickhouseRepo.query!( + "FROM system.query_log SELECT log_comment WHERE JSONExtractString(log_comment, 'site_domain') = {$0:String}", + [site.domain] + ) + + for [unparsed_log_comment] <- [r1, r2] do + decoded = Jason.decode!(unparsed_log_comment) + + assert_matches ^strict_map(%{ + # params are asserted below + "params" => %{}, + "phoenix_action" => "query", + "phoenix_controller" => + "Elixir.PlausibleWeb.Api.ExternalQueryApiController", + "request_method" => "POST", + "request_path" => "/api/v2/query", + "site_domain" => ^site.domain, + "site_id" => ^site.id, + "team_id" => ^team_of(user).id, + "trace_id" => _, + "user_id" => ^user.id + }) = decoded + + assert decoded["params"] == query + end + end + end +end diff --git a/test/plausible_web/controllers/api/external_stats_controller/query_consolidated_view_test.exs b/test/plausible_web/controllers/api/external_stats_controller/query_consolidated_view_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..a648af10a220ed08e5b896595fff5f555eeaebbd --- /dev/null +++ b/test/plausible_web/controllers/api/external_stats_controller/query_consolidated_view_test.exs @@ -0,0 +1,28 @@ +defmodule PlausibleWeb.Api.ExternalStatsController.QueryConsolidatedViewTest do + use PlausibleWeb.ConnCase + + on_ee do + setup [:create_user, :create_team, :create_site, :create_api_key, :use_api_key] + + test "simple aggregate query across all consolidated site_ids", %{ + team: team, + site: site, + conn: conn + } do + another_site = new_site(team: team) + cv = new_consolidated_view(team) + + populate_stats(site, [build(:pageview)]) + populate_stats(another_site, [build(:pageview)]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => cv.domain, + "date_range" => "all", + "metrics" => ["visitors"] + }) + + assert %{"results" => [%{"metrics" => [2]}]} = json_response(conn, 200) + end + end +end diff --git a/test/plausible_web/controllers/api/external_stats_controller/query_goal_custom_props_equivalence_test.exs b/test/plausible_web/controllers/api/external_stats_controller/query_goal_custom_props_equivalence_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..953815b7500fa4268f38282b7d1a58b44a3dad4f --- /dev/null +++ b/test/plausible_web/controllers/api/external_stats_controller/query_goal_custom_props_equivalence_test.exs @@ -0,0 +1,261 @@ +defmodule PlausibleWeb.Api.ExternalStatsController.QueryGoalCustomPropsEquivalenceTest do + @moduledoc """ + Tests that filtering by a goal with custom props produces the same results + as filtering by the goal without custom props AND a separate custom prop filter. + + For example, these two queries should return identical results: + 1. Filter by goal "Visit /" with custom_props: {"browser_language" => "FR"} + 2. Filter by goal "Visit /" (no custom props) AND filter by event:props:browser_language = "FR" + """ + use PlausibleWeb.ConnCase + + setup [:create_user, :create_site, :create_api_key, :use_api_key] + + alias Plausible.Goals + + describe "goal with custom props equivalence" do + test "page goal with custom props equals page goal + separate prop filter", %{ + conn: conn, + site: site + } do + {:ok, _goal_with_props} = + Goals.create(site, %{ + "page_path" => "/landing", + "custom_props" => %{"browser_language" => "FR"} + }) + + {:ok, _goal_without_props} = + Goals.create(site, %{ + "page_path" => "/landing", + "display_name" => "Visit /landing-no-props" + }) + + populate_stats(site, [ + # Session 1 (FR): Has pageview with browser_language=FR + build(:pageview, + user_id: 1, + pathname: "/landing", + country_code: "FR", + "meta.key": ["browser_language"], + "meta.value": ["FR"], + timestamp: ~N[2021-01-01 00:00:00] + ), + # Session 2 (US): Has pageview with browser_language=EN + build(:pageview, + user_id: 2, + pathname: "/landing", + country_code: "US", + "meta.key": ["browser_language"], + "meta.value": ["EN"], + timestamp: ~N[2021-01-01 00:00:00] + ), + # Session 3 (GB): Has pageview with no props + build(:pageview, + user_id: 3, + pathname: "/landing", + country_code: "GB", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + # Query 1: Page goal with custom props + conn1 = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors", "visits", "events"], + "dimensions" => ["visit:country"], + "filters" => [["is", "event:goal", ["Visit /landing"]]] + }) + + result1 = json_response(conn1, 200)["results"] + + # Query 2: Page goal without props + separate prop filter + conn2 = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors", "visits", "events"], + "dimensions" => ["visit:country"], + "filters" => [ + ["is", "event:goal", ["Visit /landing-no-props"]], + ["is", "event:props:browser_language", ["FR"]] + ] + }) + + result2 = json_response(conn2, 200)["results"] + + # Both should return only FR + assert result1 == [%{"dimensions" => ["FR"], "metrics" => [1, 1, 1]}] + assert result2 == [%{"dimensions" => ["FR"], "metrics" => [1, 1, 1]}] + assert result1 == result2 + end + + test "event goal with custom props equals event goal + separate prop filter", %{ + conn: conn, + site: site + } do + {:ok, _goal_with_props} = + Goals.create(site, %{ + "event_name" => "Signup", + "custom_props" => %{"plan" => "premium"} + }) + + {:ok, _goal_without_props} = + Goals.create(site, %{ + "display_name" => "SignupNoProps", + "event_name" => "Signup" + }) + + populate_stats(site, [ + # Session 1 (US): Has correct event with plan=premium + build(:pageview, + user_id: 1, + country_code: "US", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + user_id: 1, + name: "Signup", + "meta.key": ["plan"], + "meta.value": ["premium"], + timestamp: ~N[2021-01-01 00:00:01] + ), + # Session 2 (GB): Has event with plan=basic + build(:pageview, + user_id: 2, + country_code: "GB", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + user_id: 2, + name: "Signup", + "meta.key": ["plan"], + "meta.value": ["basic"], + timestamp: ~N[2021-01-01 00:00:01] + ), + # Session 3 (CA): Has event but no props at all + build(:pageview, + user_id: 3, + country_code: "CA", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + user_id: 3, + name: "Signup", + timestamp: ~N[2021-01-01 00:00:01] + ) + ]) + + # Query 1: Event goal with custom props + conn1 = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors", "visits", "events"], + "dimensions" => ["visit:country"], + "filters" => [["is", "event:goal", ["Signup"]]] + }) + + result1 = json_response(conn1, 200)["results"] + + # Query 2: Event goal without props + separate prop filter + conn2 = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors", "visits", "events"], + "dimensions" => ["visit:country"], + "filters" => [ + ["is", "event:goal", ["SignupNoProps"]], + ["is", "event:props:plan", ["premium"]] + ] + }) + + result2 = json_response(conn2, 200)["results"] + + # Both should return only US + assert result1 == [%{"dimensions" => ["US"], "metrics" => [1, 1, 1]}] + assert result2 == [%{"dimensions" => ["US"], "metrics" => [1, 1, 1]}] + assert result1 == result2 + end + + test "page goal with multiple custom props equals page goal + multiple separate prop filters", + %{conn: conn, site: site} do + {:ok, _goal_with_props} = + Goals.create(site, %{ + "page_path" => "/checkout", + "custom_props" => %{"currency" => "EUR", "country" => "DE"} + }) + + {:ok, _goal_without_props} = + Goals.create(site, %{ + "page_path" => "/checkout", + "display_name" => "Visit /checkout-no-props" + }) + + populate_stats(site, [ + # Session 1 (DE): Has pageview with both props matching + build(:pageview, + user_id: 1, + pathname: "/checkout", + country_code: "DE", + "meta.key": ["currency", "country"], + "meta.value": ["EUR", "DE"], + timestamp: ~N[2021-01-01 00:00:00] + ), + # Session 2 (FR): Has pageview with only currency matching + build(:pageview, + user_id: 2, + pathname: "/checkout", + country_code: "FR", + "meta.key": ["currency", "country"], + "meta.value": ["EUR", "FR"], + timestamp: ~N[2021-01-01 00:00:00] + ), + # Session 3 (US): Has pageview with neither matching + build(:pageview, + user_id: 3, + pathname: "/checkout", + country_code: "US", + "meta.key": ["currency", "country"], + "meta.value": ["USD", "US"], + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + # Query 1: Page goal with multiple custom props + conn1 = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors", "visits", "events"], + "dimensions" => ["visit:country"], + "filters" => [["is", "event:goal", ["Visit /checkout"]]] + }) + + result1 = json_response(conn1, 200)["results"] + + # Query 2: Page goal without props + multiple separate prop filters + conn2 = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors", "visits", "events"], + "dimensions" => ["visit:country"], + "filters" => [ + ["is", "event:goal", ["Visit /checkout-no-props"]], + ["is", "event:props:currency", ["EUR"]], + ["is", "event:props:country", ["DE"]] + ] + }) + + result2 = json_response(conn2, 200)["results"] + + # Both should return only DE (both props must match) + assert result1 == [%{"dimensions" => ["DE"], "metrics" => [1, 1, 1]}] + assert result2 == [%{"dimensions" => ["DE"], "metrics" => [1, 1, 1]}] + assert result1 == result2 + end + end +end diff --git a/test/plausible_web/controllers/api/external_stats_controller/query_goal_custom_props_test.exs b/test/plausible_web/controllers/api/external_stats_controller/query_goal_custom_props_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..360e00a642496e7732b906310de4ce44fb354189 --- /dev/null +++ b/test/plausible_web/controllers/api/external_stats_controller/query_goal_custom_props_test.exs @@ -0,0 +1,370 @@ +defmodule PlausibleWeb.Api.ExternalStatsController.QueryGoalCustomPropsTest do + use PlausibleWeb.ConnCase + + setup [:create_user, :create_site, :create_api_key, :use_api_key] + + alias Plausible.Goals + + describe "goals with custom property filters" do + test "filters custom event goals by custom properties", %{conn: conn, site: site} do + {:ok, _goal} = + Goals.create(site, %{ + "event_name" => "Purchase", + "custom_props" => %{"variant" => "A"} + }) + + populate_stats(site, [ + build(:event, + name: "Purchase", + "meta.key": ["variant"], + "meta.value": ["A"], + timestamp: ~N[2021-01-01 00:00:01] + ), + build(:event, + name: "Purchase", + "meta.key": ["variant"], + "meta.value": ["A"], + timestamp: ~N[2021-01-01 00:00:02] + ), + build(:event, + name: "Purchase", + "meta.key": ["variant"], + "meta.value": ["B"], + timestamp: ~N[2021-01-01 00:00:03] + ), + build(:event, + name: "Purchase", + timestamp: ~N[2021-01-01 00:00:04] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors", "events"], + "filters" => [["is", "event:goal", ["Purchase"]]] + }) + + resp = json_response(conn, 200) + + assert resp["results"] == [ + %{"dimensions" => [], "metrics" => [2, 2]} + ] + end + + test "filters with multiple custom properties (AND logic)", %{conn: conn, site: site} do + {:ok, _goal} = + Goals.create(site, %{ + "event_name" => "Purchase", + "custom_props" => %{"variant" => "A", "plan" => "premium"} + }) + + populate_stats(site, [ + build(:event, + name: "Purchase", + "meta.key": ["variant", "plan"], + "meta.value": ["A", "premium"] + ), + build(:event, + name: "Purchase", + "meta.key": ["variant", "plan"], + "meta.value": ["A", "free"], + user_id: 1 + ), + build(:event, + name: "Purchase", + "meta.key": ["variant", "plan"], + "meta.value": ["B", "premium"], + user_id: 2 + ), + build(:event, + name: "Purchase", + "meta.key": ["variant"], + "meta.value": ["A"], + user_id: 3 + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors", "events"], + "filters" => [["is", "event:goal", ["Purchase"]]] + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => [], "metrics" => [1, 1]} + ] + end + + test "goals without custom_props filter match all events", %{conn: conn, site: site} do + {:ok, _goal} = Goals.create(site, %{"event_name" => "Signup"}) + + populate_stats(site, [ + build(:event, + name: "Signup", + "meta.key": ["source"], + "meta.value": ["google"] + ), + build(:event, + name: "Signup", + "meta.key": ["source"], + "meta.value": ["twitter"] + ), + build(:event, name: "Signup") + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors", "events"], + "filters" => [["is", "event:goal", ["Signup"]]] + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => [], "metrics" => [3, 3]} + ] + end + + test "breakdown by event:goal with custom property filters", %{conn: conn, site: site} do + {:ok, _g1} = + Goals.create(site, %{ + "event_name" => "Purchase A", + "custom_props" => %{"variant" => "A"} + }) + + {:ok, _g2} = + Goals.create(site, %{ + "event_name" => "Purchase B", + "custom_props" => %{"variant" => "B"} + }) + + populate_stats(site, [ + build(:event, + name: "Purchase A", + "meta.key": ["variant"], + "meta.value": ["A"], + user_id: 1 + ), + build(:event, + name: "Purchase A", + "meta.key": ["variant"], + "meta.value": ["A"], + user_id: 1 + ), + build(:event, + name: "Purchase B", + "meta.key": ["variant"], + "meta.value": ["B"], + user_id: 1 + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors", "events"], + "dimensions" => ["event:goal"] + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => ["Purchase A"], "metrics" => [1, 2]}, + %{"dimensions" => ["Purchase B"], "metrics" => [1, 1]} + ] + end + + test "custom property filters work with conversion_rate metric", %{conn: conn, site: site} do + {:ok, _goal} = + Goals.create(site, %{ + "event_name" => "Signup", + "custom_props" => %{"method" => "email"} + }) + + populate_stats(site, [ + build(:pageview, user_id: 1), + build(:event, + name: "Signup", + "meta.key": ["method"], + "meta.value": ["email"], + user_id: 1 + ), + build(:pageview, user_id: 2), + build(:event, + name: "Signup", + "meta.key": ["method"], + "meta.value": ["google"], + user_id: 2 + ), + build(:pageview, user_id: 3) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors", "events", "conversion_rate"], + "filters" => [["is", "event:goal", ["Signup"]]] + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => [], "metrics" => [1, 1, 33.33]} + ] + end + + test "custom property filters work with multi-dimensional breakdown", %{ + conn: conn, + site: site + } do + {:ok, _goal} = + Goals.create(site, %{ + "event_name" => "Purchase", + "custom_props" => %{"variant" => "A"} + }) + + populate_stats(site, [ + build(:event, + name: "Purchase", + "meta.key": ["variant", "plan"], + "meta.value": ["A", "premium"] + ), + build(:event, + name: "Purchase", + "meta.key": ["variant", "plan"], + "meta.value": ["A", "free"], + user_id: 2 + ), + build(:event, + name: "Purchase", + "meta.key": ["variant", "plan"], + "meta.value": ["B", "premium"], + user_id: 3 + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors", "events"], + "dimensions" => ["event:goal", "event:props:plan"], + "filters" => [["is", "event:goal", ["Purchase"]]] + }) + + results = json_response(conn, 200)["results"] + assert length(results) == 2 + assert Enum.member?(results, %{"dimensions" => ["Purchase", "free"], "metrics" => [1, 1]}) + + assert Enum.member?(results, %{"dimensions" => ["Purchase", "premium"], "metrics" => [1, 1]}) + end + + test "custom property filters work with time series queries", %{conn: conn, site: site} do + {:ok, _goal} = + Goals.create(site, %{ + "event_name" => "Click", + "custom_props" => %{"button" => "cta"} + }) + + populate_stats(site, [ + build(:event, + name: "Click", + "meta.key": ["button"], + "meta.value": ["cta"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Click", + "meta.key": ["button"], + "meta.value": ["nav"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Click", + "meta.key": ["button"], + "meta.value": ["cta"], + timestamp: ~N[2021-01-02 00:00:00] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => ["2021-01-01", "2021-01-02"], + "metrics" => ["visitors"], + "dimensions" => ["time:day"], + "filters" => [["is", "event:goal", ["Click"]]] + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => ["2021-01-01"], "metrics" => [1]}, + %{"dimensions" => ["2021-01-02"], "metrics" => [1]} + ] + end + + test "different goals with same event name but different custom props are distinguished", %{ + conn: conn, + site: site + } do + {:ok, _g1} = + Goals.create(site, %{ + "event_name" => "Button Click", + "display_name" => "Red Button", + "custom_props" => %{"color" => "red"} + }) + + {:ok, _g2} = + Goals.create(site, %{ + "event_name" => "Button Click", + "display_name" => "Blue Button", + "custom_props" => %{"color" => "blue"} + }) + + populate_stats(site, [ + build(:event, + name: "Button Click", + "meta.key": ["color"], + "meta.value": ["red"], + user_id: 1 + ), + build(:event, + name: "Button Click", + "meta.key": ["color"], + "meta.value": ["red"], + user_id: 1 + ), + build(:event, + name: "Button Click", + "meta.key": ["color"], + "meta.value": ["blue"], + user_id: 2 + ), + build(:event, name: "Button Click", user_id: 3) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors", "events"], + "dimensions" => ["event:goal"] + }) + + results = json_response(conn, 200)["results"] + + assert Enum.find(results, &(&1["dimensions"] == ["Red Button"])) == %{ + "dimensions" => ["Red Button"], + "metrics" => [1, 2] + } + + assert Enum.find(results, &(&1["dimensions"] == ["Blue Button"])) == %{ + "dimensions" => ["Blue Button"], + "metrics" => [1, 1] + } + + refute Enum.find(results, &(&1["dimensions"] == ["Button Click"])) + end + end +end diff --git a/test/plausible_web/controllers/api/external_stats_controller/query_goal_custom_props_visit_dimensions_test.exs b/test/plausible_web/controllers/api/external_stats_controller/query_goal_custom_props_visit_dimensions_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..070d1bc124f9da98ee61e261e11a53fdc2f0c91a --- /dev/null +++ b/test/plausible_web/controllers/api/external_stats_controller/query_goal_custom_props_visit_dimensions_test.exs @@ -0,0 +1,411 @@ +defmodule PlausibleWeb.Api.ExternalStatsController.QueryGoalCustomPropsVisitDimensionsTest do + use PlausibleWeb.ConnCase + + setup [:create_user, :create_site, :create_api_key, :use_api_key] + + alias Plausible.Goals + + describe "visit dimensions filtered by goals with custom props" do + test "filters visit:country by goal with custom props correctly", %{conn: conn, site: site} do + {:ok, _goal} = + Goals.create(site, %{ + "event_name" => "Signup", + "custom_props" => %{"plan" => "premium"} + }) + + populate_stats(site, [ + # Session 1 (US): Has correct event (Signup with plan=premium) + build(:pageview, + user_id: 1, + country_code: "US", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + user_id: 1, + name: "Signup", + "meta.key": ["plan"], + "meta.value": ["premium"], + timestamp: ~N[2021-01-01 00:00:01] + ), + # Session 2 (GB): Has wrong event (Signup with plan=basic) + build(:pageview, + user_id: 2, + country_code: "GB", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + user_id: 2, + name: "Signup", + "meta.key": ["plan"], + "meta.value": ["basic"], + timestamp: ~N[2021-01-01 00:00:02] + ), + # Session 3 (CA): Has event with event name but no custom props + build(:pageview, + user_id: 3, + country_code: "CA", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + user_id: 4, + name: "Signup", + timestamp: ~N[2021-01-01 00:00:03] + ), + # Session 4 (DE): Has no Signup event at all + build(:pageview, + user_id: 5, + country_code: "DE", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors", "events"], + "dimensions" => ["visit:country"], + "filters" => [["is", "event:goal", ["Signup"]]] + }) + + resp = json_response(conn, 200) + + # Only session 1 (US) should be included, as it's the only one with + # the correct event name AND matching custom props + assert resp["results"] == [ + %{"dimensions" => ["US"], "metrics" => [1, 1]} + ] + end + + test "filters visit:browser by goal with multiple custom props", %{conn: conn, site: site} do + {:ok, _goal} = + Goals.create(site, %{ + "event_name" => "Purchase", + "custom_props" => %{"variant" => "A", "tier" => "gold"} + }) + + populate_stats(site, [ + # Chrome - matches both props + build(:pageview, + user_id: 1, + browser: "Chrome", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + user_id: 1, + name: "Purchase", + "meta.key": ["variant", "tier"], + "meta.value": ["A", "gold"], + timestamp: ~N[2021-01-01 00:00:01] + ), + # Firefox - matches only variant, not tier + build(:pageview, + user_id: 2, + browser: "Firefox", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + user_id: 3, + name: "Purchase", + "meta.key": ["variant", "tier"], + "meta.value": ["A", "silver"], + timestamp: ~N[2021-01-01 00:00:02] + ), + # Safari - matches only tier, not variant + build(:pageview, + user_id: 4, + browser: "Safari", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + user_id: 5, + name: "Purchase", + "meta.key": ["variant", "tier"], + "meta.value": ["B", "gold"], + timestamp: ~N[2021-01-01 00:00:03] + ), + # Edge - has event name but missing custom props entirely + build(:pageview, + user_id: 6, + browser: "Edge", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + user_id: 7, + name: "Purchase", + timestamp: ~N[2021-01-01 00:00:04] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors", "events"], + "dimensions" => ["visit:browser"], + "filters" => [["is", "event:goal", ["Purchase"]]] + }) + + resp = json_response(conn, 200) + + # Only session 1 (Chrome) should be included + assert resp["results"] == [ + %{"dimensions" => ["Chrome"], "metrics" => [1, 1]} + ] + end + + test "filters visit:city by goal with custom props", %{conn: conn, site: site} do + {:ok, _goal} = + Goals.create(site, %{ + "event_name" => "Download", + "custom_props" => %{"file_type" => "pdf"} + }) + + populate_stats(site, [ + # San Francisco - correct props + build(:pageview, + user_id: 1, + city_geoname_id: 5_391_959, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + user_id: 1, + name: "Download", + "meta.key": ["file_type"], + "meta.value": ["pdf"], + timestamp: ~N[2021-01-01 00:00:01] + ), + # New York - wrong prop value + build(:pageview, + user_id: 2, + city_geoname_id: 5_128_581, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + user_id: 3, + name: "Download", + "meta.key": ["file_type"], + "meta.value": ["docx"], + timestamp: ~N[2021-01-01 00:00:02] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors", "events"], + "dimensions" => ["visit:city"], + "filters" => [["is", "event:goal", ["Download"]]] + }) + + resp = json_response(conn, 200) + + # Only San Francisco session should be included + assert length(resp["results"]) == 1 + assert [%{"dimensions" => [5_391_959], "metrics" => [1, 1]}] = resp["results"] + end + + test "filters visit:os by goal with custom prop containing special characters", %{ + conn: conn, + site: site + } do + {:ok, _goal} = + Goals.create(site, %{ + "event_name" => "Custom Event", + "custom_props" => %{"tag" => "test-value"} + }) + + populate_stats(site, [ + # Mac - correct props + build(:pageview, + user_id: 1, + operating_system: "Mac", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + user_id: 1, + name: "Custom Event", + "meta.key": ["tag"], + "meta.value": ["test-value"], + timestamp: ~N[2021-01-01 00:00:01] + ), + # Windows - wrong value + build(:pageview, + user_id: 2, + operating_system: "Windows", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + user_id: 2, + name: "Custom Event", + "meta.key": ["tag"], + "meta.value": ["other-value"], + timestamp: ~N[2021-01-01 00:00:02] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors", "events"], + "dimensions" => ["visit:os"], + "filters" => [["is", "event:goal", ["Custom Event"]]] + }) + + resp = json_response(conn, 200) + + # Only Mac session should be included + assert resp["results"] == [ + %{"dimensions" => ["Mac"], "metrics" => [1, 1]} + ] + end + + test "works with additional visit dimension filters combined", %{conn: conn, site: site} do + {:ok, _goal} = + Goals.create(site, %{ + "event_name" => "Newsletter", + "custom_props" => %{"source" => "sidebar"} + }) + + populate_stats(site, [ + # US/Chrome - correct props + build(:pageview, + user_id: 1, + country_code: "US", + browser: "Chrome", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + user_id: 1, + name: "Newsletter", + "meta.key": ["source"], + "meta.value": ["sidebar"], + timestamp: ~N[2021-01-01 00:00:01] + ), + # US/Firefox - wrong props + build(:pageview, + user_id: 2, + country_code: "US", + browser: "Firefox", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + user_id: 2, + name: "Newsletter", + "meta.key": ["source"], + "meta.value": ["footer"], + timestamp: ~N[2021-01-01 00:00:02] + ), + # GB/Chrome - correct props + build(:pageview, + user_id: 3, + country_code: "GB", + browser: "Chrome", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + user_id: 3, + name: "Newsletter", + "meta.key": ["source"], + "meta.value": ["sidebar"], + timestamp: ~N[2021-01-01 00:00:03] + ) + ]) + + # Filter by goal with custom props AND by country=US + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors", "events"], + "dimensions" => ["visit:browser"], + "filters" => [ + ["is", "event:goal", ["Newsletter"]], + ["is", "visit:country", ["US"]] + ] + }) + + resp = json_response(conn, 200) + + # Only session 1 (US/Chrome + correct props) should match + assert resp["results"] == [ + %{"dimensions" => ["Chrome"], "metrics" => [1, 1]} + ] + end + + test "handles multiple goal filters with different custom props", %{conn: conn, site: site} do + {:ok, _goal1} = + Goals.create(site, %{ + "event_name" => "Action A", + "custom_props" => %{"type" => "alpha"} + }) + + {:ok, _goal2} = + Goals.create(site, %{ + "event_name" => "Action B", + "custom_props" => %{"type" => "beta"} + }) + + populate_stats(site, [ + build(:pageview, + user_id: 1, + country_code: "US", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + user_id: 1, + name: "Action A", + "meta.key": ["type"], + "meta.value": ["alpha"], + timestamp: ~N[2021-01-01 00:00:01] + ), + build(:pageview, + user_id: 2, + country_code: "GB", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + user_id: 2, + name: "Action B", + "meta.key": ["type"], + "meta.value": ["beta"], + timestamp: ~N[2021-01-01 00:00:02] + ), + build(:pageview, + user_id: 3, + country_code: "CA", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + user_id: 3, + name: "Action A", + "meta.key": ["type"], + "meta.value": ["gamma"], + timestamp: ~N[2021-01-01 00:00:03] + ) + ]) + + # Query for both goals (OR logic) + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors", "events"], + "dimensions" => ["visit:country"], + "filters" => [["is", "event:goal", ["Action A", "Action B"]]] + }) + + resp = json_response(conn, 200) + + # Should match sessions 1 and 2 (both with correct props), but not 3 + assert length(resp["results"]) == 2 + + assert Enum.sort(resp["results"]) == [ + %{"dimensions" => ["GB"], "metrics" => [1, 1]}, + %{"dimensions" => ["US"], "metrics" => [1, 1]} + ] + end + end +end diff --git a/test/plausible_web/controllers/api/external_stats_controller/query_goal_dimension_test.exs b/test/plausible_web/controllers/api/external_stats_controller/query_goal_dimension_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..9deb1ee2ce75aac46f57d55ceaa81091a0d095f4 --- /dev/null +++ b/test/plausible_web/controllers/api/external_stats_controller/query_goal_dimension_test.exs @@ -0,0 +1,654 @@ +defmodule PlausibleWeb.Api.ExternalStatsController.QueryGoalDimensionTest do + use PlausibleWeb.ConnCase + + @user_id Enum.random(1000..9999) + + setup [:create_user, :create_site, :create_api_key, :use_api_key] + + describe "breakdown by event:goal" do + test "returns custom event goals and pageview goals", %{conn: conn, site: site} do + insert(:goal, %{site: site, event_name: "Purchase"}) + insert(:goal, %{site: site, page_path: "/test"}) + + populate_stats(site, [ + build(:pageview, + timestamp: ~N[2021-01-01 00:00:01], + pathname: "/test" + ), + build(:event, + name: "Purchase", + timestamp: ~N[2021-01-01 00:00:03] + ), + build(:event, + name: "Purchase", + timestamp: ~N[2021-01-01 00:00:03] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors"], + "dimensions" => ["event:goal"] + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => ["Purchase"], "metrics" => [2]}, + %{"dimensions" => ["Visit /test"], "metrics" => [1]} + ] + end + + test "returns pageview goals containing wildcards", %{conn: conn, site: site} do + insert(:goal, %{site: site, page_path: "/**/post"}) + insert(:goal, %{site: site, page_path: "/blog**"}) + + populate_stats(site, [ + build(:pageview, pathname: "/blog", user_id: @user_id), + build(:pageview, pathname: "/blog/post-1", user_id: @user_id), + build(:pageview, pathname: "/blog/post-2", user_id: @user_id), + build(:pageview, pathname: "/blog/something/post"), + build(:pageview, pathname: "/different/page/post") + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors", "pageviews"], + "dimensions" => ["event:goal"], + "order_by" => [["pageviews", "desc"]] + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => ["Visit /blog**"], "metrics" => [2, 4]}, + %{"dimensions" => ["Visit /**/post"], "metrics" => [2, 2]} + ] + end + + test "does not return goals that are not configured for the site", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/register"), + build(:event, name: "Signup") + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors", "pageviews"], + "dimensions" => ["event:goal"] + }) + + assert json_response(conn, 200)["results"] == [] + end + + test "returns conversion_rate in an event:goal breakdown", %{conn: conn, site: site} do + populate_stats(site, [ + build(:event, name: "Signup", user_id: 1), + build(:event, name: "Signup", user_id: 1), + build(:pageview, pathname: "/blog"), + build(:pageview, pathname: "/blog/post"), + build(:pageview) + ]) + + insert(:goal, %{site: site, event_name: "Signup"}) + insert(:goal, %{site: site, page_path: "/blog**"}) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors", "events", "conversion_rate"], + "dimensions" => ["event:goal"] + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => ["Visit /blog**"], "metrics" => [2, 2, 50.0]}, + %{"dimensions" => ["Signup"], "metrics" => [1, 2, 25.0]} + ] + end + + test "returns conversion_rate alone in an event:goal breakdown", %{conn: conn, site: site} do + populate_stats(site, [ + build(:event, name: "Signup", user_id: 1), + build(:pageview) + ]) + + insert(:goal, %{site: site, event_name: "Signup"}) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["conversion_rate"], + "dimensions" => ["event:goal"] + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => ["Signup"], "metrics" => [50.0]} + ] + end + + test "returns goals up to a limit", %{conn: conn, site: site} do + for i <- 1..11, do: insert(:goal, %{site: site, event_name: "G#{i}"}) + + populate_stats( + site, + for( + i <- 1..11, + do: + build(:event, + name: "G#{i}", + timestamp: NaiveDateTime.add(~N[2021-01-01 00:00:03], i, :second) + ) + ) + ) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors"], + "dimensions" => ["event:goal"] + }) + + results = json_response(conn, 200)["results"] + + assert Enum.find(results, &(&1["dimensions"] == ["G11"])) + assert Enum.find(results, &(&1["dimensions"] == ["G2"])) + refute Enum.find(results, &(&1["dimensions"] == ["G1"])) + + assert length(results) == Plausible.Goals.max_goals_per_site() + end + end + + describe "page scroll goals" do + test "a scroll depth of 255 (missing) is not considered a conversion (page scroll goal filter)", + %{ + conn: conn, + site: site + } do + insert(:goal, + site: site, + page_path: "/blog", + scroll_threshold: 90, + display_name: "Scroll /blog 90" + ) + + populate_stats(site, [ + build(:pageview, user_id: 12, pathname: "/blog", timestamp: ~N[2021-01-01 00:00:00]), + build(:engagement, + user_id: 12, + pathname: "/blog", + timestamp: ~N[2021-01-01 00:00:00], + scroll_depth: 255 + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "filters" => [["is", "event:goal", ["Scroll /blog 90"]]], + "date_range" => "all" + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => [], "metrics" => [0]} + ] + end + + test "a scroll depth of 255 (missing) is not considered a conversion (page scroll goal breakdown)", + %{ + conn: conn, + site: site + } do + insert(:goal, + site: site, + page_path: "/blog", + scroll_threshold: 90, + display_name: "Scroll /blog 90" + ) + + populate_stats(site, [ + build(:pageview, user_id: 12, pathname: "/blog", timestamp: ~N[2021-01-01 00:00:00]), + build(:engagement, + user_id: 12, + pathname: "/blog", + timestamp: ~N[2021-01-01 00:00:00], + scroll_depth: 255 + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => "all", + "dimensions" => ["event:goal"] + }) + + assert json_response(conn, 200)["results"] == [] + end + + test "returns page scroll goals and pageview goals in breakdown with page filter", %{ + conn: conn, + site: site + } do + insert(:goal, site: site, page_path: "/blog") + + insert(:goal, + site: site, + page_path: "/blog", + scroll_threshold: 25, + display_name: "Scroll /blog 25" + ) + + populate_stats(site, [ + build(:pageview, user_id: 12, pathname: "/blog", timestamp: ~N[2021-01-01 00:00:00]), + build(:engagement, + user_id: 12, + pathname: "/blog", + timestamp: ~N[2021-01-01 00:00:00], + scroll_depth: 10 + ), + build(:pageview, user_id: 34, pathname: "/blog", timestamp: ~N[2021-01-01 00:00:00]), + build(:engagement, + user_id: 34, + pathname: "/blog", + timestamp: ~N[2021-01-01 00:00:00], + scroll_depth: 30 + ), + build(:pageview) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors", "events", "conversion_rate"], + "filters" => [["is", "event:page", ["/blog"]]], + "date_range" => "all", + "dimensions" => ["event:goal"] + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => ["Visit /blog"], "metrics" => [2, 2, 100.0]}, + %{"dimensions" => ["Scroll /blog 25"], "metrics" => [1, nil, 50.0]} + ] + end + + test "custom props and page scroll goals in a double-dimension breakdown", %{ + conn: conn, + site: site + } do + for threshold <- [25, 50, 75] do + insert(:goal, + site: site, + page_path: "/blog**", + scroll_threshold: threshold, + display_name: "Scroll /blog** #{threshold}" + ) + end + + populate_stats(site, [ + # visitor 1 + build(:pageview, + pathname: "/blog/december", + timestamp: ~N[2021-01-01 00:00:00], + "meta.key": ["author"], + "meta.value": ["bob"] + ), + # visitor 2 + build(:pageview, + user_id: 12, + pathname: "/blog/december", + timestamp: ~N[2021-01-01 00:00:00], + "meta.key": ["author"], + "meta.value": ["bob"] + ), + build(:engagement, + user_id: 12, + pathname: "/blog/december", + timestamp: ~N[2021-01-01 00:00:10], + scroll_depth: 30, + "meta.key": ["author"], + "meta.value": ["bob"] + ), + # visitor 3 + build(:pageview, + user_id: 34, + pathname: "/blog/january", + timestamp: ~N[2021-01-01 00:00:00], + "meta.key": ["author"], + "meta.value": ["jane"] + ), + build(:engagement, + user_id: 34, + pathname: "/blog/january", + timestamp: ~N[2021-01-01 00:00:10], + scroll_depth: 50, + "meta.key": ["author"], + "meta.value": ["jane"] + ), + # visitor 4 + build(:pageview, + user_id: 56, + pathname: "/blog/january", + timestamp: ~N[2021-01-01 00:00:00], + "meta.key": ["author"], + "meta.value": ["jane"] + ), + build(:engagement, + user_id: 56, + pathname: "/blog/january", + timestamp: ~N[2021-01-01 00:00:10], + scroll_depth: 75, + "meta.key": ["author"], + "meta.value": ["jane"] + ), + # visitor 5 + build(:pageview, + user_id: 89, + pathname: "/blog/january", + timestamp: ~N[2021-01-01 00:00:00], + "meta.key": ["author"], + "meta.value": ["jane"] + ), + build(:engagement, + user_id: 89, + pathname: "/blog/january", + timestamp: ~N[2021-01-01 00:00:10], + scroll_depth: 49, + "meta.key": ["author"], + "meta.value": ["jane"] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors", "events", "conversion_rate"], + "order_by" => [["visitors", "desc"], ["event:props:author", "asc"]], + "date_range" => "all", + "dimensions" => ["event:goal", "event:props:author"] + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => ["Scroll /blog** 25", "jane"], "metrics" => [3, nil, 60.0]}, + %{"dimensions" => ["Scroll /blog** 50", "jane"], "metrics" => [2, nil, 40.0]}, + %{"dimensions" => ["Scroll /blog** 25", "bob"], "metrics" => [1, nil, 20.0]}, + %{"dimensions" => ["Scroll /blog** 75", "jane"], "metrics" => [1, nil, 20.0]} + ] + end + + test "returns group_conversion_rate for a page scroll goal filter in source breakdown", %{ + conn: conn, + site: site + } do + insert(:goal, + site: site, + page_path: "/blog", + scroll_threshold: 50, + display_name: "Scroll /blog 50" + ) + + populate_stats(site, [ + build(:pageview, referrer_source: "Twitter"), + build(:pageview, + user_id: 12, + pathname: "/blog", + timestamp: ~N[2021-01-01 00:00:00], + referrer_source: "Google" + ), + build(:engagement, + user_id: 12, + pathname: "/blog", + timestamp: ~N[2021-01-01 00:00:10], + scroll_depth: 30, + referrer_source: "Google" + ), + build(:pageview, + user_id: 34, + pathname: "/blog", + timestamp: ~N[2021-01-01 00:00:00], + referrer_source: "Twitter" + ), + build(:engagement, + user_id: 34, + pathname: "/blog", + timestamp: ~N[2021-01-01 00:00:10], + scroll_depth: 50, + referrer_source: "Twitter" + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors", "events", "group_conversion_rate"], + "date_range" => "all", + "filters" => [["is", "event:goal", ["Scroll /blog 50"]]], + "dimensions" => ["visit:source"] + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => ["Twitter"], "metrics" => [1, nil, 50.0]} + ] + end + + test "breakdown by event:props:author with a page scroll goal filter", %{ + conn: conn, + site: site + } do + for threshold <- [25, 50, 75] do + insert(:goal, + site: site, + page_path: "/blog**", + scroll_threshold: threshold, + display_name: "Scroll /blog #{threshold}" + ) + end + + populate_stats(site, [ + build(:pageview, + pathname: "/blog/john-post", + "meta.key": ["author"], + "meta.value": ["john"] + ), + build(:pageview, + user_id: 12, + pathname: "/blog/john-post", + timestamp: ~N[2021-01-01 00:00:00], + "meta.key": ["author"], + "meta.value": ["john"] + ), + build(:engagement, + user_id: 12, + pathname: "/blog/john-post", + timestamp: ~N[2021-01-01 00:00:10], + scroll_depth: 30, + "meta.key": ["author"], + "meta.value": ["john"] + ), + build(:pageview, + user_id: 34, + pathname: "/blog/jane-post", + timestamp: ~N[2021-01-01 00:00:00], + "meta.key": ["author"], + "meta.value": ["jane"] + ), + build(:engagement, + user_id: 34, + pathname: "/blog/jane-post", + timestamp: ~N[2021-01-01 00:00:10], + scroll_depth: 50, + "meta.key": ["author"], + "meta.value": ["jane"] + ), + build(:pageview, + user_id: 56, + pathname: "/blog/jane-post", + timestamp: ~N[2021-01-01 00:00:00], + "meta.key": ["author"], + "meta.value": ["jane"] + ), + build(:engagement, + user_id: 56, + pathname: "/blog/jane-post", + timestamp: ~N[2021-01-01 00:00:10], + scroll_depth: 75, + "meta.key": ["author"], + "meta.value": ["jane"] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors", "events", "conversion_rate"], + "date_range" => "all", + "filters" => [["is", "event:goal", ["Scroll /blog 25"]]], + "dimensions" => ["event:props:author"] + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => ["jane"], "metrics" => [2, nil, 50.0]}, + %{"dimensions" => ["john"], "metrics" => [1, nil, 25.0]} + ] + end + + test "breaks down page scroll goals with a custom prop filter", %{ + conn: conn, + site: site + } do + for threshold <- [25, 50, 75] do + insert(:goal, + site: site, + page_path: "/blog**", + scroll_threshold: threshold, + display_name: "Scroll /blog #{threshold}" + ) + end + + populate_stats(site, [ + build(:pageview, + pathname: "/blog/john-post", + "meta.key": ["author"], + "meta.value": ["john"] + ), + build(:pageview, + user_id: 12, + pathname: "/blog/john-post", + timestamp: ~N[2021-01-01 00:00:00], + "meta.key": ["author"], + "meta.value": ["john"] + ), + build(:engagement, + user_id: 12, + pathname: "/blog/john-post", + timestamp: ~N[2021-01-01 00:00:10], + scroll_depth: 30, + "meta.key": ["author"], + "meta.value": ["john"] + ), + build(:pageview, + user_id: 34, + pathname: "/blog/jane-post", + timestamp: ~N[2021-01-01 00:00:00], + "meta.key": ["author"], + "meta.value": ["jane"] + ), + build(:engagement, + user_id: 34, + pathname: "/blog/jane-post", + timestamp: ~N[2021-01-01 00:00:10], + scroll_depth: 50, + "meta.key": ["author"], + "meta.value": ["jane"] + ), + build(:pageview, + user_id: 56, + pathname: "/blog/jane-post", + timestamp: ~N[2021-01-01 00:00:00], + "meta.key": ["author"], + "meta.value": ["jane"] + ), + build(:engagement, + user_id: 56, + pathname: "/blog/jane-post", + timestamp: ~N[2021-01-01 00:00:10], + scroll_depth: 75, + "meta.key": ["author"], + "meta.value": ["jane"] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors", "events", "conversion_rate"], + "date_range" => "all", + "filters" => [["is", "event:props:author", ["jane"]]], + "dimensions" => ["event:goal"] + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => ["Scroll /blog 25"], "metrics" => [2, nil, 50.0]}, + %{"dimensions" => ["Scroll /blog 50"], "metrics" => [2, nil, 50.0]}, + %{"dimensions" => ["Scroll /blog 75"], "metrics" => [1, nil, 25.0]} + ] + end + + test "breakdown by event:goal with goal filters", %{conn: conn, site: site} do + insert(:goal, + site: site, + page_path: "/blog", + scroll_threshold: 50, + display_name: "Scroll /blog 50" + ) + + insert(:goal, site: site, page_path: "/blog", display_name: "Visit /blog") + + populate_stats(site, [ + build(:pageview, user_id: 1, pathname: "/blog", timestamp: ~N[2021-01-01 00:00:00]), + build(:engagement, + user_id: 1, + pathname: "/blog", + scroll_depth: 20, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, user_id: 2, pathname: "/blog", timestamp: ~N[2021-01-01 00:00:00]), + build(:engagement, + user_id: 2, + pathname: "/blog", + scroll_depth: 70, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, user_id: 3, pathname: "/blog", timestamp: ~N[2021-01-01 00:00:00]), + build(:engagement, + user_id: 3, + pathname: "/blog", + scroll_depth: 50, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, user_id: 4, pathname: "/blog", timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, user_id: 4, pathname: "/blog", timestamp: ~N[2021-01-01 00:01:00]) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors", "events", "conversion_rate"], + "date_range" => "all", + "filters" => [["is", "event:goal", ["Scroll /blog 50", "Visit /blog"]]], + "dimensions" => ["event:goal"] + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => ["Visit /blog"], "metrics" => [4, 5, 100.0]}, + %{"dimensions" => ["Scroll /blog 50"], "metrics" => [2, nil, 50.0]} + ] + end + end +end diff --git a/test/plausible_web/controllers/api/external_stats_controller/query_imported_test.exs b/test/plausible_web/controllers/api/external_stats_controller/query_imported_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..9ed544765cccfb09f0ea57c11edb208a00135500 --- /dev/null +++ b/test/plausible_web/controllers/api/external_stats_controller/query_imported_test.exs @@ -0,0 +1,1882 @@ +defmodule PlausibleWeb.Api.ExternalStatsController.QueryImportedTest do + use PlausibleWeb.ConnCase + + @user_id Enum.random(1000..9999) + + @unsupported_interval_warning Plausible.Stats.QueryResult.imports_warnings().unsupported_interval + @unsupported_query_warning Plausible.Stats.QueryResult.imports_warnings().unsupported_query + + def unsupported_query_warning(), do: @unsupported_query_warning + + @no_imported_scroll_depth_metric_warning %{ + "code" => "no_imported_scroll_depth", + "warning" => "No imports with scroll depth data were found" + } + + setup [:create_user, :create_site, :create_api_key, :use_api_key] + + describe "aggregation with imported data" do + setup :create_site_import + + test "does not count imported stats unless specified", %{ + conn: conn, + site: site, + site_import: site_import + } do + populate_stats(site, site_import.id, [ + build(:imported_visitors, date: ~D[2023-01-01]), + build(:pageview, timestamp: ~N[2023-01-01 00:00:00]) + ]) + + query_params = %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["pageviews"] + } + + conn1 = post(conn, "/api/v2/query", query_params) + + assert json_response(conn1, 200)["results"] == [%{"metrics" => [1], "dimensions" => []}] + + conn2 = post(conn, "/api/v2/query", Map.put(query_params, "include", %{"imports" => true})) + + assert json_response(conn2, 200)["results"] == [%{"metrics" => [2], "dimensions" => []}] + assert json_response(conn2, 200)["meta"]["imports_included"] + refute json_response(conn2, 200)["meta"]["imports_warning"] + end + + test "filters correctly with 'is' operator", %{ + conn: conn, + site: site, + site_import: site_import + } do + populate_stats(site, site_import.id, [ + build(:pageview, pathname: "/blog", timestamp: ~N[2023-01-01 00:00:00]), + build(:pageview, pathname: "/blog", timestamp: ~N[2023-01-01 00:00:00]), + build(:pageview, pathname: "/blog/post/1", timestamp: ~N[2023-01-01 00:00:00]), + build(:pageview, pathname: "/about", timestamp: ~N[2023-01-01 00:00:00]), + build(:imported_pages, + page: "/blog", + pageviews: 5, + visitors: 3, + date: ~D[2023-01-01] + ), + build(:imported_pages, + page: "/blog/post/1", + pageviews: 2, + visitors: 2, + date: ~D[2023-01-01] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors", "pageviews"], + "filters" => [ + ["is", "event:page", ["/blog"]] + ], + "include" => %{"imports" => true} + }) + + assert json_response(conn, 200)["results"] == [ + %{"metrics" => [5, 7], "dimensions" => []} + ] + end + + test "filters correctly with 'is' operator (case insensitive)", %{ + conn: conn, + site: site, + site_import: site_import + } do + populate_stats(site, site_import.id, [ + build(:pageview, pathname: "/BLOG", timestamp: ~N[2023-01-01 00:00:00]), + build(:pageview, pathname: "/blog", timestamp: ~N[2023-01-01 00:00:00]), + build(:pageview, pathname: "/blog/post/1", timestamp: ~N[2023-01-01 00:00:00]), + build(:pageview, pathname: "/about", timestamp: ~N[2023-01-01 00:00:00]), + build(:imported_pages, + page: "/BLOG", + pageviews: 5, + visitors: 3, + date: ~D[2023-01-01] + ), + build(:imported_pages, + page: "/blog/post/1", + pageviews: 2, + visitors: 2, + date: ~D[2023-01-01] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors", "pageviews"], + "filters" => [ + ["is", "event:page", ["/blOG"], %{"case_sensitive" => false}] + ], + "include" => %{"imports" => true} + }) + + assert json_response(conn, 200)["results"] == [ + %{"metrics" => [5, 7], "dimensions" => []} + ] + end + + test "filters correctly with 'contains' operator", %{ + conn: conn, + site: site, + site_import: site_import + } do + populate_stats(site, site_import.id, [ + build(:pageview, pathname: "/blog", timestamp: ~N[2023-01-01 00:00:00]), + build(:pageview, pathname: "/blog/post/1", timestamp: ~N[2023-01-01 00:00:00]), + build(:pageview, pathname: "/blog/post/2", timestamp: ~N[2023-01-01 00:00:00]), + build(:pageview, pathname: "/about", timestamp: ~N[2023-01-01 00:00:00]), + build(:imported_pages, + page: "/blog", + pageviews: 5, + visitors: 3, + date: ~D[2023-01-01] + ), + build(:imported_pages, + page: "/blog/post/1", + pageviews: 2, + visitors: 2, + date: ~D[2023-01-01] + ), + build(:imported_pages, + page: "/blog/POST/2", + pageviews: 3, + visitors: 1, + date: ~D[2023-01-01] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors", "pageviews"], + "filters" => [ + ["contains", "event:page", ["blog/post"]] + ], + "include" => %{"imports" => true} + }) + + assert json_response(conn, 200)["results"] == [ + %{"metrics" => [4, 4], "dimensions" => []} + ] + end + + test "filters correctly with 'contains' operator (case insensitive)", %{ + conn: conn, + site: site, + site_import: site_import + } do + populate_stats(site, site_import.id, [ + build(:pageview, pathname: "/BLOG/post/1", timestamp: ~N[2023-01-01 00:00:00]), + build(:pageview, pathname: "/blog/POST/2", timestamp: ~N[2023-01-01 00:00:00]), + build(:pageview, pathname: "/about", timestamp: ~N[2023-01-01 00:00:00]), + build(:imported_pages, + page: "/BLOG/POST/1", + pageviews: 5, + visitors: 3, + date: ~D[2023-01-01] + ), + build(:imported_pages, + page: "/blog/post/2", + pageviews: 2, + visitors: 2, + date: ~D[2023-01-01] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors", "pageviews"], + "filters" => [ + ["contains", "event:page", ["blog/POST"], %{"case_sensitive" => false}] + ], + "include" => %{"imports" => true} + }) + + assert json_response(conn, 200)["results"] == [ + %{"metrics" => [7, 9], "dimensions" => []} + ] + end + + test "aggregates custom event goals with 'is' and 'contains' operators", %{ + conn: conn, + site: site, + site_import: site_import + } do + insert(:goal, event_name: "Purchase", site: site) + + populate_stats(site, site_import.id, [ + build(:event, + name: "Purchase", + timestamp: ~N[2023-01-01 00:00:00] + ), + build(:event, + name: "Purchase", + timestamp: ~N[2023-01-01 00:00:00] + ), + build(:event, + name: "Signup", + timestamp: ~N[2023-01-01 00:00:00] + ), + build(:imported_custom_events, + name: "Purchase", + visitors: 3, + events: 5, + date: ~D[2023-01-01] + ), + build(:imported_custom_events, + name: "Signup", + visitors: 2, + events: 2, + date: ~D[2023-01-01] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors", "events"], + "filters" => [ + ["is", "event:goal", ["Purchase"]] + ], + "include" => %{"imports" => true} + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => [], "metrics" => [5, 7]} + ] + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors", "events"], + "filters" => [ + ["contains", "event:goal", ["Purch", "sign"]] + ], + "include" => %{"imports" => true} + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => [], "metrics" => [5, 7]} + ] + end + + test "aggregates custom event goals with 'is' and 'contains' operators (case insensitive)", %{ + conn: conn, + site: site, + site_import: site_import + } do + insert(:goal, event_name: "Purchase", site: site) + + populate_stats(site, site_import.id, [ + build(:event, + name: "Purchase", + timestamp: ~N[2023-01-01 00:00:00] + ), + build(:event, + name: "Purchase", + timestamp: ~N[2023-01-01 00:00:00] + ), + build(:event, + name: "Signup", + timestamp: ~N[2023-01-01 00:00:00] + ), + build(:imported_custom_events, + name: "Purchase", + visitors: 3, + events: 5, + date: ~D[2023-01-01] + ), + build(:imported_custom_events, + name: "Signup", + visitors: 2, + events: 2, + date: ~D[2023-01-01] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors", "events"], + "filters" => [ + ["is", "event:goal", ["purchase"], %{"case_sensitive" => false}] + ], + "include" => %{"imports" => true} + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => [], "metrics" => [5, 7]} + ] + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors", "events"], + "filters" => [ + ["contains", "event:goal", ["PURCH"], %{"case_sensitive" => false}] + ], + "include" => %{"imports" => true} + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => [], "metrics" => [5, 7]} + ] + end + end + + describe "timeseries with imported data" do + setup :create_site_import + + test "views_per_visit breakdown by time:month", %{ + conn: conn, + site: site, + site_import: site_import + } do + populate_stats(site, site_import.id, [ + # January 2021 - only imported + build(:imported_visitors, date: ~D[2021-01-01], visits: 6, pageviews: 7), + # March 2021 - imported + native combined + build(:imported_visitors, date: ~D[2021-03-01], visits: 1, pageviews: 4), + build(:pageview, user_id: 1, timestamp: ~N[2021-03-15 00:00:00]), + build(:pageview, user_id: 1, timestamp: ~N[2021-03-15 00:05:00]), + # September 2021 - only native + build(:pageview, user_id: 2, timestamp: ~N[2021-09-01 00:00:00]) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["views_per_visit"], + "date_range" => ["2021-01-01", "2021-12-31"], + "dimensions" => ["time:month"], + "include" => %{"imports" => true} + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => ["2021-01-01"], "metrics" => [1.17]}, + %{"dimensions" => ["2021-03-01"], "metrics" => [3.0]}, + %{"dimensions" => ["2021-09-01"], "metrics" => [1.0]} + ] + end + end + + test "breaks down all metrics by visit:referrer with imported data", %{conn: conn, site: site} do + site_import = + insert(:site_import, + site: site, + start_date: ~D[2005-01-01], + end_date: Date.utc_today(), + source: :universal_analytics + ) + + populate_stats(site, site_import.id, [ + build(:pageview, referrer: "site.com", timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, referrer: "site.com/1", timestamp: ~N[2021-01-01 00:00:00]), + build(:imported_sources, + referrer: "site.com", + date: ~D[2021-01-01], + visitors: 2, + visits: 2, + pageviews: 2, + bounces: 1, + visit_duration: 120 + ), + build(:imported_sources, + referrer: "site.com/2", + date: ~D[2021-01-01], + visitors: 2, + visits: 2, + pageviews: 2, + bounces: 2, + visit_duration: 0 + ), + build(:imported_sources, + date: ~D[2021-01-01], + visitors: 10, + visits: 11, + pageviews: 50, + bounces: 0, + visit_duration: 1100 + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors", "visits", "pageviews", "bounce_rate", "visit_duration"], + "date_range" => "all", + "dimensions" => ["visit:referrer"], + "include" => %{"imports" => true} + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => ["Direct / None"], "metrics" => [10, 11, 50, 0.0, 100.0]}, + %{"dimensions" => ["site.com"], "metrics" => [3, 3, 3, 67.0, 40.0]}, + %{"dimensions" => ["site.com/2"], "metrics" => [2, 2, 2, 100.0, 0.0]}, + %{"dimensions" => ["site.com/1"], "metrics" => [1, 1, 1, 100.0, 0.0]} + ] + end + + test "breaks down all metrics by visit:utm_source with imported data", %{conn: conn, site: site} do + site_import = + insert(:site_import, + site: site, + start_date: ~D[2005-01-01], + end_date: Date.utc_today(), + source: :universal_analytics + ) + + populate_stats(site, site_import.id, [ + build(:pageview, utm_source: "SomeUTMSource", timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, utm_source: "SomeUTMSource-1", timestamp: ~N[2021-01-01 00:00:00]), + build(:imported_sources, + utm_source: "SomeUTMSource", + date: ~D[2021-01-01], + visitors: 2, + visits: 2, + pageviews: 2, + bounces: 1, + visit_duration: 120 + ), + build(:imported_sources, + utm_source: "SomeUTMSource-2", + date: ~D[2021-01-01], + visitors: 2, + visits: 2, + pageviews: 2, + bounces: 2, + visit_duration: 0 + ), + build(:imported_sources, + date: ~D[2021-01-01], + visitors: 10, + visits: 11, + pageviews: 50, + bounces: 0, + visit_duration: 1100 + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors", "visits", "pageviews", "bounce_rate", "visit_duration"], + "date_range" => "all", + "dimensions" => ["visit:utm_source"], + "include" => %{"imports" => true} + }) + + %{"results" => results} = json_response(conn, 200) + + assert results == [ + %{"dimensions" => ["SomeUTMSource"], "metrics" => [3, 3, 3, 67.0, 40.0]}, + %{"dimensions" => ["SomeUTMSource-2"], "metrics" => [2, 2, 2, 100.0, 0.0]}, + %{"dimensions" => ["SomeUTMSource-1"], "metrics" => [1, 1, 1, 100.0, 0.0]} + ] + end + + test "pageviews breakdown by event:page - imported data having pageviews=0 and visitors=n should be bypassed", + %{conn: conn, site: site} do + site_import = + insert(:site_import, + site: site, + start_date: ~D[2005-01-01], + end_date: Date.utc_today(), + source: :universal_analytics + ) + + populate_stats(site, site_import.id, [ + build(:pageview, pathname: "/", timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, pathname: "/", timestamp: ~N[2021-01-01 00:25:00]), + build(:pageview, + pathname: "/plausible.io", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:imported_pages, + page: "/skip-me", + date: ~D[2021-01-01], + visitors: 1, + pageviews: 0 + ), + build(:imported_pages, + page: "/include-me", + date: ~D[2021-01-01], + visitors: 1, + pageviews: 1 + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["pageviews"], + "date_range" => "all", + "dimensions" => ["event:page"], + "include" => %{"imports" => true} + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => ["/"], "metrics" => [2]}, + %{"dimensions" => ["/plausible.io"], "metrics" => [1]}, + %{"dimensions" => ["/include-me"], "metrics" => [1]} + ] + end + + describe "breakdown by visit:exit_page with" do + setup %{site: site} do + site_import = insert(:site_import, site: site) + + populate_stats(site, site_import.id, [ + build(:pageview, + pathname: "/a", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: @user_id, + pathname: "/a", + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, + user_id: @user_id, + pathname: "/b", + timestamp: ~N[2021-01-01 00:35:00] + ), + build(:imported_exit_pages, + exit_page: "/b", + exits: 3, + visitors: 2, + pageviews: 5, + date: ~D[2021-01-01] + ) + ]) + end + + test "can query with visit:exit_page dimension", %{conn: conn, site: site} do + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visits"], + "date_range" => "all", + "dimensions" => ["visit:exit_page"], + "include" => %{"imports" => true} + }) + + %{"results" => results} = json_response(conn, 200) + + assert results == [ + %{"dimensions" => ["/b"], "metrics" => [4]}, + %{"dimensions" => ["/a"], "metrics" => [1]} + ] + end + end + + describe "imported data" do + test "returns screen sizes breakdown when filtering by screen size", %{conn: conn, site: site} do + site_import = insert(:site_import, site: site) + + populate_stats(site, site_import.id, [ + build(:pageview, + timestamp: ~N[2021-01-01 00:00:01], + screen_size: "Mobile" + ), + build(:imported_devices, + device: "Mobile", + visitors: 3, + pageviews: 5, + date: ~D[2021-01-01] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors", "pageviews"], + "date_range" => "all", + "dimensions" => ["visit:device"], + "filters" => [ + ["is", "visit:device", ["Mobile"]] + ], + "include" => %{"imports" => true} + }) + + %{"results" => results} = json_response(conn, 200) + + assert results == [%{"dimensions" => ["Mobile"], "metrics" => [4, 6]}] + end + + test "returns custom event goals and pageview goals", %{conn: conn, site: site} do + insert(:goal, site: site, event_name: "Purchase") + insert(:goal, site: site, page_path: "/test") + + site_import = insert(:site_import, site: site) + + populate_stats(site, site_import.id, [ + build(:pageview, + timestamp: ~N[2021-01-01 00:00:01], + pathname: "/test" + ), + build(:event, + name: "Purchase", + timestamp: ~N[2021-01-01 00:00:03] + ), + build(:event, + name: "Purchase", + timestamp: ~N[2021-01-01 00:00:03] + ), + build(:imported_custom_events, + name: "Purchase", + visitors: 3, + events: 5, + date: ~D[2021-01-01] + ), + build(:imported_pages, + page: "/test", + visitors: 2, + pageviews: 2, + date: ~D[2021-01-01] + ), + build(:imported_visitors, visitors: 5, date: ~D[2021-01-01]) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "dimensions" => ["event:goal"], + "metrics" => ["visitors", "events", "pageviews", "conversion_rate"], + "include" => %{"imports" => true} + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => ["Purchase"], "metrics" => [5, 7, 0, 62.5]}, + %{"dimensions" => ["Visit /test"], "metrics" => [3, 3, 3, 37.5]} + ] + end + + test "pageviews are returned as events for breakdown reports other than custom events", %{ + conn: conn, + site: site + } do + site_import = insert(:site_import, site: site) + + populate_stats(site, site_import.id, [ + build(:imported_browsers, browser: "Chrome", pageviews: 1, date: ~D[2021-01-01]), + build(:imported_devices, device: "Desktop", pageviews: 1, date: ~D[2021-01-01]), + build(:imported_entry_pages, entry_page: "/test", pageviews: 1, date: ~D[2021-01-01]), + build(:imported_exit_pages, exit_page: "/test", pageviews: 1, date: ~D[2021-01-01]), + build(:imported_locations, country: "EE", pageviews: 1, date: ~D[2021-01-01]), + build(:imported_operating_systems, + operating_system: "Mac", + pageviews: 1, + date: ~D[2021-01-01] + ), + build(:imported_pages, page: "/test", pageviews: 1, date: ~D[2021-01-01]), + build(:imported_sources, source: "Google", pageviews: 1, date: ~D[2021-01-01]) + ]) + + breakdown_and_first = fn dimension -> + conn + |> post("/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["events"], + "date_range" => ["2021-01-01", "2021-01-01"], + "dimensions" => [dimension], + "include" => %{"imports" => true} + }) + |> json_response(200) + |> Map.get("results") + |> List.first() + end + + assert %{"dimensions" => ["Chrome"], "metrics" => [1]} = + breakdown_and_first.("visit:browser") + + assert %{"dimensions" => ["Desktop"], "metrics" => [1]} = + breakdown_and_first.("visit:device") + + assert %{"dimensions" => ["EE"], "metrics" => [1]} = breakdown_and_first.("visit:country") + assert %{"dimensions" => ["Mac"], "metrics" => [1]} = breakdown_and_first.("visit:os") + assert %{"dimensions" => ["/test"], "metrics" => [1]} = breakdown_and_first.("event:page") + + assert %{"dimensions" => ["Google"], "metrics" => [1]} = + breakdown_and_first.("visit:source") + end + + for event_name <- Plausible.Event.SystemEvents.events_with_url_prop() do + test "returns url breakdown for #{event_name} goal", %{conn: conn, site: site} do + insert(:goal, event_name: unquote(event_name), site: site) + site_import = insert(:site_import, site: site) + + populate_stats(site, site_import.id, [ + build(:event, + name: unquote(event_name), + "meta.key": ["url"], + "meta.value": ["https://one.com"] + ), + build(:imported_custom_events, + name: unquote(event_name), + visitors: 2, + events: 5, + link_url: "https://one.com" + ), + build(:imported_custom_events, + name: unquote(event_name), + visitors: 5, + events: 10, + link_url: "https://two.com" + ), + build(:imported_custom_events, + name: "some goal", + visitors: 5, + events: 10 + ), + build(:imported_visitors, visitors: 9) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors", "events", "conversion_rate"], + "date_range" => "all", + "dimensions" => ["event:props:url"], + "filters" => [ + ["is", "event:goal", [unquote(event_name)]] + ], + "include" => %{"imports" => true} + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => ["https://two.com"], "metrics" => [5, 10, 50]}, + %{"dimensions" => ["https://one.com"], "metrics" => [3, 6, 30]} + ] + + assert json_response(conn, 200)["meta"]["imports_included"] + refute json_response(conn, 200)["meta"]["imports_warning"] + end + end + + for event_name <- Plausible.Event.SystemEvents.events_with_path_prop() do + test "returns path breakdown for #{event_name} goal", %{conn: conn, site: site} do + insert(:goal, event_name: unquote(event_name), site: site) + site_import = insert(:site_import, site: site) + + populate_stats(site, site_import.id, [ + build(:event, + name: unquote(event_name), + "meta.key": ["path"], + "meta.value": ["/one"] + ), + build(:imported_custom_events, + name: unquote(event_name), + visitors: 2, + events: 5, + path: "/one" + ), + build(:imported_custom_events, + name: unquote(event_name), + visitors: 5, + events: 10, + path: "/two" + ), + build(:imported_custom_events, + name: "some goal", + visitors: 5, + events: 10 + ), + build(:imported_visitors, visitors: 9) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors", "events", "conversion_rate"], + "date_range" => "all", + "dimensions" => ["event:props:path"], + "filters" => [ + ["is", "event:goal", [unquote(event_name)]] + ], + "include" => %{"imports" => true} + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => ["/two"], "metrics" => [5, 10, 50]}, + %{"dimensions" => ["/one"], "metrics" => [3, 6, 30]} + ] + + assert json_response(conn, 200)["meta"]["imports_included"] + refute json_response(conn, 200)["meta"]["imports_warning"] + end + end + + test "gracefully ignores unsupported WP Search Queries goal for imported data", %{ + conn: conn, + site: site + } do + insert(:goal, event_name: "WP Search Queries", site: site) + site_import = insert(:site_import, site: site) + + populate_stats(site, site_import.id, [ + build(:event, + name: "WP Search Queries", + "meta.key": ["search_query", "result_count"], + "meta.value": ["some phrase", "12"] + ), + build(:imported_custom_events, + name: "view_search_results", + visitors: 100, + events: 200 + ), + build(:imported_visitors, visitors: 9) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors", "events", "conversion_rate"], + "date_range" => "all", + "dimensions" => ["event:props:search_query"], + "filters" => [ + ["is", "event:goal", ["WP Search Queries"]] + ], + "include" => %{"imports" => true} + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => ["some phrase"], "metrics" => [1, 1, 100.0]} + ] + end + + test "includes imported data for time:day dimension", %{ + conn: conn, + site: site + } do + site_import = insert(:site_import, site: site) + + insert(:goal, event_name: "Signup", site: site) + + populate_stats(site, site_import.id, [ + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-01 00:10:00]), + build(:pageview, timestamp: ~N[2021-01-01 23:59:00]), + build(:imported_visitors, date: ~D[2021-01-01], visitors: 5) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => ["2021-01-01", "2021-01-02"], + "dimensions" => ["time:day"], + "include" => %{"imports" => true} + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => ["2021-01-01"], "metrics" => [8]} + ] + + assert json_response(conn, 200)["meta"] == %{"imports_included" => true} + end + + test "adds a warning when time:hour dimension", %{ + conn: conn, + site: site + } do + site_import = insert(:site_import, site: site) + + insert(:goal, event_name: "Signup", site: site) + + populate_stats(site, site_import.id, [ + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-01 00:10:00]), + build(:pageview, timestamp: ~N[2021-01-01 23:59:00]), + build(:imported_visitors, date: ~D[2021-01-01], visitors: 5) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => ["2021-01-01", "2021-01-02"], + "dimensions" => ["time:hour"], + "include" => %{"imports" => true} + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => ["2021-01-01 00:00:00"], "metrics" => [2]}, + %{"dimensions" => ["2021-01-01 23:00:00"], "metrics" => [1]} + ] + + refute json_response(conn, 200)["meta"]["imports_included"] + assert json_response(conn, 200)["meta"]["imports_skip_reason"] == "unsupported_interval" + + assert json_response(conn, 200)["meta"]["imports_warning"] == @unsupported_interval_warning + end + + test "adds a warning when query params are not supported for imported data", %{ + conn: conn, + site: site + } do + site_import = insert(:site_import, site: site) + + insert(:goal, event_name: "Signup", site: site) + + populate_stats(site, site_import.id, [ + build(:event, + name: "Signup", + "meta.key": ["package"], + "meta.value": ["large"] + ), + build(:imported_visitors, visitors: 9) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => "all", + "dimensions" => ["event:props:package"], + "filters" => [ + ["is", "event:goal", ["Signup"]] + ], + "include" => %{"imports" => true} + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => ["large"], "metrics" => [1]} + ] + + refute json_response(conn, 200)["meta"]["imports_included"] + assert json_response(conn, 200)["meta"]["imports_skip_reason"] == "unsupported_query" + + assert json_response(conn, 200)["meta"]["imports_warning"] == @unsupported_query_warning + end + + test "does not add a warning when there are no site imports", %{conn: conn, site: site} do + insert(:goal, event_name: "Signup", site: site) + + populate_stats(site, [ + build(:event, + name: "Signup", + "meta.key": ["package"], + "meta.value": ["large"] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => "all", + "dimensions" => ["event:props:package"], + "filters" => [ + ["is", "event:goal", ["Signup"]] + ], + "include" => %{"imports" => true} + }) + + assert json_response(conn, 200)["meta"] == %{ + "imports_included" => false, + "imports_skip_reason" => "no_imported_data" + } + end + + test "does not add a warning when import is out of queried date range", %{ + conn: conn, + site: site + } do + site_import = insert(:site_import, site: site, end_date: Date.add(Date.utc_today(), -3)) + + insert(:goal, event_name: "Signup", site: site) + + populate_stats(site, site_import.id, [ + build(:event, + name: "Signup", + "meta.key": ["package"], + "meta.value": ["large"] + ), + build(:imported_visitors, visitors: 9) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => "day", + "dimensions" => ["event:props:package"], + "filters" => [ + ["is", "event:goal", ["Signup"]] + ], + "include" => %{"imports" => true} + }) + + assert json_response(conn, 200)["meta"] == %{ + "imports_included" => false, + "imports_skip_reason" => "out_of_range" + } + end + + test "applies multiple filters if the properties belong to the same table", %{ + conn: conn, + site: site + } do + site_import = insert(:site_import, site: site) + + populate_stats(site, site_import.id, [ + build(:imported_sources, source: "Google", utm_medium: "organic", utm_term: "one"), + build(:imported_sources, source: "Twitter", utm_medium: "organic", utm_term: "two"), + build(:imported_sources, + source: "Facebook", + utm_medium: "something_else", + utm_term: "one" + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => "day", + "dimensions" => ["visit:source"], + "filters" => [ + ["is", "visit:utm_medium", ["organic"]], + ["is", "visit:utm_term", ["one"]] + ], + "include" => %{"imports" => true} + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => ["Google"], "metrics" => [1]} + ] + end + + test "ignores imported data if filtered property belongs to a different table than the breakdown property", + %{ + conn: conn, + site: site + } do + site_import = insert(:site_import, site: site) + + populate_stats(site, site_import.id, [ + build(:imported_sources, source: "Google"), + build(:imported_devices, device: "Desktop") + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => "day", + "dimensions" => ["visit:source"], + "filters" => [ + ["is", "visit:device", ["Desktop"]] + ], + "include" => %{"imports" => true} + }) + + assert %{ + "results" => [], + "meta" => meta + } = json_response(conn, 200) + + refute json_response(conn, 200)["meta"]["imports_included"] + assert meta["imports_warning"] == @unsupported_query_warning + end + + test "imported country, region and city data", + %{ + conn: conn, + site: site + } do + site_import = insert(:site_import, site: site) + + populate_stats(site, site_import.id, [ + build(:pageview, + timestamp: ~N[2021-01-01 00:15:00], + country_code: "DE", + subdivision1_code: "DE-BE", + city_geoname_id: 2_950_159 + ), + build(:pageview, + timestamp: ~N[2021-01-01 00:15:00], + country_code: "DE", + subdivision1_code: "DE-BE", + city_geoname_id: 2_950_159 + ), + build(:pageview, + timestamp: ~N[2021-01-01 00:15:00], + country_code: "EE", + subdivision1_code: "EE-37", + city_geoname_id: 588_409 + ), + build(:imported_locations, country: "EE", region: "EE-37", city: 588_409, visitors: 33) + ]) + + for {dimension, stats_value, imports_value} <- [ + {"visit:country", "DE", "EE"}, + {"visit:region", "DE-BE", "EE-37"}, + {"visit:city", 2_950_159, 588_409}, + {"visit:country_name", "Germany", "Estonia"}, + {"visit:region_name", "Berlin", "Harjumaa"}, + {"visit:city_name", "Berlin", "Tallinn"} + ] do + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => "all", + "dimensions" => [dimension], + "include" => %{"imports" => true} + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => [imports_value], "metrics" => [34]}, + %{"dimensions" => [stats_value], "metrics" => [2]} + ] + end + end + + test "imported non-empty region IDs that don't have a corresponding name in our locations dictionary have region_name set to the ID itself, empty region IDs are excluded", + %{conn: conn, site: site} do + site_import = insert(:site_import, site: site) + + populate_stats(site, site_import.id, [ + # GA4 import: `region` is the name of the `region` + build(:imported_locations, country: "US", region: "California", visitors: 10), + # Plausible import: `region` is the region ID + build(:imported_locations, country: "EE", region: "EE-37", visitors: 5), + # Plausible import: `region` is the region ID, but the ID is stale + build(:imported_locations, country: "NO", region: "NO-99", visitors: 50), + # Plausible / GA4 import when the region is unknown + build(:imported_locations, country: "US", region: "", visitors: 99) + ]) + + for {dimensions, expected_dimension_values} <- [ + {["visit:region_name", "visit:region"], + [ + NO: ["NO-99", "NO-99"], + US: ["California", "California"], + EE: ["Harjumaa", "EE-37"] + ]}, + {["visit:region"], + [ + NO: ["NO-99"], + US: ["California"], + EE: ["EE-37"] + ]}, + {["visit:region_name"], + [ + NO: ["NO-99"], + US: ["California"], + EE: ["Harjumaa"] + ]} + ] do + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => "all", + "dimensions" => dimensions, + "include" => %{"imports" => true} + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => expected_dimension_values[:NO], "metrics" => [50]}, + %{"dimensions" => expected_dimension_values[:US], "metrics" => [10]}, + %{"dimensions" => expected_dimension_values[:EE], "metrics" => [5]} + ] + end + end + + test "imported country and city names", %{ + site: site, + conn: conn + } do + site_import = insert(:site_import, site: site) + + populate_stats(site, site_import.id, [ + build(:pageview, + country_code: "GB", + # London + city_geoname_id: 2_643_743 + ), + build(:pageview, + country_code: "CA", + # Different London + city_geoname_id: 6_058_560 + ), + build(:imported_locations, country: "GB", city: 2_643_743, visitors: 33) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => "all", + "dimensions" => ["visit:city_name"], + "include" => %{"imports" => true} + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => ["London"], "metrics" => [35]} + ] + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => "all", + "dimensions" => ["visit:city_name", "visit:country_name"], + "include" => %{"imports" => true} + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => ["London", "United Kingdom"], "metrics" => [34]}, + %{"dimensions" => ["London", "Canada"], "metrics" => [1]} + ] + end + + test "imported country and city names with complex conditions", %{ + site: site, + conn: conn + } do + site_import = insert(:site_import, site: site) + + populate_stats(site, site_import.id, [ + build(:pageview, + country_code: "GB", + # London + city_geoname_id: 2_643_743 + ), + build(:pageview, + country_code: "CA", + # Different London + city_geoname_id: 6_058_560 + ), + build(:imported_locations, + country: "EE", + # Tallinn + city: 588_409, + visitors: 3 + ), + build(:imported_locations, + country: "EE", + # Kärdla + city: 591_632, + visitors: 2 + ), + build(:imported_locations, + country: "GB", + # London + city: 2_643_743, + visitors: 33 + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => "all", + "dimensions" => ["visit:city_name", "visit:country_name"], + "filters" => [ + [ + "or", + [ + [ + "and", + [ + ["is", "visit:city_name", ["London"]], + ["not", ["is", "visit:country_name", ["Canada"]]] + ] + ], + ["is", "visit:country_name", ["Estonia"]] + ] + ] + ], + "include" => %{"imports" => true} + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => ["London", "United Kingdom"], "metrics" => [34]}, + %{"dimensions" => ["Tallinn", "Estonia"], "metrics" => [3]}, + %{"dimensions" => ["Kärdla", "Estonia"], "metrics" => [2]} + ] + end + + test "page breakdown with paginate_optimization (ideal case)", %{ + conn: conn, + site: site + } do + site_import = insert(:site_import, site: site) + + populate_stats( + site, + site_import.id, + [ + build(:pageview, pathname: "/99", timestamp: ~N[2021-01-01 00:00:00]) + ] ++ + Enum.map(1..100, fn i -> + build(:imported_pages, page: "/#{i}", pageviews: 1, visitors: 1, date: ~D[2021-01-01]) + end) + ) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["pageviews"], + "date_range" => "all", + "dimensions" => ["event:page"], + "include" => %{"imports" => true}, + "pagination" => %{"limit" => 1} + }) + + assert json_response(conn, 200)["results"] == [%{"dimensions" => ["/99"], "metrics" => [2]}] + end + + test "page breakdown with paginate_optimization (lossy case)", %{ + conn: conn, + site: site + } do + site_import = insert(:site_import, site: site) + + populate_stats( + site, + site_import.id, + [ + build(:pageview, pathname: "/99", timestamp: ~N[2021-01-01 00:00:00]) + ] ++ + Enum.map(1..200, fn i -> + build(:imported_pages, page: "/#{i}", pageviews: 1, visitors: 1, date: ~D[2021-01-01]) + end) + ) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["pageviews"], + "date_range" => "all", + "dimensions" => ["event:page"], + "include" => %{"imports" => true}, + "pagination" => %{"limit" => 1} + }) + + [%{"dimensions" => ["/99"], "metrics" => [pageviews]}] = json_response(conn, 200)["results"] + + # This is non-deterministic since /99 might not be in the top N items of imported pages subquery. + assert pageviews in 1..2 + end + end + + describe "metric warnings" do + test "returns scroll_depth warning when a site_import without scroll depth is in queried range", + %{ + conn: conn, + site: site + } do + site_import = + insert(:site_import, site: site, start_date: ~D[2021-02-01], end_date: ~D[2021-02-28]) + + populate_stats(site, site_import.id, [ + build(:pageview, user_id: 123, pathname: "/", timestamp: ~N[2021-02-01 00:00:00]), + build(:engagement, + user_id: 123, + pathname: "/", + timestamp: ~N[2021-02-01 00:01:00], + scroll_depth: 70 + ), + build(:imported_pages, page: "/", date: ~D[2021-02-01]), + build(:imported_pages, page: "/", date: ~D[2021-02-28]) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors", "scroll_depth"], + "date_range" => "all", + "filters" => [["is", "event:page", ["/"]]], + "include" => %{"imports" => true} + }) + + assert %{"results" => results, "meta" => meta} = json_response(conn, 200) + + assert results == [%{"dimensions" => [], "metrics" => [3, 70]}] + + assert meta["imports_included"] == true + + assert meta["metric_warnings"] == %{ + "scroll_depth" => @no_imported_scroll_depth_metric_warning + } + end + + test "does not return scroll depth warning when imports requested but rejected", %{ + conn: conn, + site: site + } do + site_import = + insert(:site_import, site: site, start_date: ~D[2021-02-01], end_date: ~D[2021-02-28]) + + populate_stats(site, site_import.id, [ + build(:pageview, user_id: 123, pathname: "/", timestamp: ~N[2021-02-01 00:00:00]), + build(:engagement, + user_id: 123, + pathname: "/", + timestamp: ~N[2021-02-01 00:01:00], + scroll_depth: 70 + ), + build(:imported_pages, page: "/", date: ~D[2021-02-01]), + build(:imported_pages, page: "/", date: ~D[2021-02-28]) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors", "scroll_depth"], + "date_range" => "all", + "filters" => [["is", "event:page", ["/"]], ["is_not", "visit:browser", ["abc"]]], + "include" => %{"imports" => true} + }) + + assert %{"results" => results, "meta" => meta} = json_response(conn, 200) + + assert results == [%{"dimensions" => [], "metrics" => [1, 70]}] + + refute meta["imports_included"] + refute meta["metric_warnings"]["scroll_depth"] + end + + test "does not return scroll_depth warning when imports without scroll depth exist but outside the queried range", + %{ + conn: conn, + site: site + } do + site_import = + insert(:site_import, + site: site, + start_date: ~D[2021-02-01], + end_date: ~D[2021-02-28] + ) + + populate_stats(site, site_import.id, [ + build(:imported_pages, page: "/", date: ~D[2021-02-01]), + build(:imported_pages, page: "/", date: ~D[2021-02-28]) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors", "scroll_depth"], + "date_range" => ["2022-01-01", "2022-01-31"], + "dimensions" => ["event:page"], + "include" => %{"imports" => true} + }) + + assert %{"results" => [], "meta" => meta} = json_response(conn, 200) + + refute meta["imports_included"] + refute meta["metric_warnings"]["scroll_depth"] + end + + test "does not return scroll_depth warning when imported scroll depth exists", %{ + conn: conn, + site: site + } do + site_import = + insert(:site_import, + site: site, + start_date: ~D[2021-02-01], + end_date: ~D[2021-02-28], + has_scroll_depth: true + ) + + populate_stats(site, site_import.id, [ + build(:pageview, user_id: 123, pathname: "/", timestamp: ~N[2021-02-01 00:00:00]), + build(:engagement, + user_id: 123, + pathname: "/", + timestamp: ~N[2021-02-01 00:01:00], + scroll_depth: 70 + ), + build(:imported_pages, + page: "/", + date: ~D[2021-02-01], + total_scroll_depth: 80, + total_scroll_depth_visits: 1 + ), + build(:imported_pages, page: "/", date: ~D[2021-02-28]) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors", "scroll_depth"], + "date_range" => "all", + "filters" => [["is", "event:page", ["/"]]], + "include" => %{"imports" => true} + }) + + assert %{"results" => results, "meta" => meta} = json_response(conn, 200) + + assert results == [%{"dimensions" => [], "metrics" => [3, 75]}] + + assert meta["imports_included"] + refute meta["metric_warnings"]["scroll_depth"] + end + + test "returns bounce_rate warning when query includes imported and page filter used", %{ + conn: conn, + site: site + } do + site_import = + insert(:site_import, site: site, start_date: ~D[2021-02-01], end_date: ~D[2021-02-28]) + + populate_stats(site, site_import.id, [ + build(:pageview, user_id: 123, pathname: "/", timestamp: ~N[2021-02-01 00:00:00]), + build(:pageview, user_id: 123, pathname: "/next", timestamp: ~N[2021-02-01 00:10:00]), + build(:pageview, pathname: "/", timestamp: ~N[2021-02-01 00:00:00]), + build(:imported_pages, page: "/", date: ~D[2021-02-01]), + build(:imported_pages, pageviews: 3, page: "/", date: ~D[2021-02-02]), + build(:imported_visitors, date: ~D[2021-02-01]), + build(:imported_visitors, pageviews: 3, date: ~D[2021-02-02]) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors", "pageviews", "bounce_rate"], + "date_range" => "all", + "filters" => [["is", "event:page", ["/"]]], + "include" => %{"imports" => true} + }) + + assert %{"results" => results, "meta" => meta} = json_response(conn, 200) + + assert results == [%{"dimensions" => [], "metrics" => [4, 6, 50.0]}] + + assert meta["imports_included"] == true + + assert bounce_rate_warning = meta["metric_warnings"]["bounce_rate"] + + expected_warning = Plausible.Stats.QueryResult.no_imported_bounce_rate_warning() + assert bounce_rate_warning["code"] == to_string(expected_warning.code) + assert bounce_rate_warning["warning"] == expected_warning.warning + end + + test "returns bounce_rate warning when query includes imported and page dimension used", %{ + conn: conn, + site: site + } do + site_import = + insert(:site_import, site: site, start_date: ~D[2021-02-01], end_date: ~D[2021-02-28]) + + populate_stats(site, site_import.id, [ + build(:pageview, user_id: 123, pathname: "/", timestamp: ~N[2021-02-01 00:00:00]), + build(:pageview, user_id: 123, pathname: "/next", timestamp: ~N[2021-02-01 00:10:00]), + build(:pageview, pathname: "/", timestamp: ~N[2021-02-01 00:00:00]), + build(:imported_pages, page: "/", date: ~D[2021-02-01]), + build(:imported_pages, pageviews: 3, page: "/", date: ~D[2021-02-02]), + build(:imported_visitors, date: ~D[2021-02-01]), + build(:imported_visitors, pageviews: 3, date: ~D[2021-02-02]) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors", "pageviews", "bounce_rate"], + "date_range" => "all", + "dimensions" => ["event:page"], + "include" => %{"imports" => true} + }) + + assert %{"results" => results, "meta" => meta} = json_response(conn, 200) + + assert results == [ + %{"dimensions" => ["/"], "metrics" => [4, 6, 50.0]}, + %{"dimensions" => ["/next"], "metrics" => [1, 1, 0.0]} + ] + + assert meta["imports_included"] == true + + assert bounce_rate_warning = meta["metric_warnings"]["bounce_rate"] + + expected_warning = Plausible.Stats.QueryResult.no_imported_bounce_rate_warning() + assert bounce_rate_warning["code"] == to_string(expected_warning.code) + assert bounce_rate_warning["warning"] == expected_warning.warning + end + end + + describe "order_by" do + test "bounce_rate", %{site: site, conn: conn} do + site_import = + insert(:site_import, site: site, start_date: ~D[2021-01-01], end_date: ~D[2021-02-28]) + + populate_stats(site, site_import.id, [ + build(:pageview, user_id: 123, pathname: "/a", timestamp: ~N[2021-01-01 00:00:00]), + build(:imported_entry_pages, + entry_page: "/a", + entrances: 2, + bounces: 1, + date: ~D[2021-01-01] + ), + build(:imported_entry_pages, + entry_page: "/b", + entrances: 4, + bounces: 1, + date: ~D[2021-01-01] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["bounce_rate"], + "order_by" => [["bounce_rate", "asc"]], + "date_range" => "all", + "dimensions" => ["visit:entry_page"], + "include" => %{"imports" => true} + }) + + response = json_response(conn, 200) + + assert [ + %{"dimensions" => ["/b"], "metrics" => [25.0]}, + %{"dimensions" => ["/a"], "metrics" => [67.0]} + ] = response["results"] + end + + test "visit_duration", %{site: site, conn: conn} do + site_import = + insert(:site_import, site: site, start_date: ~D[2021-01-01], end_date: ~D[2021-02-28]) + + populate_stats(site, site_import.id, [ + build(:pageview, user_id: 123, pathname: "/a", timestamp: ~N[2021-01-01 00:00:00]), + build(:imported_exit_pages, + exit_page: "/a", + exits: 2, + visit_duration: 90, + date: ~D[2021-01-01] + ), + build(:imported_exit_pages, + exit_page: "/b", + exits: 4, + visit_duration: 400, + date: ~D[2021-01-01] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visit_duration"], + "order_by" => [["visit_duration", "desc"]], + "dimensions" => ["visit:exit_page"], + "date_range" => "all", + "include" => %{"imports" => true} + }) + + response = json_response(conn, 200) + + assert [ + %{"dimensions" => ["/b"], "metrics" => [100.0]}, + %{"dimensions" => ["/a"], "metrics" => [30.0]} + ] = response["results"] + end + + test "views_per_visit", %{site: site, conn: conn} do + site_import = + insert(:site_import, site: site, start_date: ~D[2021-01-01], end_date: ~D[2021-02-28]) + + populate_stats(site, site_import.id, [ + build(:pageview, user_id: 123, pathname: "/a", timestamp: ~N[2021-01-01 00:00:00]), + build(:imported_visitors, + visits: 1, + pageviews: 3, + date: ~D[2021-01-01] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["views_per_visit"], + "order_by" => [["views_per_visit", "asc"]], + "date_range" => "all", + "include" => %{"imports" => true} + }) + + response = json_response(conn, 200) + + assert [%{"dimensions" => [], "metrics" => [2.0]}] = response["results"] + end + end + + describe "page scroll goals" do + setup :create_site_import + + test "rejects imported data with warning when page scroll goal is filtered by", %{ + conn: conn, + site: site, + site_import: site_import + } do + insert(:goal, + site: site, + page_path: "/blog", + scroll_threshold: 50, + display_name: "Scroll /blog 50" + ) + + insert(:goal, site: site, page_path: "/blog**") + + populate_stats(site, site_import.id, [ + build(:pageview, + user_id: 12, + pathname: "/blog", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:engagement, + user_id: 12, + pathname: "/blog", + timestamp: ~N[2021-01-01 00:00:10], + scroll_depth: 50 + ), + build(:imported_pages, page: "/blog", date: ~D[2021-01-01]) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors", "events", "conversion_rate"], + "date_range" => "all", + "filters" => [["is", "event:goal", ["Scroll /blog 50"]]], + "include" => %{"imports" => true} + }) + + assert %{ + "results" => results, + "meta" => %{ + "imports_included" => false, + "imports_skip_reason" => "unsupported_query", + "imports_warning" => @unsupported_query_warning + } + } = json_response(conn, 200) + + assert results == [%{"dimensions" => [], "metrics" => [1, nil, 100.0]}] + end + + test "rejects imported data with warning when page scroll goal is filtered by (IN filter)", %{ + conn: conn, + site: site, + site_import: site_import + } do + insert(:goal, + site: site, + page_path: "/blog", + scroll_threshold: 50, + display_name: "Scroll /blog 50" + ) + + insert(:goal, site: site, page_path: "/blog**") + + populate_stats(site, site_import.id, [ + build(:pageview, + user_id: 12, + pathname: "/blog", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:engagement, + user_id: 12, + pathname: "/blog", + timestamp: ~N[2021-01-01 00:00:10], + scroll_depth: 50 + ), + build(:imported_pages, page: "/blog", date: ~D[2021-01-01]) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors", "events", "conversion_rate"], + "date_range" => "all", + "filters" => [["is", "event:goal", ["Scroll /blog 50", "Visit /blog**"]]], + "include" => %{"imports" => true} + }) + + assert %{ + "results" => results, + "meta" => %{ + "imports_included" => false, + "imports_skip_reason" => "unsupported_query", + "imports_warning" => @unsupported_query_warning + } + } = json_response(conn, 200) + + assert results == [%{"dimensions" => [], "metrics" => [1, nil, 100.0]}] + end + + test "ignores imported data for page scroll goal conversions in goal breakdown", %{ + conn: conn, + site: site, + site_import: site_import + } do + insert(:goal, + site: site, + page_path: "/blog", + scroll_threshold: 50, + display_name: "Scroll /blog 50" + ) + + insert(:goal, site: site, page_path: "/blog**") + + populate_stats(site, site_import.id, [ + build(:pageview, + user_id: 12, + pathname: "/blog", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:engagement, + user_id: 12, + pathname: "/blog", + timestamp: ~N[2021-01-01 00:00:10], + scroll_depth: 50 + ), + build(:imported_pages, page: "/blog"), + build(:imported_visitors) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors", "events", "conversion_rate"], + "date_range" => "all", + "include" => %{"imports" => true}, + "dimensions" => ["event:goal"] + }) + + assert %{ + "results" => results, + "meta" => %{"imports_included" => true} + } = json_response(conn, 200) + + assert results == [ + %{"dimensions" => ["Visit /blog**"], "metrics" => [2, 2, 100.0]}, + %{"dimensions" => ["Scroll /blog 50"], "metrics" => [1, nil, 50.0]} + ] + end + end +end diff --git a/test/plausible_web/controllers/api/external_stats_controller/query_special_metrics_test.exs b/test/plausible_web/controllers/api/external_stats_controller/query_special_metrics_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..1b69d1a4a8ec42beed7e9d010c8c912e20778811 --- /dev/null +++ b/test/plausible_web/controllers/api/external_stats_controller/query_special_metrics_test.exs @@ -0,0 +1,219 @@ +defmodule PlausibleWeb.Api.ExternalStatsController.QuerySpecialMetricsTest do + use PlausibleWeb.ConnCase + + setup [:create_user, :create_site, :create_api_key, :use_api_key] + + test "returns conversion_rate in a goal filtered custom prop breakdown", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, pathname: "/blog/1", "meta.key": ["author"], "meta.value": ["Uku"]), + build(:pageview, pathname: "/blog/2", "meta.key": ["author"], "meta.value": ["Uku"]), + build(:pageview, pathname: "/blog/3", "meta.key": ["author"], "meta.value": ["Uku"]), + build(:pageview, pathname: "/blog/1", "meta.key": ["author"], "meta.value": ["Marko"]), + build(:pageview, + pathname: "/blog/2", + "meta.key": ["author"], + "meta.value": ["Marko"], + user_id: 1 + ), + build(:pageview, + pathname: "/blog/3", + "meta.key": ["author"], + "meta.value": ["Marko"], + user_id: 1 + ), + build(:pageview, pathname: "/blog"), + build(:pageview, "meta.key": ["author"], "meta.value": ["Marko"]), + build(:pageview) + ]) + + insert(:goal, %{site: site, page_path: "/blog**"}) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "filters" => [["is", "event:goal", ["Visit /blog**"]]], + "metrics" => ["visitors", "events", "conversion_rate"], + "dimensions" => ["event:props:author"] + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => ["Uku"], "metrics" => [3, 3, 37.5]}, + %{"dimensions" => ["Marko"], "metrics" => [2, 3, 25.0]}, + %{"dimensions" => ["(none)"], "metrics" => [1, 1, 12.5]} + ] + end + + test "returns conversion_rate alone in a goal filtered custom prop breakdown", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, pathname: "/blog/1", "meta.key": ["author"], "meta.value": ["Uku"]), + build(:pageview) + ]) + + insert(:goal, %{site: site, page_path: "/blog**"}) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["conversion_rate"], + "date_range" => "all", + "dimensions" => ["event:props:author"], + "filters" => [["is", "event:goal", ["Visit /blog**"]]] + }) + + %{"results" => results} = json_response(conn, 200) + + assert results == [ + %{"dimensions" => ["Uku"], "metrics" => [50]} + ] + end + + test "returns conversion_rate in a goal filtered event:page breakdown", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:event, pathname: "/en/register", name: "pageview"), + build(:event, pathname: "/en/register", name: "Signup"), + build(:event, pathname: "/en/register", name: "Signup"), + build(:event, pathname: "/it/register", name: "Signup", user_id: 1), + build(:event, pathname: "/it/register", name: "Signup", user_id: 1), + build(:event, pathname: "/it/register", name: "pageview") + ]) + + insert(:goal, %{site: site, event_name: "Signup"}) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "dimensions" => ["event:page"], + "filters" => [["is", "event:goal", ["Signup"]]], + "metrics" => ["visitors", "events", "group_conversion_rate"] + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => ["/en/register"], "metrics" => [2, 2, 66.67]}, + %{"dimensions" => ["/it/register"], "metrics" => [1, 2, 50.0]} + ] + end + + test "returns conversion_rate alone in a goal filtered event:page breakdown", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:event, pathname: "/en/register", name: "pageview"), + build(:event, pathname: "/en/register", name: "Signup") + ]) + + insert(:goal, %{site: site, event_name: "Signup"}) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "filters" => [["is", "event:goal", ["Signup"]]], + "metrics" => ["group_conversion_rate"], + "dimensions" => ["event:page"] + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => ["/en/register"], "metrics" => [50.0]} + ] + end + + test "returns conversion_rate in a multi-goal filtered visit:screen_size breakdown", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:event, screen_size: "Mobile", name: "pageview"), + build(:event, screen_size: "Mobile", name: "AddToCart"), + build(:event, screen_size: "Mobile", name: "AddToCart"), + build(:event, screen_size: "Desktop", name: "AddToCart", user_id: 1), + build(:event, screen_size: "Desktop", name: "Purchase", user_id: 1), + build(:event, screen_size: "Desktop", name: "pageview") + ]) + + # Make sure that revenue goals are treated the same + # way as regular custom event goals + insert(:goal, %{site: site, event_name: "Purchase", currency: :EUR}) + insert(:goal, %{site: site, event_name: "AddToCart"}) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors", "events", "group_conversion_rate"], + "date_range" => "all", + "dimensions" => ["visit:device"], + "filters" => [["is", "event:goal", ["AddToCart", "Purchase"]]] + }) + + %{"results" => results} = json_response(conn, 200) + + assert results == [ + %{"dimensions" => ["Mobile"], "metrics" => [2, 2, 66.67]}, + %{"dimensions" => ["Desktop"], "metrics" => [1, 2, 50]} + ] + end + + test "returns conversion_rate alone in a goal filtered visit:screen_size breakdown", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:event, screen_size: "Mobile", name: "pageview"), + build(:event, screen_size: "Mobile", name: "AddToCart") + ]) + + insert(:goal, %{site: site, event_name: "AddToCart"}) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["conversion_rate"], + "date_range" => "all", + "dimensions" => ["visit:device"], + "filters" => [["is", "event:goal", ["AddToCart"]]] + }) + + %{"results" => results} = json_response(conn, 200) + + assert results == [ + %{"dimensions" => ["Mobile"], "metrics" => [50]} + ] + end + + test "can break down by visit:device with only percentage metric", %{conn: conn, site: site} do + site_import = insert(:site_import, site: site) + + populate_stats(site, site_import.id, [ + build(:pageview, screen_size: "Mobile"), + build(:pageview, screen_size: "Mobile"), + build(:pageview, screen_size: "Desktop"), + build(:imported_visitors, visitors: 5, date: ~D[2021-01-01]), + build(:imported_devices, device: "Desktop", visitors: 5, date: ~D[2021-01-01]) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["percentage"], + "date_range" => "all", + "dimensions" => ["visit:device"], + "include" => %{"imports" => true} + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => ["Desktop"], "metrics" => [75.0]}, + %{"dimensions" => ["Mobile"], "metrics" => [25.0]} + ] + end +end diff --git a/test/plausible_web/controllers/api/external_stats_controller/query_test.exs b/test/plausible_web/controllers/api/external_stats_controller/query_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..0bf16505e1646da3d986f2c1c1d05a38310a321e --- /dev/null +++ b/test/plausible_web/controllers/api/external_stats_controller/query_test.exs @@ -0,0 +1,5331 @@ +defmodule PlausibleWeb.Api.ExternalStatsController.QueryTest do + use PlausibleWeb.ConnCase + + @user_id Enum.random(1000..9999) + + setup [:create_user, :create_site, :create_api_key, :use_api_key] + + test "aggregates a single metric", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, user_id: @user_id, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, user_id: @user_id, timestamp: ~N[2021-01-01 00:25:00]), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["pageviews"], + "date_range" => "all" + }) + + assert json_response(conn, 200)["results"] == [%{"metrics" => [3], "dimensions" => []}] + end + + test "aggregate views_per_visit rounds to two decimal places", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, user_id: @user_id, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, user_id: @user_id, timestamp: ~N[2021-01-01 00:25:00]), + build(:pageview, user_id: 456, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, user_id: 456, timestamp: ~N[2021-01-01 00:25:00]), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["views_per_visit"], + "date_range" => "all" + }) + + assert json_response(conn, 200)["results"] == [%{"metrics" => [1.67], "dimensions" => []}] + end + + test "aggregates all metrics in a single query", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, user_id: @user_id, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, user_id: @user_id, timestamp: ~N[2021-01-01 00:25:00]), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => [ + "pageviews", + "visits", + "views_per_visit", + "visitors", + "bounce_rate", + "visit_duration" + ] + }) + + assert json_response(conn, 200)["results"] == [ + %{"metrics" => [3, 2, 1.5, 2, 50, 750], "dimensions" => []} + ] + end + + describe "aggregation with filters" do + test "can filter by source", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + referrer_source: "Google", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: @user_id, + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["pageviews", "visitors", "bounce_rate", "visit_duration"], + "filters" => [["is", "visit:source", ["Google"]]] + }) + + assert json_response(conn, 200)["results"] == [ + %{"metrics" => [2, 1, 0, 1500], "dimensions" => []} + ] + end + + test "does not count engagement events towards the events metric in a simple aggregate query", + %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, user_id: 234, timestamp: ~N[2021-01-01 00:00:00]), + build(:engagement, user_id: 234, timestamp: ~N[2021-01-01 00:00:01]) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["events"] + }) + + assert json_response(conn, 200)["results"] == [ + %{"metrics" => [1], "dimensions" => []} + ] + end + + test "engagement events do not affect bounce rate and visit duration", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, user_id: 123, timestamp: ~N[2021-01-01 00:00:00]), + build(:engagement, user_id: 123, timestamp: ~N[2021-01-01 00:00:03]) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["bounce_rate", "visit_duration"] + }) + + assert json_response(conn, 200)["results"] == [ + %{"metrics" => [100, 0], "dimensions" => []} + ] + end + + test "can filter by channel", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + referrer_source: "Google", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: @user_id, + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["pageviews", "visitors", "bounce_rate", "visit_duration"], + "filters" => [["is", "visit:channel", ["Organic Search"]]] + }) + + assert json_response(conn, 200)["results"] == [ + %{"metrics" => [2, 1, 0, 1500], "dimensions" => []} + ] + end + + test "can filter by no source/referrer", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: @user_id, + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, + referrer_source: "Google", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["pageviews", "visitors", "bounce_rate", "visit_duration"], + "filters" => [["is", "visit:source", ["Direct / None"]]] + }) + + assert json_response(conn, 200)["results"] == [ + %{"metrics" => [2, 1, 0, 1500], "dimensions" => []} + ] + end + + test "can filter by referrer", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + referrer: "https://facebook.com", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: @user_id, + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["pageviews", "visitors", "bounce_rate", "visit_duration"], + "filters" => [["is", "visit:referrer", ["https://facebook.com"]]] + }) + + assert json_response(conn, 200)["results"] == [ + %{"metrics" => [2, 1, 0, 1500], "dimensions" => []} + ] + end + + test "contains referrer filter", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, referrer: "https://a.com"), + build(:pageview, referrer: "https://a.com"), + build(:pageview, referrer: "https://ab.com") + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors"], + "filters" => [ + ["contains", "visit:referrer", ["a.com"]] + ] + }) + + assert json_response(conn, 200)["results"] == [%{"metrics" => [2], "dimensions" => []}] + end + + test "can filter by utm_medium", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + utm_medium: "social", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: @user_id, + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["pageviews", "visitors", "bounce_rate", "visit_duration"], + "filters" => [["is", "visit:utm_medium", ["social"]]] + }) + + assert json_response(conn, 200)["results"] == [ + %{"metrics" => [2, 1, 0, 1500], "dimensions" => []} + ] + end + + test "can filter by utm_medium case insensitively", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + utm_medium: "Social", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + utm_medium: "SOCIAL", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["pageviews", "visitors", "bounce_rate", "visit_duration"], + "filters" => [["is", "visit:utm_medium", ["sociaL"], %{"case_sensitive" => false}]] + }) + + assert json_response(conn, 200)["results"] == [ + %{"metrics" => [2, 1, 0, 1500], "dimensions" => []} + ] + end + + test "can filter by is_not utm_medium case insensitively", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + utm_medium: "Social", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + utm_medium: "SOCIAL", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["pageviews"], + "filters" => [["is_not", "visit:utm_medium", ["sociaL"], %{"case_sensitive" => false}]] + }) + + assert json_response(conn, 200)["results"] == [ + %{"metrics" => [1], "dimensions" => []} + ] + end + + test "can filter by utm_source", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + utm_source: "Twitter", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: @user_id, + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["pageviews", "visitors", "bounce_rate", "visit_duration"], + "filters" => [["is", "visit:utm_source", ["Twitter"]]] + }) + + assert json_response(conn, 200)["results"] == [ + %{"metrics" => [2, 1, 0, 1500], "dimensions" => []} + ] + end + + test "can filter by utm_campaign", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + utm_campaign: "profile", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: @user_id, + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["pageviews", "visitors", "bounce_rate", "visit_duration"], + "filters" => [["is", "visit:utm_campaign", ["profile"]]] + }) + + assert json_response(conn, 200)["results"] == [ + %{"metrics" => [2, 1, 0, 1500], "dimensions" => []} + ] + end + + test "can filter by device type", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + screen_size: "Desktop", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: @user_id, + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["pageviews", "visitors", "bounce_rate", "visit_duration"], + "filters" => [["is", "visit:device", ["Desktop"]]] + }) + + assert json_response(conn, 200)["results"] == [ + %{"metrics" => [2, 1, 0, 1500], "dimensions" => []} + ] + end + + test "can filter by browser", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + browser: "Chrome", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: @user_id, + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["pageviews", "visitors", "bounce_rate", "visit_duration"], + "filters" => [["is", "visit:browser", ["Chrome"]]] + }) + + assert json_response(conn, 200)["results"] == [ + %{"metrics" => [2, 1, 0, 1500], "dimensions" => []} + ] + end + + test "can filter by browser version", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + browser: "Chrome", + browser_version: "56", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: @user_id, + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["pageviews", "visitors", "bounce_rate", "visit_duration"], + "filters" => [["is", "visit:browser_version", ["56"]]] + }) + + assert json_response(conn, 200)["results"] == [ + %{"metrics" => [2, 1, 0, 1500], "dimensions" => []} + ] + end + + test "can filter by operating system", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + operating_system: "Mac", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: @user_id, + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["pageviews", "visitors", "bounce_rate", "visit_duration"], + "filters" => [["is", "visit:os", ["Mac"]]] + }) + + assert json_response(conn, 200)["results"] == [ + %{"metrics" => [2, 1, 0, 1500], "dimensions" => []} + ] + end + + test "can filter by operating system version", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + operating_system_version: "10.5", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: @user_id, + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["pageviews", "visitors", "bounce_rate", "visit_duration"], + "filters" => [["is", "visit:os_version", ["10.5"]]] + }) + + assert json_response(conn, 200)["results"] == [ + %{"metrics" => [2, 1, 0, 1500], "dimensions" => []} + ] + end + + test "can filter by country", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + country_code: "EE", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: @user_id, + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["pageviews", "visitors", "bounce_rate", "visit_duration"], + "filters" => [["is", "visit:country", ["EE"]]] + }) + + assert json_response(conn, 200)["results"] == [ + %{"metrics" => [2, 1, 0, 1500], "dimensions" => []} + ] + end + + test "can filter by page", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: @user_id, + pathname: "/blogpost", + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, + pathname: "/blogpost", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["pageviews", "visitors", "bounce_rate", "visit_duration"], + "filters" => [ + ["is", "event:page", ["/blogpost"]] + ] + }) + + assert json_response(conn, 200)["results"] == [ + %{"metrics" => [2, 2, 100, 750], "dimensions" => []} + ] + end + + test "can filter by hostname", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + hostname: "one.example.com", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + hostname: "example.com", + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["pageviews", "visitors", "bounce_rate", "visit_duration"], + "filters" => [ + ["contains", "event:hostname", ["one.example.com", "example.com"]] + ] + }) + + assert json_response(conn, 200)["results"] == [ + %{"metrics" => [2, 2, 100, 0], "dimensions" => []} + ] + end + + test "filtering by event:name", %{conn: conn, site: site} do + populate_stats(site, [ + build(:event, + name: "Signup", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Signup", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:event, + name: "Signup", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors", "pageviews"], + "filters" => [ + ["is", "event:name", ["Signup"]] + ] + }) + + assert json_response(conn, 200)["results"] == [%{"metrics" => [2, 0], "dimensions" => []}] + end + + test "filtering by a custom event goal", %{conn: conn, site: site} do + populate_stats(site, [ + build(:event, + name: "Signup", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Signup", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:event, + name: "Signup", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:event, + name: "NotConfigured", + timestamp: ~N[2021-01-01 00:25:00] + ) + ]) + + insert(:goal, %{site: site, event_name: "Signup"}) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors", "events"], + "filters" => [ + ["is", "event:goal", ["Signup"]] + ] + }) + + assert json_response(conn, 200)["results"] == [%{"metrics" => [2, 3], "dimensions" => []}] + end + + test "filtering by a custom event goal (case insensitive)", %{conn: conn, site: site} do + populate_stats(site, [ + build(:event, + name: "Signup", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Signup", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:event, + name: "Signup", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:event, + name: "NotConfigured", + timestamp: ~N[2021-01-01 00:25:00] + ) + ]) + + insert(:goal, %{site: site, event_name: "Signup"}) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors", "events"], + "filters" => [ + ["is", "event:goal", ["signup"], %{"case_sensitive" => false}] + ] + }) + + assert json_response(conn, 200)["results"] == [%{"metrics" => [2, 3], "dimensions" => []}] + end + + test "filtering by a revenue goal", %{conn: conn, site: site} do + populate_stats(site, [ + build(:event, + name: "Purchase", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Purchase", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:event, + name: "Purchase", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:25:00] + ) + ]) + + insert(:goal, site: site, currency: :USD, event_name: "Purchase") + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors", "events"], + "filters" => [ + ["is", "event:goal", ["Purchase"]] + ] + }) + + assert json_response(conn, 200)["results"] == [%{"metrics" => [2, 3], "dimensions" => []}] + end + + test "filtering by a simple pageview goal", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/register", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/register", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, + pathname: "/register", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, + pathname: "/", + timestamp: ~N[2021-01-01 00:25:00] + ) + ]) + + insert(:goal, %{site: site, page_path: "/register"}) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors", "pageviews"], + "filters" => [ + ["is", "event:goal", ["Visit /register"]] + ] + }) + + assert json_response(conn, 200)["results"] == [%{"metrics" => [2, 3], "dimensions" => []}] + end + + test "filtering by a wildcard pageview goal", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/blog/post-1"), + build(:pageview, pathname: "/blog/post-2", user_id: @user_id), + build(:pageview, pathname: "/blog", user_id: @user_id), + build(:pageview, pathname: "/") + ]) + + insert(:goal, %{site: site, page_path: "/blog**"}) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors", "pageviews"], + "filters" => [ + ["is", "event:goal", ["Visit /blog**"]] + ] + }) + + assert json_response(conn, 200)["results"] == [%{"metrics" => [2, 3], "dimensions" => []}] + end + + test "filtering by multiple custom event goals", %{conn: conn, site: site} do + populate_stats(site, [ + build(:event, name: "Signup"), + build(:event, name: "Purchase", user_id: @user_id), + build(:event, name: "Purchase", user_id: @user_id), + build(:pageview) + ]) + + insert(:goal, %{site: site, event_name: "Signup"}) + insert(:goal, %{site: site, event_name: "Purchase"}) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors", "events"], + "filters" => [ + ["is", "event:goal", ["Signup", "Purchase"]] + ] + }) + + assert json_response(conn, 200)["results"] == [%{"metrics" => [2, 3], "dimensions" => []}] + end + + test "filtering by multiple mixed goals", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/account/register"), + build(:pageview, pathname: "/register", user_id: @user_id), + build(:event, name: "Signup", user_id: @user_id), + build(:pageview) + ]) + + insert(:goal, %{site: site, event_name: "Signup"}) + insert(:goal, %{site: site, page_path: "/**register"}) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors", "events", "pageviews"], + "filters" => [ + ["is", "event:goal", ["Signup", "Visit /**register"]] + ] + }) + + assert json_response(conn, 200)["results"] == [ + %{"metrics" => [2, 3, 2], "dimensions" => []} + ] + end + + test "combining filters", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/blogpost", + country_code: "EE", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: @user_id, + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, + pathname: "/blogpost", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["pageviews", "visitors", "bounce_rate", "visit_duration"], + "filters" => [ + ["is", "event:page", ["/blogpost"]], + ["is", "visit:country", ["EE"]] + ] + }) + + assert json_response(conn, 200)["results"] == [ + %{"metrics" => [1, 1, 0, 1500], "dimensions" => []} + ] + end + + test "contains page filter", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/en/page1"), + build(:pageview, pathname: "/en/page2"), + build(:pageview, pathname: "/pl/page1") + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors"], + "filters" => [ + ["contains", "event:page", ["/en/"]] + ] + }) + + assert json_response(conn, 200)["results"] == [%{"metrics" => [2], "dimensions" => []}] + end + + test "contains page filter case insensitive", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/en/page1"), + build(:pageview, pathname: "/EN/page2"), + build(:pageview, pathname: "/pl/page1") + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors"], + "filters" => [ + ["contains", "event:page", ["/En/"], %{"case_sensitive" => false}] + ] + }) + + assert json_response(conn, 200)["results"] == [%{"metrics" => [2], "dimensions" => []}] + end + + test "contains_not page filter case insensitive", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/en/page1"), + build(:pageview, pathname: "/EN/page2"), + build(:pageview, pathname: "/pl/page1") + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors"], + "filters" => [ + ["contains_not", "event:page", ["/En/"], %{"case_sensitive" => false}] + ] + }) + + assert json_response(conn, 200)["results"] == [%{"metrics" => [1], "dimensions" => []}] + end + + test "contains_not page filter", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/en/page1"), + build(:pageview, pathname: "/en/page2"), + build(:pageview, pathname: "/pl/page1") + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors"], + "filters" => [ + ["contains_not", "event:page", ["/en/"]] + ] + }) + + assert json_response(conn, 200)["results"] == [%{"metrics" => [1], "dimensions" => []}] + end + + test "contains with and/or/not filters", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/en/page1"), + build(:pageview, pathname: "/en/page2"), + build(:pageview, pathname: "/eng/page1"), + build(:pageview, pathname: "/pl/page1"), + build(:pageview, pathname: "/gb/page1") + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors"], + "filters" => [ + [ + "or", + [ + [ + "and", + [ + ["contains", "event:page", ["/en"]], + ["not", ["contains", "event:page", ["/eng"]]] + ] + ], + ["contains", "event:page", ["/gb"]] + ] + ] + ] + }) + + assert json_response(conn, 200)["results"] == [%{"metrics" => [3], "dimensions" => []}] + end + + test "contains and member filter combined", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/en/page1"), + build(:pageview, pathname: "/en/page2"), + build(:pageview, pathname: "/pl/page1"), + build(:pageview, pathname: "/ee/page1") + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors"], + "filters" => [ + ["contains", "event:page", ["/en/", "/pl/"]] + ] + }) + + assert json_response(conn, 200)["results"] == [%{"metrics" => [3], "dimensions" => []}] + end + + test "can escape pipe character in member + contains filter", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/blog/post|1"), + build(:pageview, pathname: "/otherpost|1"), + build(:pageview, pathname: "/blog/post|2"), + build(:pageview, pathname: "/something-else") + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors"], + "filters" => [ + ["contains", "event:page", ["post|1", "/something-else"]] + ] + }) + + assert json_response(conn, 200)["results"] == [%{"metrics" => [3], "dimensions" => []}] + end + + test "contains source filter with no ref value match", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, referrer: ""), + build(:pageview, referrer_source: "Google"), + build(:pageview, referrer_source: "https://DirectX.com") + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors"], + "filters" => [ + ["contains", "visit:source", ["Direct"]] + ] + }) + + assert json_response(conn, 200)["results"] == [%{"metrics" => [2], "dimensions" => []}] + end + + test "contains not source filter with no ref value match", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, referrer: ""), + build(:pageview, referrer_source: "Google"), + build(:pageview, referrer_source: "https://DirectX.com") + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors"], + "filters" => [ + ["contains_not", "visit:source", ["Direct"]] + ] + }) + + assert json_response(conn, 200)["results"] == [%{"metrics" => [1], "dimensions" => []}] + end + + test "contains source filter with no ref value match combined with member filter", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, referrer: ""), + build(:pageview, referrer_source: "Google"), + build(:pageview, referrer_source: "Bing"), + build(:pageview, referrer_source: "https://DirectX.com") + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors"], + "filters" => [ + ["contains", "visit:source", ["Direct", "Google"]] + ] + }) + + assert json_response(conn, 200)["results"] == [%{"metrics" => [3], "dimensions" => []}] + end + + test "contains source filter with no ref value match without case sensitivity", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, referrer: ""), + build(:pageview, referrer_source: "Google"), + build(:pageview, referrer_source: "Bing"), + build(:pageview, referrer_source: "https://DirectX.com") + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors"], + "filters" => [ + ["contains", "visit:source", ["direct"], %{"case_sensitive" => false}] + ] + }) + + assert json_response(conn, 200)["results"] == [%{"metrics" => [2], "dimensions" => []}] + end + + test "`matches` operator", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/user/1234"), + build(:pageview, pathname: "/user/789/contributions"), + build(:pageview, pathname: "/blog/user/1234"), + build(:pageview, pathname: "/user/ef/contributions"), + build(:pageview, pathname: "/other/path") + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors"], + "filters" => [ + ["matches", "event:page", ["^/user/[0-9]+"]] + ] + }) + + assert json_response(conn, 200)["results"] == [%{"metrics" => [2], "dimensions" => []}] + end + + test "`matches_not` operator", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/user/1234"), + build(:pageview, pathname: "/user/789/contributions"), + build(:pageview, pathname: "/blog/user/1234"), + build(:pageview, pathname: "/user/ef/contributions"), + build(:pageview, pathname: "/other/path") + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors"], + "filters" => [ + ["matches_not", "event:page", ["^/user/[0-9]+"]] + ] + }) + + assert json_response(conn, 200)["results"] == [%{"metrics" => [3], "dimensions" => []}] + end + + test "`contains` and `contains_not` operator with custom properties", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, + "meta.key": ["tier", "value"], + "meta.value": ["large-1", "ax"] + ), + build(:pageview, + "meta.key": ["tier", "value"], + "meta.value": ["small-1", "bx"] + ), + build(:pageview, + "meta.key": ["tier", "value"], + "meta.value": ["small-1", "ax"] + ), + build(:pageview, + "meta.key": ["tier", "value"], + "meta.value": ["small-2", "bx"] + ), + build(:pageview, + "meta.key": ["tier", "value"], + "meta.value": ["small-2", "cx"] + ), + build(:pageview, + "meta.key": ["tier"], + "meta.value": ["small-3"] + ), + build(:pageview, + "meta.key": ["value"], + "meta.value": ["ax"] + ), + build(:pageview) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors"], + "filters" => [ + ["contains", "event:props:tier", ["small"]], + ["contains_not", "event:props:value", ["b", "c"]] + ] + }) + + assert json_response(conn, 200)["results"] == [%{"metrics" => [1], "dimensions" => []}] + end + + test "`contains` operator with custom properties case insensitive", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, + "meta.key": ["name"], + "meta.value": ["large-1"] + ), + build(:pageview, + "meta.key": ["name"], + "meta.value": ["Small-1"] + ), + build(:pageview, + "meta.key": ["name"], + "meta.value": ["mall-1"] + ), + build(:pageview, + "meta.key": ["name"], + "meta.value": ["SMALL-2"] + ), + build(:pageview, + "meta.key": ["name"], + "meta.value": ["small-2"] + ), + build(:pageview) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors"], + "filters" => [ + ["contains", "event:props:name", ["maLL"], %{"case_sensitive" => false}] + ] + }) + + assert json_response(conn, 200)["results"] == [%{"metrics" => [4], "dimensions" => []}] + end + + test "`contains_not` operator with custom properties case insensitive", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, + "meta.key": ["name"], + "meta.value": ["large-1"] + ), + build(:pageview, + "meta.key": ["name"], + "meta.value": ["Small-1"] + ), + build(:pageview, + "meta.key": ["name"], + "meta.value": ["mall-1"] + ), + build(:pageview, + "meta.key": ["name"], + "meta.value": ["SMALL-2"] + ), + build(:pageview, + "meta.key": ["name"], + "meta.value": ["small-2"] + ), + build(:pageview) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors"], + "filters" => [ + ["contains_not", "event:props:name", ["maLL"], %{"case_sensitive" => false}] + ] + }) + + assert json_response(conn, 200)["results"] == [%{"metrics" => [1], "dimensions" => []}] + end + + test "`matches` and `matches_not` operator with custom properties", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, + "meta.key": ["tier", "value"], + "meta.value": ["large-1", "a"] + ), + build(:pageview, + "meta.key": ["tier", "value"], + "meta.value": ["small-1", "b"] + ), + build(:pageview, + "meta.key": ["tier", "value"], + "meta.value": ["small-1", "a"] + ), + build(:pageview, + "meta.key": ["tier", "value"], + "meta.value": ["small-2", "b"] + ), + build(:pageview, + "meta.key": ["tier", "value"], + "meta.value": ["small-2", "c"] + ), + build(:pageview, + "meta.key": ["tier"], + "meta.value": ["small-3"] + ), + build(:pageview, + "meta.key": ["value"], + "meta.value": ["a"] + ), + build(:pageview) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors"], + "filters" => [ + ["matches", "event:props:tier", ["small.+"]], + ["matches_not", "event:props:value", ["b|c"]] + ] + }) + + assert json_response(conn, 200)["results"] == [%{"metrics" => [1], "dimensions" => []}] + end + + test "handles filtering by visit:country", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, country_code: "EE"), + build(:pageview, country_code: "EE"), + build(:pageview, country_code: "EE") + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["pageviews"], + "filters" => [["is", "visit:country", ["EE"]]] + }) + + assert json_response(conn, 200)["results"] == [%{"metrics" => [3], "dimensions" => []}] + end + + test "handles filtering by visit:country with contains", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, country_code: "EE"), + build(:pageview, country_code: "EE"), + build(:pageview, country_code: "IT"), + build(:pageview, country_code: "DE") + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["pageviews"], + "filters" => [["contains", "visit:country", ["E"]]] + }) + + assert json_response(conn, 200)["results"] == [%{"metrics" => [3], "dimensions" => []}] + end + + test "handles filtering by visit:city", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, city_geoname_id: 588_409), + build(:pageview, city_geoname_id: 689_123), + build(:pageview, city_geoname_id: 0), + build(:pageview, city_geoname_id: 10) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["pageviews"], + "filters" => [["is", "visit:city", [588_409, 689_123]]] + }) + + assert json_response(conn, 200)["results"] == [%{"metrics" => [2], "dimensions" => []}] + end + end + + describe "timeseries" do + test "shows hourly data for a certain date with time_labels", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, user_id: @user_id, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, user_id: @user_id, timestamp: ~N[2021-01-01 00:10:00]), + build(:pageview, timestamp: ~N[2021-01-01 23:59:00]) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors", "pageviews", "visits", "visit_duration", "bounce_rate"], + "date_range" => ["2021-01-01", "2021-01-01"], + "dimensions" => ["time:hour"], + "include" => %{"time_labels" => true} + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => ["2021-01-01 00:00:00"], "metrics" => [1, 2, 1, 600, 0]}, + %{"dimensions" => ["2021-01-01 23:00:00"], "metrics" => [1, 1, 1, 0, 100]} + ] + + assert json_response(conn, 200)["meta"]["time_labels"] == [ + "2021-01-01 00:00:00", + "2021-01-01 01:00:00", + "2021-01-01 02:00:00", + "2021-01-01 03:00:00", + "2021-01-01 04:00:00", + "2021-01-01 05:00:00", + "2021-01-01 06:00:00", + "2021-01-01 07:00:00", + "2021-01-01 08:00:00", + "2021-01-01 09:00:00", + "2021-01-01 10:00:00", + "2021-01-01 11:00:00", + "2021-01-01 12:00:00", + "2021-01-01 13:00:00", + "2021-01-01 14:00:00", + "2021-01-01 15:00:00", + "2021-01-01 16:00:00", + "2021-01-01 17:00:00", + "2021-01-01 18:00:00", + "2021-01-01 19:00:00", + "2021-01-01 20:00:00", + "2021-01-01 21:00:00", + "2021-01-01 22:00:00", + "2021-01-01 23:00:00" + ] + end + + test "shows last 7 days of visitors with time labels", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-07 23:59:00]) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => ["2021-01-01", "2021-01-07"], + "dimensions" => ["time"], + "include" => %{"time_labels" => true} + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => ["2021-01-01"], "metrics" => [1]}, + %{"dimensions" => ["2021-01-07"], "metrics" => [1]} + ] + + assert json_response(conn, 200)["meta"]["time_labels"] == [ + "2021-01-01", + "2021-01-02", + "2021-01-03", + "2021-01-04", + "2021-01-05", + "2021-01-06", + "2021-01-07" + ] + end + + test "shows weekly data with time labels", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-03 23:59:00]), + build(:pageview, timestamp: ~N[2021-01-07 23:59:00]) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => ["2020-12-20", "2021-01-07"], + "dimensions" => ["time:week"], + "include" => %{"time_labels" => true} + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => ["2020-12-28"], "metrics" => [2]}, + %{"dimensions" => ["2021-01-04"], "metrics" => [1]} + ] + + assert json_response(conn, 200)["meta"]["time_labels"] == [ + "2020-12-20", + "2020-12-21", + "2020-12-28", + "2021-01-04" + ] + end + + test "shows last 6 months of visitors", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, timestamp: ~N[2020-08-13 00:00:00]), + build(:pageview, timestamp: ~N[2020-12-31 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => ["2020-07-01", "2021-01-31"], + "dimensions" => ["time:month"] + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => ["2020-08-01"], "metrics" => [1]}, + %{"dimensions" => ["2020-12-01"], "metrics" => [1]}, + %{"dimensions" => ["2021-01-01"], "metrics" => [2]} + ] + end + + test "shows last 12 months of visitors", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, timestamp: ~N[2020-02-01 00:00:00]), + build(:pageview, timestamp: ~N[2020-12-31 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => ["2020-01-01", "2021-01-01"], + "dimensions" => ["time:month"] + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => ["2020-02-01"], "metrics" => [1]}, + %{"dimensions" => ["2020-12-01"], "metrics" => [1]}, + %{"dimensions" => ["2021-01-01"], "metrics" => [2]} + ] + end + + test "shows last 12 months of visitors with interval daily", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, timestamp: ~N[2020-02-01 00:00:00]), + build(:pageview, timestamp: ~N[2020-12-31 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => ["2020-01-01", "2021-01-07"], + "dimensions" => ["time:day"], + "include" => %{"time_labels" => true} + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => ["2020-02-01"], "metrics" => [1]}, + %{"dimensions" => ["2020-12-31"], "metrics" => [1]}, + %{"dimensions" => ["2021-01-01"], "metrics" => [2]} + ] + + assert length(json_response(conn, 200)["meta"]["time_labels"]) == 373 + end + + test "shows a custom range with daily interval", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-02 00:00:00]) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => ["2021-01-01", "2021-01-02"], + "dimensions" => ["time:day"] + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => ["2021-01-01"], "metrics" => [2]}, + %{"dimensions" => ["2021-01-02"], "metrics" => [1]} + ] + end + + test "shows a custom range with monthly interval", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, user_id: @user_id, timestamp: ~N[2020-12-01 00:00:00]), + build(:pageview, user_id: @user_id, timestamp: ~N[2020-12-01 00:05:00]), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-02 00:00:00]) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["pageviews", "visitors", "bounce_rate", "visit_duration"], + "date_range" => ["2020-12-01", "2021-01-02"], + "dimensions" => ["time:month"] + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => ["2020-12-01"], "metrics" => [2, 1, 0, 300]}, + %{"dimensions" => ["2021-01-01"], "metrics" => [2, 2, 100, 0]} + ] + end + + test "timeseries with explicit order_by", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-02 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-02 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-02 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-03 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-03 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-04 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-04 00:00:00]) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["pageviews"], + "date_range" => ["2020-12-01", "2021-01-04"], + "dimensions" => ["time"], + "order_by" => [["pageviews", "desc"], ["time", "asc"]] + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => ["2021-01-02"], "metrics" => [3]}, + %{"dimensions" => ["2021-01-03"], "metrics" => [2]}, + %{"dimensions" => ["2021-01-04"], "metrics" => [2]}, + %{"dimensions" => ["2021-01-01"], "metrics" => [1]} + ] + end + + test "timeseries with quarter-hour timezone", %{conn: conn, user: user} do + # GMT+05:45 + site = new_site(timezone: "Asia/Katmandu", owner: user) + + populate_stats(site, [ + build(:pageview, timestamp: ~N[2021-01-02 05:00:00]), + build(:pageview, timestamp: ~N[2021-01-02 05:15:00]), + build(:pageview, timestamp: ~N[2021-01-02 05:30:00]), + build(:pageview, timestamp: ~N[2021-01-02 05:45:00]), + build(:pageview, timestamp: ~N[2021-01-02 06:00:00]), + build(:pageview, timestamp: ~N[2021-01-02 06:15:00]), + build(:pageview, timestamp: ~N[2021-01-02 06:30:00]) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visits"], + "date_range" => ["2021-01-02", "2021-01-02"], + "dimensions" => ["time:hour"] + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => ["2021-01-02 10:00:00"], "metrics" => [1]}, + %{"dimensions" => ["2021-01-02 11:00:00"], "metrics" => [4]}, + %{"dimensions" => ["2021-01-02 12:00:00"], "metrics" => [2]} + ] + end + + test "visitors and visits are smeared across time:hour buckets but visit_duration is not", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, user_id: 1, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, user_id: 1, timestamp: ~N[2021-01-01 00:20:00]), + build(:pageview, user_id: 1, timestamp: ~N[2021-01-01 00:40:00]), + build(:pageview, user_id: 1, timestamp: ~N[2021-01-01 01:00:00]), + build(:pageview, user_id: 1, timestamp: ~N[2021-01-01 01:20:00]), + build(:pageview, user_id: 1, timestamp: ~N[2021-01-01 01:40:00]), + build(:pageview, user_id: 1, timestamp: ~N[2021-01-01 02:00:00]), + build(:pageview, user_id: 1, timestamp: ~N[2021-01-01 02:20:00]), + build(:pageview, user_id: 1, timestamp: ~N[2021-01-01 02:40:00]), + build(:pageview, user_id: 1, timestamp: ~N[2021-01-01 03:00:00]), + build(:pageview, user_id: 1, timestamp: ~N[2021-01-01 03:20:00]), + build(:pageview, user_id: 2, timestamp: ~N[2021-01-01 01:05:00]), + build(:pageview, user_id: 2, timestamp: ~N[2021-01-01 01:10:00]), + build(:pageview, user_id: 3, timestamp: ~N[2021-01-01 02:10:00]), + build(:pageview, user_id: 3, timestamp: ~N[2021-01-01 02:20:00]) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visits", "visitors", "visit_duration", "pageviews"], + "date_range" => ["2021-01-01", "2021-01-01"], + "dimensions" => ["time:hour"] + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => ["2021-01-01 00:00:00"], "metrics" => [1, 1, 0, 3]}, + %{"dimensions" => ["2021-01-01 01:00:00"], "metrics" => [2, 2, 300, 5]}, + %{"dimensions" => ["2021-01-01 02:00:00"], "metrics" => [2, 2, 600, 5]}, + %{"dimensions" => ["2021-01-01 03:00:00"], "metrics" => [1, 1, 12_000, 2]} + ] + end + end + + test "breakdown by visit:source", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + referrer_source: "Google", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + referrer_source: "Google", + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, + referrer_source: "", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => "all", + "dimensions" => ["visit:source"] + }) + + %{"results" => results} = json_response(conn, 200) + + assert results == [ + %{"dimensions" => ["Google"], "metrics" => [2]}, + %{"dimensions" => ["Direct / None"], "metrics" => [1]} + ] + end + + test "breakdown by visit:channel", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + referrer_source: "Google", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + referrer_source: "Google", + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => "all", + "dimensions" => ["visit:channel"] + }) + + %{"results" => results} = json_response(conn, 200) + + assert results == [ + %{"dimensions" => ["Organic Search"], "metrics" => [2]}, + %{"dimensions" => ["Direct"], "metrics" => [1]} + ] + end + + test "breakdown by visit:country", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, country_code: "EE", timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, country_code: "EE", timestamp: ~N[2021-01-01 00:25:00]), + build(:pageview, country_code: "US", timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => "all", + "dimensions" => ["visit:country"] + }) + + %{"results" => results} = json_response(conn, 200) + + assert results == [ + %{"dimensions" => ["EE"], "metrics" => [2]}, + %{"dimensions" => ["US"], "metrics" => [1]} + ] + end + + for {dimension, attr} <- [ + {"visit:utm_campaign", :utm_campaign}, + {"visit:utm_source", :utm_source}, + {"visit:utm_term", :utm_term}, + {"visit:utm_content", :utm_content} + ] do + test "breakdown by #{dimension} when filtered by hostname", %{conn: conn, site: site} do + populate_stats(site, [ + # session starts at two.example.com with utm_param=ad + build( + :pageview, + [ + {unquote(attr), "ad"}, + {:user_id, @user_id}, + {:hostname, "two.example.com"}, + {:timestamp, ~N[2021-01-01 00:00:00]} + ] + ), + # session continues on one.example.com without any utm_params + build( + :pageview, + [ + {:user_id, @user_id}, + {:hostname, "one.example.com"}, + {:timestamp, ~N[2021-01-01 00:15:00]} + ] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => "all", + "filters" => [["is", "event:hostname", ["one.example.com"]]], + "dimensions" => [unquote(dimension)] + }) + + # nobody landed on one.example.com from utm_param=ad + assert json_response(conn, 200)["results"] == [] + end + end + + for {dimension, column, value1, value2, blank_value} <- [ + {"visit:source", :referrer_source, "Google", "Twitter", "Direct / None"}, + {"visit:referrer", :referrer, "example.com", "google.com", "Direct / None"}, + {"visit:utm_medium", :utm_medium, "Search", "social", "(not set)"}, + {"visit:utm_source", :utm_source, "Google", "Bing", "(not set)"}, + {"visit:utm_campaign", :utm_campaign, "ads", "profile", "(not set)"}, + {"visit:utm_content", :utm_content, "Content1", "blog2", "(not set)"}, + {"visit:utm_term", :utm_term, "Term1", "favicon", "(not set)"}, + {"visit:os", :operating_system, "Mac", "Windows", "(not set)"}, + {"visit:browser", :browser, "Chrome", "Safari", "(not set)"}, + {"visit:device", :screen_size, "Mobile", "Desktop", "(not set)"} + ] do + test "simple breakdown by #{dimension}", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, [ + {unquote(column), unquote(value1)}, + {:timestamp, ~N[2021-01-01 00:00:00]} + ]), + build(:pageview, [ + {unquote(column), unquote(value1)}, + {:timestamp, ~N[2021-01-01 00:25:00]} + ]), + build(:pageview, [ + {unquote(column), unquote(value1)}, + {:timestamp, ~N[2021-01-01 00:55:00]} + ]), + build(:pageview, [ + {unquote(column), unquote(value2)}, + {:timestamp, ~N[2021-01-01 01:00:00]} + ]), + build(:pageview, [ + {unquote(column), unquote(value2)}, + {:timestamp, ~N[2021-01-01 01:25:00]} + ]), + build(:pageview, [ + {unquote(column), ""}, + {:timestamp, ~N[2021-01-01 00:00:00]} + ]) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors", "percentage"], + "date_range" => ["2021-01-01", "2021-01-01"], + "dimensions" => [unquote(dimension)] + }) + + %{"results" => results} = json_response(conn, 200) + + assert results == [ + %{"dimensions" => [unquote(value1)], "metrics" => [3, 50]}, + %{"dimensions" => [unquote(value2)], "metrics" => [2, 33.33]}, + %{"dimensions" => [unquote(blank_value)], "metrics" => [1, 16.67]} + ] + end + end + + test "breakdown by visit:os and visit:os_version", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, operating_system: "Mac", operating_system_version: "14"), + build(:pageview, operating_system: "Mac", operating_system_version: "14"), + build(:pageview, operating_system: "Mac", operating_system_version: "14"), + build(:pageview, operating_system_version: "14"), + build(:pageview, + operating_system: "Windows", + operating_system_version: "11" + ), + build(:pageview, + operating_system: "Windows", + operating_system_version: "11" + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => "all", + "dimensions" => ["visit:os", "visit:os_version"] + }) + + %{"results" => results} = json_response(conn, 200) + + assert results == [ + %{"dimensions" => ["Mac", "14"], "metrics" => [3]}, + %{"dimensions" => ["Windows", "11"], "metrics" => [2]}, + %{"dimensions" => ["(not set)", "14"], "metrics" => [1]} + ] + end + + test "breakdown by visit:browser and visit:browser_version", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, browser: "Chrome", browser_version: "14"), + build(:pageview, browser: "Chrome", browser_version: "14"), + build(:pageview, browser: "Chrome", browser_version: "14"), + build(:pageview, browser_version: "14"), + build(:pageview, + browser: "Firefox", + browser_version: "11" + ), + build(:pageview, + browser: "Firefox", + browser_version: "11" + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => "all", + "dimensions" => ["visit:browser", "visit:browser_version"] + }) + + %{"results" => results} = json_response(conn, 200) + + assert results == [ + %{"dimensions" => ["Chrome", "14"], "metrics" => [3]}, + %{"dimensions" => ["Firefox", "11"], "metrics" => [2]}, + %{"dimensions" => ["(not set)", "14"], "metrics" => [1]} + ] + end + + test "explicit order_by", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, operating_system: "Windows", browser: "Chrome"), + build(:pageview, operating_system: "Windows", browser: "Firefox"), + build(:pageview, operating_system: "Linux", browser: "Firefox"), + build(:pageview, operating_system: "Linux", browser: "Firefox"), + build(:pageview, operating_system: "Mac", browser: "Chrome") + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => "all", + "dimensions" => ["visit:os", "visit:browser"], + "order_by" => [["visitors", "asc"], ["visit:browser", "desc"], ["visit:os", "asc"]] + }) + + %{"results" => results} = json_response(conn, 200) + + assert results == [ + %{"dimensions" => ["Windows", "Firefox"], "metrics" => [1]}, + %{"dimensions" => ["Mac", "Chrome"], "metrics" => [1]}, + %{"dimensions" => ["Windows", "Chrome"], "metrics" => [1]}, + %{"dimensions" => ["Linux", "Firefox"], "metrics" => [2]} + ] + end + + test "breakdown by event:page", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/", timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, pathname: "/", timestamp: ~N[2021-01-01 00:25:00]), + build(:pageview, + pathname: "/plausible.io", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => "all", + "dimensions" => ["event:page"] + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => ["/"], "metrics" => [2]}, + %{"dimensions" => ["/plausible.io"], "metrics" => [1]} + ] + end + + test "attempting to breakdown by event:hostname returns an error", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, hostname: "a.example.com"), + build(:pageview, hostname: "a.example.com"), + build(:pageview, hostname: "a.example.com"), + build(:pageview, hostname: "b.example.com") + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["pageviews"], + "dimensions" => ["event:hostname"] + }) + + %{"results" => results} = json_response(conn, 200) + + assert results == [ + %{"dimensions" => ["a.example.com"], "metrics" => [3]}, + %{"dimensions" => ["b.example.com"], "metrics" => [1]} + ] + end + + describe "custom events" do + test "can breakdown by event:name", %{conn: conn, site: site} do + populate_stats(site, [ + build(:event, + name: "Signup", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Signup", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + timestamp: ~N[2021-01-01 00:25:00] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => "all", + "dimensions" => ["event:name"] + }) + + %{"results" => results} = json_response(conn, 200) + + assert results == [ + %{"dimensions" => ["Signup"], "metrics" => [2]}, + %{"dimensions" => ["pageview"], "metrics" => [1]} + ] + end + + test "can breakdown by event:name with visitors and events metrics", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/non-existing", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "404", + pathname: "/non-existing", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/non-existing", + timestamp: ~N[2021-01-01 00:00:01] + ), + build(:pageview, + pathname: "/non-existing", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:02] + ), + build(:event, + name: "404", + pathname: "/non-existing", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:02] + ), + build(:pageview, + pathname: "/non-existing", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:03] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors", "events"], + "date_range" => "all", + "dimensions" => ["event:name"] + }) + + %{"results" => results} = json_response(conn, 200) + + assert results == [ + %{"dimensions" => ["pageview"], "metrics" => [2, 4]}, + %{"dimensions" => ["404"], "metrics" => [1, 2]} + ] + end + + test "can breakdown by event:name while filtering for something", %{conn: conn, site: site} do + populate_stats(site, [ + build(:event, + name: "Signup", + pathname: "/pageA", + browser: "Chrome", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Signup", + pathname: "/pageA", + browser: "Chrome", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Signup", + pathname: "/pageA", + browser: "Safari", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Signup", + pathname: "/pageB", + browser: "Chrome", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/pageA", + browser: "Chrome", + timestamp: ~N[2021-01-01 00:25:00] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => "all", + "dimensions" => ["event:name"], + "filters" => [ + ["is", "event:page", ["/pageA"]], + ["is", "visit:browser", ["Chrome"]] + ] + }) + + %{"results" => results} = json_response(conn, 200) + + assert results == [ + %{"dimensions" => ["Signup"], "metrics" => [2]}, + %{"dimensions" => ["pageview"], "metrics" => [1]} + ] + end + + test "can breakdown by a visit:property when filtering by event:name", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, + referrer_source: "Google", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Signup", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + referrer_source: "Twitter", + timestamp: ~N[2021-01-01 00:25:00] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => "all", + "dimensions" => ["visit:source"], + "filters" => [ + ["is", "event:name", ["Signup"]] + ] + }) + + %{"results" => results} = json_response(conn, 200) + + assert results == [ + %{"dimensions" => ["Google"], "metrics" => [1]} + ] + end + + test "can breakdown by event:name when filtering by event:page", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/pageA", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/pageA", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + pathname: "/pageA", + name: "Signup", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/pageB", + referrer_source: "Twitter", + timestamp: ~N[2021-01-01 00:25:00] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => "all", + "dimensions" => ["event:name"], + "filters" => [ + ["contains", "event:page", ["/pageA"]] + ] + }) + + %{"results" => results} = json_response(conn, 200) + + assert results == [ + %{"dimensions" => ["pageview"], "metrics" => [2]}, + %{"dimensions" => ["Signup"], "metrics" => [1]} + ] + end + + test "can breakdown by event:page when filtering by event:name", %{conn: conn, site: site} do + populate_stats(site, [ + build(:event, + name: "Signup", + pathname: "/pageA", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Signup", + pathname: "/pageA", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Signup", + pathname: "/pageB", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/pageB", + referrer_source: "Twitter", + timestamp: ~N[2021-01-01 00:25:00] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => "all", + "dimensions" => ["event:page"], + "filters" => [ + ["is", "event:name", ["Signup"]] + ] + }) + + %{"results" => results} = json_response(conn, 200) + + assert results == [ + %{"dimensions" => ["/pageA"], "metrics" => [2]}, + %{"dimensions" => ["/pageB"], "metrics" => [1]} + ] + end + + test "can filter event:page with a wildcard", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, pathname: "/en/page1"), + build(:pageview, pathname: "/en/page2"), + build(:pageview, pathname: "/en/page2"), + build(:pageview, pathname: "/pl/page1") + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => "all", + "dimensions" => ["event:page"], + "filters" => [ + ["contains", "event:page", ["/en/"]] + ] + }) + + %{"results" => results} = json_response(conn, 200) + + assert results == [ + %{"dimensions" => ["/en/page2"], "metrics" => [2]}, + %{"dimensions" => ["/en/page1"], "metrics" => [1]} + ] + end + + test "can filter event:hostname with a contains", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, hostname: "alice-m.example.com", pathname: "/a"), + build(:pageview, hostname: "anna-m.example.com", pathname: "/a"), + build(:pageview, hostname: "adam-m.example.com", pathname: "/a"), + build(:pageview, hostname: "bob.example.com", pathname: "/b") + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => "all", + "dimensions" => ["event:page"], + "filters" => [ + ["contains", "event:hostname", ["-m.example.com"]] + ] + }) + + %{"results" => results} = json_response(conn, 200) + + assert results == [ + %{"dimensions" => ["/a"], "metrics" => [3]} + ] + end + + test "breakdown by custom event property", %{conn: conn, site: site} do + populate_stats(site, [ + build(:event, + name: "Purchase", + "meta.key": ["package"], + "meta.value": ["business"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Purchase", + "meta.key": ["package"], + "meta.value": ["personal"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Purchase", + "meta.key": ["package"], + "meta.value": ["business"], + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:event, + name: "Some other event", + "meta.key": ["package"], + "meta.value": ["business"], + timestamp: ~N[2021-01-01 00:25:00] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => "all", + "dimensions" => ["event:props:package"], + "filters" => [ + ["is", "event:name", ["Purchase"]] + ] + }) + + %{"results" => results} = json_response(conn, 200) + + assert results == [ + %{"dimensions" => ["business"], "metrics" => [2]}, + %{"dimensions" => ["personal"], "metrics" => [1]} + ] + end + + test "breakdown by custom event property, with pageviews metric", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + "meta.key": ["package"], + "meta.value": ["business"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + "meta.key": ["package"], + "meta.value": ["personal"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + "meta.key": ["package"], + "meta.value": ["business"], + timestamp: ~N[2021-01-01 00:25:00] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["pageviews"], + "date_range" => "all", + "dimensions" => ["event:props:package"] + }) + + %{"results" => results} = json_response(conn, 200) + + assert results == [ + %{"dimensions" => ["business"], "metrics" => [2]}, + %{"dimensions" => ["personal"], "metrics" => [1]} + ] + end + + test "breakdown by custom event property, with (none)", %{conn: conn, site: site} do + populate_stats(site, [ + build(:event, + name: "Purchase", + "meta.key": ["cost"], + "meta.value": ["16"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Purchase", + "meta.key": ["cost"], + "meta.value": ["16"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Purchase", + "meta.key": ["cost"], + "meta.value": ["16"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Purchase", + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:event, + name: "Purchase", + "meta.key": ["cost"], + "meta.value": ["14"], + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:event, + name: "Purchase", + "meta.key": ["cost"], + "meta.value": ["14"], + timestamp: ~N[2021-01-01 00:26:00] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => "all", + "dimensions" => ["event:props:cost"], + "filters" => [["is", "event:name", ["Purchase"]]] + }) + + %{"results" => results} = json_response(conn, 200) + + assert results == [ + %{"dimensions" => ["16"], "metrics" => [3]}, + %{"dimensions" => ["14"], "metrics" => [2]}, + %{"dimensions" => ["(none)"], "metrics" => [1]} + ] + end + end + + test "event:goal filter returns 400 when goal not configured", %{conn: conn, site: site} do + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => "all", + "dimensions" => ["visit:browser"], + "filters" => [ + ["is", "event:goal", ["Register"]] + ] + }) + + assert %{"error" => msg} = json_response(conn, 400) + assert msg =~ "The goal `Register` is not configured for this site. Find out how" + end + + test "validates that filters are valid", %{conn: conn, site: site} do + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => "all", + "dimensions" => ["visit:browser"], + "filters" => [ + ["is", "badproperty", ["bar"]] + ] + }) + + assert %{"error" => msg} = json_response(conn, 400) + assert msg =~ "Invalid filter" + end + + test "event:page filter for breakdown by session props", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/ignore", + browser: "Chrome", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/plausible.io", + browser: "Chrome", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/plausible.io", + browser: "Chrome", + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, + browser: "Safari", + pathname: "/plausible.io", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => "all", + "dimensions" => ["visit:browser"], + "filters" => [ + ["is", "event:page", ["/plausible.io"]] + ] + }) + + %{"results" => results} = json_response(conn, 200) + + assert results == [ + %{"dimensions" => ["Chrome"], "metrics" => [2]}, + %{"dimensions" => ["Safari"], "metrics" => [1]} + ] + end + + test "event:page filter shows sources of sessions that have visited that page", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, + pathname: "/", + referrer_source: "Twitter", + utm_medium: "Twitter", + utm_source: "Twitter", + utm_campaign: "Twitter", + user_id: @user_id + ), + build(:pageview, + pathname: "/plausible.io", + user_id: @user_id + ), + build(:pageview, + pathname: "/plausible.io", + referrer_source: "Google", + utm_medium: "Google", + utm_source: "Google", + utm_campaign: "Google" + ), + build(:pageview, + pathname: "/plausible.io", + referrer_source: "Google", + utm_medium: "Google", + utm_source: "Google", + utm_campaign: "Google" + ) + ]) + + for dimension <- [ + "visit:source", + "visit:utm_medium", + "visit:utm_source", + "visit:utm_campaign" + ] do + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => "all", + "dimensions" => [dimension], + "filters" => [ + ["is", "event:page", ["/plausible.io"]] + ] + }) + + %{"results" => results} = json_response(conn, 200) + + assert results == [ + %{"dimensions" => ["Google"], "metrics" => [2]}, + %{"dimensions" => ["Twitter"], "metrics" => [1]} + ] + end + end + + test "top sources for a custom goal and filtered by hostname", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + hostname: "blog.example.com", + referrer_source: "Facebook", + user_id: @user_id + ), + build(:pageview, + hostname: "app.example.com", + pathname: "/register", + user_id: @user_id + ), + build(:event, + name: "Signup", + hostname: "app.example.com", + pathname: "/register", + user_id: @user_id + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => "all", + "dimensions" => ["visit:source"], + "filters" => [ + ["is", "event:hostname", ["app.example.com"]] + ] + }) + + %{"results" => results} = json_response(conn, 200) + + assert results == [] + end + + test "top sources for a custom goal and filtered by hostname (2)", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + hostname: "app.example.com", + referrer_source: "Facebook", + pathname: "/register", + user_id: @user_id + ), + build(:event, + name: "Signup", + hostname: "app.example.com", + pathname: "/register", + user_id: @user_id + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => "all", + "dimensions" => ["visit:source"], + "filters" => [ + ["is", "event:hostname", ["app.example.com"]] + ] + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => ["Facebook"], "metrics" => [1]} + ] + end + + test "event:page filter is interpreted as entry_page filter only for bounce_rate", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, + browser: "Chrome", + user_id: @user_id, + pathname: "/ignore", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + browser: "Chrome", + user_id: @user_id, + pathname: "/plausible.io", + timestamp: ~N[2021-01-01 00:01:00] + ), + build(:pageview, + browser: "Chrome", + user_id: 456, + pathname: "/important-page", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + browser: "Chrome", + user_id: 456, + pathname: "/", + timestamp: ~N[2021-01-01 00:01:00] + ), + build(:pageview, + browser: "Chrome", + pathname: "/plausible.io", + timestamp: ~N[2021-01-01 00:01:00] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors", "bounce_rate"], + "filters" => [["is", "event:page", ["/plausible.io", "/important-page"]]], + "dimensions" => ["visit:browser"] + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => ["Chrome"], "metrics" => [3, 50]} + ] + end + + test "event:goal pageview filter for breakdown by visit source", %{conn: conn, site: site} do + insert(:goal, %{site: site, page_path: "/plausible.io"}) + + populate_stats(site, [ + build(:pageview, + referrer_source: "Bing", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + referrer_source: "Google", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/plausible.io", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => "all", + "dimensions" => ["visit:source"], + "filters" => [ + ["is", "event:goal", ["Visit /plausible.io"]] + ] + }) + + %{"results" => results} = json_response(conn, 200) + + assert results == [ + %{"dimensions" => ["Google"], "metrics" => [1]} + ] + end + + test "event:goal custom event filter for breakdown by visit source", %{conn: conn, site: site} do + insert(:goal, %{site: site, event_name: "Register"}) + + populate_stats(site, [ + build(:pageview, + referrer_source: "Bing", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + referrer_source: "Google", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Register", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => "all", + "dimensions" => ["visit:source"], + "filters" => [ + ["is", "event:goal", ["Register"]] + ] + }) + + %{"results" => results} = json_response(conn, 200) + + assert results == [ + %{"dimensions" => ["Google"], "metrics" => [1]} + ] + end + + test "wildcard pageview goal filter for breakdown by event:page", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/en/register"), + build(:pageview, pathname: "/en/register", user_id: @user_id), + build(:pageview, pathname: "/en/register", user_id: @user_id), + build(:pageview, pathname: "/123/it/register"), + build(:pageview, pathname: "/should-not-appear") + ]) + + insert(:goal, %{site: site, page_path: "/**register"}) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors", "pageviews"], + "date_range" => "all", + "dimensions" => ["event:page"], + "filters" => [ + ["is", "event:goal", ["Visit /**register"]] + ] + }) + + %{"results" => results} = json_response(conn, 200) + + assert results == [ + %{"dimensions" => ["/en/register"], "metrics" => [2, 3]}, + %{"dimensions" => ["/123/it/register"], "metrics" => [1, 1]} + ] + end + + test "goal contains filter for goal breakdown", %{conn: conn, site: site} do + populate_stats(site, [ + build(:event, name: "Onboarding conversion: Step 1"), + build(:event, name: "Onboarding conversion: Step 1"), + build(:event, name: "Onboarding conversion: Step 2"), + build(:event, name: "Unrelated"), + build(:pageview, pathname: "/conversion") + ]) + + insert(:goal, site: site, event_name: "Onboarding conversion: Step 1") + insert(:goal, site: site, event_name: "Onboarding conversion: Step 2") + insert(:goal, site: site, event_name: "Unrelated") + insert(:goal, site: site, page_path: "/conversion") + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => "all", + "dimensions" => ["event:goal"], + "filters" => [ + ["contains", "event:goal", ["conversion"]] + ] + }) + + %{"results" => results} = json_response(conn, 200) + + assert results == [ + %{"dimensions" => ["Onboarding conversion: Step 1"], "metrics" => [2]}, + %{"dimensions" => ["Onboarding conversion: Step 2"], "metrics" => [1]}, + %{"dimensions" => ["Visit /conversion"], "metrics" => [1]} + ] + end + + test "goal contains filter for goal breakdown (case insensitive)", %{conn: conn, site: site} do + populate_stats(site, [ + build(:event, name: "Onboarding conversion: Step 1"), + build(:event, name: "Onboarding conversion: Step 1"), + build(:event, name: "Onboarding conversion: Step 2"), + build(:event, name: "Unrelated"), + build(:pageview, pathname: "/conversion") + ]) + + insert(:goal, site: site, event_name: "Onboarding conversion: Step 1") + insert(:goal, site: site, event_name: "Onboarding conversion: Step 2") + insert(:goal, site: site, event_name: "Unrelated") + insert(:goal, site: site, page_path: "/conversion") + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => "all", + "dimensions" => ["event:goal"], + "filters" => [ + ["contains", "event:goal", ["step"], %{"case_sensitive" => false}] + ] + }) + + %{"results" => results} = json_response(conn, 200) + + assert results == [ + %{"dimensions" => ["Onboarding conversion: Step 1"], "metrics" => [2]}, + %{"dimensions" => ["Onboarding conversion: Step 2"], "metrics" => [1]} + ] + end + + test "mixed multi-goal filter for breakdown by visit:country", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, country_code: "EE", pathname: "/en/register"), + build(:event, country_code: "EE", name: "Signup", pathname: "/en/register"), + build(:pageview, country_code: "US", pathname: "/123/it/register"), + build(:pageview, country_code: "US", pathname: "/different") + ]) + + insert(:goal, %{site: site, page_path: "/**register"}) + insert(:goal, %{site: site, event_name: "Signup"}) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors", "pageviews", "events"], + "date_range" => "all", + "dimensions" => ["visit:country"], + "filters" => [ + ["is", "event:goal", ["Signup", "Visit /**register"]] + ] + }) + + %{"results" => results} = json_response(conn, 200) + + assert results == [ + %{"dimensions" => ["EE"], "metrics" => [2, 1, 2]}, + %{"dimensions" => ["US"], "metrics" => [1, 1, 1]} + ] + end + + test "event:goal custom event filter for breakdown by event page", %{conn: conn, site: site} do + insert(:goal, %{site: site, event_name: "Register"}) + + populate_stats(site, [ + build(:event, + pathname: "/en/register", + name: "Register" + ), + build(:event, + pathname: "/en/register", + name: "Register" + ), + build(:event, + pathname: "/it/register", + name: "Register" + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => "all", + "dimensions" => ["event:page"], + "filters" => [ + ["is", "event:goal", ["Register"]] + ] + }) + + %{"results" => results} = json_response(conn, 200) + + assert results == [ + %{"dimensions" => ["/en/register"], "metrics" => [2]}, + %{"dimensions" => ["/it/register"], "metrics" => [1]} + ] + end + + test "IN filter for event:page", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/ignore", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/plausible.io", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/plausible.io", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/important-page", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => "all", + "dimensions" => ["event:page"], + "filters" => [ + ["is", "event:page", ["/plausible.io", "/important-page"]] + ] + }) + + %{"results" => results} = json_response(conn, 200) + + assert results == [ + %{"dimensions" => ["/plausible.io"], "metrics" => [2]}, + %{"dimensions" => ["/important-page"], "metrics" => [1]} + ] + end + + test "IN filter for visit:browser", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/ignore", + browser: "Firefox", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/plausible.io", + browser: "Chrome", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/plausible.io", + browser: "Safari", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/important-page", + browser: "Safari", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => "all", + "dimensions" => ["event:page"], + "filters" => [ + ["is", "visit:browser", ["Chrome", "Safari"]] + ] + }) + + %{"results" => results} = json_response(conn, 200) + + assert results == [ + %{"dimensions" => ["/plausible.io"], "metrics" => [2]}, + %{"dimensions" => ["/important-page"], "metrics" => [1]} + ] + end + + test "IN filter for visit:entry_page", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/ignore", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/plausible.io", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/plausible.io", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/important-page", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["bounce_rate"], + "date_range" => "all", + "dimensions" => ["event:page"], + "filters" => [ + ["is", "event:page", ["/plausible.io", "/important-page"]] + ] + }) + + results = json_response(conn, 200)["results"] + + assert length(results) == 2 + assert %{"dimensions" => ["/plausible.io"], "metrics" => [100]} in results + assert %{"dimensions" => ["/important-page"], "metrics" => [100]} in results + end + + test "IN filter for event:name", %{conn: conn, site: site} do + populate_stats(site, [ + build(:event, + name: "Signup", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Signup", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Login", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Irrelevant", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => "all", + "dimensions" => ["event:name"], + "filters" => [ + ["is", "event:name", ["Signup", "Login"]] + ] + }) + + %{"results" => results} = json_response(conn, 200) + + assert results == [ + %{"dimensions" => ["Signup"], "metrics" => [2]}, + %{"dimensions" => ["Login"], "metrics" => [1]} + ] + end + + test "IN filter for event:name case insensitive", %{conn: conn, site: site} do + populate_stats(site, [ + build(:event, + name: "Signup", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Signup", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Login", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Irrelevant", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => "all", + "filters" => [ + ["is", "event:name", ["signup", "LOGIN"], %{"case_sensitive" => false}] + ] + }) + + %{"results" => results} = json_response(conn, 200) + + assert results == [ + %{"dimensions" => [], "metrics" => [3]} + ] + end + + test "IN filter for event:props:*", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + browser: "Chrome", + "meta.key": ["browser"], + "meta.value": ["Chrome"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + browser: "Chrome", + "meta.key": ["browser"], + "meta.value": ["Chrome"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + browser: "Safari", + "meta.key": ["browser"], + "meta.value": ["Safari"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + browser: "Firefox", + "meta.key": ["browser"], + "meta.value": ["Firefox"], + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => "all", + "dimensions" => ["visit:browser"], + "filters" => [ + ["is", "event:props:browser", ["Chrome", "Safari"]] + ] + }) + + %{"results" => results} = json_response(conn, 200) + + assert results == [ + %{"dimensions" => ["Chrome"], "metrics" => [2]}, + %{"dimensions" => ["Safari"], "metrics" => [1]} + ] + end + + test "Multiple event:props:* filters", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + browser: "Chrome", + "meta.key": ["browser"], + "meta.value": ["Chrome"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + browser: "Chrome", + "meta.key": ["browser", "prop"], + "meta.value": ["Chrome", "xyz"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + browser: "Safari", + "meta.key": ["browser", "prop"], + "meta.value": ["Safari", "target_value"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + browser: "Chrome", + "meta.key": ["browser", "prop"], + "meta.value": ["Chrome", "target_value"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + browser: "Firefox", + "meta.key": ["browser", "prop"], + "meta.value": ["Firefox", "target_value"], + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => "all", + "dimensions" => ["visit:browser"], + "filters" => [ + ["is", "event:props:browser", ["CHROME", "sAFari"], %{"case_sensitive" => false}], + # Negate a previously set filter + ["is_not", "event:props:browser", ["Chrome"], %{"case_sensitive" => false}], + ["is", "event:props:prop", ["target_value"]] + ] + }) + + %{"results" => results} = json_response(conn, 200) + + assert results == [ + %{"dimensions" => ["Safari"], "metrics" => [1]} + ] + end + + test "IN filter for event:props:* including (none) value", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + browser: "Chrome", + "meta.key": ["browser"], + "meta.value": ["Chrome"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + browser: "Chrome", + "meta.key": ["browser"], + "meta.value": ["Chrome"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + browser: "Safari", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + browser: "Firefox", + "meta.key": ["browser"], + "meta.value": ["Firefox"], + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => "all", + "dimensions" => ["visit:browser"], + "filters" => [["is", "event:props:browser", ["Chrome", "(none)"]]] + }) + + %{"results" => results} = json_response(conn, 200) + + assert results == [ + %{"dimensions" => ["Chrome"], "metrics" => [2]}, + %{"dimensions" => ["Safari"], "metrics" => [1]} + ] + end + + test "can use a is_not filter", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, browser: "Chrome"), + build(:pageview, browser: "Safari"), + build(:pageview, browser: "Safari"), + build(:pageview, browser: "Edge") + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => "all", + "dimensions" => ["visit:browser"], + "filters" => [ + ["is_not", "visit:browser", ["Chrome"]] + ] + }) + + %{"results" => results} = json_response(conn, 200) + + assert results == [ + %{"dimensions" => ["Safari"], "metrics" => [2]}, + %{"dimensions" => ["Edge"], "metrics" => [1]} + ] + end + + describe "metrics" do + test "all metrics for breakdown by visit prop", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + user_id: 1, + referrer_source: "Google", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "signup", + user_id: 1, + referrer_source: "Google", + timestamp: ~N[2021-01-01 00:05:00] + ), + build(:pageview, + user_id: 1, + referrer_source: "Google", + timestamp: ~N[2021-01-01 00:10:00] + ), + build(:pageview, + referrer_source: "Google", + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, + referrer_source: "Twitter", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => [ + "visitors", + "visits", + "pageviews", + "events", + "bounce_rate", + "visit_duration" + ], + "date_range" => "all", + "dimensions" => ["visit:source"] + }) + + %{"results" => results} = json_response(conn, 200) + + assert results == [ + %{"dimensions" => ["Google"], "metrics" => [2, 2, 3, 4, 50, 300]}, + %{"dimensions" => ["Twitter"], "metrics" => [1, 1, 1, 1, 100, 0]} + ] + end + + test "metrics=bounce_rate does not add visits to the response", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + user_id: 1, + pathname: "/entry-page-1", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: 1, + pathname: "/some-page", + timestamp: ~N[2021-01-01 00:10:00] + ), + build(:pageview, + user_id: 2, + pathname: "/entry-page-2", + referrer_source: "Google", + timestamp: ~N[2021-01-01 00:05:00] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["bounce_rate"], + "date_range" => "all", + "dimensions" => ["visit:entry_page"], + "order_by" => [["visit:entry_page", "asc"]] + }) + + %{"results" => results} = json_response(conn, 200) + + assert results == [ + %{"dimensions" => ["/entry-page-1"], "metrics" => [0]}, + %{"dimensions" => ["/entry-page-2"], "metrics" => [100]} + ] + end + + test "all metrics for breakdown by event prop", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + user_id: 1, + pathname: "/", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: 1, + pathname: "/plausible.io", + timestamp: ~N[2021-01-01 00:10:00] + ), + build(:pageview, pathname: "/", timestamp: ~N[2021-01-01 00:25:00]), + build(:pageview, + pathname: "/plausible.io", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => [ + "visitors", + "visits", + "pageviews", + "events", + "bounce_rate", + "visit_duration" + ], + "date_range" => "all", + "dimensions" => ["event:page"], + "order_by" => [["event:page", "desc"]] + }) + + %{"results" => results} = json_response(conn, 200) + + assert results == [ + %{"dimensions" => ["/plausible.io"], "metrics" => [2, 2, 2, 2, 100, 0]}, + %{"dimensions" => ["/"], "metrics" => [2, 2, 2, 2, 50, 300]} + ] + end + + test "views_per_visit in a time:week dimension query", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, user_id: 1, timestamp: ~N[2021-01-04 00:00:00]), + build(:pageview, user_id: 1, timestamp: ~N[2021-01-04 00:05:00]), + build(:pageview, user_id: 2, timestamp: ~N[2021-01-18 00:00:00]), + build(:pageview, user_id: 2, timestamp: ~N[2021-01-18 00:05:00]), + build(:pageview, user_id: 2, timestamp: ~N[2021-01-18 00:10:00]) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["views_per_visit"], + "date_range" => ["2021-01-01", "2021-01-28"], + "dimensions" => ["time:week"] + }) + + %{"results" => results} = json_response(conn, 200) + + assert results == [ + %{"dimensions" => ["2021-01-04"], "metrics" => [2.0]}, + %{"dimensions" => ["2021-01-18"], "metrics" => [3.0]} + ] + end + end + + test "filtering by custom event property", %{conn: conn, site: site} do + populate_stats(site, [ + build(:event, + name: "Purchase", + "meta.key": ["package"], + "meta.value": ["business"], + browser: "Chrome", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Purchase", + "meta.key": ["package"], + "meta.value": ["business"], + browser: "Safari", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Purchase", + "meta.key": ["package"], + "meta.value": ["business"], + browser: "Safari", + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:event, + name: "Purchase", + "meta.key": ["package"], + "meta.value": ["personal"], + browser: "IE", + timestamp: ~N[2021-01-01 00:25:00] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => "all", + "dimensions" => ["visit:browser"], + "filters" => [ + ["is", "event:name", ["Purchase"]], + ["is", "event:props:package", ["business"]] + ] + }) + + %{"results" => results} = json_response(conn, 200) + + assert results == [ + %{"dimensions" => ["Safari"], "metrics" => [2]}, + %{"dimensions" => ["Chrome"], "metrics" => [1]} + ] + end + + test "multiple breakdown timeseries with sources", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + referrer_source: "Google", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, timestamp: ~N[2021-01-02 00:00:00]), + build(:pageview, + referrer_source: "Google", + timestamp: ~N[2021-01-02 00:00:00] + ), + build(:pageview, + referrer_source: "Google", + timestamp: ~N[2021-01-02 00:00:00] + ), + build(:pageview, timestamp: ~N[2021-01-03 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-03 00:00:00]), + build(:pageview, + referrer_source: "Twitter", + timestamp: ~N[2021-01-03 00:00:00] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => ["2021-01-01", "2021-01-03"], + "dimensions" => ["time", "visit:source"] + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => ["2021-01-01", "Google"], "metrics" => [1]}, + %{"dimensions" => ["2021-01-02", "Google"], "metrics" => [2]}, + %{"dimensions" => ["2021-01-02", "Direct / None"], "metrics" => [1]}, + %{"dimensions" => ["2021-01-03", "Direct / None"], "metrics" => [2]}, + %{"dimensions" => ["2021-01-03", "Twitter"], "metrics" => [1]} + ] + end + + test "filtering by visit:country_name, visit:region_name, visit:city_name", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + # GB, London + build(:pageview, + country_code: "GB", + subdivision1_code: "GB-LND", + city_geoname_id: 2_643_743 + ), + # CA, London + build(:pageview, + country_code: "CA", + subdivision1_code: "CA-ON", + city_geoname_id: 6_058_560 + ), + # EE, Tallinn + build(:pageview, + country_code: "EE", + subdivision1_code: "EE-37", + city_geoname_id: 588_409 + ), + # EE, Tartu + build(:pageview, + country_code: "EE", + subdivision1_code: "EE-79", + city_geoname_id: 588_335 + ), + # EE, Jõgeva + build(:pageview, + country_code: "EE", + subdivision1_code: "EE-50", + city_geoname_id: 591_902 + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["pageviews"], + "order_by" => [["pageviews", "desc"], ["visit:country_name", "asc"]], + "filters" => [ + ["is", "visit:country_name", ["Estonia", "United Kingdom"]], + ["is_not", "visit:region_name", ["Tartumaa"]], + ["contains", "visit:city_name", ["n"]] + ], + "dimensions" => ["visit:country_name", "visit:region_name", "visit:city_name"] + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => ["Estonia", "Harjumaa", "Tallinn"], "metrics" => [1]}, + %{"dimensions" => ["United Kingdom", "London", "London"], "metrics" => [1]} + ] + end + + test "bounce rate calculation handles invalid session data gracefully", %{ + conn: conn, + site: site + } do + # NOTE: At the time of this test is added, it appears it does _not_ + # catch the regression on MacOS (ARM), regardless if Clickhouse is run + # natively or from a docker container. The test still does catch + # that regression when ran on Linux for instance (including CI). + user_id = System.unique_integer([:positive]) + + populate_stats(site, [ + build(:pageview, + user_id: user_id, + pathname: "/", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + first_session = Plausible.Cache.Adapter.get(:sessions, {site.id, user_id}) + + populate_stats(site, [ + build(:pageview, + user_id: user_id, + pathname: "/", + timestamp: ~N[2021-01-01 00:01:00] + ) + ]) + + Plausible.Cache.Adapter.put(:sessions, {site.id, user_id}, first_session) + + populate_stats(site, [ + build(:pageview, + user_id: user_id, + pathname: "/", + timestamp: ~N[2021-01-01 00:01:00] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["bounce_rate"], + "dimensions" => ["event:page"] + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => ["/"], "metrics" => [0]} + ] + end + + test "visits, visit_duration and views_per_visit calculation handles invalid session data gracefully", + %{ + conn: conn, + site: site + } do + # NOTE: At the time of this test is added, it appears it does _not_ + # catch the regression on MacOS (ARM), regardless if Clickhouse is run + # natively or from a docker container. The test still does catch + # that regression when ran on Linux for instance (including CI). + + user_id = System.unique_integer([:positive]) + + populate_stats(site, [ + build(:pageview, + user_id: user_id, + pathname: "/", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + session = + Plausible.IngestRepo.get_by!(Plausible.ClickhouseSessionV2, + site_id: site.id, + user_id: user_id + ) + + # The session values are manually manipulated to trigger negative values for certain metrics + negative1 = %{session | pageviews: 0, events: session.events + 1, sign: -1} + negative2 = %{session | pageviews: 0, events: session.events + 2, sign: -1} + + Plausible.Session.WriteBuffer.insert([negative1, negative2]) + Plausible.Session.WriteBuffer.flush() + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => [ + "visits", + "visit_duration" + ], + "dimensions" => ["visit:exit_page"] + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => ["/"], "metrics" => [0, 0]} + ] + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => [ + "views_per_visit" + ], + "dimensions" => [] + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => [], "metrics" => [0]} + ] + end + + describe "using the returned query object in a new POST request" do + test "yields the same results for a simple aggregate query", %{conn: conn, site: site} do + Plausible.Site.changeset(site, %{timezone: "Europe/Tallinn"}) + |> Plausible.Repo.update() + + populate_stats(site, [ + build(:pageview, user_id: @user_id, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, user_id: @user_id, timestamp: ~N[2021-01-01 00:25:00]), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn1 = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["pageviews"], + "date_range" => "all" + }) + + assert %{"results" => results1, "query" => query} = json_response(conn1, 200) + assert results1 == [%{"metrics" => [3], "dimensions" => []}] + + conn2 = post(conn, "/api/v2/query", query) + + assert %{"results" => results2} = json_response(conn2, 200) + assert results2 == [%{"metrics" => [3], "dimensions" => []}] + end + end + + describe "pagination" do + setup %{site: site} = context do + populate_stats(site, [ + build(:pageview, pathname: "/1"), + build(:pageview, pathname: "/2"), + build(:pageview, pathname: "/3"), + build(:pageview, pathname: "/4"), + build(:pageview, pathname: "/5"), + build(:pageview, pathname: "/6"), + build(:pageview, pathname: "/7"), + build(:pageview, pathname: "/8") + ]) + + context + end + + test "pagination above total count - all results are returned", %{conn: conn, site: site} do + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["pageviews"], + "date_range" => "all", + "dimensions" => ["event:page"], + "order_by" => [["event:page", "asc"]], + "include" => %{"total_rows" => true}, + "pagination" => %{"limit" => 10} + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => ["/1"], "metrics" => [1]}, + %{"dimensions" => ["/2"], "metrics" => [1]}, + %{"dimensions" => ["/3"], "metrics" => [1]}, + %{"dimensions" => ["/4"], "metrics" => [1]}, + %{"dimensions" => ["/5"], "metrics" => [1]}, + %{"dimensions" => ["/6"], "metrics" => [1]}, + %{"dimensions" => ["/7"], "metrics" => [1]}, + %{"dimensions" => ["/8"], "metrics" => [1]} + ] + + assert json_response(conn, 200)["meta"]["total_rows"] == 8 + end + + test "pagination with offset", %{conn: conn, site: site} do + query = %{ + "site_id" => site.domain, + "metrics" => ["pageviews"], + "date_range" => "all", + "dimensions" => ["event:page"], + "order_by" => [["event:page", "asc"]], + "include" => %{"total_rows" => true} + } + + conn1 = post(conn, "/api/v2/query", Map.put(query, "pagination", %{"limit" => 3})) + + assert json_response(conn1, 200)["results"] == [ + %{"dimensions" => ["/1"], "metrics" => [1]}, + %{"dimensions" => ["/2"], "metrics" => [1]}, + %{"dimensions" => ["/3"], "metrics" => [1]} + ] + + assert json_response(conn1, 200)["meta"]["total_rows"] == 8 + + conn2 = + post(conn, "/api/v2/query", Map.put(query, "pagination", %{"limit" => 3, "offset" => 3})) + + assert json_response(conn2, 200)["results"] == [ + %{"dimensions" => ["/4"], "metrics" => [1]}, + %{"dimensions" => ["/5"], "metrics" => [1]}, + %{"dimensions" => ["/6"], "metrics" => [1]} + ] + + assert json_response(conn2, 200)["meta"]["total_rows"] == 8 + + conn3 = + post(conn, "/api/v2/query", Map.put(query, "pagination", %{"limit" => 3, "offset" => 6})) + + assert json_response(conn3, 200)["results"] == [ + %{"dimensions" => ["/7"], "metrics" => [1]}, + %{"dimensions" => ["/8"], "metrics" => [1]} + ] + + assert json_response(conn3, 200)["meta"]["total_rows"] == 8 + + conn4 = + post(conn, "/api/v2/query", Map.put(query, "pagination", %{"limit" => 3, "offset" => 9})) + + assert json_response(conn4, 200)["results"] == [] + end + end + + describe "scroll_depth" do + setup [:create_user, :create_site, :create_api_key, :use_api_key] + + test "cannot query scroll depth without page filter or dimension", %{conn: conn, site: site} do + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["scroll_depth"] + }) + + assert %{"error" => error} = json_response(conn, 400) + + assert error == + "Metric `scroll_depth` can only be queried with event:page filters or dimensions." + end + + test "can query scroll_depth metric with a page filter", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, user_id: 123, timestamp: ~N[2021-01-01 00:00:00]), + build(:engagement, user_id: 123, timestamp: ~N[2021-01-01 00:00:10], scroll_depth: 40), + build(:pageview, user_id: 123, timestamp: ~N[2021-01-01 00:00:10]), + build(:engagement, user_id: 123, timestamp: ~N[2021-01-01 00:00:20], scroll_depth: 60), + build(:pageview, user_id: 456, timestamp: ~N[2021-01-01 00:00:00]), + build(:engagement, user_id: 456, timestamp: ~N[2021-01-01 00:00:10], scroll_depth: 80) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "filters" => [["is", "event:page", ["/"]]], + "date_range" => "all", + "metrics" => ["scroll_depth"] + }) + + assert json_response(conn, 200)["results"] == [ + %{"metrics" => [70], "dimensions" => []} + ] + end + + test "can query scroll_depth with page + custom prop filter", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, user_id: 123, timestamp: ~N[2021-01-01 00:00:00]), + build(:engagement, user_id: 123, timestamp: ~N[2021-01-01 00:00:10], scroll_depth: 40), + build(:pageview, + "meta.key": ["author"], + "meta.value": ["john"], + user_id: 123, + timestamp: ~N[2021-01-01 00:00:10] + ), + build(:engagement, + "meta.key": ["author"], + "meta.value": ["john"], + user_id: 123, + timestamp: ~N[2021-01-01 00:00:20], + scroll_depth: 60 + ), + build(:pageview, user_id: 456, timestamp: ~N[2021-01-01 00:00:00]), + build(:engagement, user_id: 456, timestamp: ~N[2021-01-01 00:00:10], scroll_depth: 80) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "filters" => [["is", "event:page", ["/"]], ["is", "event:props:author", ["john"]]], + "date_range" => "all", + "metrics" => ["scroll_depth"] + }) + + assert json_response(conn, 200)["results"] == [ + %{"metrics" => [60], "dimensions" => []} + ] + end + + test "scroll depth is nil when no engagement data in range", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "filters" => [["is", "event:page", ["/"]]], + "date_range" => "all", + "metrics" => ["visitors", "scroll_depth"] + }) + + assert json_response(conn, 200)["results"] == [ + %{"metrics" => [1, nil], "dimensions" => []} + ] + end + + test "scroll depth is nil when no data at all in range", %{conn: conn, site: site} do + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "filters" => [["is", "event:page", ["/"]]], + "date_range" => "all", + "metrics" => ["scroll_depth"] + }) + + assert json_response(conn, 200)["results"] == [ + %{"metrics" => [nil], "dimensions" => []} + ] + end + + test "scroll_depth metric in a time:day breakdown", %{conn: conn, site: site} do + t0 = ~N[2020-01-01 00:00:00] + [t1, t2, t3] = for i <- 1..3, do: NaiveDateTime.add(t0, i, :minute) + + populate_stats(site, [ + build(:pageview, user_id: 12, timestamp: t0), + build(:engagement, user_id: 12, timestamp: t1, scroll_depth: 20), + build(:pageview, user_id: 34, timestamp: t0), + build(:engagement, user_id: 34, timestamp: t1, scroll_depth: 17), + build(:pageview, user_id: 34, timestamp: t2), + build(:engagement, user_id: 34, timestamp: t3, scroll_depth: 60), + build(:pageview, user_id: 56, timestamp: NaiveDateTime.add(t0, 1, :day)), + build(:engagement, + user_id: 56, + timestamp: NaiveDateTime.add(t1, 1, :day), + scroll_depth: 20 + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["scroll_depth"], + "date_range" => "all", + "dimensions" => ["time:day"], + "filters" => [["is", "event:page", ["/"]]] + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => ["2020-01-01"], "metrics" => [40]}, + %{"dimensions" => ["2020-01-02"], "metrics" => [20]} + ] + end + + test "breakdown by event:page with scroll_depth metric", %{conn: conn, site: site} do + t0 = ~N[2020-01-01 00:00:00] + [t1, t2, t3] = for i <- 1..3, do: NaiveDateTime.add(t0, i, :minute) + + populate_stats(site, [ + build(:pageview, user_id: 12, pathname: "/blog", timestamp: t0), + build(:engagement, user_id: 12, pathname: "/blog", timestamp: t1, scroll_depth: 20), + build(:pageview, user_id: 12, pathname: "/another", timestamp: t1), + build(:engagement, user_id: 12, pathname: "/another", timestamp: t2, scroll_depth: 24), + build(:pageview, user_id: 34, pathname: "/blog", timestamp: t0), + build(:engagement, user_id: 34, pathname: "/blog", timestamp: t1, scroll_depth: 17), + build(:pageview, user_id: 34, pathname: "/another", timestamp: t1), + build(:engagement, user_id: 34, pathname: "/another", timestamp: t2, scroll_depth: 26), + build(:pageview, user_id: 34, pathname: "/blog", timestamp: t2), + build(:engagement, user_id: 34, pathname: "/blog", timestamp: t3, scroll_depth: 60), + build(:pageview, user_id: 56, pathname: "/blog", timestamp: t0), + build(:engagement, user_id: 56, pathname: "/blog", timestamp: t1, scroll_depth: 100) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["scroll_depth"], + "date_range" => "all", + "dimensions" => ["event:page"] + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => ["/blog"], "metrics" => [60]}, + %{"dimensions" => ["/another"], "metrics" => [25]} + ] + end + + test "scroll_depth metric is session-based", %{conn: conn, site: site} do + t0 = ~N[2020-01-01 00:00:00] + t1 = NaiveDateTime.add(t0, 10, :minute) + t2 = NaiveDateTime.add(t0, 3, :hour) + + populate_stats(site, [ + build(:pageview, user_id: 12, pathname: "/blog", timestamp: t0), + build(:engagement, user_id: 12, pathname: "/blog", timestamp: t0, scroll_depth: 20), + build(:pageview, user_id: 12, pathname: "/blog", timestamp: t1), + build(:engagement, user_id: 12, pathname: "/blog", timestamp: t1, scroll_depth: 40), + # Different session + build(:pageview, user_id: 12, pathname: "/blog", timestamp: t2), + build(:engagement, user_id: 12, pathname: "/blog", timestamp: t2, scroll_depth: 80) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["scroll_depth"], + "date_range" => "all", + "dimensions" => ["event:page"] + }) + + assert json_response(conn, 200)["results"] == [ + # avg(max(20, 40), 80) = 60 + %{"dimensions" => ["/blog"], "metrics" => [60]} + ] + end + + test "can sort by scroll_depth in event:page breakdown", %{conn: conn, site: site} do + t0 = ~N[2020-01-01 00:00:00] + [t1, t2, t3] = for i <- 1..3, do: NaiveDateTime.add(t0, i, :minute) + + populate_stats(site, [ + build(:pageview, user_id: 12, pathname: "/blog", timestamp: t0), + build(:engagement, user_id: 12, pathname: "/blog", timestamp: t1, scroll_depth: 20), + build(:pageview, user_id: 12, pathname: "/another", timestamp: t1), + build(:engagement, user_id: 12, pathname: "/another", timestamp: t2, scroll_depth: 24), + build(:pageview, user_id: 34, pathname: "/blog", timestamp: t0), + build(:engagement, user_id: 34, pathname: "/blog", timestamp: t1, scroll_depth: 17), + build(:pageview, user_id: 34, pathname: "/another", timestamp: t1), + build(:engagement, user_id: 34, pathname: "/another", timestamp: t2, scroll_depth: 26), + build(:pageview, user_id: 34, pathname: "/blog", timestamp: t2), + build(:engagement, user_id: 34, pathname: "/blog", timestamp: t3, scroll_depth: 60), + build(:pageview, user_id: 56, pathname: "/blog", timestamp: t0), + build(:engagement, user_id: 56, pathname: "/blog", timestamp: t1, scroll_depth: 100) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["scroll_depth"], + "date_range" => "all", + "order_by" => [["scroll_depth", "asc"]], + "dimensions" => ["event:page"] + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => ["/another"], "metrics" => [25]}, + %{"dimensions" => ["/blog"], "metrics" => [60]} + ] + end + + test "breakdown by event:page + visit:source with scroll_depth metric", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, + referrer_source: "Google", + user_id: 12, + pathname: "/blog", + timestamp: ~N[2020-01-01 00:00:00] + ), + build(:engagement, + referrer_source: "Google", + user_id: 12, + pathname: "/blog", + timestamp: ~N[2020-01-01 00:00:00] |> NaiveDateTime.add(1, :minute), + scroll_depth: 20 + ), + build(:pageview, + referrer_source: "Google", + user_id: 34, + pathname: "/blog", + timestamp: ~N[2020-01-01 00:00:00] + ), + build(:engagement, + referrer_source: "Google", + user_id: 34, + pathname: "/blog", + timestamp: ~N[2020-01-01 00:00:00] |> NaiveDateTime.add(1, :minute), + scroll_depth: 17 + ), + build(:pageview, + referrer_source: "Google", + user_id: 34, + pathname: "/blog", + timestamp: ~N[2020-01-01 00:00:00] |> NaiveDateTime.add(2, :minute) + ), + build(:engagement, + referrer_source: "Google", + user_id: 34, + pathname: "/blog", + timestamp: ~N[2020-01-01 00:00:00] |> NaiveDateTime.add(3, :minute), + scroll_depth: 60 + ), + build(:pageview, + referrer_source: "Twitter", + user_id: 56, + pathname: "/blog", + timestamp: ~N[2020-01-01 00:00:00] + ), + build(:engagement, + referrer_source: "Twitter", + user_id: 56, + pathname: "/blog", + timestamp: ~N[2020-01-01 00:00:00] |> NaiveDateTime.add(1, :minute), + scroll_depth: 20 + ), + build(:pageview, + referrer_source: "Twitter", + user_id: 56, + pathname: "/another", + timestamp: ~N[2020-01-01 00:00:00] |> NaiveDateTime.add(1, :minute) + ), + build(:engagement, + referrer_source: "Twitter", + user_id: 56, + pathname: "/another", + timestamp: ~N[2020-01-01 00:00:00] |> NaiveDateTime.add(2, :minute), + scroll_depth: 24 + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["scroll_depth"], + "order_by" => [["scroll_depth", "asc"]], + "date_range" => "all", + "dimensions" => ["event:page", "visit:source"] + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => ["/blog", "Twitter"], "metrics" => [20]}, + %{"dimensions" => ["/another", "Twitter"], "metrics" => [24]}, + %{"dimensions" => ["/blog", "Google"], "metrics" => [40]} + ] + end + + test "breakdown by event:page + time:day with scroll_depth metric", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, user_id: 12, pathname: "/blog", timestamp: ~N[2020-01-01 00:00:00]), + build(:engagement, + user_id: 12, + pathname: "/blog", + timestamp: ~N[2020-01-01 00:01:00], + scroll_depth: 20 + ), + build(:pageview, user_id: 12, pathname: "/another", timestamp: ~N[2020-01-01 00:01:00]), + build(:engagement, + user_id: 12, + pathname: "/another", + timestamp: ~N[2020-01-01 00:02:00], + scroll_depth: 24 + ), + build(:pageview, user_id: 34, pathname: "/blog", timestamp: ~N[2020-01-01 00:00:00]), + build(:engagement, + user_id: 34, + pathname: "/blog", + timestamp: ~N[2020-01-01 00:01:00], + scroll_depth: 17 + ), + build(:pageview, user_id: 34, pathname: "/another", timestamp: ~N[2020-01-01 00:01:00]), + build(:engagement, + user_id: 34, + pathname: "/another", + timestamp: ~N[2020-01-01 00:02:00], + scroll_depth: 26 + ), + build(:pageview, user_id: 34, pathname: "/blog", timestamp: ~N[2020-01-01 00:02:00]), + build(:engagement, + user_id: 34, + pathname: "/blog", + timestamp: ~N[2020-01-01 00:03:00], + scroll_depth: 60 + ), + build(:pageview, user_id: 56, pathname: "/blog", timestamp: ~N[2020-01-02 00:00:00]), + build(:engagement, + user_id: 56, + pathname: "/blog", + timestamp: ~N[2020-01-02 00:01:00], + scroll_depth: 20 + ), + build(:pageview, user_id: 56, pathname: "/another", timestamp: ~N[2020-01-02 00:01:00]), + build(:engagement, + user_id: 56, + pathname: "/another", + timestamp: ~N[2020-01-02 00:02:00], + scroll_depth: 24 + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["scroll_depth"], + "date_range" => "all", + "dimensions" => ["event:page", "time:day"] + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => ["/blog", "2020-01-01"], "metrics" => [40]}, + %{"dimensions" => ["/another", "2020-01-01"], "metrics" => [25]}, + %{"dimensions" => ["/another", "2020-01-02"], "metrics" => [24]}, + %{"dimensions" => ["/blog", "2020-01-02"], "metrics" => [20]} + ] + end + + test "breakdown by a custom prop with a page filter", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, user_id: 123, timestamp: ~N[2021-01-01 00:00:00], pathname: "/blog"), + build(:engagement, + user_id: 123, + timestamp: ~N[2021-01-01 00:01:00], + pathname: "/blog", + scroll_depth: 91 + ), + build(:pageview, + user_id: 123, + timestamp: ~N[2021-01-01 00:02:00], + "meta.key": ["author"], + "meta.value": ["john"], + pathname: "/blog/john-post" + ), + build(:engagement, + user_id: 123, + timestamp: ~N[2021-01-01 00:03:00], + "meta.key": ["author"], + "meta.value": ["john"], + pathname: "/blog/john-post", + scroll_depth: 40 + ), + build(:pageview, + user_id: 123, + timestamp: ~N[2021-01-01 00:02:00], + "meta.key": ["author"], + "meta.value": ["john"], + pathname: "/another-blog/john-post" + ), + build(:engagement, + user_id: 123, + timestamp: ~N[2021-01-01 00:03:00], + "meta.key": ["author"], + "meta.value": ["john"], + pathname: "/another-blog/john-post", + scroll_depth: 90 + ), + build(:pageview, + user_id: 456, + timestamp: ~N[2021-01-01 00:02:00], + "meta.key": ["author"], + "meta.value": ["john"], + pathname: "/blog/john-post" + ), + build(:engagement, + user_id: 456, + timestamp: ~N[2021-01-01 00:03:00], + "meta.key": ["author"], + "meta.value": ["john"], + pathname: "/blog/john-post", + scroll_depth: 46 + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["scroll_depth"], + "order_by" => [["scroll_depth", "desc"]], + "date_range" => "all", + "filters" => [["matches", "event:page", ["/blog.*"]]], + "dimensions" => ["event:props:author"] + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => ["(none)"], "metrics" => [91]}, + %{"dimensions" => ["john"], "metrics" => [43]} + ] + end + end + + test "can filter by utm_medium case insensitively", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + utm_medium: "Social", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + utm_medium: "SOCIAL", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["pageviews", "visitors", "bounce_rate", "visit_duration"], + "filters" => [["is", "visit:utm_medium", ["social"], %{"case_sensitive" => false}]] + }) + + assert json_response(conn, 200)["results"] == [ + %{"metrics" => [2, 1, 0, 1500], "dimensions" => []} + ] + end + + describe "revenue metrics" do + @describetag :ee_only + + setup %{user: user} do + subscribe_to_enterprise_plan(user, + features: [Plausible.Billing.Feature.StatsAPI, Plausible.Billing.Feature.RevenueGoals] + ) + + :ok + end + + test "not requested leads to no metric_warnings", %{conn: conn, site: site} do + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["pageviews"] + }) + + assert json_response(conn, 200)["results"] == [%{"dimensions" => [], "metrics" => [0]}] + assert "metric_warnings" not in json_response(conn, 200)["meta"] + end + + test "no revenue goals configured", %{conn: conn, site: site} do + insert(:goal, site: site, event_name: "Signup") + + populate_stats(site, [ + build(:event, + name: "Signup", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["average_revenue", "total_revenue"], + "dimensions" => ["event:goal"] + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => ["Signup"], "metrics" => [nil, nil]} + ] + + assert json_response(conn, 200)["meta"]["metric_warnings"] == %{ + "average_revenue" => %{ + "code" => "no_revenue_goals_matching", + "warning" => "Revenue metrics are null as there are no matching revenue goals." + }, + "total_revenue" => %{ + "code" => "no_revenue_goals_matching", + "warning" => "Revenue metrics are null as there are no matching revenue goals." + } + } + end + + test "not filtering or grouping by goals leads to warnings", %{conn: conn, site: site} do + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["average_revenue", "total_revenue"] + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => [], "metrics" => [nil, nil]} + ] + + assert json_response(conn, 200)["meta"]["metric_warnings"] == %{ + "average_revenue" => %{ + "code" => "no_revenue_goals_matching", + "warning" => "Revenue metrics are null as there are no matching revenue goals." + }, + "total_revenue" => %{ + "code" => "no_revenue_goals_matching", + "warning" => "Revenue metrics are null as there are no matching revenue goals." + } + } + end + + test "filtering by revenue goals with same currency", %{conn: conn, site: site} do + insert(:goal, site: site, event_name: "Purchase", currency: "USD") + insert(:goal, site: site, event_name: "Payment", currency: "USD") + insert(:goal, site: site, event_name: "Subscription", currency: "EUR") + insert(:goal, site: site, event_name: "Signup") + + populate_stats(site, [ + build(:event, + name: "Purchase", + timestamp: ~N[2021-01-01 00:00:00], + revenue_reporting_amount: Decimal.new("100.3"), + revenue_reporting_currency: "USD" + ), + build(:event, + name: "Payment", + timestamp: ~N[2021-01-01 00:00:00], + revenue_reporting_amount: Decimal.new("40.3"), + revenue_reporting_currency: "USD" + ), + build(:event, + name: "Payment", + timestamp: ~N[2021-01-01 00:00:00], + revenue_reporting_amount: Decimal.new("60.6"), + revenue_reporting_currency: "USD" + ), + build(:event, + name: "Subscription", + timestamp: ~N[2021-01-01 00:00:00], + revenue_reporting_amount: Decimal.new("100"), + revenue_reporting_currency: "EUR" + ), + build(:event, + name: "Signup", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["events", "average_revenue", "total_revenue"], + "filters" => [ + ["is", "event:goal", ["Signup", "Purchase", "Payment"]] + ] + }) + + assert json_response(conn, 200)["results"] == [ + %{ + "dimensions" => [], + "metrics" => [ + 4, + %{ + "currency" => "USD", + "long" => "$67.07", + "short" => "$67.1", + "value" => 67.066 + }, + %{ + "currency" => "USD", + "long" => "$201.20", + "short" => "$201.2", + "value" => 201.2 + } + ] + } + ] + + assert "metric_warnings" not in json_response(conn, 200)["meta"] + end + + test "filtering by revenue goals with different currencies", %{conn: conn, site: site} do + insert(:goal, site: site, event_name: "Purchase", currency: "USD") + insert(:goal, site: site, event_name: "Payment", currency: "USD") + insert(:goal, site: site, event_name: "Subscription", currency: "EUR") + insert(:goal, site: site, event_name: "Signup") + + populate_stats(site, [ + build(:event, + name: "Purchase", + timestamp: ~N[2021-01-01 00:00:00], + revenue_reporting_amount: Decimal.new("100.3"), + revenue_reporting_currency: "USD" + ), + build(:event, + name: "Payment", + timestamp: ~N[2021-01-01 00:00:00], + revenue_reporting_amount: Decimal.new("40.3"), + revenue_reporting_currency: "USD" + ), + build(:event, + name: "Payment", + timestamp: ~N[2021-01-01 00:00:00], + revenue_reporting_amount: Decimal.new("60.6"), + revenue_reporting_currency: "USD" + ), + build(:event, + name: "Subscription", + timestamp: ~N[2021-01-01 00:00:00], + revenue_reporting_amount: Decimal.new("100"), + revenue_reporting_currency: "EUR" + ), + build(:event, + name: "Signup", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["average_revenue", "total_revenue"], + "filters" => [ + ["is", "event:goal", ["Subscription", "Payment"]] + ] + }) + + assert json_response(conn, 200)["results"] == [ + %{ + "dimensions" => [], + "metrics" => [nil, nil] + } + ] + + assert json_response(conn, 200)["meta"]["metric_warnings"] == %{ + "average_revenue" => %{ + "code" => "no_single_revenue_currency", + "warning" => + "Revenue metrics are null as there are multiple currencies for the selected event:goals." + }, + "total_revenue" => %{ + "code" => "no_single_revenue_currency", + "warning" => + "Revenue metrics are null as there are multiple currencies for the selected event:goals." + } + } + end + + test "breakdown by revenue goals", %{conn: conn, site: site} do + insert(:goal, site: site, event_name: "Purchase", currency: "USD") + insert(:goal, site: site, event_name: "Payment", currency: "USD") + insert(:goal, site: site, event_name: "Subscription", currency: "EUR") + insert(:goal, site: site, event_name: "Signup") + + populate_stats(site, [ + build(:event, + name: "Purchase", + timestamp: ~N[2021-01-01 00:00:00], + revenue_reporting_amount: Decimal.new("100.3"), + revenue_reporting_currency: "USD" + ), + build(:event, + name: "Payment", + timestamp: ~N[2021-01-01 00:00:00], + revenue_reporting_amount: Decimal.new("40.3"), + revenue_reporting_currency: "USD" + ), + build(:event, + name: "Payment", + timestamp: ~N[2021-01-01 00:00:00], + revenue_reporting_amount: Decimal.new("60.6"), + revenue_reporting_currency: "USD" + ), + build(:event, + name: "Subscription", + timestamp: ~N[2021-01-01 00:00:00], + revenue_reporting_amount: Decimal.new("100"), + revenue_reporting_currency: "EUR" + ), + build(:event, + name: "Signup", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["average_revenue", "total_revenue"], + "dimensions" => ["event:goal"] + }) + + assert json_response(conn, 200)["results"] == [ + %{ + "dimensions" => ["Purchase"], + "metrics" => [ + %{ + "currency" => "USD", + "long" => "$100.30", + "short" => "$100.3", + "value" => 100.3 + }, + %{ + "currency" => "USD", + "long" => "$100.30", + "short" => "$100.3", + "value" => 100.3 + } + ] + }, + %{ + "dimensions" => ["Subscription"], + "metrics" => [ + %{ + "currency" => "EUR", + "long" => "€100.00", + "short" => "€100.0", + "value" => 100.0 + }, + %{ + "currency" => "EUR", + "long" => "€100.00", + "short" => "€100.0", + "value" => 100.0 + } + ] + }, + %{ + "dimensions" => ["Payment"], + "metrics" => [ + %{ + "currency" => "USD", + "long" => "$50.45", + "short" => "$50.4", + "value" => 50.45 + }, + %{ + "currency" => "USD", + "long" => "$100.90", + "short" => "$100.9", + "value" => 100.9 + } + ] + }, + %{"dimensions" => ["Signup"], "metrics" => [nil, nil]} + ] + + assert "metric_warnings" not in json_response(conn, 200)["meta"] + end + + test "breakdown by revenue goals + filtering", %{conn: conn, site: site} do + insert(:goal, site: site, event_name: "Purchase", currency: "USD") + insert(:goal, site: site, event_name: "Payment", currency: "USD") + insert(:goal, site: site, event_name: "Subscription", currency: "EUR") + insert(:goal, site: site, event_name: "Signup") + + populate_stats(site, [ + build(:event, + name: "Purchase", + timestamp: ~N[2021-01-01 00:00:00], + revenue_reporting_amount: Decimal.new("100.3"), + revenue_reporting_currency: "USD" + ), + build(:event, + name: "Payment", + timestamp: ~N[2021-01-01 00:00:00], + revenue_reporting_amount: Decimal.new("40.3"), + revenue_reporting_currency: "USD" + ), + build(:event, + name: "Payment", + timestamp: ~N[2021-01-01 00:00:00], + revenue_reporting_amount: Decimal.new("60.6"), + revenue_reporting_currency: "USD" + ), + build(:event, + name: "Subscription", + timestamp: ~N[2021-01-01 00:00:00], + revenue_reporting_amount: Decimal.new("100"), + revenue_reporting_currency: "EUR" + ), + build(:event, + name: "Signup", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["average_revenue", "total_revenue"], + "filters" => [["is", "event:goal", ["Signup", "Purchase"]]], + "dimensions" => ["event:goal"] + }) + + assert json_response(conn, 200)["results"] == [ + %{ + "dimensions" => ["Purchase"], + "metrics" => [ + %{ + "currency" => "USD", + "long" => "$100.30", + "short" => "$100.3", + "value" => 100.3 + }, + %{ + "currency" => "USD", + "long" => "$100.30", + "short" => "$100.3", + "value" => 100.3 + } + ] + }, + %{"dimensions" => ["Signup"], "metrics" => [nil, nil]} + ] + + assert "metric_warnings" not in json_response(conn, 200)["meta"] + end + + test "breakdown by session dimension (entry page)", %{conn: conn, site: site} do + insert(:goal, site: site, event_name: "Purchase", currency: "USD") + + populate_stats(site, [ + build(:pageview, user_id: 1, pathname: "/blog"), + build(:event, + name: "Purchase", + user_id: 1, + revenue_reporting_amount: Decimal.new("100.00"), + revenue_reporting_currency: "USD" + ), + build(:pageview, user_id: 2, pathname: "/blog"), + build(:event, + name: "Purchase", + user_id: 2, + revenue_reporting_amount: Decimal.new("50.00"), + revenue_reporting_currency: "USD" + ), + build(:pageview, user_id: 3, pathname: "/home"), + build(:event, + name: "Purchase", + user_id: 3, + revenue_reporting_amount: Decimal.new("20.00"), + revenue_reporting_currency: "USD" + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["total_revenue", "average_revenue"], + "dimensions" => ["visit:entry_page"], + "filters" => [["is", "event:goal", ["Purchase"]]] + }) + + assert json_response(conn, 200)["results"] == [ + %{ + "dimensions" => ["/blog"], + "metrics" => [ + %{ + "currency" => "USD", + "long" => "$150.00", + "short" => "$150.0", + "value" => 150.0 + }, + %{"currency" => "USD", "long" => "$75.00", "short" => "$75.0", "value" => 75.0} + ] + }, + %{ + "dimensions" => ["/home"], + "metrics" => [ + %{ + "currency" => "USD", + "long" => "$20.00", + "short" => "$20.0", + "value" => 20.0 + }, + %{"currency" => "USD", "long" => "$20.00", "short" => "$20.0", "value" => 20.0} + ] + } + ] + end + end + + describe "behavioral (has_done/has_not_done) filters" do + test "has_done does counting by sessions", %{conn: conn, site: site} do + populate_stats(site, [ + # Session 1 + build(:event, name: "Conversion", user_id: 1, timestamp: ~N[2021-01-01 00:00:00]), + build(:event, name: "pageview", user_id: 1, timestamp: ~N[2021-01-01 00:01:00]), + # Session 2 + build(:event, name: "pageview", user_id: 1, timestamp: ~N[2021-01-02 00:00:00]), + build(:event, name: "pageview", user_id: 1, timestamp: ~N[2021-01-02 00:01:00]), + # Session 3 + build(:event, name: "Conversion", user_id: 1, timestamp: ~N[2021-01-03 00:00:00]), + build(:event, name: "pageview", user_id: 1, timestamp: ~N[2021-01-03 00:01:00]), + build(:event, name: "pageview", user_id: 1, timestamp: ~N[2021-01-03 00:02:00]), + build(:event, name: "pageview", user_id: 1, timestamp: ~N[2021-01-03 00:03:00]), + # Session 4 + build(:event, name: "Conversion", user_id: 2, timestamp: ~N[2021-01-01 00:00:00]), + build(:event, name: "pageview", user_id: 2, timestamp: ~N[2021-01-01 00:01:00]), + build(:event, name: "pageview", user_id: 2, timestamp: ~N[2021-01-01 00:02:00]), + build(:event, name: "pageview", user_id: 2, timestamp: ~N[2021-01-01 00:03:00]), + build(:event, name: "pageview", user_id: 2, timestamp: ~N[2021-01-01 00:04:00]) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors", "visits", "events", "pageviews"], + "date_range" => "all", + "filters" => [["has_done", ["is", "event:name", ["Conversion"]]]] + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => [], "metrics" => [2, 3, 11, 8]} + ] + end + + test "has_done returns all events by users who match condition if no further filters are provided", + %{conn: conn, site: site} do + populate_stats(site, [ + build(:event, name: "pageview", user_id: 1, timestamp: ~N[2021-01-01 00:00:00]), + build(:event, name: "AddToCart", user_id: 1, timestamp: ~N[2021-01-01 00:00:00]), + build(:event, name: "Purchase", user_id: 1, timestamp: ~N[2021-01-01 00:00:00]), + build(:event, name: "pageview", user_id: 2, timestamp: ~N[2021-01-01 00:00:00]), + build(:event, name: "Purchase", user_id: 2, timestamp: ~N[2021-01-01 00:00:00]), + build(:event, name: "pageview", user_id: 3, timestamp: ~N[2021-01-01 00:00:00]), + build(:event, name: "pageview", user_id: 4, timestamp: ~N[2021-01-01 00:00:00]), + build(:event, name: "Purchase", user_id: 5, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors", "events", "pageviews"], + "date_range" => "all", + "filters" => [["has_done", ["is", "event:name", ["Purchase"]]]] + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => [], "metrics" => [3, 6, 2]} + ] + end + + test "has_done event:page filter", %{conn: conn, site: site} do + populate_stats(site, [ + build(:event, + name: "pageview", + user_id: 1, + pathname: "/blog/post/1", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "pageview", + user_id: 1, + pathname: "/", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "pageview", + user_id: 2, + pathname: "/", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "pageview", + user_id: 3, + pathname: "/blog/post/1", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors", "pageviews"], + "date_range" => "all", + "filters" => [["has_done", ["contains", "event:page", ["/blog/"]]]] + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => [], "metrics" => [2, 3]} + ] + end + + test "has_not_done event:page filter", %{conn: conn, site: site} do + populate_stats(site, [ + build(:event, + name: "pageview", + user_id: 1, + pathname: "/blog/post/1", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "pageview", + user_id: 1, + pathname: "/", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "pageview", + user_id: 2, + pathname: "/", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "pageview", + user_id: 3, + pathname: "/blog/post/1", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors", "pageviews"], + "date_range" => "all", + "filters" => [["has_not_done", ["contains", "event:page", ["/blog/"]]]] + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => [], "metrics" => [1, 1]} + ] + end + + test "has_done with complex event:props and event:name filters", %{conn: conn, site: site} do + populate_stats(site, [ + build(:event, name: "Purchase", user_id: 1, timestamp: ~N[2021-01-01 00:00:00]), + build(:event, name: "Purchase", user_id: 1, timestamp: ~N[2021-01-01 00:10:00]), + build(:event, name: "Purchase", user_id: 1, timestamp: ~N[2021-01-01 00:20:00]), + build(:event, name: "pageview", user_id: 2, timestamp: ~N[2021-01-01 00:00:00]), + build(:event, name: "Purchase", user_id: 2, timestamp: ~N[2021-01-01 00:10:00]), + build(:event, + name: "Signup", + user_id: 3, + "meta.key": ["paid"], + "meta.value": ["true"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, name: "pageview", user_id: 3, timestamp: ~N[2021-01-01 00:00:00]), + build(:event, + name: "Signup", + user_id: 4, + "meta.key": ["paid"], + "meta.value": ["true"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, name: "pageview", user_id: 4, timestamp: ~N[2021-01-01 00:00:00]), + build(:event, + name: "Signup", + user_id: 5, + "meta.key": ["paid"], + "meta.value": ["false"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, name: "pageview", user_id: 5, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors", "pageviews"], + "date_range" => "all", + "filters" => [ + [ + "has_done", + [ + "or", + [ + ["is", "event:name", ["Purchase"]], + [ + "and", + [ + ["is", "event:name", ["Signup"]], + ["is", "event:props:paid", ["true"]] + ] + ] + ] + ] + ] + ] + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => [], "metrics" => [4, 3]} + ] + end + + test "has_done event:goal filter", %{conn: conn, site: site} do + insert(:goal, site: site, event_name: "Conversion") + + populate_stats(site, [ + build(:event, name: "Conversion", user_id: 1, timestamp: ~N[2021-01-01 00:00:00]), + build(:event, name: "Conversion", user_id: 1, timestamp: ~N[2021-01-01 00:00:00]), + build(:event, name: "pageview", user_id: 1, timestamp: ~N[2021-01-01 00:00:00]), + build(:event, name: "pageview", user_id: 1, timestamp: ~N[2021-01-01 00:00:00]), + build(:event, name: "pageview", user_id: 1, timestamp: ~N[2021-01-01 00:00:00]), + build(:event, name: "Conversion", user_id: 2, timestamp: ~N[2021-01-01 00:00:00]), + build(:event, name: "pageview", user_id: 2, timestamp: ~N[2021-01-01 00:00:00]), + build(:event, name: "Conversion", user_id: 3, timestamp: ~N[2021-01-01 00:00:00]), + build(:event, name: "pageview", user_id: 4, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors", "pageviews"], + "date_range" => "all", + "filters" => [ + ["has_done", ["is", "event:goal", ["Conversion"]]], + ["is", "event:name", ["pageview"]] + ] + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => [], "metrics" => [2, 4]} + ] + end + + test "has_not_done event:goal filter", %{conn: conn, site: site} do + insert(:goal, site: site, event_name: "Conversion") + + populate_stats(site, [ + build(:event, name: "Conversion", user_id: 1, timestamp: ~N[2021-01-01 00:00:00]), + build(:event, name: "Conversion", user_id: 1, timestamp: ~N[2021-01-01 00:00:00]), + build(:event, name: "pageview", user_id: 1, timestamp: ~N[2021-01-01 00:00:00]), + build(:event, name: "pageview", user_id: 1, timestamp: ~N[2021-01-01 00:00:00]), + build(:event, name: "pageview", user_id: 1, timestamp: ~N[2021-01-01 00:00:00]), + build(:event, name: "Conversion", user_id: 2, timestamp: ~N[2021-01-01 00:00:00]), + build(:event, name: "pageview", user_id: 2, timestamp: ~N[2021-01-01 00:00:00]), + build(:event, name: "Conversion", user_id: 3, timestamp: ~N[2021-01-01 00:00:00]), + build(:event, name: "pageview", user_id: 4, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors", "pageviews"], + "date_range" => "all", + "filters" => [ + ["has_not_done", ["is", "event:goal", ["Conversion"]]], + ["is", "event:name", ["pageview"]] + ] + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => [], "metrics" => [1, 1]} + ] + end + + test "visit filters are not allowed with has_done/has_not_done filters", %{ + conn: conn, + site: site + } do + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => "all", + "dimensions" => ["visit:browser"], + "filters" => [ + ["has_done", ["is", "visit:browser", ["Chrome"]]] + ] + }) + + assert %{"error" => error} = json_response(conn, 400) + + assert error =~ + "Invalid filters. Behavioral filters (has_done, has_not_done) can only be used with event dimension filters." + end + end + + describe "segment filters" do + setup [:create_user, :create_site, :create_api_key, :use_api_key] + + test "site segments of other sites don't resolve", %{ + conn: conn, + site: site + } do + other_site = new_site() + other_user = add_guest(other_site, role: :editor) + + segment = + insert(:segment, + name: "Segment of another site", + type: :site, + owner: other_user, + site: other_site, + segment_data: %{ + "filters" => [["is", "event:page", ["/blog"]]] + } + ) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "filters" => [["is", "segment", [segment.id]]], + "date_range" => "all", + "metrics" => ["events"] + }) + + assert json_response(conn, 400) == %{ + "error" => "Invalid filters. Some segments don't exist or aren't accessible." + } + end + + test ":has_not_done filter resolves in /api/v2/query endpoint when it is part of a segment", + %{ + conn: conn, + site: site + } do + other_user = add_guest(site, role: :editor) + + segment = + insert(:segment, + type: :site, + owner: other_user, + site: site, + name: "Signups", + segment_data: %{ + "filters" => [["has_not_done", ["is", "event:goal", ["Signup"]]]] + } + ) + + insert(:goal, %{site: site, event_name: "Signup"}) + + populate_stats(site, [ + build(:event, + name: "Signup", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Signup", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Signup", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "AnyOtherEvent", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "filters" => [["is", "segment", [segment.id]]], + "date_range" => "all", + "metrics" => ["events"] + }) + + assert json_response(conn, 200)["results"] == [%{"dimensions" => [], "metrics" => [1]}] + + # response shows what filters the segment was resolved to + assert json_response(conn, 200)["query"]["filters"] == [ + ["has_not_done", ["is", "event:goal", ["Signup"]]] + ] + end + + test "even personal segments of other users of the same site resolve to filters, with segments expanded in response", + %{ + conn: conn, + site: site + } do + other_user = add_guest(site, role: :editor) + + segment = + insert(:segment, + type: :personal, + owner: other_user, + site: site, + name: "Signups", + segment_data: %{ + "filters" => [["is", "event:goal", ["Signup"]]] + } + ) + + insert(:goal, %{site: site, event_name: "Signup"}) + + populate_stats(site, [ + build(:event, + name: "Signup", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Signup", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Signup", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "AnyOtherEvent", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "filters" => [["is", "segment", [segment.id]]], + "date_range" => "all", + "metrics" => ["events"] + }) + + assert json_response(conn, 200)["results"] == [%{"dimensions" => [], "metrics" => [3]}] + + # response shows what filters the segment was resolved to + assert json_response(conn, 200)["query"]["filters"] == [ + ["is", "event:goal", ["Signup"]] + ] + end + end +end diff --git a/test/plausible_web/controllers/api/external_stats_controller/query_time_on_page_test.exs b/test/plausible_web/controllers/api/external_stats_controller/query_time_on_page_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..5e164b1c1779807b33e832ac9891c82834dabbb2 --- /dev/null +++ b/test/plausible_web/controllers/api/external_stats_controller/query_time_on_page_test.exs @@ -0,0 +1,642 @@ +defmodule PlausibleWeb.Api.ExternalStatsController.QueryTimeOnPageTest do + use PlausibleWeb.ConnCase + + setup [:create_user, :create_site, :create_api_key, :use_api_key, :create_site_import] + + setup %{site: site} = context do + FunWithFlags.enable(:new_time_on_page, for_actor: site) + + context + end + + test "aggregated time_on_page metric based on engagement data", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, user_id: 12, pathname: "/blog", timestamp: ~N[2021-01-01 00:00:00]), + build(:engagement, + user_id: 12, + pathname: "/blog", + timestamp: ~N[2021-01-01 00:10:00], + engagement_time: 120_000 + ), + build(:engagement, + user_id: 12, + pathname: "/blog", + timestamp: ~N[2021-01-01 00:11:00], + engagement_time: 20_000 + ), + build(:pageview, user_id: 13, pathname: "/blog", timestamp: ~N[2021-01-01 00:10:00]), + build(:engagement, + user_id: 13, + pathname: "/blog", + timestamp: ~N[2021-01-01 00:10:00], + engagement_time: 60_000 + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["time_on_page"], + "date_range" => "all", + "dimensions" => ["event:page"], + "include" => %{"imports" => true} + }) + + assert_matches %{ + "results" => [ + %{"dimensions" => ["/blog"], "metrics" => [100]} + ], + "meta" => + ^strict_map(%{ + "imports_included" => true + }) + } = json_response(conn, 200) + end + + test "aggregated time_on_page metric with imported data", %{ + conn: conn, + site: site, + site_import: site_import + } do + populate_stats(site, site_import.id, [ + build(:pageview, user_id: 12, pathname: "/blog", timestamp: ~N[2021-01-01 00:00:00]), + build(:engagement, + user_id: 12, + pathname: "/blog", + timestamp: ~N[2021-01-01 00:10:00], + engagement_time: 120_000 + ), + build(:imported_pages, + page: "/blog", + date: ~D[2021-01-01], + visitors: 9, + total_time_on_page: 9 * 20, + total_time_on_page_visits: 9 + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["time_on_page"], + "date_range" => "all", + "dimensions" => ["event:page"], + "include" => %{"imports" => true} + }) + + assert_matches %{ + "results" => [ + %{"dimensions" => ["/blog"], "metrics" => [30]} + ], + "meta" => + ^strict_map(%{ + "imports_included" => true + }) + } = json_response(conn, 200) + end + + test "time_on_page time series", %{conn: conn, site: site, site_import: site_import} do + populate_stats(site, site_import.id, [ + build(:pageview, user_id: 12, pathname: "/", timestamp: ~N[2021-01-01 00:00:00]), + build(:engagement, + user_id: 12, + pathname: "/", + timestamp: ~N[2021-01-01 00:03:00], + engagement_time: 100_000 + ), + build(:pageview, user_id: 12, pathname: "/", timestamp: ~N[2021-01-02 00:00:00]), + build(:engagement, + user_id: 12, + pathname: "/", + timestamp: ~N[2021-01-02 00:03:00], + engagement_time: 100_000 + ), + build(:pageview, user_id: 13, pathname: "/", timestamp: ~N[2021-01-02 00:00:00]), + build(:engagement, + user_id: 13, + pathname: "/", + timestamp: ~N[2021-01-02 00:10:00], + engagement_time: 300_000 + ), + build(:pageview, user_id: 12, pathname: "/", timestamp: ~N[2021-01-03 00:00:00]), + build(:engagement, + user_id: 12, + pathname: "/", + timestamp: ~N[2021-01-03 00:03:00], + engagement_time: 100_000 + ), + build(:pageview, user_id: 12, pathname: "/", timestamp: ~N[2021-01-04 00:00:00]), + build(:engagement, + user_id: 12, + pathname: "/", + timestamp: ~N[2021-01-04 00:03:00], + engagement_time: 100_000 + ), + build(:imported_pages, + page: "/blog", + date: ~D[2021-01-01], + visitors: 9, + total_time_on_page: 9 * 20, + total_time_on_page_visits: 9 + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["time_on_page"], + "date_range" => ["2021-01-01", "2021-01-04"], + "dimensions" => ["time:day", "event:page"], + "include" => %{"imports" => true} + }) + + assert_matches %{ + "results" => [ + %{"dimensions" => ["2021-01-01", "/"], "metrics" => [100]}, + %{"dimensions" => ["2021-01-01", "/blog"], "metrics" => [20]}, + %{"dimensions" => ["2021-01-02", "/"], "metrics" => [200]}, + %{"dimensions" => ["2021-01-03", "/"], "metrics" => [100]}, + %{"dimensions" => ["2021-01-04", "/"], "metrics" => [100]} + ], + "meta" => + ^strict_map(%{ + "imports_included" => true + }) + } = json_response(conn, 200) + end + + describe "site.legacy_time_on_page_cutoff" do + setup %{site: site, site_import: site_import} = context do + populate_stats(site, site_import.id, [ + build(:pageview, user_id: 12, pathname: "/", timestamp: ~N[2021-01-01 00:00:00]), + build(:engagement, + user_id: 12, + pathname: "/", + timestamp: ~N[2021-01-01 00:02:00], + engagement_time: 100_000 + ), + build(:pageview, user_id: 12, pathname: "/blog", timestamp: ~N[2021-01-01 00:02:00]), + build(:engagement, + user_id: 12, + pathname: "/blog", + timestamp: ~N[2021-01-01 00:05:00], + engagement_time: 100_000 + ), + build(:pageview, user_id: 12, pathname: "/", timestamp: ~N[2021-01-01 00:05:00]), + build(:engagement, + user_id: 12, + pathname: "/", + timestamp: ~N[2021-01-01 00:10:00], + engagement_time: 100_000 + ), + build(:pageview, user_id: 12, pathname: "/blog", timestamp: ~N[2021-01-01 00:10:00]), + build(:engagement, + user_id: 12, + pathname: "/blog", + timestamp: ~N[2021-01-01 00:15:00], + engagement_time: 100_000 + ), + build(:pageview, user_id: 13, pathname: "/pricing", timestamp: ~N[2021-01-02 01:00:00]), + build(:engagement, + user_id: 13, + pathname: "/pricing", + timestamp: ~N[2021-01-02 01:02:00], + engagement_time: 30_000 + ), + build(:pageview, user_id: 14, pathname: "/", timestamp: ~N[2021-01-02 01:00:00]), + build(:engagement, + user_id: 14, + pathname: "/", + timestamp: ~N[2021-01-02 01:02:00], + engagement_time: 30_000 + ), + build(:imported_pages, + page: "/blog", + date: ~D[2021-01-01], + visitors: 9, + total_time_on_page: 9 * 20, + total_time_on_page_visits: 9 + ) + ]) + + context + end + + test "breakdown with cutoff being before data (new time-on-page query used)", %{ + conn: conn, + site: site + } do + site = Plausible.Sites.update_legacy_time_on_page_cutoff!(site, ~D[1970-01-01]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["time_on_page"], + "date_range" => "all", + "dimensions" => ["event:page"], + "include" => %{ + "imports" => true + } + }) + + assert_matches %{ + "results" => [ + %{"dimensions" => ["/"], "metrics" => [115]}, + # (2 * 100s + 9 * 20s) / 10 = 38 + %{"dimensions" => ["/blog"], "metrics" => [38]}, + %{"dimensions" => ["/pricing"], "metrics" => [30]} + ], + "meta" => + ^strict_map(%{ + "imports_included" => true + }) + } = json_response(conn, 200) + end + + test "breakdown with cutoff being after data (legacy query used)", %{conn: conn, site: site} do + site = Plausible.Sites.update_legacy_time_on_page_cutoff!(site, ~D[2021-01-05]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["time_on_page"], + "date_range" => "all", + "dimensions" => ["event:page"], + "include" => %{ + "imports" => true + } + }) + + assert_matches %{ + "results" => [ + %{"dimensions" => ["/"], "metrics" => [420]}, + %{"dimensions" => ["/blog"], "metrics" => [180]}, + %{"dimensions" => ["/pricing"], "metrics" => [nil]} + ], + "meta" => + ^strict_map(%{ + "imports_included" => true, + "metric_warnings" => %{ + "time_on_page" => %{ + "code" => "legacy_time_on_page_used", + "message" => + "This period includes data calculated with the legacy time on page method up to 2021-01-05" + } + } + }) + } = json_response(conn, 200) + end + + test "breakdown with cutoff being mid-data (two queries joined)", %{conn: conn, site: site} do + site = Plausible.Sites.update_legacy_time_on_page_cutoff!(site, ~D[2021-01-02]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["time_on_page"], + "date_range" => "all", + "dimensions" => ["event:page"], + "include" => %{ + "imports" => true + } + }) + + assert_matches %{ + "results" => [ + %{"dimensions" => ["/"], "metrics" => [225]}, + %{"dimensions" => ["/blog"], "metrics" => [180]}, + %{"dimensions" => ["/pricing"], "metrics" => [30]} + ], + "meta" => + ^strict_map(%{ + "imports_included" => true, + "metric_warnings" => %{ + "time_on_page" => %{ + "code" => "legacy_time_on_page_used", + "message" => + "This period includes data calculated with the legacy time on page method up to 2021-01-02" + } + } + }) + } = json_response(conn, 200) + end + + test "breakdown with cutoff being mid-data (two queries joined), no imports", %{ + conn: conn, + site: site + } do + site = Plausible.Sites.update_legacy_time_on_page_cutoff!(site, ~D[2021-01-02]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["time_on_page"], + "date_range" => "all", + "dimensions" => ["event:page"] + }) + + assert_matches %{ + "results" => [ + %{"dimensions" => ["/"], "metrics" => [225]}, + %{"dimensions" => ["/blog"], "metrics" => [180]}, + %{"dimensions" => ["/pricing"], "metrics" => [30]} + ], + "meta" => + ^strict_map(%{ + "metric_warnings" => %{ + "time_on_page" => %{ + "code" => "legacy_time_on_page_used", + "message" => + "This period includes data calculated with the legacy time on page method up to 2021-01-02" + } + } + }) + } = json_response(conn, 200) + end + + test "aggregation with cutoff being after data (legacy query used)", %{conn: conn, site: site} do + site = Plausible.Sites.update_legacy_time_on_page_cutoff!(site, ~D[2021-01-05]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["time_on_page"], + "date_range" => "all", + "filters" => [["is", "event:page", ["/blog"]]], + "include" => %{ + "imports" => true + } + }) + + assert_matches %{ + "results" => [ + %{"dimensions" => [], "metrics" => [180]} + ], + "meta" => + ^strict_map(%{ + "imports_included" => true, + "metric_warnings" => %{ + "time_on_page" => %{ + "code" => "legacy_time_on_page_used", + "message" => + "This period includes data calculated with the legacy time on page method up to 2021-01-05" + } + } + }) + } = json_response(conn, 200) + end + + test "aggregation with cutoff being mid-data (two queries joined)", %{conn: conn, site: site} do + site = Plausible.Sites.update_legacy_time_on_page_cutoff!(site, ~D[2021-01-02]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["time_on_page"], + "date_range" => "all", + "filters" => [["is", "event:page", ["/blog"]]], + "include" => %{ + "imports" => true + } + }) + + assert_matches %{ + "results" => [ + %{"dimensions" => [], "metrics" => [180]} + ], + "meta" => + ^strict_map(%{ + "imports_included" => true, + "metric_warnings" => %{ + "time_on_page" => %{ + "code" => "legacy_time_on_page_used", + "message" => + "This period includes data calculated with the legacy time on page method up to 2021-01-02" + } + } + }) + } = json_response(conn, 200) + end + + test "aggregation with cutoff being mid-data (two queries joined), no imports", %{ + conn: conn, + site: site + } do + site = Plausible.Sites.update_legacy_time_on_page_cutoff!(site, ~D[2021-01-02]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["time_on_page"], + "date_range" => "all", + "filters" => [["is", "event:page", ["/blog"]]] + }) + + assert_matches %{ + "results" => [ + %{"dimensions" => [], "metrics" => [180]} + ], + "meta" => + ^strict_map(%{ + "metric_warnings" => %{ + "time_on_page" => %{ + "code" => "legacy_time_on_page_used", + "message" => + "This period includes data calculated with the legacy time on page method up to 2021-01-02" + } + } + }) + } = json_response(conn, 200) + end + end + + describe "legacy time_on_page metric" do + test "aggregated", %{conn: conn, site: site, site_import: site_import} do + site = Plausible.Sites.update_legacy_time_on_page_cutoff!(site, ~D[2100-01-01]) + + populate_stats(site, site_import.id, [ + build(:pageview, pathname: "/A", user_id: 111, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, pathname: "/B", user_id: 111, timestamp: ~N[2021-01-01 00:01:00]), + build(:pageview, pathname: "/A", user_id: 999, timestamp: ~N[2021-01-02 00:00:00]), + build(:pageview, pathname: "/B", user_id: 999, timestamp: ~N[2021-01-02 00:01:30]), + # These are ignored for time_on_page metric + build(:imported_pages, page: "/A", total_time_on_page: 40, date: ~D[2021-01-01]), + build(:imported_pages, page: "/B", total_time_on_page: 499, date: ~D[2021-01-01]) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors", "time_on_page"], + "date_range" => "all", + "filters" => [["is", "event:page", ["/A"]]], + "include" => %{ + "imports" => true + } + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => [], "metrics" => [3, 75]} + ] + end + + test "breakdown", %{conn: conn, site: site, site_import: site_import} do + site = Plausible.Sites.update_legacy_time_on_page_cutoff!(site, ~D[2100-01-01]) + + populate_stats(site, site_import.id, [ + build(:pageview, pathname: "/A", user_id: 111, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, pathname: "/B", user_id: 111, timestamp: ~N[2021-01-01 00:01:00]), + build(:pageview, pathname: "/A", user_id: 999, timestamp: ~N[2021-01-02 00:00:00]), + build(:pageview, pathname: "/B", user_id: 999, timestamp: ~N[2021-01-02 00:01:30]), + build(:pageview, pathname: "/C", user_id: 999, timestamp: ~N[2021-01-02 00:02:00]), + build(:imported_pages, page: "/A", total_time_on_page: 40, date: ~D[2021-01-01]), + build(:imported_pages, page: "/B", total_time_on_page: 499, date: ~D[2021-01-01]) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors", "time_on_page"], + "date_range" => "all", + "dimensions" => ["event:page"], + "include" => %{ + "imports" => true + } + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => ["/A"], "metrics" => [3, 63]}, + %{"dimensions" => ["/B"], "metrics" => [3, 264]}, + %{"dimensions" => ["/C"], "metrics" => [1, nil]} + ] + end + + test "ignores page refresh", %{conn: conn, site: site} do + site = Plausible.Sites.update_legacy_time_on_page_cutoff!(site, ~D[2100-01-01]) + + populate_stats(site, [ + build(:pageview, user_id: 123, timestamp: ~N[2021-01-01 00:00:00], pathname: "/"), + build(:pageview, user_id: 123, timestamp: ~N[2021-01-01 00:01:00], pathname: "/"), + build(:pageview, user_id: 123, timestamp: ~N[2021-01-01 00:02:00], pathname: "/"), + build(:pageview, user_id: 123, timestamp: ~N[2021-01-01 00:03:00], pathname: "/exit") + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors", "time_on_page"], + "date_range" => "all", + "filters" => [["is", "event:page", ["/"]]] + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => [], "metrics" => [1, 180]} + ] + end + end + + describe "timeseries" do + setup %{site: site} = context do + populate_stats(site, [ + build(:pageview, user_id: 12, pathname: "/", timestamp: ~N[2021-01-01 00:00:00]), + build(:engagement, + user_id: 12, + pathname: "/", + timestamp: ~N[2021-01-01 00:05:00], + engagement_time: 100_000 + ), + build(:pageview, user_id: 12, pathname: "/", timestamp: ~N[2021-01-02 00:00:00]), + build(:engagement, + user_id: 12, + pathname: "/", + timestamp: ~N[2021-01-02 00:05:00], + engagement_time: 200_000 + ), + build(:pageview, user_id: 12, pathname: "/", timestamp: ~N[2021-01-03 00:00:00]), + build(:engagement, + user_id: 12, + pathname: "/", + timestamp: ~N[2021-01-03 00:05:00], + engagement_time: 250_000 + ), + build(:pageview, user_id: 12, pathname: "/", timestamp: ~N[2021-01-04 00:00:00]), + build(:engagement, + user_id: 12, + pathname: "/", + timestamp: ~N[2021-01-04 00:05:00], + engagement_time: 200_000 + ), + build(:pageview, user_id: 13, pathname: "/", timestamp: ~N[2021-01-04 00:00:00]), + build(:engagement, + user_id: 13, + pathname: "/", + timestamp: ~N[2021-01-04 00:05:00], + engagement_time: 100_000 + ) + ]) + + context + end + + test "reports average new time-on-page per day", %{conn: conn, site: site} do + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["time_on_page"], + "date_range" => ["2021-01-01", "2021-01-04"], + "filters" => [["is", "event:page", ["/"]]], + "dimensions" => ["time:day"] + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => ["2021-01-01"], "metrics" => [100]}, + %{"dimensions" => ["2021-01-02"], "metrics" => [200]}, + %{"dimensions" => ["2021-01-03"], "metrics" => [250]}, + %{"dimensions" => ["2021-01-04"], "metrics" => [150]} + ] + end + + test "reports legacy time-on-page as nulls per day", %{conn: conn, site: site} do + site = Plausible.Sites.update_legacy_time_on_page_cutoff!(site, ~D[2100-01-01]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["time_on_page"], + "date_range" => ["2021-01-01", "2021-01-04"], + "filters" => [["is", "event:page", ["/"]]], + "dimensions" => ["time:day"] + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => ["2021-01-01"], "metrics" => [nil]}, + %{"dimensions" => ["2021-01-02"], "metrics" => [nil]}, + %{"dimensions" => ["2021-01-03"], "metrics" => [nil]}, + %{"dimensions" => ["2021-01-04"], "metrics" => [nil]} + ] + end + + test "respects `legacy_time_on_page_cutoff`", %{conn: conn, site: site} do + site = Plausible.Sites.update_legacy_time_on_page_cutoff!(site, ~D[2021-01-03]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["time_on_page"], + "date_range" => ["2021-01-01", "2021-01-04"], + "filters" => [["is", "event:page", ["/"]]], + "dimensions" => ["time:day"] + }) + + assert json_response(conn, 200)["results"] == [ + %{"dimensions" => ["2021-01-01"], "metrics" => [nil]}, + %{"dimensions" => ["2021-01-02"], "metrics" => [nil]}, + %{"dimensions" => ["2021-01-03"], "metrics" => [250]}, + %{"dimensions" => ["2021-01-04"], "metrics" => [150]} + ] + end + end +end diff --git a/test/plausible_web/controllers/api/external_stats_controller/query_timezone_test.exs b/test/plausible_web/controllers/api/external_stats_controller/query_timezone_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..32c69ff0de491269732d1e198ce2e13b941c1327 --- /dev/null +++ b/test/plausible_web/controllers/api/external_stats_controller/query_timezone_test.exs @@ -0,0 +1,295 @@ +defmodule PlausibleWeb.Api.ExternalStatsController.QueryTimezoneTest do + use PlausibleWeb.ConnCase + + setup [:create_user, :create_site, :create_api_key, :use_api_key] + + describe "query parser timezone conversion for date ranges" do + test "uses site timezone to determine 'today' (negative offset)", %{ + conn: conn, + site: site + } do + site = Plausible.Repo.update!(Ecto.Changeset.change(site, timezone: "America/New_York")) + + populate_stats(site, [ + build(:pageview, timestamp: ~N[2024-01-01 12:00:00]), + build(:pageview, timestamp: ~N[2024-01-02 12:00:00]), + build(:pageview, timestamp: ~N[2024-01-02 14:00:00]) + ]) + + conn = + conn + # 9pm on Jan 1st, 2024 in EST (UTC-5) + |> Plug.Conn.put_private(:now, ~U[2024-01-02 02:00:00Z]) + |> post("/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["pageviews"], + "date_range" => "day" + }) + + response = json_response(conn, 200) + + assert [%{"metrics" => [pageview_count], "dimensions" => []}] = response["results"] + assert pageview_count == 1 + end + + test "uses site timezone to determine 'today' (positive offset)", %{ + conn: conn, + site: site + } do + site = Plausible.Repo.update!(Ecto.Changeset.change(site, timezone: "Asia/Tokyo")) + + populate_stats(site, [ + build(:pageview, timestamp: ~N[2024-01-01 10:00:00]), + build(:pageview, timestamp: ~N[2024-01-01 12:00:00]), + build(:pageview, timestamp: ~N[2024-01-01 15:30:00]) + ]) + + conn = + conn + # 01:00 on Jan 2nd, 2024 in JST (UTC+9) + |> Plug.Conn.put_private(:now, ~U[2024-01-01 16:00:00Z]) + |> post("/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["pageviews"], + "date_range" => "day" + }) + + response = json_response(conn, 200) + + assert [%{"metrics" => [pageview_count], "dimensions" => []}] = response["results"] + assert pageview_count == 1 + end + + test "handles timezone gap when date starts at non-existent midnight (spring forward)", %{ + conn: conn, + site: site + } do + # On 2022-09-11 in America/Santiago, clocks spring forward from 00:00 to 01:00 + # So midnight doesn't exist - it's a "gap" + site = Plausible.Repo.update!(Ecto.Changeset.change(site, timezone: "America/Santiago")) + + populate_stats(site, [ + # 2022-09-11 03:59:00 UTC = 2022-09-10 23:59:00 CLT (before gap) + build(:pageview, timestamp: ~N[2022-09-11 03:59:00]), + # 2022-09-11 04:00:00 UTC = 2022-09-11 01:00:00 CLT (after gap - midnight doesn't exist) + build(:pageview, timestamp: ~N[2022-09-11 04:00:00]), + # 2022-09-11 05:00:00 UTC = 2022-09-11 02:00:00 CLT (well after gap) + build(:pageview, timestamp: ~N[2022-09-11 05:00:00]) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["pageviews"], + "date_range" => ["2022-09-11", "2022-09-11"] + }) + + response = json_response(conn, 200) + + # Should include pageviews from 01:00:00 CLT onwards (after the gap) + assert [%{"metrics" => [pageview_count], "dimensions" => []}] = response["results"] + assert pageview_count == 2 + end + + test "handles ambiguous datetime at start of range (fall back)", %{ + conn: conn, + site: site + } do + # On 2023-11-05 in America/Havana, clocks fall back at 01:00 CDT to 00:00 EST + # So 00:00:00 happens twice - it's "ambiguous" + site = Plausible.Repo.update!(Ecto.Changeset.change(site, timezone: "America/Havana")) + + populate_stats(site, [ + # 2023-11-05 04:30:00 UTC = 2023-11-05 00:30:00 CDT (in first occurrence hour) + build(:pageview, timestamp: ~N[2023-11-05 04:30:00]), + # 2023-11-05 05:30:00 UTC = 2023-11-05 00:30:00 EST (in second occurrence hour) + build(:pageview, timestamp: ~N[2023-11-05 05:30:00]), + # 2023-11-05 06:30:00 UTC = 2023-11-05 01:30:00 EST + build(:pageview, timestamp: ~N[2023-11-05 06:30:00]) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["pageviews"], + "date_range" => ["2023-11-05", "2023-11-05"] + }) + + response = json_response(conn, 200) + + # Range is 05:00:00 UTC to 04:59:59 UTC next day + # Should include pageviews at 05:30 and 06:30 (2 pageviews) + assert [%{"metrics" => [pageview_count], "dimensions" => []}] = response["results"] + assert pageview_count == 2 + end + + test "handles ambiguous datetime at end of range (fall back)", %{ + conn: conn, + site: site + } do + # On 2024-03-23 in America/Asuncion, clocks fall back creating ambiguous times + site = Plausible.Repo.update!(Ecto.Changeset.change(site, timezone: "America/Asuncion")) + + populate_stats(site, [ + # 2024-03-23 12:00:00 UTC = 2024-03-23 09:00:00 PYT (during the day) + build(:pageview, timestamp: ~N[2024-03-23 12:00:00]), + # 2024-03-24 02:58:00 UTC = 2024-03-23 23:58:00 PYT (before end of day) + build(:pageview, timestamp: ~N[2024-03-24 02:58:00]), + # 2024-03-24 03:00:00 UTC = 2024-03-24 00:00:00 PYT (next day) + build(:pageview, timestamp: ~N[2024-03-24 03:00:00]) + ]) + + # Query for just the 23rd + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["pageviews"], + "date_range" => ["2024-03-23", "2024-03-23"] + }) + + response = json_response(conn, 200) + + # Should include first 2 pageviews but not the third (which is on the 24th) + assert [%{"metrics" => [pageview_count], "dimensions" => []}] = response["results"] + assert pageview_count == 2 + end + end + + describe "legacy_time_on_page_cutoff timezone handling" do + setup [:create_user, :create_site, :create_api_key, :use_api_key] + + setup %{site: site} = context do + FunWithFlags.enable(:new_time_on_page, for_actor: site) + context + end + + test "uses site timezone for legacy_time_on_page_cutoff conversion", %{ + conn: conn, + site: site + } do + # Set site to EST timezone (UTC-5) + site = Plausible.Repo.update!(Ecto.Changeset.change(site, timezone: "America/New_York")) + # Cutoff at 2024-01-01 in EST = 2024-01-01 05:00:00 UTC + site = Plausible.Sites.update_legacy_time_on_page_cutoff!(site, ~D[2024-01-01]) + + populate_stats(site, [ + # 2024-01-01 03:00:00 UTC = 2023-12-31 22:00:00 EST (before cutoff in EST) + build(:pageview, user_id: 1, timestamp: ~N[2024-01-01 03:00:00]), + build(:pageview, user_id: 1, pathname: "/exit", timestamp: ~N[2024-01-01 03:10:00]), + # 2024-01-01 06:00:00 UTC = 2024-01-01 01:00:00 EST (after cutoff in EST) + build(:pageview, user_id: 2, timestamp: ~N[2024-01-01 06:00:00]), + build(:engagement, + user_id: 2, + timestamp: ~N[2024-01-01 06:05:00], + engagement_time: 90_000 + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["time_on_page"], + "date_range" => "all", + "dimensions" => ["event:page"] + }) + + response = json_response(conn, 200) + + # First pageview (03:00 < 05:00) uses legacy: 600s + # Second pageview (06:00 > 05:00) uses new metric: merged (600 + 90) / 2 = 345s + assert [ + %{"metrics" => [345], "dimensions" => ["/"]}, + %{"metrics" => [nil], "dimensions" => ["/exit"]} + ] = response["results"] + end + + test "handles timezone gap in legacy_time_on_page_cutoff date", %{ + conn: conn, + site: site + } do + # On 2022-09-11 in America/Santiago, midnight doesn't exist (gap) + # When converting 00:00:00 on 2022-09-11 to datetime, it should use just_before + # which is 2022-09-10 23:59:59.999999-04:00 CLT + site = Plausible.Repo.update!(Ecto.Changeset.change(site, timezone: "America/Santiago")) + site = Plausible.Sites.update_legacy_time_on_page_cutoff!(site, ~D[2022-09-11]) + + populate_stats(site, [ + # Before cutoff (03:30 < 03:59:59) - will use legacy time_on_page (based on exit) + build(:pageview, user_id: 1, timestamp: ~N[2022-09-11 03:30:00]), + build(:pageview, user_id: 1, pathname: "/exit", timestamp: ~N[2022-09-11 03:40:00]), + # After cutoff (05:00 > 03:59:59) - will use new time_on_page (based on engagement) + build(:pageview, user_id: 2, timestamp: ~N[2022-09-11 05:00:00]), + build(:engagement, + user_id: 2, + timestamp: ~N[2022-09-11 05:05:00], + engagement_time: 60_000 + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["time_on_page"], + "date_range" => "all", + "dimensions" => ["event:page"] + }) + + response = json_response(conn, 200) + + # First pageview (before cutoff) uses legacy: 600s + # Second pageview (after cutoff) uses new metric but it merges both: (600 + 60) / 2 = 330s + assert [ + %{"metrics" => [330], "dimensions" => ["/"]}, + %{"metrics" => [nil], "dimensions" => ["/exit"]} + ] = response["results"] + end + + test "handles ambiguous datetime in legacy_time_on_page_cutoff date", %{ + conn: conn, + site: site + } do + # On 2023-11-05 in America/Havana, 00:00:00 happens twice (ambiguous) + # When converting 00:00:00 to datetime, it should use first_datetime + # first_datetime = 2023-11-05 00:00:00-04:00 CDT = 2023-11-05 04:00:00 UTC + site = Plausible.Repo.update!(Ecto.Changeset.change(site, timezone: "America/Havana")) + site = Plausible.Sites.update_legacy_time_on_page_cutoff!(site, ~D[2023-11-05]) + + populate_stats(site, [ + # Before cutoff (03:30 < 04:00) - will use legacy time_on_page + build(:pageview, user_id: 1, timestamp: ~N[2023-11-05 03:30:00]), + build(:pageview, user_id: 1, pathname: "/exit", timestamp: ~N[2023-11-05 03:40:00]), + # After cutoff (04:30 > 04:00) - will use new time_on_page + build(:pageview, user_id: 2, timestamp: ~N[2023-11-05 04:30:00]), + build(:engagement, + user_id: 2, + timestamp: ~N[2023-11-05 04:35:00], + engagement_time: 90_000 + ), + # Also after cutoff (05:30 > 04:00) - will use new time_on_page + build(:pageview, user_id: 3, timestamp: ~N[2023-11-05 05:30:00]), + build(:engagement, + user_id: 3, + timestamp: ~N[2023-11-05 05:35:00], + engagement_time: 30_000 + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["time_on_page"], + "date_range" => "all", + "dimensions" => ["event:page"] + }) + + response = json_response(conn, 200) + + # First pageview (before cutoff) uses legacy: 600s + # Other pageviews (after cutoff) use new metric, merged: (600 + 90 + 30) / 3 = 240s + assert [ + %{"metrics" => [240], "dimensions" => ["/"]}, + %{"metrics" => [nil], "dimensions" => ["/exit"]} + ] = response["results"] + end + end +end diff --git a/test/plausible_web/controllers/api/external_stats_controller/query_validations_test.exs b/test/plausible_web/controllers/api/external_stats_controller/query_validations_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..fedf806a71df32e59e1e9804853c6c98710ab4c2 --- /dev/null +++ b/test/plausible_web/controllers/api/external_stats_controller/query_validations_test.exs @@ -0,0 +1,258 @@ +defmodule PlausibleWeb.Api.ExternalStatsController.QueryValidationsTest do + use PlausibleWeb.ConnCase + + alias Plausible.Billing.Feature + + setup [:create_user, :create_site, :create_api_key, :use_api_key] + + describe "feature access" do + test "cannot break down by a custom prop without access to the props feature", %{ + conn: conn, + user: user, + site: site + } do + subscribe_to_enterprise_plan(user, features: [Feature.StatsAPI]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => "all", + "dimensions" => ["event:props:author"] + }) + + assert json_response(conn, 400)["error"] == + "The owner of this site does not have access to the custom properties feature." + end + + test "can break down by an internal prop key without access to the props feature", %{ + conn: conn, + user: user, + site: site + } do + subscribe_to_enterprise_plan(user, features: [Feature.StatsAPI]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => "all", + "dimensions" => ["event:props:path"] + }) + + assert json_response(conn, 200)["results"] + end + + test "cannot filter by a custom prop without access to the props feature", %{ + conn: conn, + user: user, + site: site + } do + subscribe_to_enterprise_plan(user, features: [Feature.StatsAPI]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => "all", + "dimensions" => ["visit:source"], + "filters" => [["is", "event:props:author", ["Uku"]]] + }) + + assert json_response(conn, 400)["error"] == + "The owner of this site does not have access to the custom properties feature." + end + + test "can filter by an internal prop key without access to the props feature", %{ + conn: conn, + user: user, + site: site + } do + subscribe_to_enterprise_plan(user, features: [Feature.StatsAPI]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => "all", + "dimensions" => ["visit:source"], + "filters" => [["is", "event:props:url", ["whatever"]]] + }) + + assert json_response(conn, 200)["results"] + end + end + + describe "param validation" do + test "does not allow querying conversion_rate without a goal filter", %{ + conn: conn, + site: site + } do + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["conversion_rate"], + "date_range" => "all", + "dimensions" => ["event:page"], + "filters" => [["is", "event:props:author", ["Uku"]]] + }) + + assert json_response(conn, 400)["error"] == + "Metric `conversion_rate` can only be queried with event:goal filters or dimensions." + end + + test "validates that dimensions are valid", %{conn: conn, site: site} do + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => "all", + "dimensions" => ["badproperty"] + }) + + assert json_response(conn, 400)["error"] == + "#/dimensions/0: Invalid dimension \"badproperty\"" + end + + test "empty custom property is invalid", %{conn: conn, site: site} do + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => "all", + "dimensions" => ["event:props:"] + }) + + assert json_response(conn, 400)["error"] == + "#/dimensions/0: Invalid dimension \"event:props:\"" + end + + test "validates that correct date range is used", %{conn: conn, site: site} do + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors"], + "date_range" => "bad_period", + "dimensions" => ["event:name"] + }) + + assert json_response(conn, 400)["error"] == + "#/date_range: Invalid date range \"bad_period\"" + end + + test "fails when an invalid metric is provided", %{conn: conn, site: site} do + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors", "baa"], + "date_range" => "all", + "dimensions" => ["event:name"] + }) + + assert json_response(conn, 400)["error"] == "#/metrics/1: Invalid metric \"baa\"" + end + + test "session metrics cannot be used with event:name dimension", %{conn: conn, site: site} do + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors", "bounce_rate"], + "date_range" => "all", + "dimensions" => ["event:name"] + }) + + assert json_response(conn, 400)["error"] =~ + "Session metric(s) `bounce_rate` cannot be queried along with event dimension(s) `event:name`" + end + + test "session metrics cannot be used with event:props:* dimension", %{conn: conn, site: site} do + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "metrics" => ["visitors", "bounce_rate"], + "date_range" => "all", + "dimensions" => ["event:props:url"] + }) + + assert json_response(conn, 400)["error"] =~ + "Session metric(s) `bounce_rate` cannot be queried along with event dimension(s) `event:props:url`" + end + + test "validates that metric views_per_visit cannot be used with event:page filter", %{ + conn: conn, + site: site + } do + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["views_per_visit"], + "filters" => [["is", "event:page", ["/something"]]] + }) + + assert json_response(conn, 400) == %{ + "error" => + "Metric `views_per_visit` cannot be queried with a filter on `event:page`." + } + end + + test "validates that metric views_per_visit cannot be used together with non-time dimensions", + %{ + conn: conn, + site: site + } do + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["views_per_visit"], + "dimensions" => ["event:name"] + }) + + assert json_response(conn, 400) == %{ + "error" => "Metric `views_per_visit` cannot be queried with non-time dimensions." + } + end + + test "validates a metric can't be asked multiple times", %{ + conn: conn, + site: site + } do + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["views_per_visit", "visitors", "visitors"] + }) + + assert json_response(conn, 400) == %{ + "error" => "#/metrics: Expected items to be unique but they were not." + } + end + + test "handles filtering by visit:country with invalid country code", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, country_code: "EE"), + build(:pageview, country_code: "EE"), + build(:pageview, country_code: "IT"), + build(:pageview, country_code: "DE") + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["pageviews"], + "filters" => [["is", "visit:country", ["USA"]]] + }) + + assert json_response(conn, 400) == %{ + "error" => + "Invalid visit:country filter, visit:country needs to be a valid 2-letter country code." + } + end + end +end diff --git a/test/plausible_web/controllers/api/external_stats_controller/timeseries_test.exs b/test/plausible_web/controllers/api/external_stats_controller/timeseries_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..8d12db34525889c236d3fd0b4c057182431de35c --- /dev/null +++ b/test/plausible_web/controllers/api/external_stats_controller/timeseries_test.exs @@ -0,0 +1,1991 @@ +defmodule PlausibleWeb.Api.ExternalStatsController.TimeseriesTest do + use PlausibleWeb.ConnCase + alias Plausible.Billing.Feature + + setup [:create_user, :create_site, :create_api_key, :use_api_key] + + describe "feature access" do + test "cannot filter by a custom prop without access to the props feature", %{ + conn: conn, + user: user, + site: site + } do + subscribe_to_enterprise_plan(user, features: [Feature.StatsAPI]) + + conn = + get(conn, "/api/v1/stats/timeseries", %{ + "site_id" => site.domain, + "period" => "month", + "date" => "2021-01-01", + "filters" => "event:props:author==Uku" + }) + + assert json_response(conn, 402)["error"] == + "The owner of this site does not have access to the custom properties feature" + end + + test "can filter by an internal prop without access to the props feature", %{ + conn: conn, + user: user, + site: site + } do + subscribe_to_enterprise_plan(user, features: [Feature.StatsAPI]) + + conn = + get(conn, "/api/v1/stats/timeseries", %{ + "site_id" => site.domain, + "period" => "month", + "date" => "2021-01-01", + "filters" => "event:props:path==/404" + }) + + assert json_response(conn, 200)["results"] + end + end + + describe "param validation" do + test "validates that date can be parsed", %{conn: conn, site: site} do + conn = + get(conn, "/api/v1/stats/timeseries", %{ + "site_id" => site.domain, + "period" => "6mo", + "date" => "2021-dkjbAS" + }) + + assert json_response(conn, 400) == %{ + "error" => + "Error parsing `date` parameter: invalid_format. Please specify a valid date in ISO-8601 format." + } + end + + test "ignores a given property parameter", %{conn: conn, site: site} do + conn = + get(conn, "/api/v1/stats/timeseries", %{ + "site_id" => site.domain, + "property" => "event:props:author", + "metrics" => "visit_duration" + }) + + assert json_response(conn, 200) + end + + test "validates that period can be parsed", %{conn: conn, site: site} do + conn = + get(conn, "/api/v1/stats/timeseries", %{ + "site_id" => site.domain, + "period" => "aosuhsacp" + }) + + assert json_response(conn, 400) == %{ + "error" => + "Error parsing `period` parameter: invalid period `aosuhsacp`. Please find accepted values in our docs: https://plausible.io/docs/stats-api#time-periods" + } + end + + test "legacy `date` interval is overridden to `day`", %{conn: conn, site: site} do + conn = + get(conn, "/api/v1/stats/timeseries", %{ + "site_id" => site.domain, + "date" => "2021-07-15", + "period" => "6mo", + "interval" => "date" + }) + + results = json_response(conn, 200)["results"] + + assert Enum.at(results, 0)["date"] == "2021-01-01" + assert Enum.at(results, 1)["date"] == "2021-01-02" + end + + test "validates that interval is `day` or `month`", %{conn: conn, site: site} do + conn = + get(conn, "/api/v1/stats/timeseries", %{ + "site_id" => site.domain, + "period" => "12mo", + "interval" => "alskd" + }) + + assert json_response(conn, 400) == %{ + "error" => + "Error parsing `interval` parameter: invalid interval `alskd`. Valid intervals are `day`, `month`" + } + end + end + + @user_id Enum.random(1000..9999) + + test "shows hourly data for a certain date", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, user_id: @user_id, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, user_id: @user_id, timestamp: ~N[2021-01-01 00:10:00]), + build(:pageview, timestamp: ~N[2021-01-01 23:59:00]) + ]) + + conn = + get(conn, "/api/v1/stats/timeseries", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "metrics" => "visitors,pageviews,visits,visit_duration,bounce_rate" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{ + "date" => "2021-01-01 00:00:00", + "visitors" => 1, + "visits" => 1, + "pageviews" => 2, + "visit_duration" => 600, + "bounce_rate" => 0 + }, + %{ + "date" => "2021-01-01 01:00:00", + "visitors" => 0, + "visits" => 0, + "pageviews" => 0, + "visit_duration" => nil, + "bounce_rate" => 0 + }, + %{ + "date" => "2021-01-01 02:00:00", + "visitors" => 0, + "visits" => 0, + "pageviews" => 0, + "visit_duration" => nil, + "bounce_rate" => 0 + }, + %{ + "date" => "2021-01-01 03:00:00", + "visitors" => 0, + "visits" => 0, + "pageviews" => 0, + "visit_duration" => nil, + "bounce_rate" => 0 + }, + %{ + "date" => "2021-01-01 04:00:00", + "visitors" => 0, + "visits" => 0, + "pageviews" => 0, + "visit_duration" => nil, + "bounce_rate" => 0 + }, + %{ + "date" => "2021-01-01 05:00:00", + "visitors" => 0, + "visits" => 0, + "pageviews" => 0, + "visit_duration" => nil, + "bounce_rate" => 0 + }, + %{ + "date" => "2021-01-01 06:00:00", + "visitors" => 0, + "visits" => 0, + "pageviews" => 0, + "visit_duration" => nil, + "bounce_rate" => 0 + }, + %{ + "date" => "2021-01-01 07:00:00", + "visitors" => 0, + "visits" => 0, + "pageviews" => 0, + "visit_duration" => nil, + "bounce_rate" => 0 + }, + %{ + "date" => "2021-01-01 08:00:00", + "visitors" => 0, + "visits" => 0, + "pageviews" => 0, + "visit_duration" => nil, + "bounce_rate" => 0 + }, + %{ + "date" => "2021-01-01 09:00:00", + "visitors" => 0, + "visits" => 0, + "pageviews" => 0, + "visit_duration" => nil, + "bounce_rate" => 0 + }, + %{ + "date" => "2021-01-01 10:00:00", + "visitors" => 0, + "visits" => 0, + "pageviews" => 0, + "visit_duration" => nil, + "bounce_rate" => 0 + }, + %{ + "date" => "2021-01-01 11:00:00", + "visitors" => 0, + "visits" => 0, + "pageviews" => 0, + "visit_duration" => nil, + "bounce_rate" => 0 + }, + %{ + "date" => "2021-01-01 12:00:00", + "visitors" => 0, + "visits" => 0, + "pageviews" => 0, + "visit_duration" => nil, + "bounce_rate" => 0 + }, + %{ + "date" => "2021-01-01 13:00:00", + "visitors" => 0, + "visits" => 0, + "pageviews" => 0, + "visit_duration" => nil, + "bounce_rate" => 0 + }, + %{ + "date" => "2021-01-01 14:00:00", + "visitors" => 0, + "visits" => 0, + "pageviews" => 0, + "visit_duration" => nil, + "bounce_rate" => 0 + }, + %{ + "date" => "2021-01-01 15:00:00", + "visitors" => 0, + "visits" => 0, + "pageviews" => 0, + "visit_duration" => nil, + "bounce_rate" => 0 + }, + %{ + "date" => "2021-01-01 16:00:00", + "visitors" => 0, + "visits" => 0, + "pageviews" => 0, + "visit_duration" => nil, + "bounce_rate" => 0 + }, + %{ + "date" => "2021-01-01 17:00:00", + "visitors" => 0, + "visits" => 0, + "pageviews" => 0, + "visit_duration" => nil, + "bounce_rate" => 0 + }, + %{ + "date" => "2021-01-01 18:00:00", + "visitors" => 0, + "visits" => 0, + "pageviews" => 0, + "visit_duration" => nil, + "bounce_rate" => 0 + }, + %{ + "date" => "2021-01-01 19:00:00", + "visitors" => 0, + "visits" => 0, + "pageviews" => 0, + "visit_duration" => nil, + "bounce_rate" => 0 + }, + %{ + "date" => "2021-01-01 20:00:00", + "visitors" => 0, + "visits" => 0, + "pageviews" => 0, + "visit_duration" => nil, + "bounce_rate" => 0 + }, + %{ + "date" => "2021-01-01 21:00:00", + "visitors" => 0, + "visits" => 0, + "pageviews" => 0, + "visit_duration" => nil, + "bounce_rate" => 0 + }, + %{ + "date" => "2021-01-01 22:00:00", + "visitors" => 0, + "visits" => 0, + "pageviews" => 0, + "visit_duration" => nil, + "bounce_rate" => 0 + }, + %{ + "date" => "2021-01-01 23:00:00", + "visitors" => 1, + "visits" => 1, + "pageviews" => 1, + "visit_duration" => 0, + "bounce_rate" => 100 + } + ] + } + end + + test "shows last 7 days of visitors", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-07 23:59:00]) + ]) + + conn = + get(conn, "/api/v1/stats/timeseries", %{ + "site_id" => site.domain, + "period" => "7d", + "date" => "2021-01-08" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{"date" => "2021-01-01", "visitors" => 1}, + %{"date" => "2021-01-02", "visitors" => 0}, + %{"date" => "2021-01-03", "visitors" => 0}, + %{"date" => "2021-01-04", "visitors" => 0}, + %{"date" => "2021-01-05", "visitors" => 0}, + %{"date" => "2021-01-06", "visitors" => 0}, + %{"date" => "2021-01-07", "visitors" => 1} + ] + } + end + + test "shows last 6 months of visitors", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, timestamp: ~N[2020-12-31 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + get(conn, "/api/v1/stats/timeseries", %{ + "site_id" => site.domain, + "period" => "6mo", + "date" => "2021-02-10" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{"date" => "2020-08-01", "visitors" => 0}, + %{"date" => "2020-09-01", "visitors" => 0}, + %{"date" => "2020-10-01", "visitors" => 0}, + %{"date" => "2020-11-01", "visitors" => 0}, + %{"date" => "2020-12-01", "visitors" => 1}, + %{"date" => "2021-01-01", "visitors" => 2} + ] + } + end + + test "shows last 12 months of visitors", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, timestamp: ~N[2020-02-01 00:00:00]), + build(:pageview, timestamp: ~N[2020-12-31 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + get(conn, "/api/v1/stats/timeseries", %{ + "site_id" => site.domain, + "period" => "12mo", + "date" => "2021-02-01" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{"date" => "2020-02-01", "visitors" => 1}, + %{"date" => "2020-03-01", "visitors" => 0}, + %{"date" => "2020-04-01", "visitors" => 0}, + %{"date" => "2020-05-01", "visitors" => 0}, + %{"date" => "2020-06-01", "visitors" => 0}, + %{"date" => "2020-07-01", "visitors" => 0}, + %{"date" => "2020-08-01", "visitors" => 0}, + %{"date" => "2020-09-01", "visitors" => 0}, + %{"date" => "2020-10-01", "visitors" => 0}, + %{"date" => "2020-11-01", "visitors" => 0}, + %{"date" => "2020-12-01", "visitors" => 1}, + %{"date" => "2021-01-01", "visitors" => 2} + ] + } + end + + test "shows last 12 months of visitors with interval daily", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, timestamp: ~N[2020-02-01 00:00:00]), + build(:pageview, timestamp: ~N[2020-12-31 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + get(conn, "/api/v1/stats/timeseries", %{ + "site_id" => site.domain, + "period" => "12mo", + "interval" => "date" + }) + + res = json_response(conn, 200) + assert Enum.count(res["results"]) in [365, 366] + end + + test "shows a custom range with daily interval", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-02 00:00:00]) + ]) + + conn = + get(conn, "/api/v1/stats/timeseries", %{ + "site_id" => site.domain, + "period" => "custom", + "date" => "2021-01-01,2021-01-02" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{"date" => "2021-01-01", "visitors" => 2}, + %{"date" => "2021-01-02", "visitors" => 1} + ] + } + end + + test "shows a custom range with monthly interval", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, user_id: @user_id, timestamp: ~N[2020-12-01 00:00:00]), + build(:pageview, user_id: @user_id, timestamp: ~N[2020-12-01 00:05:00]), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-02 00:00:00]) + ]) + + conn = + get(conn, "/api/v1/stats/timeseries", %{ + "site_id" => site.domain, + "period" => "custom", + "date" => "2020-12-01, 2021-01-02", + "interval" => "month", + "metrics" => "pageviews,visitors,bounce_rate,visit_duration" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{ + "date" => "2020-12-01", + "visitors" => 1, + "pageviews" => 2, + "bounce_rate" => 0, + "visit_duration" => 300 + }, + %{ + "date" => "2021-01-01", + "visitors" => 2, + "pageviews" => 2, + "bounce_rate" => 100, + "visit_duration" => 0 + } + ] + } + end + + describe "filters" do + test "event:goal filter returns 400 when goal not configured", %{conn: conn, site: site} do + conn = + get(conn, "/api/v1/stats/timeseries", %{ + "site_id" => site.domain, + "period" => "month", + "date" => "2021-01-01", + "metrics" => "visitors,events", + "filters" => "event:goal==Visit /register**" + }) + + assert %{"error" => msg} = json_response(conn, 400) + + assert msg =~ + "The pageview goal for the pathname `/register**` is not configured for this site" + end + + test "validates that filters are valid", %{conn: conn, site: site} do + conn = + get(conn, "/api/v1/stats/timeseries", %{ + "site_id" => site.domain, + "period" => "month", + "date" => "2021-01-01", + "metrics" => "visitors,events", + "filters" => "badproperty==bar" + }) + + assert json_response(conn, 400) == %{ + "error" => + "Invalid filter property 'badproperty'. Please provide a valid filter property: https://plausible.io/docs/stats-api#properties" + } + end + + test "can filter by a custom event goal", %{conn: conn, site: site} do + populate_stats(site, [ + build(:event, + name: "Signup", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Signup", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:event, + name: "Signup", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:event, + name: "NotConfigured", + timestamp: ~N[2021-01-01 00:25:00] + ) + ]) + + insert(:goal, %{site: site, event_name: "Signup"}) + + conn = + get(conn, "/api/v1/stats/timeseries", %{ + "site_id" => site.domain, + "period" => "month", + "date" => "2021-01-01", + "metrics" => "visitors,events", + "filters" => "event:goal==Signup" + }) + + res = json_response(conn, 200) + + assert List.first(res["results"]) == %{ + "date" => "2021-01-01", + "visitors" => 2, + "events" => 3 + } + end + + test "can filter by a simple pageview goal", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/register", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/register", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, + pathname: "/register", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, + pathname: "/", + timestamp: ~N[2021-01-01 00:25:00] + ) + ]) + + insert(:goal, %{site: site, page_path: "/register"}) + + conn = + get(conn, "/api/v1/stats/timeseries", %{ + "site_id" => site.domain, + "period" => "month", + "date" => "2021-01-01", + "metrics" => "visitors,pageviews", + "filters" => "event:goal==Visit /register" + }) + + res = json_response(conn, 200) + + assert List.first(res["results"]) == %{ + "date" => "2021-01-01", + "visitors" => 2, + "pageviews" => 3 + } + end + + test "can filter by a wildcard pageview goal", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/blog/post-1", timestamp: ~N[2021-01-01 00:25:00]), + build(:pageview, + pathname: "/blog/post-2", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, + pathname: "/blog", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:pageview, pathname: "/", timestamp: ~N[2021-01-01 00:25:00]) + ]) + + insert(:goal, %{site: site, page_path: "/blog**"}) + + conn = + get(conn, "/api/v1/stats/timeseries", %{ + "site_id" => site.domain, + "period" => "month", + "date" => "2021-01-01", + "metrics" => "visitors,pageviews", + "filters" => "event:goal==Visit /blog**" + }) + + res = json_response(conn, 200) + + assert List.first(res["results"]) == %{ + "date" => "2021-01-01", + "visitors" => 2, + "pageviews" => 3 + } + end + + test "can filter by multiple custom event goals", %{conn: conn, site: site} do + populate_stats(site, [ + build(:event, name: "Signup", timestamp: ~N[2021-01-01 00:25:00]), + build(:event, name: "Purchase", user_id: @user_id, timestamp: ~N[2021-01-01 00:25:00]), + build(:event, name: "Purchase", user_id: @user_id, timestamp: ~N[2021-01-01 00:25:00]), + build(:pageview, timestamp: ~N[2021-01-01 00:25:00]) + ]) + + insert(:goal, %{site: site, event_name: "Signup"}) + insert(:goal, %{site: site, event_name: "Purchase"}) + + conn = + get(conn, "/api/v1/stats/timeseries", %{ + "site_id" => site.domain, + "period" => "month", + "date" => "2021-01-01", + "metrics" => "visitors,events", + "filters" => "event:goal==Signup|Purchase" + }) + + res = json_response(conn, 200) + + assert List.first(res["results"]) == %{ + "date" => "2021-01-01", + "visitors" => 2, + "events" => 3 + } + end + + test "can filter by multiple mixed goals", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/account/register", timestamp: ~N[2021-01-01 00:25:00]), + build(:pageview, + pathname: "/register", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:event, name: "Signup", user_id: @user_id, timestamp: ~N[2021-01-01 00:25:00]), + build(:pageview, timestamp: ~N[2021-01-01 00:25:00]) + ]) + + insert(:goal, %{site: site, event_name: "Signup"}) + insert(:goal, %{site: site, page_path: "/**register"}) + + conn = + get(conn, "/api/v1/stats/timeseries", %{ + "site_id" => site.domain, + "period" => "month", + "date" => "2021-01-01", + "metrics" => "visitors,events,pageviews", + "filters" => "event:goal==Signup|Visit /**register" + }) + + res = json_response(conn, 200) + + assert List.first(res["results"]) == %{ + "date" => "2021-01-01", + "visitors" => 2, + "events" => 3, + "pageviews" => 2 + } + end + + test "can filter by source", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + referrer_source: "Google", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + get(conn, "/api/v1/stats/timeseries", %{ + "site_id" => site.domain, + "period" => "month", + "date" => "2021-01-01", + "filters" => "visit:source==Google" + }) + + res = json_response(conn, 200) + assert List.first(res["results"]) == %{"date" => "2021-01-01", "visitors" => 1} + end + + test "can filter by channel", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + referrer_source: "Bing", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + get(conn, "/api/v1/stats/timeseries", %{ + "site_id" => site.domain, + "period" => "month", + "date" => "2021-01-01", + "filters" => "visit:channel==Organic Search" + }) + + res = json_response(conn, 200) + assert List.first(res["results"]) == %{"date" => "2021-01-01", "visitors" => 1} + end + + test "can filter by no source/referrer", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, + referrer_source: "Google", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + conn = + get(conn, "/api/v1/stats/timeseries", %{ + "site_id" => site.domain, + "period" => "month", + "date" => "2021-01-01", + "filters" => "visit:source==Direct / None" + }) + + res = json_response(conn, 200)["results"] + assert List.first(res) == %{"date" => "2021-01-01", "visitors" => 1} + end + + test "can filter by referrer", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + referrer: "https://facebook.com", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + get(conn, "/api/v1/stats/timeseries", %{ + "site_id" => site.domain, + "period" => "month", + "date" => "2021-01-01", + "filters" => "visit:referrer==https://facebook.com" + }) + + res = json_response(conn, 200)["results"] + assert List.first(res) == %{"date" => "2021-01-01", "visitors" => 1} + end + + test "can filter by utm_medium", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + utm_medium: "social", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + get(conn, "/api/v1/stats/timeseries", %{ + "site_id" => site.domain, + "period" => "month", + "date" => "2021-01-01", + "filters" => "visit:utm_medium==social" + }) + + res = json_response(conn, 200)["results"] + assert List.first(res) == %{"date" => "2021-01-01", "visitors" => 1} + end + + test "can filter by utm_source", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + utm_source: "Twitter", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + get(conn, "/api/v1/stats/timeseries", %{ + "site_id" => site.domain, + "period" => "month", + "date" => "2021-01-01", + "filters" => "visit:utm_source==Twitter" + }) + + res = json_response(conn, 200)["results"] + assert List.first(res) == %{"date" => "2021-01-01", "visitors" => 1} + end + + test "can filter by utm_campaign", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + utm_campaign: "profile", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + get(conn, "/api/v1/stats/timeseries", %{ + "site_id" => site.domain, + "period" => "month", + "date" => "2021-01-01", + "filters" => "visit:utm_campaign==profile" + }) + + res = json_response(conn, 200)["results"] + assert List.first(res) == %{"date" => "2021-01-01", "visitors" => 1} + end + + test "can filter by device type", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + screen_size: "Desktop", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + get(conn, "/api/v1/stats/timeseries", %{ + "site_id" => site.domain, + "period" => "month", + "date" => "2021-01-01", + "filters" => "visit:device==Desktop" + }) + + res = json_response(conn, 200)["results"] + assert List.first(res) == %{"date" => "2021-01-01", "visitors" => 1} + end + + test "can filter by browser", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + browser: "Chrome", + browser_version: "56.1", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + browser: "Chrome", + browser_version: "55", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + get(conn, "/api/v1/stats/timeseries", %{ + "site_id" => site.domain, + "period" => "month", + "date" => "2021-01-01", + "filters" => "visit:browser==Chrome;visit:browser_version==56.1" + }) + + res = json_response(conn, 200)["results"] + assert List.first(res) == %{"date" => "2021-01-01", "visitors" => 1} + end + + test "can filter by operating system", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + operating_system: "Mac", + operating_system_version: "10.5", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + operating_system: "Something else", + operating_system_version: "10.5", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + operating_system: "Mac", + operating_system_version: "10.4", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + get(conn, "/api/v1/stats/timeseries", %{ + "site_id" => site.domain, + "period" => "month", + "date" => "2021-01-01", + "filters" => "visit:os == Mac;visit:os_version==10.5" + }) + + res = json_response(conn, 200)["results"] + assert List.first(res) == %{"date" => "2021-01-01", "visitors" => 1} + end + + test "can filter by country", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + user_id: @user_id, + country_code: "EE", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: @user_id, + country_code: "EE", + timestamp: ~N[2021-01-01 00:15:00] + ), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + get(conn, "/api/v1/stats/timeseries", %{ + "site_id" => site.domain, + "period" => "month", + "date" => "2021-01-01", + "filters" => "visit:country==EE", + "metrics" => "visitors,pageviews,bounce_rate,visit_duration" + }) + + res = json_response(conn, 200)["results"] + + assert List.first(res) == %{ + "date" => "2021-01-01", + "visitors" => 1, + "pageviews" => 2, + "bounce_rate" => 0, + "visit_duration" => 900 + } + end + + test "can filter by page", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/hello", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:05:00] + ), + build(:pageview, + pathname: "/hello", + timestamp: ~N[2021-01-01 05:00:00] + ), + build(:pageview, + pathname: "/goodbye", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + conn = + get(conn, "/api/v1/stats/timeseries", %{ + "site_id" => site.domain, + "period" => "month", + "date" => "2021-01-01", + "filters" => "event:page==/hello", + "metrics" => "visitors,visits,pageviews,bounce_rate,visit_duration" + }) + + res = json_response(conn, 200)["results"] + + assert List.first(res) == %{ + "date" => "2021-01-01", + "visitors" => 2, + "visits" => 2, + "pageviews" => 2, + "bounce_rate" => 100, + "visit_duration" => 150 + } + end + + test "can filter by hostname", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, + user_id: @user_id, + hostname: "landing.example.com", + timestamp: ~N[2021-01-01 00:00:01] + ), + build(:pageview, + user_id: @user_id, + hostname: "example.com", + timestamp: ~N[2021-01-01 00:00:02] + ), + build(:pageview, + user_id: @user_id, + hostname: "example.com", + timestamp: ~N[2021-01-01 00:00:06] + ) + ]) + + conn = + get(conn, "/api/v1/stats/timeseries", %{ + "site_id" => site.domain, + "period" => "day", + "date" => "2021-01-01", + "filters" => "event:hostname==example.com", + "metrics" => "visitors,visits,pageviews,bounce_rate,visit_duration" + }) + + res = + json_response(conn, 200)["results"] + + assert List.first(res) == %{ + "bounce_rate" => 0, + "date" => "2021-01-01 00:00:00", + "pageviews" => 2, + "visit_duration" => 5, + "visitors" => 1, + "visits" => 1 + } + end + + test "can filter by event:name", %{conn: conn, site: site} do + populate_stats(site, [ + build(:event, + name: "Signup", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + get(conn, "/api/v1/stats/timeseries", %{ + "site_id" => site.domain, + "period" => "month", + "date" => "2021-01-01", + "filters" => "event:name==Signup" + }) + + res = json_response(conn, 200) + assert List.first(res["results"]) == %{"date" => "2021-01-01", "visitors" => 1} + end + + test "filter by custom event property", %{conn: conn, site: site} do + populate_stats(site, [ + build(:event, + name: "Purchase", + "meta.key": ["package"], + "meta.value": ["business"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Purchase", + "meta.key": ["package"], + "meta.value": ["business"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Purchase", + "meta.key": ["package"], + "meta.value": ["personal"], + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:event, + name: "Purchase", + "meta.key": ["package"], + "meta.value": ["business"], + timestamp: ~N[2021-01-02 00:25:00] + ) + ]) + + conn = + get(conn, "/api/v1/stats/timeseries", %{ + "site_id" => site.domain, + "period" => "month", + "date" => "2021-01-01", + "filters" => "event:name==Purchase;event:props:package==business" + }) + + %{"results" => [first, second | _rest]} = json_response(conn, 200) + assert first == %{"date" => "2021-01-01", "visitors" => 2} + assert second == %{"date" => "2021-01-02", "visitors" => 1} + end + + test "filter by multiple custom event properties", %{conn: conn, site: site} do + populate_stats(site, [ + build(:event, + name: "Purchase", + "meta.key": ["package", "browser"], + "meta.value": ["business", "Chrome"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Purchase", + "meta.key": ["package", "browser"], + "meta.value": ["business", "Firefox"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Purchase", + "meta.key": ["package", "browser"], + "meta.value": ["personal", "Firefox"], + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:event, + name: "Purchase", + "meta.key": ["package"], + "meta.value": ["business"], + timestamp: ~N[2021-01-02 00:25:00] + ) + ]) + + conn = + get(conn, "/api/v1/stats/timeseries", %{ + "site_id" => site.domain, + "period" => "month", + "date" => "2021-01-01", + "filters" => + "event:name==Purchase;event:props:package==business;event:props:browser==Firefox" + }) + + %{"results" => [first, second | _rest]} = json_response(conn, 200) + assert first == %{"date" => "2021-01-01", "visitors" => 1} + assert second == %{"date" => "2021-01-02", "visitors" => 0} + end + + test "filter by multiple custom event properties matching", %{conn: conn, site: site} do + populate_stats(site, [ + build(:event, + name: "Purchase", + "meta.key": ["package", "browser"], + "meta.value": ["business", "Chrome"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Purchase", + "meta.key": ["package", "browser"], + "meta.value": ["business", "Firefox"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Purchase", + "meta.key": ["package", "browser"], + "meta.value": ["personal", "Safari"], + timestamp: ~N[2021-01-02 00:25:00] + ), + build(:event, + name: "Purchase", + "meta.key": ["package"], + "meta.value": ["business"], + timestamp: ~N[2021-01-02 00:25:00] + ) + ]) + + conn = + get(conn, "/api/v1/stats/timeseries", %{ + "site_id" => site.domain, + "period" => "month", + "date" => "2021-01-01", + "filters" => "event:name==Purchase;event:props:browser==F*|S*" + }) + + %{"results" => [first, second | _rest]} = json_response(conn, 200) + assert first == %{"date" => "2021-01-01", "visitors" => 1} + assert second == %{"date" => "2021-01-02", "visitors" => 1} + end + end + + describe "metrics" do + test "returns conversion rate as 0 when no stats exist", %{ + conn: conn, + site: site + } do + insert(:goal, site: site, event_name: "Signup") + + conn = + get(conn, "/api/v1/stats/timeseries", %{ + "site_id" => site.domain, + "metrics" => "conversion_rate", + "filters" => "event:goal==Signup", + "period" => "7d", + "date" => "2021-01-10" + }) + + Enum.each(json_response(conn, 200)["results"], fn bucket -> + bucket["conversion_rate"] == 0.0 + end) + end + + test "returns conversion rate when goal filter is applied", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:event, name: "Signup", timestamp: ~N[2021-01-04 00:00:00]), + build(:event, name: "Signup", timestamp: ~N[2021-01-04 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-04 00:00:00]), + build(:event, name: "Signup", timestamp: ~N[2021-01-05 00:00:00]) + ]) + + insert(:goal, site: site, event_name: "Signup") + + conn = + get(conn, "/api/v1/stats/timeseries", %{ + "site_id" => site.domain, + "metrics" => "conversion_rate", + "filters" => "event:goal==Signup", + "period" => "7d", + "date" => "2021-01-11" + }) + + assert [first, second | _] = json_response(conn, 200)["results"] + + assert [first, second] == [ + %{ + "date" => "2021-01-04", + "conversion_rate" => 66.67 + }, + %{ + "date" => "2021-01-05", + "conversion_rate" => 100.0 + } + ] + end + + test "returns conversion rate with a goal + custom prop filter", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:event, + name: "Signup", + "meta.key": ["author"], + "meta.value": ["Teet"], + timestamp: ~N[2021-01-04 00:12:00] + ), + build(:event, + name: "Signup", + "meta.key": ["author"], + "meta.value": ["Tiit"], + timestamp: ~N[2021-01-04 00:12:00] + ), + build(:event, name: "Signup", timestamp: ~N[2021-01-04 00:12:00]), + build(:pageview, + "meta.key": ["author"], + "meta.value": ["Teet"], + timestamp: ~N[2021-01-04 00:12:00] + ) + ]) + + insert(:goal, site: site, event_name: "Signup") + + conn = + get(conn, "/api/v1/stats/timeseries", %{ + "site_id" => site.domain, + "metrics" => "conversion_rate", + "filters" => "event:goal==Signup;event:props:author==Teet", + "period" => "7d", + "date" => "2021-01-11" + }) + + [first | _] = json_response(conn, 200)["results"] + + assert first == %{ + "date" => "2021-01-04", + "conversion_rate" => 25.0 + } + end + + test "returns conversion rate with a goal + page filter", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:event, + name: "Signup", + pathname: "/yes", + timestamp: ~N[2021-01-04 00:12:00] + ), + build(:event, + name: "Signup", + pathname: "/no", + timestamp: ~N[2021-01-04 00:12:00] + ), + build(:event, name: "Signup", timestamp: ~N[2021-01-04 00:12:00]), + build(:pageview, pathname: "/yes", timestamp: ~N[2021-01-04 00:12:00]), + build(:pageview, pathname: "/yes", timestamp: ~N[2021-01-04 00:12:00]) + ]) + + insert(:goal, site: site, event_name: "Signup") + + conn = + get(conn, "/api/v1/stats/timeseries", %{ + "site_id" => site.domain, + "metrics" => "conversion_rate", + "filters" => "event:goal==Signup;event:page==/yes", + "period" => "7d", + "date" => "2021-01-11" + }) + + [first | _] = json_response(conn, 200)["results"] + + assert first == %{ + "date" => "2021-01-04", + "conversion_rate" => 33.33 + } + end + + test "returns conversion rate with a goal + session filter", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:event, + name: "Signup", + screen_size: "Mobile", + timestamp: ~N[2021-01-04 00:12:00] + ), + build(:event, + name: "Signup", + screen_size: "Laptop", + timestamp: ~N[2021-01-04 00:12:00] + ), + build(:event, name: "Signup", timestamp: ~N[2021-01-04 00:12:00]), + build(:pageview, screen_size: "Mobile", timestamp: ~N[2021-01-04 00:12:00]), + build(:pageview, screen_size: "Mobile", timestamp: ~N[2021-01-04 00:12:00]) + ]) + + insert(:goal, site: site, event_name: "Signup") + + conn = + get(conn, "/api/v1/stats/timeseries", %{ + "site_id" => site.domain, + "metrics" => "conversion_rate", + "filters" => "event:goal==Signup;visit:device==Mobile", + "period" => "7d", + "date" => "2021-01-11" + }) + + [first | _] = json_response(conn, 200)["results"] + + assert first == %{ + "date" => "2021-01-04", + "conversion_rate" => 33.33 + } + end + + test "validates that conversion_rate cannot be queried without a goal filter", %{ + conn: conn, + site: site + } do + conn = + get(conn, "/api/v1/stats/timeseries", %{ + "site_id" => site.domain, + "metrics" => "conversion_rate" + }) + + assert %{"error" => msg} = json_response(conn, 400) + + assert msg == + "Metric `conversion_rate` can only be queried in a goal breakdown or with a goal filter" + end + + test "shows pageviews,visits,views_per_visit for last 7d", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: @user_id, + timestamp: ~N[2021-01-01 00:05:00] + ), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-07 23:59:00]) + ]) + + conn = + get(conn, "/api/v1/stats/timeseries", %{ + "site_id" => site.domain, + "period" => "7d", + "metrics" => "pageviews,visits,views_per_visit", + "date" => "2021-01-08" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{ + "date" => "2021-01-01", + "pageviews" => 3, + "visits" => 2, + "views_per_visit" => 1.5 + }, + %{ + "date" => "2021-01-02", + "pageviews" => 0, + "visits" => 0, + "views_per_visit" => 0.0 + }, + %{ + "date" => "2021-01-03", + "pageviews" => 0, + "visits" => 0, + "views_per_visit" => 0.0 + }, + %{ + "date" => "2021-01-04", + "pageviews" => 0, + "visits" => 0, + "views_per_visit" => 0.0 + }, + %{ + "date" => "2021-01-05", + "pageviews" => 0, + "visits" => 0, + "views_per_visit" => 0.0 + }, + %{ + "date" => "2021-01-06", + "pageviews" => 0, + "visits" => 0, + "views_per_visit" => 0.0 + }, + %{ + "date" => "2021-01-07", + "pageviews" => 1, + "visits" => 1, + "views_per_visit" => 1.0 + } + ] + } + end + + test "shows events for last 7d", %{conn: conn, site: site} do + populate_stats(site, [ + build(:event, name: "Signup", timestamp: ~N[2021-01-01 00:00:00]), + build(:event, name: "Signup", timestamp: ~N[2021-01-01 00:00:00]), + build(:event, name: "Signup", timestamp: ~N[2021-01-07 23:59:00]) + ]) + + conn = + get(conn, "/api/v1/stats/timeseries", %{ + "site_id" => site.domain, + "period" => "7d", + "metrics" => "events", + "date" => "2021-01-08" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{ + "date" => "2021-01-01", + "events" => 2 + }, + %{ + "date" => "2021-01-02", + "events" => 0 + }, + %{ + "date" => "2021-01-03", + "events" => 0 + }, + %{ + "date" => "2021-01-04", + "events" => 0 + }, + %{ + "date" => "2021-01-05", + "events" => 0 + }, + %{ + "date" => "2021-01-06", + "events" => 0 + }, + %{ + "date" => "2021-01-07", + "events" => 1 + } + ] + } + end + + test "rounds views_per_visit to two decimal places", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: @user_id, + timestamp: ~N[2021-01-01 00:05:00] + ), + build(:pageview, + user_id: @user_id, + timestamp: ~N[2021-01-03 00:00:00] + ), + build(:pageview, + user_id: @user_id, + timestamp: ~N[2021-01-03 00:01:00] + ), + build(:pageview, timestamp: ~N[2021-01-03 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-03 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-07 23:59:00]) + ]) + + conn = + get(conn, "/api/v1/stats/timeseries", %{ + "site_id" => site.domain, + "period" => "7d", + "metrics" => "views_per_visit", + "date" => "2021-01-08" + }) + + assert json_response(conn, 200) == %{ + "results" => [ + %{"date" => "2021-01-01", "views_per_visit" => 2.0}, + %{"date" => "2021-01-02", "views_per_visit" => 0.0}, + %{"date" => "2021-01-03", "views_per_visit" => 1.33}, + %{"date" => "2021-01-04", "views_per_visit" => 0.0}, + %{"date" => "2021-01-05", "views_per_visit" => 0.0}, + %{"date" => "2021-01-06", "views_per_visit" => 0.0}, + %{"date" => "2021-01-07", "views_per_visit" => 1.0} + ] + } + end + end + + describe "imported data" do + test "returns pageviews as the value of events metric", %{ + conn: conn, + site: site + } do + site_import = insert(:site_import, site: site) + + populate_stats(site, site_import.id, [ + build(:imported_visitors, pageviews: 1, date: ~D[2021-01-01]) + ]) + + conn = + conn + |> get("/api/v1/stats/timeseries", %{ + "site_id" => site.domain, + "period" => "7d", + "metrics" => "events", + "date" => "2021-01-08", + "with_imported" => "true" + }) + + first_result = + conn + |> json_response(200) + |> Map.get("results") + |> List.first() + + assert first_result == %{"date" => "2021-01-01", "events" => 1} + + refute json_response(conn, 200)["warning"] + end + + test "adds a warning when query params are not supported for imported data", %{ + conn: conn, + site: site + } do + site_import = insert(:site_import, site: site) + + populate_stats(site, site_import.id, [ + build(:pageview, + "meta.key": ["package"], + "meta.value": ["large"], + timestamp: ~N[2021-01-01 12:00:00] + ), + build(:imported_visitors, pageviews: 1, date: ~D[2021-01-01]) + ]) + + conn = + conn + |> get("/api/v1/stats/timeseries", %{ + "site_id" => site.domain, + "period" => "7d", + "metrics" => "events", + "date" => "2021-01-08", + "with_imported" => "true", + "filters" => "event:props:package==large" + }) + + first_result = + conn + |> json_response(200) + |> Map.get("results") + |> List.first() + + assert first_result == %{"date" => "2021-01-01", "events" => 1} + + assert json_response(conn, 200)["warning"] =~ + "Imported stats are not included in the results because query parameters are not supported." + end + + test "is not included for a day period and an appropriate warning is returned", %{ + conn: conn, + site: site + } do + site_import = insert(:site_import, site: site) + + populate_stats(site, site_import.id, [ + build(:imported_visitors, visitors: 1, date: ~D[2021-01-01]) + ]) + + conn = + conn + |> get("/api/v1/stats/timeseries", %{ + "site_id" => site.domain, + "period" => "day", + "metrics" => "visitors", + "date" => "2021-01-01", + "with_imported" => "true" + }) + + assert %{"results" => results, "warning" => warning} = json_response(conn, 200) + + Enum.each(results, &assert(&1["visitors"] == 0)) + + assert warning == + "Imported stats are not included because the time dimension (i.e. the interval) is too short." + end + + test "does not add a warning when there are no site imports", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + "meta.key": ["package"], + "meta.value": ["large"], + timestamp: ~N[2021-01-01 12:00:00] + ) + ]) + + conn = + conn + |> get("/api/v1/stats/timeseries", %{ + "site_id" => site.domain, + "period" => "7d", + "metrics" => "events", + "date" => "2021-01-07", + "with_imported" => "true", + "filters" => "event:props:package==large" + }) + + refute json_response(conn, 200)["warning"] + end + + test "does not add a warning when import is out of queried date range", %{ + conn: conn, + site: site + } do + site_import = insert(:site_import, site: site, end_date: ~D[2020-12-29]) + + populate_stats(site, site_import.id, [ + build(:pageview, + "meta.key": ["package"], + "meta.value": ["large"], + timestamp: ~N[2021-01-01 12:00:00] + ), + build(:imported_visitors, pageviews: 1, date: ~D[2020-12-29]) + ]) + + conn = + conn + |> get("/api/v1/stats/timeseries", %{ + "site_id" => site.domain, + "period" => "7d", + "metrics" => "events", + "date" => "2021-01-07", + "with_imported" => "true", + "filters" => "event:props:package==large" + }) + + refute json_response(conn, 200)["warning"] + end + + test "returns all metrics based on imported/native data when filtering by browser", %{ + conn: conn, + site: site + } do + site_import = insert(:site_import, site: site) + + populate_stats(site, site_import.id, [ + build(:pageview, browser: "Chrome", user_id: 1, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, browser: "Chrome", user_id: 1, timestamp: ~N[2021-01-01 00:03:00]), + build(:pageview, browser: "Firefox", timestamp: ~N[2021-01-01 00:00:00]), + build(:imported_browsers, browser: "Firefox", date: ~D[2021-01-02]), + build(:imported_browsers, + browser: "Chrome", + visitors: 1, + pageviews: 1, + bounces: 1, + visit_duration: 3, + visits: 1, + date: ~D[2021-01-03] + ), + build(:pageview, browser: "Chrome", user_id: 2, timestamp: ~N[2021-01-04 00:00:00]), + build(:event, + name: "Signup", + browser: "Chrome", + user_id: 2, + timestamp: ~N[2021-01-04 00:10:00] + ), + build(:imported_browsers, + browser: "Chrome", + visitors: 4, + pageviews: 6, + bounces: 1, + visit_duration: 300, + visits: 5, + date: ~D[2021-01-04] + ) + ]) + + results = + conn + |> get("/api/v1/stats/timeseries", %{ + "site_id" => site.domain, + "period" => "custom", + "date" => "2021-01-01,2021-01-04", + "metrics" => + "visitors,pageviews,events,visits,views_per_visit,bounce_rate,visit_duration", + "filters" => "visit:browser==Chrome", + "with_imported" => "true" + }) + |> json_response(200) + |> Map.get("results") + + assert results == [ + %{ + "bounce_rate" => 0.0, + "date" => "2021-01-01", + "events" => 2, + "pageviews" => 2, + "views_per_visit" => 2.0, + "visit_duration" => 180.0, + "visitors" => 1, + "visits" => 1 + }, + %{ + "bounce_rate" => 0, + "date" => "2021-01-02", + "events" => 0, + "pageviews" => 0, + "views_per_visit" => 0.0, + "visit_duration" => nil, + "visitors" => 0, + "visits" => 0 + }, + %{ + "bounce_rate" => 100, + "date" => "2021-01-03", + "events" => 1, + "pageviews" => 1, + "views_per_visit" => 1.0, + "visit_duration" => 3, + "visitors" => 1, + "visits" => 1 + }, + %{ + "bounce_rate" => 17.0, + "date" => "2021-01-04", + "events" => 8, + "pageviews" => 7, + "views_per_visit" => 1.17, + "visit_duration" => 150, + "visitors" => 5, + "visits" => 6 + } + ] + end + + test "returns conversion rate timeseries with a goal filter", %{ + conn: conn, + site: site + } do + site_import = insert(:site_import, site: site) + + insert(:goal, site: site, event_name: "Outbound Link: Click") + + populate_stats(site, site_import.id, [ + # 2021-01-01 + build(:event, name: "Outbound Link: Click", timestamp: ~N[2021-01-01 00:00:00]), + build(:imported_custom_events, name: "Outbound Link: Click", date: ~D[2021-01-01]), + build(:imported_visitors, date: ~D[2021-01-01], visitors: 4), + # 2021-01-02 + build(:event, name: "Outbound Link: Click", timestamp: ~N[2021-01-02 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-02 00:00:00]), + # 2021-01-03 + build(:imported_custom_events, name: "Outbound Link: Click", date: ~D[2021-01-03]), + build(:imported_visitors, date: ~D[2021-01-03]), + # 2021-01-04 + build(:event, name: "Outbound Link: Click", timestamp: ~N[2021-01-04 00:00:00]), + build(:imported_visitors, date: ~D[2021-01-04], visitors: 2) + ]) + + results = + conn + |> get("/api/v1/stats/timeseries", %{ + "site_id" => site.domain, + "period" => "custom", + "date" => "2021-01-01,2021-01-04", + "metrics" => "conversion_rate", + "filters" => "event:goal==Outbound Link: Click", + "with_imported" => "true" + }) + |> json_response(200) + |> Map.get("results") + + assert results == [ + %{ + "date" => "2021-01-01", + "conversion_rate" => 40.0 + }, + %{ + "date" => "2021-01-02", + "conversion_rate" => 50.0 + }, + %{ + "date" => "2021-01-03", + "conversion_rate" => 100.0 + }, + %{ + "date" => "2021-01-04", + "conversion_rate" => 33.33 + } + ] + end + + test "ignores imported data in conversion rate total calculation when imported data cannot be included", + %{ + conn: conn, + site: site + } do + site_import = insert(:site_import, site: site) + + insert(:goal, site: site, event_name: "Signup") + + populate_stats(site, site_import.id, [ + build(:event, name: "Signup", pathname: "/register", timestamp: ~N[2021-01-01 00:00:00]), + build(:imported_custom_events, name: "Signup", date: ~D[2021-01-01], visitors: 1), + build(:imported_pages, page: "/register", date: ~D[2021-01-01], visitors: 2) + ]) + + %{"results" => results, "warning" => warning} = + conn + |> get("/api/v1/stats/timeseries", %{ + "site_id" => site.domain, + "period" => "custom", + "date" => "2021-01-01,2021-01-01", + "interval" => "day", + "metrics" => "conversion_rate", + "filters" => "event:goal==Signup;event:page==/register", + "with_imported" => "true" + }) + |> json_response(200) + + assert results == [ + %{ + "date" => "2021-01-01", + "conversion_rate" => 100.0 + } + ] + + assert warning =~ "Imported stats are not included in the results" + end + + test "returns conversion rate timeseries with a goal + custom prop filter", %{ + conn: conn, + site: site + } do + site_import = insert(:site_import, site: site) + + insert(:goal, site: site, event_name: "Outbound Link: Click") + + populate_stats(site, site_import.id, [ + # 2021-01-01 + build(:event, + name: "Outbound Link: Click", + "meta.key": ["url"], + "meta.value": ["https://site.com"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:imported_custom_events, + name: "Outbound Link: Click", + link_url: "https://site.com", + date: ~D[2021-01-01] + ), + build(:imported_custom_events, + name: "File Download", + link_url: "https://site.com", + date: ~D[2021-01-01] + ), + build(:imported_custom_events, + name: "Outbound Link: Click", + link_url: "https://notthis.com", + date: ~D[2021-01-01] + ), + build(:imported_visitors, date: ~D[2021-01-01], visitors: 4), + # 2021-01-03 + build(:imported_custom_events, + name: "Outbound Link: Click", + link_url: "https://site.com", + date: ~D[2021-01-03] + ), + build(:imported_visitors, date: ~D[2021-01-03]), + # 2021-01-04 + build(:event, + name: "Outbound Link: Click", + "meta.key": ["url"], + "meta.value": ["https://site.com"], + timestamp: ~N[2021-01-04 00:00:00] + ), + build(:imported_visitors, date: ~D[2021-01-04], visitors: 2) + ]) + + results = + conn + |> get("/api/v1/stats/timeseries", %{ + "site_id" => site.domain, + "period" => "custom", + "date" => "2021-01-01,2021-01-04", + "metrics" => "conversion_rate", + "filters" => "event:goal==Outbound Link: Click;event:props:url==https://site.com", + "with_imported" => "true" + }) + |> json_response(200) + |> Map.get("results") + + assert results == [ + %{ + "date" => "2021-01-01", + "conversion_rate" => 40.0 + }, + %{ + "date" => "2021-01-02", + "conversion_rate" => 0.0 + }, + %{ + "date" => "2021-01-03", + "conversion_rate" => 100.0 + }, + %{ + "date" => "2021-01-04", + "conversion_rate" => 33.33 + } + ] + end + end +end diff --git a/test/plausible_web/controllers/api/internal_controller/annotations_controller_test.exs b/test/plausible_web/controllers/api/internal_controller/annotations_controller_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..0f383668d4d577794ddd8ca50776d1e9ffc2f8d8 --- /dev/null +++ b/test/plausible_web/controllers/api/internal_controller/annotations_controller_test.exs @@ -0,0 +1,784 @@ +defmodule PlausibleWeb.Api.Internal.AnnotationsControllerTest do + use PlausibleWeb.ConnCase, async: true + use Plausible.Repo + + describe "GET /api/:domain/annotations" do + setup [:create_user, :log_in] + + test "returns 400 when date_range is missing", %{conn: conn, user: user} do + site = new_site(owner: user) + + conn = get(conn, "/api/#{site.domain}/annotations") + + assert %{"error" => _} = json_response(conn, 400) + end + + test "returns 400 when date_range is unrecognized", %{conn: conn, user: user} do + site = new_site(owner: user) + + conn = get(conn, "/api/#{site.domain}/annotations?date_range=banana") + + assert %{"error" => _} = json_response(conn, 400) + end + + test "private site returns 404 for non-member", %{conn: conn} do + private_site = new_site() + + conn = + get( + conn, + "/api/#{private_site.domain}/annotations?date_range=day&relative_date=2026-01-04" + ) + + assert json_response(conn, 404) + end + + test "only site annotations are shown when viewing a public site, with owners info hidden", + %{conn: conn} do + site_owner = new_user() + public_site = new_site(public: true, owner: site_owner) + insert(:annotation, site: public_site, owner: site_owner, type: :site, note: "site note") + insert(:annotation, site: public_site, owner: site_owner, type: :personal, note: "private") + + conn = + get( + conn, + "/api/#{public_site.domain}/annotations?date_range=day&relative_date=2026-01-04" + ) + + assert_matches [ + ^strict_map(%{ + "id" => ^any(:pos_integer), + "note" => "site note", + "type" => "site", + "datetime" => "2026-01-04", + "granularity" => "date", + "owner_id" => nil, + "owner_name" => nil, + "inserted_at" => ^any(:iso8601_naive_datetime), + "updated_at" => ^any(:iso8601_naive_datetime) + }) + ] = json_response(conn, 200) + end + + test "personal annotations of the user and all site annotations are shown with annotation owner info when viewing their site, ordered by datetime ascending then id descending", + %{conn: conn, user: user} do + site = new_site() + other_user = new_user() + add_guest(site, user: user, role: :viewer) + + insert(:annotation, + site: site, + owner: other_user, + type: :site, + note: "site note from other user", + granularity: :date, + datetime: ~U[2026-06-29 00:00:00Z], + updated_at: ~U[2026-07-01 12:00:00Z] + ) + + insert(:annotation, + site: site, + owner: user, + type: :site, + note: "site note from this user", + granularity: :date, + datetime: ~U[2026-06-29 00:00:00Z], + updated_at: ~U[2026-07-01 12:00:00Z] + ) + + insert(:annotation, + site: site, + owner: nil, + type: :site, + note: "site note without owner", + granularity: :date, + datetime: ~U[2026-06-01 00:00:00Z] + ) + + insert(:annotation, + site: site, + owner: other_user, + type: :personal, + note: "other user's personal note", + granularity: :date, + datetime: ~U[2026-06-29 00:00:00Z] + ) + + insert(:annotation, + site: site, + owner: user, + type: :personal, + note: "my note", + granularity: :minute, + datetime: ~U[2026-06-29 00:01:00Z], + updated_at: ~U[2026-07-01 10:00:00Z] + ) + + conn = + get( + conn, + "/api/#{site.domain}/annotations?date_range_start=2026-06-01&date_range_end=2026-06-30" + ) + + results = json_response(conn, 200) + + assert_matches [ + ^strict_map(%{ + "id" => ^any(:pos_integer), + "note" => "site note without owner", + "type" => "site", + "datetime" => "2026-06-01", + "granularity" => "date", + "owner_id" => nil, + "owner_name" => nil, + "inserted_at" => ^any(:iso8601_naive_datetime), + "updated_at" => ^any(:iso8601_naive_datetime) + }), + ^strict_map(%{ + "id" => ^any(:pos_integer), + "note" => "site note from this user", + "type" => "site", + "datetime" => "2026-06-29", + "granularity" => "date", + "owner_id" => ^user.id, + "owner_name" => ^user.name, + "inserted_at" => ^any(:iso8601_naive_datetime), + "updated_at" => ^any(:iso8601_naive_datetime) + }), + ^strict_map(%{ + "id" => ^any(:pos_integer), + "note" => "site note from other user", + "type" => "site", + "datetime" => "2026-06-29", + "granularity" => "date", + "owner_id" => ^other_user.id, + "owner_name" => ^other_user.name, + "inserted_at" => ^any(:iso8601_naive_datetime), + "updated_at" => ^any(:iso8601_naive_datetime) + }), + ^strict_map(%{ + "id" => ^any(:pos_integer), + "note" => "my note", + "type" => "personal", + "datetime" => "2026-06-29T00:01:00", + "granularity" => "minute", + "owner_id" => ^user.id, + "owner_name" => ^user.name, + "inserted_at" => ^any(:iso8601_naive_datetime), + "updated_at" => ^any(:iso8601_naive_datetime) + }) + ] = results + end + + test "filters out annotations outside a 28d window", %{conn: conn, user: user} do + site = new_site(owner: user) + + insert(:annotation, + site: site, + owner: user, + type: :personal, + granularity: :date, + datetime: ~U[2026-06-26 00:00:00Z], + note: "in range" + ) + + insert(:annotation, + site: site, + owner: user, + type: :personal, + granularity: :date, + datetime: ~U[2026-05-01 00:00:00Z], + note: "out of range" + ) + + conn = + get(conn, "/api/#{site.domain}/annotations?date_range=28d&relative_date=2026-06-29") + + results = json_response(conn, 200) + assert Enum.map(results, & &1["note"]) == ["in range"] + end + + test "filters by a custom [from,to] date_range", %{conn: conn, user: user} do + site = new_site(owner: user) + + insert(:annotation, + site: site, + owner: user, + type: :personal, + granularity: :date, + datetime: ~U[2026-06-22 00:00:00Z], + note: "in range" + ) + + insert(:annotation, + site: site, + owner: user, + type: :personal, + granularity: :date, + datetime: ~U[2026-06-26 00:00:00Z], + note: "out of range" + ) + + conn = + get( + conn, + "/api/#{site.domain}/annotations?date_range_start=2026-06-20&date_range_end=2026-06-25" + ) + + results = json_response(conn, 200) + assert Enum.map(results, & &1["note"]) == ["in range"] + end + + test "filters minute granularity annotations to be within the UTC datetime range", + %{conn: conn, user: user} do + # NY is UTC-4 in June (DST). + # Full day of 2026-06-28 -> [~U[2026-06-28 04:00:00Z], ~U[2026-06-29 03:59:59]]. + site = new_site(owner: user, timezone: "America/New_York") + + insert(:annotation, + site: site, + owner: user, + type: :personal, + granularity: :minute, + # 11:00 local — inside the UTC window + datetime: ~U[2026-06-28 15:00:00Z], + note: "in window" + ) + + insert(:annotation, + site: site, + owner: user, + type: :personal, + granularity: :minute, + # next-day 01:00 local — past the UTC window end + datetime: ~U[2026-06-29 05:00:00Z], + note: "past window" + ) + + conn = + get(conn, "/api/#{site.domain}/annotations?date_range=day&relative_date=2026-06-28") + + results = json_response(conn, 200) + assert Enum.map(results, & &1["note"]) == ["in window"] + end + + test "filters date granularity annotations that are within the local date range", + %{conn: conn, user: user} do + site = new_site(owner: user, timezone: "Asia/Tokyo") + + insert(:annotation, + site: site, + owner: user, + type: :personal, + granularity: :date, + datetime: ~U[2026-06-29 00:00:00Z], + note: "in range" + ) + + insert(:annotation, + site: site, + owner: user, + type: :personal, + granularity: :date, + datetime: ~U[2026-06-30 00:00:00Z], + note: "out of range" + ) + + conn = + get(conn, "/api/#{site.domain}/annotations?date_range=7d&relative_date=2026-06-30") + + results = json_response(conn, 200) + assert Enum.map(results, & &1["note"]) == ["in range"] + end + + test "fetches annotations for realtime date_range", %{conn: conn, user: user} do + site = new_site(owner: user) + + insert(:annotation, + site: site, + owner: user, + type: :personal, + granularity: :minute, + datetime: DateTime.utc_now(), + note: "now" + ) + + conn = get(conn, "/api/#{site.domain}/annotations?date_range=realtime") + + results = json_response(conn, 200) + assert Enum.map(results, & &1["note"]) == ["now"] + end + end + + describe "POST /api/:domain/annotations" do + setup [:create_user, :log_in, :create_site] + + test "rejects missing note", %{conn: conn, site: site} do + conn = + post(conn, "/api/#{site.domain}/annotations", %{ + "type" => "personal", + "granularity" => "date", + "datetime" => "2026-01-04" + }) + + assert json_response(conn, 400) == %{"error" => "note can't be blank"} + end + + test "rejects empty note", %{conn: conn, site: site} do + conn = + post(conn, "/api/#{site.domain}/annotations", %{ + "note" => "", + "type" => "personal", + "granularity" => "date", + "datetime" => "2026-01-04" + }) + + assert json_response(conn, 400) == %{"error" => "note can't be blank"} + end + + test "rejects note over 255 bytes", %{conn: conn, site: site} do + conn = + post(conn, "/api/#{site.domain}/annotations", %{ + "note" => String.duplicate("a", 256), + "type" => "personal", + "granularity" => "date", + "datetime" => "2026-01-04" + }) + + assert json_response(conn, 400) == %{"error" => "note should be at most 255 byte(s)"} + end + + test "rejects missing datetime", %{conn: conn, site: site} do + conn = + post(conn, "/api/#{site.domain}/annotations", %{ + "note" => "feature released", + "type" => "personal", + "granularity" => "date" + }) + + assert json_response(conn, 400) == %{ + "error" => "date must be supplied for chosen granularity" + } + end + + test "rejects unknown granularity", %{conn: conn, site: site} do + conn = + post(conn, "/api/#{site.domain}/annotations", %{ + "note" => "feature released", + "type" => "personal", + "granularity" => "hour", + "datetime" => "2026-01-04T00:00:00Z" + }) + + assert json_response(conn, 400) == %{"error" => "granularity is invalid"} + end + + test "rejects missing granularity", %{conn: conn, site: site} do + conn = + post(conn, "/api/#{site.domain}/annotations", %{ + "note" => "feature released", + "type" => "personal", + "datetime" => "2026-01-04T00:00:00Z" + }) + + assert json_response(conn, 400) == %{"error" => "granularity can't be blank"} + end + + test "rejects unknown type as insufficient permissions", %{conn: conn, site: site} do + conn = + post(conn, "/api/#{site.domain}/annotations", %{ + "note" => "feature released", + "type" => "team", + "granularity" => "date", + "datetime" => "2026-01-04" + }) + + assert json_response(conn, 403) == %{ + "error" => "Not enough permissions to create annotation" + } + end + + for {params, error} <- [ + { + %{"granularity" => "date", "datetime" => "2026-01-04T00:00:00Z"}, + "date must be supplied for chosen granularity" + }, + { + %{"granularity" => "date", "datetime" => "2026-01-04T00:00:00"}, + "date must be supplied for chosen granularity" + }, + { + %{"granularity" => "date", "date" => ""}, + "date must be supplied for chosen granularity" + }, + { + %{"granularity" => "date", "date" => "2026-1-4"}, + "date is invalid" + }, + { + %{"granularity" => "date", "date" => "2026-13-45"}, + "date is invalid" + }, + { + %{"granularity" => "date", "date" => "not-a-date"}, + "date is invalid" + }, + { + %{"granularity" => "minute", "datetime" => "2026-01-04"}, + "datetime is invalid" + }, + { + %{"granularity" => "minute", "datetime" => "not-a-datetime"}, + "datetime is invalid" + }, + { + %{"granularity" => "minute", "datetime" => ""}, + "datetime must be supplied for chosen granularity" + }, + { + %{"granularity" => "minute", "datetime" => "2026-13-45T14:30:00Z"}, + "datetime is invalid" + } + ] do + test "rejects #{inspect(params)} with error #{error}", %{conn: conn, site: site} do + conn = + post( + conn, + "/api/#{site.domain}/annotations", + Map.merge( + %{"note" => "feature released", "type" => "personal"}, + unquote(Macro.escape(params)) + ) + ) + + assert json_response(conn, 400) == %{"error" => unquote(error)} + end + end + + test "accepts bare date string when granularity is date", + %{conn: conn, site: site} do + response = + post(conn, "/api/#{site.domain}/annotations", %{ + "note" => "feature released", + "type" => "personal", + "granularity" => "date", + "date" => "2026-01-04" + }) + |> json_response(200) + + assert_matches ^strict_map(%{ + "id" => ^any(:pos_integer), + "note" => "feature released", + "type" => "personal", + "granularity" => "date", + "datetime" => "2026-01-04", + "owner_id" => ^any(:pos_integer), + "owner_name" => ^any(:string), + "inserted_at" => ^any(:iso8601_naive_datetime), + "updated_at" => ^any(:iso8601_naive_datetime) + }) = response + end + + test "accepts full datetime string when granularity is minute", + %{conn: conn, site: site} do + # Site is Etc/UTC by default; UTC moment is returned as naive local time + response = + post(conn, "/api/#{site.domain}/annotations", %{ + "note" => "feature released", + "type" => "personal", + "granularity" => "minute", + "datetime" => "2026-01-04T14:32:00Z" + }) + |> json_response(200) + + assert response["datetime"] == "2026-01-04T14:32:00" + end + + test "converts naive local time to UTC and returns it back as local time", + %{conn: conn, user: user} do + # America/New_York is UTC-5 in January; input and output should be the same + # local time (round-trip), while the DB stores the UTC equivalent. + site = new_site(owner: user, timezone: "America/New_York") + + response = + post(conn, "/api/#{site.domain}/annotations", %{ + "note" => "feature released", + "type" => "personal", + "granularity" => "minute", + "datetime" => "2026-01-04T14:30:00" + }) + |> json_response(200) + + assert response["datetime"] == "2026-01-04T14:30:00" + + reloaded = Plausible.Repo.get!(Plausible.Annotations.Annotation, response["id"]) + assert reloaded.granularity == :minute + assert reloaded.datetime == ~U[2026-01-04 19:30:00Z] + end + + test "UTC datetime string is stored as UTC and returned as site local time", + %{conn: conn, user: user} do + # America/New_York is UTC-5 in January, so 14:30 UTC = 09:30 local + site = new_site(owner: user, timezone: "America/New_York") + + response = + post(conn, "/api/#{site.domain}/annotations", %{ + "note" => "feature released", + "type" => "personal", + "granularity" => "minute", + "datetime" => "2026-01-04T14:30:00Z" + }) + |> json_response(200) + + assert response["datetime"] == "2026-01-04T09:30:00" + end + end + + describe "PATCH /api/:domain/annotations/:annotation_id" do + setup [:create_user, :log_in, :create_site] + + test "rejects granularity change from date to minute without a new datetime", + %{conn: conn, site: site, user: user} do + annotation = + insert(:annotation, + site: site, + owner: user, + type: :personal, + granularity: :date, + datetime: ~U[2026-06-15 00:00:00Z] + ) + + conn = + patch(conn, "/api/#{site.domain}/annotations/#{annotation.id}", %{ + "granularity" => "minute" + }) + + assert json_response(conn, 400) == %{ + "error" => "datetime must be supplied for chosen granularity" + } + + reloaded = Plausible.Repo.get!(Plausible.Annotations.Annotation, annotation.id) + assert reloaded.granularity == :date + assert reloaded.datetime == ~U[2026-06-15 00:00:00Z] + end + + test "rejects granularity change from minute to date without a new datetime", + %{conn: conn, site: site, user: user} do + annotation = + insert(:annotation, + site: site, + owner: user, + type: :personal, + granularity: :minute, + datetime: ~U[2026-06-15 14:30:00Z] + ) + + conn = + patch(conn, "/api/#{site.domain}/annotations/#{annotation.id}", %{ + "granularity" => "date" + }) + + assert json_response(conn, 400) == %{ + "error" => "date must be supplied for chosen granularity" + } + + reloaded = Plausible.Repo.get!(Plausible.Annotations.Annotation, annotation.id) + assert reloaded.granularity == :minute + assert reloaded.datetime == ~U[2026-06-15 14:30:00Z] + end + + test "accepts granularity change from date to minute with a new datetime", + %{conn: conn, site: site, user: user} do + annotation = + insert(:annotation, + site: site, + owner: user, + type: :personal, + granularity: :date, + datetime: ~U[2026-06-15 00:00:00Z] + ) + + response = + patch(conn, "/api/#{site.domain}/annotations/#{annotation.id}", %{ + "granularity" => "minute", + "datetime" => "2026-06-15T14:30:00Z" + }) + |> json_response(200) + + assert response["granularity"] == "minute" + assert response["datetime"] == "2026-06-15T14:30:00" + + reloaded = Plausible.Repo.get!(Plausible.Annotations.Annotation, annotation.id) + assert reloaded.granularity == :minute + assert reloaded.datetime == ~U[2026-06-15 14:30:00Z] + end + + test "accepts granularity change from minute to date with a new datetime", + %{conn: conn, site: site, user: user} do + annotation = + insert(:annotation, + site: site, + owner: user, + type: :personal, + granularity: :minute, + datetime: ~U[2026-06-15 14:30:00Z] + ) + + response = + patch(conn, "/api/#{site.domain}/annotations/#{annotation.id}", %{ + "granularity" => "date", + "date" => "2026-06-20" + }) + |> json_response(200) + + assert response["granularity"] == "date" + assert response["datetime"] == "2026-06-20" + + reloaded = Plausible.Repo.get!(Plausible.Annotations.Annotation, annotation.id) + assert reloaded.granularity == :date + assert reloaded.datetime == ~U[2026-06-20 00:00:00Z] + end + + test "converts naive local time to UTC and returns it back as local time", + %{conn: conn, user: user} do + # America/New_York is UTC-4 in June (DST), so input and output are the same + site = new_site(owner: user, timezone: "America/New_York") + + annotation = + insert(:annotation, + site: site, + owner: user, + type: :personal, + granularity: :minute, + datetime: ~U[2026-01-01 00:00:00Z] + ) + + response = + patch(conn, "/api/#{site.domain}/annotations/#{annotation.id}", %{ + "granularity" => "minute", + "datetime" => "2026-06-15T10:00:00" + }) + |> json_response(200) + + assert response["datetime"] == "2026-06-15T10:00:00" + + reloaded = Plausible.Repo.get!(Plausible.Annotations.Annotation, annotation.id) + assert reloaded.granularity == :minute + assert reloaded.datetime == ~U[2026-06-15 14:00:00Z] + end + + test "accepts bare date string when granularity is date", + %{conn: conn, site: site, user: user} do + annotation = + insert(:annotation, + site: site, + owner: user, + type: :personal, + granularity: :date, + datetime: ~U[2026-01-01 00:00:00Z] + ) + + response = + patch(conn, "/api/#{site.domain}/annotations/#{annotation.id}", %{ + "granularity" => "date", + "date" => "2026-06-15" + }) + |> json_response(200) + + assert response["datetime"] == "2026-06-15" + end + + for {casename, note_has_owner?} <- [ + {"dangling site note", false}, + {"site note left by other user", true} + ] do + test "users with site note permissions can edit a #{casename}, becoming its new owner", + %{ + conn: conn, + site: site, + user: user + } do + note_owner = + if(unquote(note_has_owner?), + do: + site.team + |> add_member( + user: new_user(name: "other user"), + role: :editor + ), + else: nil + ) + + annotation = + insert(:annotation, + owner: note_owner, + site: site, + type: :site, + granularity: :date, + datetime: "2026-06-01T00:00:00Z" + ) + + response = + patch(conn, "/api/#{site.domain}/annotations/#{annotation.id}", %{ + "note" => "updated" + }) + |> json_response(200) + + assert_matches ^strict_map(%{ + "id" => ^annotation.id, + "note" => "updated", + "type" => + ^any( + :string, + ~r/#{annotation.type}/ + ), + "granularity" => + ^any( + :string, + ~r/#{annotation.granularity}/ + ), + "datetime" => "2026-06-01", + "owner_id" => ^user.id, + "owner_name" => ^user.name, + "inserted_at" => + ^any( + :string, + ~r/#{Calendar.strftime(annotation.inserted_at, "%Y-%m-%dT%H:%M:%S")}/ + ), + "updated_at" => ^any(:iso8601_naive_datetime) + }) = response + end + end + + test "owner on a plan without site_annotations can demote a site annotation to personal", + %{conn: conn, user: user} do + subscribe_to_starter_plan(user) + site = new_site(owner: user) + refute Plausible.Annotations.site_annotations_available?(site) + + annotation = + insert(:annotation, + site: site, + owner: user, + type: :site, + granularity: :date, + datetime: ~U[2026-06-15 00:00:00Z] + ) + + response = + patch(conn, "/api/#{site.domain}/annotations/#{annotation.id}", %{ + "type" => "personal", + "note" => "converted after downgrade" + }) + |> json_response(200) + + assert response["type"] == "personal" + assert response["note"] == "converted after downgrade" + + reloaded = Plausible.Repo.get!(Plausible.Annotations.Annotation, annotation.id) + assert reloaded.type == :personal + assert reloaded.note == "converted after downgrade" + end + end +end diff --git a/test/plausible_web/controllers/api/internal_controller/docs_query_test.exs b/test/plausible_web/controllers/api/internal_controller/docs_query_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..bf2ea3c139b30b9af0af180aae8ae821bc9fecf7 --- /dev/null +++ b/test/plausible_web/controllers/api/internal_controller/docs_query_test.exs @@ -0,0 +1,108 @@ +defmodule PlausibleWeb.Api.InternalController.DocsQueryTest do + use PlausibleWeb.ConnCase, async: true + use Plausible.Repo + @user_id Enum.random(1000..9999) + + describe "POST /api/docs/query not logged in" do + setup [:create_user, :create_site] + + test "rejects request when not logged in", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + post(conn, "/api/docs/query", %{ + "site_id" => site.domain, + "metrics" => ["pageviews"], + "date_range" => "all" + }) + + assert json_response(conn, 404) == %{ + "error" => "Site does not exist or user does not have sufficient access." + } + end + end + + describe "POST /api/docs/query logged in" do + setup [:create_user, :create_site, :log_in] + + test "rejects when accessing any other site", %{conn: conn} do + conn = + post(conn, "/api/docs/query", %{ + "site_id" => "any.other.site", + "metrics" => ["pageviews"], + "date_range" => "all" + }) + + assert json_response(conn, 404) == %{ + "error" => "Site does not exist or user does not have sufficient access." + } + end + + test "rejects when using invalid param name", %{conn: conn, site: site} do + conn = + post(conn, "/api/docs/query", %{ + "domain" => site.domain, + "metrics" => ["pageviews"], + "date_range" => "all" + }) + + assert json_response(conn, 404) == %{ + "error" => "Site does not exist or user does not have sufficient access." + } + end + + test "returns aggregated metrics when site id given", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, user_id: @user_id, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, user_id: @user_id, timestamp: ~N[2021-01-01 00:25:00]), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + post(conn, "/api/docs/query?site_id=ignored_when_site_id_present_in_body", %{ + "site_id" => site.domain, + "metrics" => ["pageviews"], + "date_range" => "all" + }) + + assert json_response(conn, 200)["results"] == [%{"metrics" => [3], "dimensions" => []}] + end + + test "succeeds when site_id missing in body, but present in query params", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, user_id: @user_id, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + post(conn, "/api/docs/query?site_id=#{site.domain}", %{ + "metrics" => ["pageviews"], + "date_range" => "all" + }) + + assert json_response(conn, 200)["results"] == [%{"metrics" => [1], "dimensions" => []}] + end + + test "supports 24h date_range shorthand", %{conn: conn, site: site} do + now = NaiveDateTime.utc_now() + + populate_stats(site, [ + build(:pageview, timestamp: NaiveDateTime.add(now, -23, :hour)), + build(:pageview, timestamp: NaiveDateTime.add(now, -25, :hour)) + ]) + + conn = + post(conn, "/api/docs/query", %{ + "site_id" => site.domain, + "metrics" => ["pageviews"], + "date_range" => "24h" + }) + + assert json_response(conn, 200)["results"] == [%{"metrics" => [1], "dimensions" => []}] + end + end +end diff --git a/test/plausible_web/controllers/api/internal_controller/schema_for_docs_test.exs b/test/plausible_web/controllers/api/internal_controller/schema_for_docs_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..28fc11b8db9a2b58d8a557452af672d39e046c05 --- /dev/null +++ b/test/plausible_web/controllers/api/internal_controller/schema_for_docs_test.exs @@ -0,0 +1,20 @@ +defmodule PlausibleWeb.Api.InternalController.SchemaForDocsTest do + use PlausibleWeb.ConnCase, async: true + use Plausible.Repo + + describe "GET /api/docs/query/schema.json" do + test "returns public schema in json format and it parses", %{conn: conn} do + conn = get(conn, "/api/docs/query/schema.json") + response = json_response(conn, 200) + + assert %{"$schema" => "http://json-schema.org/draft-07/schema#", "type" => "object"} = + response + end + + test "public schema does not contain any unexpected nodes", %{conn: conn} do + conn = get(conn, "/api/docs/query/schema.json") + refute response(conn, 200) =~ ~s/"$comment":"only :internal"/ + refute response(conn, 200) =~ ~s/"realtime"/ + end + end +end diff --git a/test/plausible_web/controllers/api/internal_controller/segments_controller_test.exs b/test/plausible_web/controllers/api/internal_controller/segments_controller_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..ddbf41a4c505b49cae46050d504b01ac3f8054a0 --- /dev/null +++ b/test/plausible_web/controllers/api/internal_controller/segments_controller_test.exs @@ -0,0 +1,718 @@ +defmodule PlausibleWeb.Api.Internal.SegmentsControllerTest do + use PlausibleWeb.ConnCase, async: true + use Plausible.Repo + + describe "POST /api/:domain/segments" do + setup [:create_user, :log_in, :create_site] + + test "forbids viewers from creating site segments", %{conn: conn, user: user} do + site = new_site() + add_guest(site, user: user, role: :viewer) + + conn = + post(conn, "/api/#{site.domain}/segments", %{ + "type" => "site", + "segment_data" => %{"filters" => [["is", "visit:entry_page", ["/blog"]]]}, + "name" => "any name" + }) + + assert json_response(conn, 403) == %{ + "error" => "Not enough permissions to create segment" + } + end + + test "forbids owners on growth plan from creating site segments", %{ + conn: conn, + user: user, + site: site + } do + user |> subscribe_to_growth_plan() + + conn = + post(conn, "/api/#{site.domain}/segments", %{ + "type" => "site", + "segment_data" => %{"filters" => [["is", "visit:entry_page", ["/blog"]]]}, + "name" => "any name" + }) + + assert json_response(conn, 403) == %{ + "error" => "Not enough permissions to create segment" + } + end + + test "forbids users from creating segments with invalid filters", + %{ + conn: conn, + site: site + } do + conn = + post(conn, "/api/#{site.domain}/segments", %{ + "type" => "site", + "segment_data" => %{ + "filters" => [["is", "entry_page", ["/blog"]]] + }, + "name" => "any name" + }) + + assert json_response(conn, 400) == %{ + "error" => "segment_data Invalid filter '[\"is\", \"entry_page\", [\"/blog\"]]'." + } + end + + test "returns error when segment limit is reached", %{conn: conn, user: user, site: site} do + insert_list(500, :segment, site: site, owner: user, type: :personal, name: "a segment") + + conn = + post(conn, "/api/#{site.domain}/segments", %{ + "type" => "personal", + "segment_data" => %{"filters" => [["is", "visit:entry_page", ["/blog"]]]}, + "name" => "any name" + }) + + assert json_response(conn, 403) == %{ + "error" => "Segment limit reached" + } + + assert Repo.aggregate( + from(segment in Plausible.Segments.Segment, where: segment.site_id == ^site.id), + :count, + :id + ) == 500 + end + + for %{role: role, type: type, membership_type: membership_type} <- [ + %{membership_type: :site_guest, role: :viewer, type: :personal}, + %{membership_type: :site_guest, role: :editor, type: :personal}, + %{membership_type: :site_guest, role: :editor, type: :site}, + %{membership_type: :team_member, role: :viewer, type: :personal}, + %{membership_type: :team_member, role: :billing, type: :personal}, + %{membership_type: :team_member, role: :editor, type: :personal}, + %{membership_type: :team_member, role: :editor, type: :site}, + %{membership_type: :team_member, role: :admin, type: :personal}, + %{membership_type: :team_member, role: :admin, type: :site}, + %{membership_type: :team_member, role: :owner, type: :personal}, + %{membership_type: :team_member, role: :owner, type: :site} + ] do + test "#{role} (#{Phoenix.Naming.humanize(membership_type)}) can create segment with type \"#{type}\" successfully", + %{conn: conn, user: user} do + site = new_site() + + add_site_guest_or_team_member(site, + user: user, + role: unquote(role), + membership_type: unquote(membership_type) + ) + + insert(:goal, site: site, event_name: "Conversion") + + response = + post(conn, "/api/#{site.domain}/segments", %{ + "name" => "Some segment", + "type" => "#{unquote(type)}", + "segment_data" => %{ + "filters" => [ + ["is", "visit:entry_page", ["/blog"]], + ["has_not_done", ["is", "event:goal", ["Conversion"]]] + ] + } + }) + |> json_response(200) + + assert_matches ^strict_map(%{ + "id" => ^any(:pos_integer), + "name" => "Some segment", + "type" => ^"#{unquote(type)}", + "segment_data" => + ^strict_map(%{ + "filters" => [ + ["is", "visit:entry_page", ["/blog"]], + ["has_not_done", ["is", "event:goal", ["Conversion"]]] + ] + }), + "owner_id" => ^user.id, + "owner_name" => ^user.name, + "inserted_at" => ^any(:iso8601_naive_datetime), + "updated_at" => ^any(:iso8601_naive_datetime) + }) = response + + assert response["inserted_at"] == response["updated_at"] + + verify_segment_in_db(%Plausible.Segments.Segment{ + id: response["id"], + name: response["name"], + type: String.to_existing_atom(response["type"]), + segment_data: response["segment_data"], + owner_id: response["owner_id"], + site_id: site.id, + inserted_at: NaiveDateTime.from_iso8601!(response["inserted_at"]), + updated_at: NaiveDateTime.from_iso8601!(response["updated_at"]) + }) + end + end + end + + describe "PATCH /api/:domain/segments/:segment_id" do + setup [:create_user, :create_site, :log_in] + + for {current_type, patch_type} <- [ + {:personal, :site}, + {:site, :personal} + ] do + test "prevents viewers from updating segments with current type #{current_type} with #{patch_type}", + %{ + conn: conn, + user: user + } do + site = new_site() + add_guest(site, user: user, role: :viewer) + + %{id: segment_id} = + insert(:segment, + site: site, + name: "any", + type: unquote(current_type), + owner: user + ) + + conn = + patch(conn, "/api/#{site.domain}/segments/#{segment_id}", %{ + "name" => "updated name", + "type" => "#{unquote(patch_type)}" + }) + + assert json_response(conn, 403) == %{ + "error" => "Not enough permissions to edit segment" + } + end + end + + for {filters, expected_error} <- [ + {[], "segment_data property \"filters\" must be an array with at least one member"}, + {[["foo", "bar"]], "segment_data Unknown operator for filter '[\"foo\", \"bar\"]'."}, + {[["not", ["is", "visit:entry_page", ["/campaigns/:campaign_name"]]]], + "segment_data Invalid filters. Deep filters are not supported."}, + {[ + [ + "or", + [ + ["is", "event:goal", ["any goal"]], + ["is", "visit:entry_page", ["/campaigns/:campaign_name"]] + ] + ] + ], + "segment_data Invalid filters. Dimension `event:goal` can only be filtered at the top level."} + ] do + test "prevents owners from updating segments to invalid filters #{inspect(filters)} with error 400", + %{ + conn: conn, + user: user, + site: site + } do + segment = + insert(:segment, + site: site, + name: "any name", + type: :personal, + owner: user + ) + + conn = + patch(conn, "/api/#{site.domain}/segments/#{segment.id}", %{ + "segment_data" => %{"filters" => unquote(filters)} + }) + + assert json_response(conn, 400) == %{ + "error" => unquote(expected_error) + } + end + end + + test "prevents editors from updating segments name beyond 255 characters with error 400", + %{ + conn: conn, + user: user, + site: site + } do + segment = + insert(:segment, + site: site, + name: "any name", + type: :personal, + owner: user + ) + + conn = + patch(conn, "/api/#{site.domain}/segments/#{segment.id}", %{ + "name" => String.duplicate("a", 256) + }) + + assert json_response(conn, 400) == %{ + "error" => "name should be at most 255 byte(s)" + } + end + + test "updating a segment containing a goal that has been deleted, with the deleted goal still in filters, fails", + %{ + conn: conn, + user: user, + site: site + } do + segment = + insert(:segment, + site: site, + name: "any name", + type: :personal, + owner: user, + segment_data: %{"filters" => [["is", "event:goal", ["Signup"]]]} + ) + + conn = + patch(conn, "/api/#{site.domain}/segments/#{segment.id}", %{ + "segment_data" => %{ + "filters" => [["is", "event:goal", ["Signup"]], ["is", "event:page", ["/register"]]] + } + }) + + assert json_response(conn, 400) == %{ + "error" => + "segment_data 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 "a segment containing a goal that has been deleted can be updated to not contain the goal", + %{ + conn: conn, + user: user, + site: site + } do + segment = + insert(:segment, + site: site, + name: "any name", + type: :personal, + owner: user, + segment_data: %{"filters" => [["is", "event:goal", ["Signup"]]]} + ) + + insert(:goal, site: site, event_name: "a new goal") + + response = + patch(conn, "/api/#{site.domain}/segments/#{segment.id}", %{ + "segment_data" => %{ + "filters" => [["is", "event:goal", ["a new goal"]]] + } + }) + |> json_response(200) + + assert_matches ^strict_map(%{ + "id" => ^segment.id, + "name" => ^segment.name, + "type" => ^any(:string, ~r/#{segment.type}/), + "segment_data" => + ^strict_map(%{ + "filters" => [ + ["is", "event:goal", ["a new goal"]] + ] + }), + "owner_id" => ^user.id, + "owner_name" => ^user.name, + "inserted_at" => ^any(:iso8601_naive_datetime), + "updated_at" => ^any(:iso8601_naive_datetime) + }) = response + end + + test "editors can update a segment", %{conn: conn, user: user} do + site = new_site() + add_guest(site, user: user, role: :editor) + + segment = + insert(:segment, + site: site, + name: "original name", + type: :site, + owner: user, + inserted_at: "2024-09-01T10:00:00", + updated_at: "2024-09-01T10:00:00" + ) + + response = + patch(conn, "/api/#{site.domain}/segments/#{segment.id}", %{ + "name" => "updated name", + "type" => "personal" + }) + |> json_response(200) + + assert_matches ^strict_map(%{ + "id" => ^segment.id, + "name" => "updated name", + "type" => "personal", + "owner_id" => ^user.id, + "owner_name" => ^user.name, + "segment_data" => ^segment.segment_data, + "inserted_at" => + ^any( + :string, + ~r/#{Calendar.strftime(segment.inserted_at, "%Y-%m-%dT%H:%M:%S")}/ + ), + "updated_at" => ^any(:iso8601_naive_datetime) + }) = response + end + + for {casename, segment_has_owner?} <- [ + {"dangling site segment", false}, + {"site segment by other user", true} + ] do + test "the last editor of a #{casename} becomes the new owner", + %{ + conn: conn, + site: site, + user: user + } do + segment_owner = + if(unquote(segment_has_owner?), + do: + site.team + |> add_member( + user: new_user(name: "other user"), + role: :editor + ), + else: nil + ) + + segment = + insert(:segment, + site: site, + name: "original name", + type: :site, + owner: segment_owner + ) + + response = + patch(conn, "/api/#{site.domain}/segments/#{segment.id}", %{"name" => "updated name"}) + |> json_response(200) + + assert_matches ^strict_map(%{ + "id" => ^segment.id, + "name" => "updated name", + "type" => + ^any( + :string, + ~r/#{segment.type}/ + ), + "owner_id" => ^user.id, + "owner_name" => ^user.name, + "segment_data" => ^segment.segment_data, + "inserted_at" => + ^any( + :string, + ~r/#{Calendar.strftime(segment.inserted_at, "%Y-%m-%dT%H:%M:%S")}/ + ), + "updated_at" => ^any(:iso8601_naive_datetime) + }) = response + end + end + + test "cannot move segment to another site by passing site_id param", %{ + conn: conn, + user: user, + site: site + } do + other_site = new_site() + segment = insert(:segment, site: site, owner: user, type: :personal, name: "test segment") + + patch(conn, "/api/#{site.domain}/segments/#{segment.id}", %{ + "name" => "updated name", + "type" => "personal", + "segment_data" => %{"filters" => [["is", "visit:entry_page", ["/blog"]]], "labels" => %{}}, + "site_id" => other_site.id + }) + + reloaded = Repo.reload!(segment) + assert reloaded.site_id == site.id + assert reloaded.site_id != other_site.id + end + end + + describe "DELETE /api/:domain/segments/:segment_id" do + setup [:create_user, :create_site, :log_in] + + test "forbids viewers from deleting site segments", %{conn: conn, user: user} do + site = new_site() + add_guest(site, user: user, role: :viewer) + + segment = + insert(:segment, + site: site, + name: "any", + type: :site, + owner_id: user.id + ) + + conn = + delete(conn, "/api/#{site.domain}/segments/#{segment.id}") + + assert json_response(conn, 403) == %{ + "error" => "Not enough permissions to delete segment" + } + + verify_segment_in_db(segment) + end + + test "even site owners can't delete personal segments of other users", + %{conn: conn, site: site} do + other_user = add_guest(site, role: :editor) + + segment = + insert(:segment, + site: site, + owner_id: other_user.id, + name: "any", + type: :personal + ) + + conn = + delete(conn, "/api/#{site.domain}/segments/#{segment.id}") + + assert %{"error" => "Segment not found with ID \"#{segment.id}\""} == + json_response(conn, 404) + + verify_segment_in_db(segment) + end + + for %{role: role, type: type} <- [ + %{role: :viewer, type: :personal}, + %{role: :editor, type: :personal}, + %{role: :editor, type: :site} + ] do + test "#{role} can delete their own segment with type \"#{type}\" successfully", + %{conn: conn, user: user} do + site = new_site() + add_guest(site, user: user, role: unquote(role)) + + segment = + insert(:segment, + site: site, + name: "any", + type: unquote(type), + owner: user + ) + + response = + delete(conn, "/api/#{site.domain}/segments/#{segment.id}") + |> json_response(200) + + assert %{ + "owner_id" => user.id, + "owner_name" => user.name, + "id" => segment.id, + "name" => segment.name, + "segment_data" => segment.segment_data, + "type" => "#{unquote(type)}", + "inserted_at" => Calendar.strftime(segment.inserted_at, "%Y-%m-%dT%H:%M:%S"), + "updated_at" => Calendar.strftime(segment.updated_at, "%Y-%m-%dT%H:%M:%S") + } == response + + verify_no_segment_in_db(segment) + end + end + + test "deleting a segment deletes the shared links linked to it", %{ + conn: conn, + site: site, + user: user + } do + segment = + insert(:segment, + site: site, + owner: user, + name: "any", + type: :personal, + segment_data: %{"filters" => [["is", "event:goal", ["Signup"]]]} + ) + + other_segment = + insert(:segment, + site: site, + owner: user, + name: "other", + type: :site, + segment_data: %{"filters" => [["is", "event:goal", ["Signup"]]]} + ) + + shared_links_with_segments = + insert_list(3, :shared_link, + site: site, + segment_id: segment.id + ) + + shared_link_with_other_segment = + insert(:shared_link, site: site, name: "Gamma", segment_id: other_segment.id) + + shared_link_with_no_segment = insert(:shared_link, site: site, name: "Delta") + + response = + delete(conn, "/api/#{site.domain}/segments/#{segment.id}") + |> json_response(200) + + assert %{ + "owner_id" => user.id, + "owner_name" => user.name, + "id" => segment.id, + "name" => segment.name, + "segment_data" => segment.segment_data, + "type" => "#{segment.type}", + "inserted_at" => Calendar.strftime(segment.inserted_at, "%Y-%m-%dT%H:%M:%S"), + "updated_at" => Calendar.strftime(segment.updated_at, "%Y-%m-%dT%H:%M:%S") + } == response + + assert Repo.reload(shared_link_with_other_segment) + assert Repo.reload(shared_link_with_no_segment) + + for link <- shared_links_with_segments do + refute Repo.reload(link) + end + end + + test "site owner can delete a site segment owned by someone else, even if it contains a non-existing goal", + %{conn: conn, site: site} do + other_user = add_guest(site, role: :editor) + + segment = + insert(:segment, + site: site, + owner: other_user, + name: "any", + type: :site, + segment_data: %{"filters" => [["is", "event:goal", ["non-existing goal"]]]} + ) + + response = + delete(conn, "/api/#{site.domain}/segments/#{segment.id}") + |> json_response(200) + + assert %{ + "owner_name" => other_user.name, + "owner_id" => other_user.id, + "id" => segment.id, + "name" => segment.name, + "segment_data" => segment.segment_data, + "type" => "site", + "inserted_at" => Calendar.strftime(segment.inserted_at, "%Y-%m-%dT%H:%M:%S"), + "updated_at" => Calendar.strftime(segment.updated_at, "%Y-%m-%dT%H:%M:%S") + } == response + + verify_no_segment_in_db(segment) + end + end + + describe "GET /api/:domain/segments/:segment_id/shared-links" do + setup [:create_user, :create_site, :log_in] + + for %{role: role} <- [ + %{role: :viewer}, + %{role: :editor}, + %{role: :admin}, + %{role: :billing}, + %{role: :owner} + ] do + test "#{role} can get related shared links for a segment", %{ + conn: conn, + site: site + } do + user = add_member(site.team, role: unquote(role)) + + segment = insert(:segment, site: site, name: "Test Segment", type: :site, owner: user) + + insert(:shared_link, site: site, name: "Shared Link 1", segment_id: segment.id) + insert(:shared_link, site: site, name: "Shared Link 2", segment_id: segment.id) + + # Create a shared link for a different segment to ensure filtering works + other_segment = insert(:segment, site: site, name: "Other Segment", type: :personal) + insert(:shared_link, site: site, name: "Other Shared Link", segment_id: other_segment.id) + + # Create a shared link with no segment + insert(:shared_link, site: site, name: "No Segment Link", segment_id: nil) + + response = + get(conn, "/api/#{site.domain}/segments/#{segment.id}/shared-links") + |> json_response(200) + + assert response == ["Shared Link 1", "Shared Link 2"] + end + end + + test "returns empty list when no shared links are related to the segment", %{ + conn: conn, + user: user, + site: site + } do + segment = insert(:segment, site: site, name: "Test Segment", type: :personal, owner: user) + + response = + get(conn, "/api/#{site.domain}/segments/#{segment.id}/shared-links") + |> json_response(200) + + assert response == [] + end + + test "returns empty list when giving a segment ID that doesn't exist", %{ + conn: conn, + site: site + } do + response = + get(conn, "/api/#{site.domain}/segments/9999999/shared-links") + |> json_response(200) + + assert response == [] + end + + test "returns empty list when requesting with the segment_id of another site", %{ + conn: conn, + site: site + } do + other_site = new_site() + other_site_segment = insert(:segment, site: other_site, name: "Test Segment") + + insert(:shared_link, + site: other_site, + name: "Link", + segment_id: other_site_segment.id + ) + + response = + get(conn, "/api/#{site.domain}/segments/#{other_site_segment.id}/shared-links") + |> json_response(200) + + assert response == [] + end + + test "returns 404 for invalid segment_id", %{conn: conn, site: site} do + conn = get(conn, "/api/#{site.domain}/segments/invalid/shared-links") + + assert %{"error" => "Segment not found with ID \"invalid\""} == + json_response(conn, 404) + end + + test "returns 403 when looking at a public site", %{conn: conn} do + public_site = new_site(public: true) + segment = insert(:segment, site: public_site, name: "segment", type: :site) + + conn = + get(conn, "/api/#{public_site.domain}/segments/#{segment.id}/shared-links") + + assert %{"error" => "Not enough permissions to get related shared links"} == + json_response(conn, 403) + end + end + + defp verify_segment_in_db(segment) do + uncomparable_keys = [:__meta__, :site] + + assert Map.drop(Repo.get(Plausible.Segments.Segment, segment.id), uncomparable_keys) == + Map.drop(segment, uncomparable_keys) + end + + defp verify_no_segment_in_db(segment) do + assert Repo.get(Plausible.Segments.Segment, segment.id) == nil + end +end diff --git a/test/plausible_web/controllers/api/internal_controller/sync_test.exs b/test/plausible_web/controllers/api/internal_controller/sync_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..66445f1f4875c4ae54a274ab7aad021ed641c044 --- /dev/null +++ b/test/plausible_web/controllers/api/internal_controller/sync_test.exs @@ -0,0 +1,19 @@ +defmodule PlausibleWeb.Api.InternalController.SyncTest do + use PlausibleWeb.ConnCase, async: false + use Plausible.Repo + + describe "PUT /api/:domain/disable-feature" do + setup [:create_user, :log_in] + + @tag :ee_only + test "when the logged-in user is an super-admin", %{conn: conn, user: user} do + site = new_site() + patch_env(:super_admin_user_ids, [user.id]) + + conn = put(conn, "/api/#{site.domain}/disable-feature", %{"feature" => "conversions"}) + + assert json_response(conn, 200) == "ok" + assert %{conversions_enabled: false} = Plausible.Sites.get_by_domain(site.domain) + end + end +end diff --git a/test/plausible_web/controllers/api/internal_controller_test.exs b/test/plausible_web/controllers/api/internal_controller_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..1039882381e399b9ee2083677188d4acdbc75cb4 --- /dev/null +++ b/test/plausible_web/controllers/api/internal_controller_test.exs @@ -0,0 +1,145 @@ +defmodule PlausibleWeb.Api.InternalControllerTest do + use PlausibleWeb.ConnCase, async: true + use Plausible.Repo + + describe "GET /api/sites" do + setup [:create_user, :log_in] + + test "returns a list of site domains for the current user", %{conn: conn, user: user} do + site = new_site(owner: user) + site2 = new_site(owner: user) + conn = get(conn, "/api/sites") + + %{"data" => sites} = json_response(conn, 200) + + assert %{"domain" => site.domain} in sites + assert %{"domain" => site2.domain} in sites + end + + test "returns a list of max 9 site domains for the current user, putting pinned first", %{ + conn: conn, + user: user + } do + inserted = + for i <- 1..10 do + i = to_string(i) + new_site(owner: user, domain: "site#{String.pad_leading(i, 2, "0")}.example.com") + end + + _rogue = new_site(domain: "site00.example.com") + + inviter = new_user() + site = new_site(owner: inviter, domain: "friend.example.com") + invite_guest(site, user, inviter: inviter, role: :viewer) + invite_guest(List.first(inserted), user, inviter: inviter, role: :viewer) + + {:ok, _} = + Plausible.Sites.toggle_pin(user, Plausible.Sites.get_by_domain!("site07.example.com")) + + {:ok, _} = + Plausible.Sites.toggle_pin(user, Plausible.Sites.get_by_domain!("site05.example.com")) + + conn = get(conn, "/api/sites") + + %{"data" => sites} = + json_response(conn, 200) + + assert Enum.count(sites) == 9 + + assert [ + %{"domain" => "site05.example.com"}, + %{"domain" => "site07.example.com"}, + %{"domain" => "site01.example.com"} | _ + ] = sites + + assert %{"domain" => "site09.example.com"} in sites + refute %{"domain" => "sites10.example.com"} in sites + end + end + + describe "GET /api/sites - user not logged in" do + test "returns 401 unauthorized", %{conn: conn} do + conn = get(conn, "/api/sites") + + assert json_response(conn, 401) == %{ + "error" => "You need to be logged in to request a list of sites" + } + end + end + + describe "PUT /api/:domain/disable-feature" do + setup [:create_user, :log_in] + + test "when the logged-in user is an admin of the site", %{conn: conn, user: user} do + site = new_site() + add_guest(site, user: user, role: :editor) + + conn = put(conn, "/api/#{site.domain}/disable-feature", %{"feature" => "conversions"}) + + assert json_response(conn, 200) == "ok" + assert %{conversions_enabled: false} = Plausible.Sites.get_by_domain(site.domain) + end + + test "can disable conversions, funnels, and props with admin access", %{ + conn: conn, + user: user + } do + site = new_site() + add_guest(site, user: user, role: :editor) + + put(conn, "/api/#{site.domain}/disable-feature", %{"feature" => "conversions"}) + put(conn, "/api/#{site.domain}/disable-feature", %{"feature" => "funnels"}) + put(conn, "/api/#{site.domain}/disable-feature", %{"feature" => "props"}) + + assert %{conversions_enabled: false, funnels_enabled: false, props_enabled: false} = + Plausible.Sites.get_by_domain(site.domain) + end + + test "when the logged-in user is an owner of the site", %{conn: conn, user: user} do + site = new_site(owner: user) + conn = put(conn, "/api/#{site.domain}/disable-feature", %{"feature" => "conversions"}) + + assert json_response(conn, 200) == "ok" + assert %{conversions_enabled: false} = Plausible.Sites.get_by_domain(site.domain) + end + + test "returns 401 when the logged-in user is a viewer of the site", %{conn: conn, user: user} do + site = new_site() + add_guest(site, user: user, role: :viewer) + + conn = put(conn, "/api/#{site.domain}/disable-feature", %{"feature" => "conversions"}) + + assert json_response(conn, 401) == %{ + "error" => "You need to be logged in as the owner or admin account of this site" + } + + assert %{conversions_enabled: true} = Plausible.Sites.get_by_domain(site.domain) + end + + test "returns 401 when the logged-in user doesn't have site access at all", %{conn: conn} do + site = new_site() + + conn = put(conn, "/api/#{site.domain}/disable-feature", %{"feature" => "conversions"}) + + assert json_response(conn, 401) == %{ + "error" => "You need to be logged in as the owner or admin account of this site" + } + + assert %{conversions_enabled: true} = Plausible.Sites.get_by_domain(site.domain) + end + end + + describe "PUT /api/:domain/disable-feature - user not logged in" do + test "returns 401 unauthorized", %{conn: conn} do + site = insert(:site) + + conn = put(conn, "/api/#{site.domain}/disable-feature", %{"feature" => "conversions"}) + + assert json_response(conn, 401) == %{ + "error" => "You need to be logged in as the owner or admin account of this site" + } + + assert %{conversions_enabled: true} = Plausible.Sites.get_by_domain(site.domain) + end + end +end diff --git a/test/plausible_web/controllers/api/paddle_controller_test.exs b/test/plausible_web/controllers/api/paddle_controller_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..f2bd0e35c9fc78ceab9ba36fd399f327124a4f75 --- /dev/null +++ b/test/plausible_web/controllers/api/paddle_controller_test.exs @@ -0,0 +1,147 @@ +defmodule PlausibleWeb.Api.PaddleControllerTest do + use PlausibleWeb.ConnCase, async: true + use Plausible.Repo + + import Mox + setup :verify_on_exit! + + @webhook_body %{ + "alert_id" => "16173800", + "alert_name" => "subscription_created", + "cancel_url" => + "https://checkout.paddle.com/subscription/cancel?user=1032746&subscription=1869424&hash=eyJpdiI6Ik5XSnhkT1k2RSticStZcEVYZ2FEeTVZTHVSaTNpcFM4XC9aMFJNSjh6TFNrPSIsInZhbHVlIjoiWTMzd3dENGQySTc0UjVPM0U3dzVDUWlBMEQ3dGVzM2lyODRPczBVcjdBRXlncmQzRUVmQjhnZVhpTzJIRjFtNW41MmZOVmJNVUJ4empmeXgyUno1Z3c9PSIsIm1hYyI6ImFjNDMyNDIxNmNmMWNiNmViMmFlNGVkMzQ3ZjYyYTQ5ZWI2YTEyMDQ4YjFhNTEyMjAxNzNlNjEwOWU4OTVhOWMifQ%3D%3D", + "checkout_id" => "38111668-chre6449b9c3cc8-f473e48a86", + "currency" => "USD", + "email" => "josh@joshuae.com", + "event_time" => "2019-08-20 21:44:54", + "marketing_consent" => 0, + "next_bill_date" => "2019-09-20", + "passthrough" => "235", + "quantity" => "1", + "status" => "active", + "subscription_id" => "1869424", + "subscription_plan_id" => "558018", + "unit_price" => "6.00", + "update_url" => + "https://checkout.paddle.com/subscription/update?user=1032746&subscription=1869424&hash=eyJpdiI6IkNUS2VZQlRxcFA5MVlEXC9Oa1ZwRDBNaGN4VVwvaU1RU2srWXc0bU1tQndyTT0iLCJ2YWx1ZSI6ImtTeU1ESkxWcEVrTDFmKzkyZ0FaSFo2Q0VNK3A2XC9NdEU4S2tGVFE2blJicGxBQzZ1XC9mMG1PcUo3MWV2OGY4YURjT1UxY2hpeHh5SFhlMmhXaFpoalE9PSIsIm1hYyI6ImZkNGU5MTg3YzQxZWYxNDJjNDkyMWFkYmZhZjIyMGQ3ZGI2YTVmMTcxZGViY2VkNzI0ZjNlMDRkZTgwNTEwMzUifQ%3D%3D", + "user_id" => "1032746", + "p_signature" => + "qfqKA3dI9d60uie9IORcvkHYV+rd1UaCu/f5kh4miTkeIQNimgusQG8pS1OHobCvN/OktwKCjFcbIwoa4nakOOWGroHJ8FjLJHBK4g1uI37Bp6l73dNl8mB4dNGW1M+atkz7ag6pETRIdEKCmC5tV9afN5CvbcqRV1lsj/x2fAsjAe/sQkmAP1jbDXOMEuHqkWssSB7Q+NGHHLHuNQ67m7YFBnZSgYzLeLMEApkZClJn0j6MokUVjW37ISn5eA5FlUbT7s6Kph54roRzLIpYvC+ff/n6ae2Iu1OsORxRBg4Uv8dqqjqBKXlv84/OB80U89yMIbRw/pbHD6+zF4FxgNV7nk2bjgK2V6h55AOuhJHHUMb4XX9R8i8iG1FOlNJaTwbhkIkvQF3q7nEItKCqizn+l4tFQ9MUcrjw8jytDznbOnSlmNhtcDVlnvXNDaSPkEA7AyR6c+BiZV/Y6I3y8sr8h/F/cBM3OPTwfdKK34jyWW4LRn15nSxq2kjH3SyLPEpTJUMdcRGAgBZc06E4lENU2x22E/JKG5BRi1aDs5OFQtrjYi2hOTI0dyPF3OLNeZcCgBCKBmKq5XIf1T0RPFWAWtKkzXhl/QH+4feNATb9/i6k5xKeUJf0ltWzsI5x84kvsC/m05hn/AuBDmcZGkVnDLXrqttR+zDXY6P1euE=" + } + + describe "webhook verification" do + test "is verified when signature is correct", %{conn: conn} do + insert(:user, id: 235) + + # NOTE: signature check happens sooner + assert_raise RuntimeError, ~r/Invalid passthrough sent via Paddle/, fn -> + post(conn, Routes.paddle_path(conn, :webhook), @webhook_body) + end + end + + test "not verified when signature is corrupted", %{conn: conn} do + corrupted = Map.put(@webhook_body, "p_signature", Base.encode64("123 fake signature")) + conn = post(conn, Routes.paddle_path(conn, :webhook), corrupted) + assert conn.status == 400 + end + end + + describe "fetching currency" do + test "retrieves successfully", %{conn: conn} do + expect_get_prices_response(get_prices_body("USD")) + + conn = get(conn, Routes.paddle_path(conn, :currency)) + assert_receive :paddle_queried + assert json_response(conn, 200) == %{"currency" => "$"} + end + + test "caches per ip", %{conn: initial_conn} do + expect_get_prices_response(get_prices_body("USD")) + + conn = get(initial_conn, Routes.paddle_path(initial_conn, :currency)) + assert json_response(conn, 200) == %{"currency" => "$"} + assert_receive :paddle_queried + + expect_get_prices_response(get_prices_body("GBP")) + + conn = get(initial_conn, Routes.paddle_path(initial_conn, :currency)) + assert json_response(conn, 200) == %{"currency" => "$"} + refute_receive :paddle_queried + + new_ip = + Plug.Conn.put_req_header(initial_conn, "x-forwarded-for", random_ip()) + + conn = get(new_ip, Routes.paddle_path(initial_conn, :currency)) + assert json_response(conn, 200) == %{"currency" => "£"} + assert_receive :paddle_queried + end + + test "falls back to EUR when paddle fails to respond", %{conn: conn} do + expect_get_prices_response(%{"response" => %{}}) + + conn = get(conn, Routes.paddle_path(conn, :currency)) + assert_receive :paddle_queried + assert json_response(conn, 200) == %{"currency" => "€"} + end + + test "does not cache failed fetches", %{conn: initial_conn} do + expect_get_prices_response(%{"response" => %{}}) + + conn = get(initial_conn, Routes.paddle_path(initial_conn, :currency)) + assert json_response(conn, 200) == %{"currency" => "€"} + + expect_get_prices_response(get_prices_body("USD")) + + conn = get(initial_conn, Routes.paddle_path(initial_conn, :currency)) + assert json_response(conn, 200) == %{"currency" => "$"} + end + + defp expect_get_prices_response(body) do + test = self() + + expect( + Plausible.HTTPClient.Mock, + :get, + fn "https://checkout.paddle.com/api/2.0/prices" <> query, _, _ -> + assert query =~ "product_ids=857097" + + send(test, :paddle_queried) + + {:ok, + %Finch.Response{ + status: 200, + headers: [{"content-type", "application/json"}], + body: body + }} + end + ) + end + + defp get_prices_body(currency) do + %{ + "response" => %{ + "customer_country" => "PL", + "products" => [ + %{ + "applied_coupon" => [], + "currency" => currency, + "list_price" => %{"gross" => 49.0, "net" => 49.0, "tax" => 0.0}, + "price" => %{"gross" => 49.0, "net" => 49.0, "tax" => 0.0}, + "product_id" => 857_097, + "product_title" => "random", + "subscription" => %{ + "frequency" => 1, + "interval" => "month", + "list_price" => %{"gross" => 49.0, "net" => 49.0, "tax" => 0.0}, + "price" => %{"gross" => 49.0, "net" => 49.0, "tax" => 0.0}, + "trial_days" => 0 + }, + "vendor_set_prices_included_tax" => false + } + ] + }, + "success" => true + } + end + end +end diff --git a/test/plausible_web/controllers/api/stats_controller/authorization_test.exs b/test/plausible_web/controllers/api/stats_controller/authorization_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..1f3d6406852cf19b6b500f53eb3ecefca1418de7 --- /dev/null +++ b/test/plausible_web/controllers/api/stats_controller/authorization_test.exs @@ -0,0 +1,224 @@ +defmodule PlausibleWeb.Api.StatsController.AuthorizationTest do + use PlausibleWeb.ConnCase, async: true + + defp query_visitors(conn, site, params) do + params = + [date_range: "day", metrics: ["visitors"], filters: []] + |> Keyword.merge(params) + + post(conn, Routes.stats_path(conn, :query, site.domain), params) + end + + describe "API authorization - as anonymous user" do + test "returns 404 for a site that doesn't exist", %{conn: conn} do + conn = init_session(conn) + + conn = + post(conn, "/api/stats/fake-site.com/query", %{}) + + assert json_response(conn, 404) == %{ + "error" => "Site does not exist or user does not have sufficient access." + } + end + + test "returns 404 for private site", %{conn: conn} do + conn = init_session(conn) + site = insert(:site, public: false) + + conn = + post(conn, "/api/stats/#{site.domain}/query", %{}) + + assert json_response(conn, 404) == %{ + "error" => "Site does not exist or user does not have sufficient access." + } + end + + test "returns stats for public site", %{conn: conn} do + conn = init_session(conn) + site = insert(:site, public: true) + + conn = + query_visitors(conn, site, []) + + assert %{"results" => _} = json_response(conn, 200) + end + end + + describe "API authorization for shared links - as anonymous user" do + test "returns 404 for non-existent shared link", %{conn: conn} do + site = new_site() + + conn = query_visitors(conn, site, auth: "does-not-exist") + + assert json_response(conn, 404) == %{ + "error" => "Site does not exist or user does not have sufficient access." + } + end + + test "returns 200 for unlisted shared link without cookie", %{conn: conn} do + site = new_site() + link = insert(:shared_link, site: site) + + conn = query_visitors(conn, site, auth: link.slug) + + assert %{"results" => _any} = json_response(conn, 200) + end + + test "returns 200 for password-protected link with valid cookie", %{conn: conn} do + site = new_site() + + link = + insert(:shared_link, site: site, password_hash: Plausible.Auth.Password.hash("password")) + + token = Plausible.Auth.Token.sign_shared_link(link.slug) + cookie_name = "shared-link-" <> link.slug + + conn = + conn + |> put_req_cookie(cookie_name, token) + |> query_visitors(site, auth: link.slug) + + assert %{"results" => _any} = json_response(conn, 200) + end + + test "returns 404 for password-protected link with invalid cookie value", %{conn: conn} do + site = new_site() + + link = + insert(:shared_link, site: site, password_hash: Plausible.Auth.Password.hash("password")) + + other_link = + insert(:shared_link, + name: "other link", + site: site, + password_hash: Plausible.Auth.Password.hash("password") + ) + + other_link_token = Plausible.Auth.Token.sign_shared_link(other_link.slug) + cookie_name = "shared-link-" <> link.slug + + conn = + conn + |> put_req_cookie(cookie_name, other_link_token) + |> query_visitors(site, auth: link.slug) + + assert json_response(conn, 404) == %{ + "error" => "Site does not exist or user does not have sufficient access." + } + end + + test "returns 404 for password-protected link without cookie", %{conn: conn} do + site = new_site() + + link = + insert(:shared_link, site: site, password_hash: Plausible.Auth.Password.hash("password")) + + conn = query_visitors(conn, site, auth: link.slug) + + assert json_response(conn, 404) == %{ + "error" => "Site does not exist or user does not have sufficient access." + } + end + end + + describe "Filter validation for limited view shared links" do + test "returns 400 for limited view shared link if there are no filters", %{conn: conn} do + site = new_site() + + segment = insert(:segment, site: site, type: :site, name: "Scandinavia") + + link = + insert(:shared_link, site: site, segment: segment) + + conn = query_visitors(conn, site, auth: link.slug) + + assert json_response(conn, 400) == %{ + "error" => "The first filter must be for the segment with id #{segment.id}" + } + end + + test "returns 400 for limited view shared link if the segment filter is not for the right segment", + %{conn: conn} do + site = new_site() + + segment = insert(:segment, site: site, type: :site, name: "Scandinavia") + + link = + insert(:shared_link, site: site, segment: segment) + + conn = + query_visitors(conn, site, + auth: link.slug, + filters: [["is", "segment", [segment.id + 1]]] + ) + + assert json_response(conn, 400) == %{ + "error" => "The first filter must be for the segment with id #{segment.id}" + } + end + + test "returns 200 for limited view shared link if the required segment is present, permits other filters to be applied", + %{conn: conn} do + site = new_site() + + segment = insert(:segment, site: site, type: :site, name: "Scandinavia") + + link = + insert(:shared_link, site: site, segment: segment) + + conn = + query_visitors(conn, site, + auth: link.slug, + filters: [ + ["is", "segment", [segment.id]], + ["is", "event:page", ["/docs"]] + ] + ) + + assert json_response(conn, 200) + end + end + + describe "API authorization - as logged in user" do + setup [:create_user, :log_in] + + test "returns 404 for a site that doesn't exist", %{conn: conn} do + conn = init_session(conn) + + conn = + post(conn, "/api/stats/fake-site.com/query", %{}) + + assert json_response(conn, 404) == %{ + "error" => "Site does not exist or user does not have sufficient access." + } + end + + test "returns 404 when user does not have access to site", %{conn: conn} do + site = new_site() + + conn = + post(conn, "/api/stats/#{site.domain}/query", %{}) + + assert json_response(conn, 404) == %{ + "error" => "Site does not exist or user does not have sufficient access." + } + end + + test "returns stats for public site", %{conn: conn} do + site = new_site(public: true) + + conn = + query_visitors(conn, site, []) + + assert %{"results" => _} = json_response(conn, 200) + end + + test "returns stats for a private site that the user owns", %{conn: conn, user: user} do + site = new_site(public: false, owner: user) + + conn = query_visitors(conn, site, []) + + assert %{"results" => _} = json_response(conn, 200) + end + end +end diff --git a/test/plausible_web/controllers/api/stats_controller/browsers_test.exs b/test/plausible_web/controllers/api/stats_controller/browsers_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..e5da814a9fb828d2d949285c4ad49560a7821afb --- /dev/null +++ b/test/plausible_web/controllers/api/stats_controller/browsers_test.exs @@ -0,0 +1,567 @@ +defmodule PlausibleWeb.Api.StatsController.BrowsersTest do + use PlausibleWeb.ConnCase + + defp query_browsers(conn, site, opts) do + params = %{ + "dimensions" => Keyword.get(opts, :dimensions, ["visit:browser"]), + "date_range" => Keyword.get(opts, :date_range, "all"), + "filters" => Keyword.get(opts, :filters, []), + "metrics" => Keyword.get(opts, :metrics, ["visitors", "percentage"]), + "include" => Keyword.get(opts, :include, nil), + "pagination" => Keyword.get(opts, :pagination, nil), + "order_by" => Keyword.get(opts, :order_by, nil) + } + + conn + |> post("/api/stats/#{site.domain}/query", params) + |> json_response(200) + end + + describe "GET /api/stats/:domain/browsers" do + setup [:create_user, :log_in, :create_site, :create_site_import] + + test "returns top browsers by unique visitors", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, browser: "Chrome"), + build(:pageview, browser: "Chrome"), + build(:pageview, browser: "Firefox") + ]) + + response = query_browsers(conn, site, date_range: "day") + + assert response["results"] == [ + %{"dimensions" => ["Chrome"], "metrics" => [2, 66.67]}, + %{"dimensions" => ["Firefox"], "metrics" => [1, 33.33]} + ] + end + + test "returns top browsers with :is filter on custom pageview props", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, + user_id: 123, + browser: "Chrome" + ), + build(:pageview, + user_id: 123, + browser: "Chrome", + "meta.key": ["author"], + "meta.value": ["John Doe"] + ), + build(:pageview, + browser: "Firefox", + "meta.key": ["author"], + "meta.value": ["other"] + ), + build(:pageview, + browser: "Safari" + ) + ]) + + response = + query_browsers(conn, site, + date_range: "day", + filters: [["is", "event:props:author", ["John Doe"]]] + ) + + assert response["results"] == [ + %{"dimensions" => ["Chrome"], "metrics" => [1, 100.0]} + ] + end + + test "returns top browsers with :is_not filter on custom pageview props", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, + user_id: 123, + browser: "Chrome", + "meta.key": ["author"], + "meta.value": ["John Doe"] + ), + build(:pageview, + user_id: 123, + browser: "Chrome", + "meta.key": ["author"], + "meta.value": ["John Doe"] + ), + build(:pageview, + browser: "Firefox", + "meta.key": ["author"], + "meta.value": ["other"] + ), + build(:pageview, + browser: "Safari" + ) + ]) + + response = + query_browsers(conn, site, + date_range: "day", + filters: [["is_not", "event:props:author", ["John Doe"]]], + order_by: [["visit:browser", "asc"]] + ) + + assert response["results"] == [ + %{"dimensions" => ["Firefox"], "metrics" => [1, 50.0]}, + %{"dimensions" => ["Safari"], "metrics" => [1, 50.0]} + ] + end + + test "calculates conversion_rate when filtering for goal", %{conn: conn, site: site} do + insert(:goal, site: site, event_name: "Signup") + + populate_stats(site, [ + build(:pageview, user_id: 1, browser: "Chrome"), + build(:pageview, user_id: 2, browser: "Chrome"), + build(:event, user_id: 1, name: "Signup") + ]) + + response = + query_browsers(conn, site, + date_range: "day", + filters: [["is", "event:goal", ["Signup"]]], + metrics: ["visitors", "total_visitors", "group_conversion_rate"] + ) + + assert response["results"] == [ + %{"dimensions" => ["Chrome"], "metrics" => [1, 2, 50.0]} + ] + end + + test "returns top browsers including imported data", %{ + conn: conn, + site: site, + site_import: site_import + } do + populate_stats(site, site_import.id, [ + build(:pageview, browser: "Chrome"), + build(:imported_browsers, browser: "Chrome"), + build(:imported_browsers, browser: "Firefox"), + build(:imported_visitors, visitors: 2) + ]) + + response1 = query_browsers(conn, site, date_range: "day") + + assert response1["results"] == [ + %{"dimensions" => ["Chrome"], "metrics" => [1, 100.0]} + ] + + response2 = + query_browsers(conn, site, date_range: "day", include: %{"imports" => true}) + + assert response2["results"] == [ + %{"dimensions" => ["Chrome"], "metrics" => [2, 66.67]}, + %{"dimensions" => ["Firefox"], "metrics" => [1, 33.33]} + ] + end + + test "skips breakdown when visitors=0 (possibly due to 'Enable Users Metric' in GA)", %{ + conn: conn, + site: site, + site_import: site_import + } do + populate_stats(site, site_import.id, [ + build(:imported_browsers, browser: "Chrome", visitors: 0, visits: 14), + build(:imported_browsers, browser: "Firefox", visitors: 0, visits: 14), + build(:imported_browsers, + browser: "''", + visitors: 0, + visits: 14, + visit_duration: 0, + bounces: 14 + ) + ]) + + response = + query_browsers(conn, site, date_range: "day", include: %{"imports" => true}) + + assert response["results"] == [] + end + + test "returns (not set) when appropriate", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + user_id: 123, + browser: "" + ) + ]) + + response = query_browsers(conn, site, date_range: "day") + + assert response["results"] == [ + %{"dimensions" => ["(not set)"], "metrics" => [1, 100.0]} + ] + end + + test "select empty imported_browsers as (not set), merging with the native (not set)", %{ + conn: conn, + site: site, + site_import: site_import + } do + populate_stats(site, site_import.id, [ + build(:pageview, user_id: 123), + build(:imported_browsers, visitors: 1), + build(:imported_visitors, visitors: 1) + ]) + + response = + query_browsers(conn, site, date_range: "day", include: %{"imports" => true}) + + assert response["results"] == [ + %{"dimensions" => ["(not set)"], "metrics" => [2, 100.0]} + ] + end + + test "returns comparisons", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, browser: "Firefox", timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, browser: "Safari", timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, browser: "Chrome", timestamp: ~N[2021-01-07 00:00:00]), + build(:pageview, browser: "Chrome", timestamp: ~N[2021-01-07 00:00:00]), + build(:pageview, browser: "Firefox", timestamp: ~N[2021-01-07 00:00:00]) + ]) + + response = + query_browsers(conn, site, + date_range: ["2021-01-07", "2021-01-13"], + include: %{"compare" => "previous_period"} + ) + + assert response["results"] == [ + %{ + "dimensions" => ["Chrome"], + "metrics" => [2, 66.67], + "comparison" => %{ + "dimensions" => ["Chrome"], + "metrics" => [0, 0.0], + "change" => [100, 100] + } + }, + %{ + "dimensions" => ["Firefox"], + "metrics" => [1, 33.33], + "comparison" => %{ + "dimensions" => ["Firefox"], + "metrics" => [1, 50.0], + "change" => [0, -33] + } + } + ] + + assert response["query"]["date_range"] == [ + "2021-01-07T00:00:00Z", + "2021-01-13T23:59:59Z" + ] + + assert response["query"]["comparison_date_range"] == [ + "2020-12-31T00:00:00Z", + "2021-01-06T23:59:59Z" + ] + end + + test "returns comparisons with limit", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, browser: "Firefox", timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, browser: "Firefox", timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, browser: "Firefox", timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, browser: "Chrome", timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, browser: "Chrome", timestamp: ~N[2021-01-07 00:00:00]), + build(:pageview, browser: "Chrome", timestamp: ~N[2021-01-07 00:00:00]), + build(:pageview, browser: "Firefox", timestamp: ~N[2021-01-07 00:00:00]) + ]) + + response = + query_browsers(conn, site, + date_range: ["2021-01-06", "2021-01-12"], + include: %{"compare" => "previous_period"}, + pagination: %{"limit" => 1} + ) + + assert response["results"] == [ + %{ + "dimensions" => ["Chrome"], + "metrics" => [2, 66.67], + "comparison" => %{ + "dimensions" => ["Chrome"], + "metrics" => [1, 25.0], + "change" => [100, 167] + } + } + ] + + assert response["query"]["date_range"] == [ + "2021-01-06T00:00:00Z", + "2021-01-12T23:59:59Z" + ] + + assert response["query"]["comparison_date_range"] == [ + "2020-12-30T00:00:00Z", + "2021-01-05T23:59:59Z" + ] + end + + @tag :ee_only + test "return revenue metrics for browsers breakdown", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, user_id: 1, browser: "Firefox"), + build(:event, + name: "Payment", + user_id: 1, + revenue_reporting_amount: Decimal.new("1000"), + revenue_reporting_currency: "USD" + ), + build(:pageview, user_id: 2, browser: "Firefox"), + build(:event, + name: "Payment", + user_id: 2, + revenue_reporting_amount: Decimal.new("2000"), + revenue_reporting_currency: "USD" + ), + build(:pageview, user_id: 3, browser: "Firefox"), + build(:pageview, user_id: 4, browser: "Safari"), + build(:event, + name: "Payment", + user_id: 4, + revenue_reporting_amount: Decimal.new("500"), + revenue_reporting_currency: "USD" + ), + build(:pageview, user_id: 5, browser: "Safari"), + build(:pageview, user_id: 6), + build(:event, + name: "Payment", + user_id: 6, + revenue_reporting_amount: Decimal.new("600"), + revenue_reporting_currency: "USD" + ), + build(:pageview, user_id: 7), + build(:event, + name: "Payment", + user_id: 7, + revenue_reporting_amount: nil + ) + ]) + + insert(:goal, %{site: site, event_name: "Payment", currency: :USD}) + + response = + query_browsers(conn, site, + date_range: "day", + filters: [["is", "event:goal", ["Payment"]]], + metrics: [ + "visitors", + "total_visitors", + "group_conversion_rate", + "average_revenue", + "total_revenue" + ], + order_by: [["visitors", "desc"], ["visit:browser", "asc"]] + ) + + assert response["results"] == [ + %{ + "dimensions" => ["(not set)"], + "metrics" => [ + 2, + 2, + 100.0, + %{ + "currency" => "USD", + "long" => "$600.00", + "short" => "$600.0", + "value" => 600.0 + }, + %{ + "currency" => "USD", + "long" => "$600.00", + "short" => "$600.0", + "value" => 600.0 + } + ] + }, + %{ + "dimensions" => ["Firefox"], + "metrics" => [ + 2, + 3, + 66.67, + %{ + "currency" => "USD", + "long" => "$1,500.00", + "short" => "$1.5K", + "value" => 1500.0 + }, + %{ + "currency" => "USD", + "long" => "$3,000.00", + "short" => "$3.0K", + "value" => 3000.0 + } + ] + }, + %{ + "dimensions" => ["Safari"], + "metrics" => [ + 1, + 2, + 50.0, + %{ + "currency" => "USD", + "long" => "$500.00", + "short" => "$500.0", + "value" => 500.0 + }, + %{ + "currency" => "USD", + "long" => "$500.00", + "short" => "$500.0", + "value" => 500.0 + } + ] + } + ] + end + end + + describe "GET /api/stats/:domain/browser-versions" do + setup [:create_user, :log_in, :create_site, :create_legacy_site_import] + + test "returns correct conversion_rate when browser_version clashes across browsers", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:event, browser: "Chrome", browser_version: "110", name: "Signup"), + build(:event, browser: "Chrome", browser_version: "110", name: "Signup"), + build(:pageview, browser: "Chrome", browser_version: "110"), + build(:pageview, browser: "Chrome", browser_version: "121"), + build(:pageview, browser: "Chrome", browser_version: "121"), + build(:event, browser: "Firefox", browser_version: "121", name: "Signup"), + build(:pageview, browser: "Firefox", browser_version: "110"), + build(:pageview, browser: "Firefox", browser_version: "110"), + build(:pageview, browser: "Firefox", browser_version: "110"), + build(:pageview, browser: "Firefox", browser_version: "110") + ]) + + insert(:goal, site: site, event_name: "Signup") + + response = + query_browsers(conn, site, + date_range: "day", + dimensions: ["visit:browser", "visit:browser_version"], + filters: [["is", "event:goal", ["Signup"]]], + metrics: ["visitors", "total_visitors", "group_conversion_rate"] + ) + + assert List.first(response["results"]) == %{ + "dimensions" => ["Chrome", "110"], + "metrics" => [2, 3, 66.67] + } + + assert List.last(response["results"]) == %{ + "dimensions" => ["Firefox", "121"], + "metrics" => [1, 1, 100.0] + } + end + + test "returns top browser versions by unique visitors", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, browser: "Chrome", browser_version: "78.0"), + build(:pageview, browser: "Chrome", browser_version: "78.0"), + build(:pageview, browser: "Chrome", browser_version: "77.0"), + build(:pageview, browser: "Firefox", browser_version: "88.0") + ]) + + response = + query_browsers(conn, site, + date_range: "day", + dimensions: ["visit:browser", "visit:browser_version"], + filters: [["is", "visit:browser", ["Chrome"]]] + ) + + assert response["results"] == [ + %{"dimensions" => ["Chrome", "78.0"], "metrics" => [2, 66.67]}, + %{"dimensions" => ["Chrome", "77.0"], "metrics" => [1, 33.33]} + ] + end + + test "returns browser and version with additional metrics", %{conn: conn, site: site} do + populate_stats(site, [build(:pageview, browser: "Chrome", browser_version: "78.0")]) + + response = + query_browsers(conn, site, + date_range: "day", + dimensions: ["visit:browser", "visit:browser_version"], + filters: [["is", "visit:browser", ["Chrome"]]], + metrics: ["visitors", "bounce_rate", "visit_duration", "percentage"] + ) + + assert response["results"] == [ + %{"dimensions" => ["Chrome", "78.0"], "metrics" => [1, 100, 0, 100.0]} + ] + end + + test "returns results for (not set)", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, browser: "", browser_version: "") + ]) + + response = + query_browsers(conn, site, + date_range: "day", + dimensions: ["visit:browser", "visit:browser_version"], + filters: [["is", "visit:browser", ["(not set)"]]] + ) + + assert response["results"] == [ + %{"dimensions" => ["(not set)", "(not set)"], "metrics" => [1, 100.0]} + ] + end + + test "with imported data", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + browser: "Chrome", + browser_version: "121", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + browser: "Chrome", + browser_version: "110", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:imported_browsers, + date: ~D[2021-01-01], + browser: "Chrome", + browser_version: "121", + visitors: 5 + ), + build(:imported_browsers, + date: ~D[2021-01-01], + browser: "Firefox", + browser_version: "121", + visitors: 3 + ), + build(:imported_browsers, date: ~D[2021-01-01], visitors: 10), + build(:imported_visitors, date: ~D[2021-01-01], visitors: 18) + ]) + + response = + query_browsers(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + dimensions: ["visit:browser", "visit:browser_version"], + include: %{"imports" => true} + ) + + assert response["results"] == [ + %{"dimensions" => ["(not set)", "(not set)"], "metrics" => [10, 50.0]}, + %{"dimensions" => ["Chrome", "121"], "metrics" => [6, 30.0]}, + %{"dimensions" => ["Firefox", "121"], "metrics" => [3, 15.0]}, + %{"dimensions" => ["Chrome", "110"], "metrics" => [1, 5.0]} + ] + end + end +end diff --git a/test/plausible_web/controllers/api/stats_controller/cities_test.exs b/test/plausible_web/controllers/api/stats_controller/cities_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..44e2876737fdd921f4967364aa4b2e7f6a2c6b0d --- /dev/null +++ b/test/plausible_web/controllers/api/stats_controller/cities_test.exs @@ -0,0 +1,224 @@ +defmodule PlausibleWeb.Api.StatsController.CitiesTest do + use PlausibleWeb.ConnCase + + defp query_cities(conn, site, opts) do + always_on_filters = ["is_not", "visit:city", [0]] + + params = %{ + "dimensions" => Keyword.get(opts, :dimensions, ["visit:city", "visit:city_name"]), + "date_range" => Keyword.get(opts, :date_range, "all"), + "filters" => [ + always_on_filters + | Keyword.get(opts, :filters, []) + ], + "metrics" => Keyword.get(opts, :metrics, ["visitors", "percentage"]), + "include" => Keyword.get(opts, :include, nil), + "pagination" => Keyword.get(opts, :pagination, nil), + "order_by" => Keyword.get(opts, :order_by, nil) + } + + conn + |> post("/api/stats/#{site.domain}/query", params) + |> json_response(200) + end + + describe "querying for cities with POST /query" do + defp seed(%{site: site}) do + populate_stats(site, [ + build(:pageview, + country_code: "EE", + subdivision1_code: "EE-37", + city_geoname_id: 588_409 + ), + build(:pageview, + country_code: "EE", + subdivision1_code: "EE-37", + city_geoname_id: 588_409 + ), + build(:pageview, + country_code: "EE", + subdivision1_code: "EE-37", + city_geoname_id: 588_409 + ), + build(:pageview, + country_code: "EE", + subdivision1_code: "EE-39", + city_geoname_id: 591_632 + ), + build(:pageview, + country_code: "EE", + subdivision1_code: "EE-39", + city_geoname_id: 591_632 + ) + ]) + end + + setup [:create_user, :log_in, :create_site, :create_legacy_site_import, :seed] + + test "returns top cities by visitors", %{conn: conn, site: site} do + response = query_cities(conn, site, date_range: "day") + + assert response["results"] == [ + %{"dimensions" => [588_409, "Tallinn"], "metrics" => [3, 60.0]}, + %{"dimensions" => [591_632, "Kärdla"], "metrics" => [2, 40.0]} + ] + end + + test "when list is filtered returns one city only", %{conn: conn, site: site} do + response = + query_cities(conn, site, + date_range: "day", + filters: [["is", "visit:city", [591_632]]] + ) + + assert response["results"] == [ + %{"dimensions" => [591_632, "Kärdla"], "metrics" => [2, 100.0]} + ] + end + + test "does not return missing cities from imported data", %{conn: conn, site: site} do + populate_stats(site, [ + build(:imported_locations, country: "EE", region: "EE-37", city: 588_409), + build(:imported_locations, country: nil, region: nil, city: 0), + build(:imported_locations, country: nil, region: nil, city: nil) + ]) + + response = + query_cities(conn, site, + date_range: "day", + include: %{"imports" => true} + ) + + assert response["results"] == [ + %{"dimensions" => [588_409, "Tallinn"], "metrics" => [4, 66.67]}, + %{"dimensions" => [591_632, "Kärdla"], "metrics" => [2, 33.33]} + ] + end + + @tag :ee_only + test "return revenue metrics for cities breakdown", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + user_id: 1, + country_code: "EE", + subdivision1_code: "EE-37", + city_geoname_id: 588_409 + ), + build(:event, + name: "Payment", + user_id: 1, + revenue_reporting_amount: Decimal.new("1000"), + revenue_reporting_currency: "USD" + ), + build(:pageview, + user_id: 2, + country_code: "EE", + subdivision1_code: "EE-37", + city_geoname_id: 588_409 + ), + build(:event, + name: "Payment", + user_id: 2, + revenue_reporting_amount: Decimal.new("2000"), + revenue_reporting_currency: "USD" + ), + build(:pageview, + user_id: 3, + country_code: "EE", + subdivision1_code: "EE-37", + city_geoname_id: 588_409 + ), + build(:pageview, + user_id: 4, + country_code: "EE", + subdivision1_code: "EE-39", + city_geoname_id: 591_632 + ), + build(:event, + name: "Payment", + user_id: 4, + revenue_reporting_amount: Decimal.new("500"), + revenue_reporting_currency: "USD" + ), + build(:pageview, + user_id: 5, + country_code: "EE", + subdivision1_code: "EE-39", + city_geoname_id: 591_632 + ), + build(:pageview, user_id: 6), + build(:event, + name: "Payment", + user_id: 6, + revenue_reporting_amount: Decimal.new("600"), + revenue_reporting_currency: "USD" + ), + build(:pageview, user_id: 7), + build(:event, + name: "Payment", + user_id: 7, + revenue_reporting_amount: nil + ) + ]) + + insert(:goal, %{site: site, event_name: "Payment", currency: :USD}) + + response = + query_cities(conn, site, + date_range: "day", + filters: [["is", "event:goal", ["Payment"]]], + metrics: [ + "visitors", + "total_visitors", + "group_conversion_rate", + "average_revenue", + "total_revenue" + ], + order_by: [["visitors", "desc"], ["visit:city", "asc"]] + ) + + assert response["results"] == [ + %{ + "dimensions" => [588_409, "Tallinn"], + "metrics" => [ + 2, + 6, + 33.33, + %{ + "currency" => "USD", + "long" => "$1,500.00", + "short" => "$1.5K", + "value" => 1500.0 + }, + %{ + "currency" => "USD", + "long" => "$3,000.00", + "short" => "$3.0K", + "value" => 3000.0 + } + ] + }, + %{ + "dimensions" => [591_632, "Kärdla"], + "metrics" => [ + 1, + 4, + 25.0, + %{ + "currency" => "USD", + "long" => "$500.00", + "short" => "$500.0", + "value" => 500.0 + }, + %{ + "currency" => "USD", + "long" => "$500.00", + "short" => "$500.0", + "value" => 500.0 + } + ] + } + ] + end + end +end diff --git a/test/plausible_web/controllers/api/stats_controller/conversions_test.exs b/test/plausible_web/controllers/api/stats_controller/conversions_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..da1504bfba6956ca3551821fc69b55a2bdca122f --- /dev/null +++ b/test/plausible_web/controllers/api/stats_controller/conversions_test.exs @@ -0,0 +1,1294 @@ +defmodule PlausibleWeb.Api.StatsController.ConversionsTest do + use PlausibleWeb.ConnCase + + @user_id Enum.random(1000..9999) + + defp query_conversions(conn, site, opts) do + params = %{ + "dimensions" => Keyword.get(opts, :dimensions, ["event:goal"]), + "date_range" => Keyword.get(opts, :date_range, "all"), + "filters" => Keyword.get(opts, :filters, []), + "metrics" => Keyword.get(opts, :metrics, ["visitors", "events", "conversion_rate"]), + "include" => Keyword.get(opts, :include, nil), + "order_by" => Keyword.get(opts, :order_by, nil) + } + + conn + |> post("/api/stats/#{site.domain}/query", params) + |> json_response(200) + end + + describe "GET /api/stats/:domain/conversions" do + setup [:create_user, :log_in, :create_site] + + test "returns mixed pageview and custom event goal conversions ordered by count", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, pathname: "/"), + build(:pageview, pathname: "/"), + build(:pageview, pathname: "/register"), + build(:pageview, pathname: "/register"), + build(:event, name: "Signup", "meta.key": ["variant"], "meta.value": ["A"]), + build(:event, + user_id: @user_id, + name: "Signup", + "meta.key": ["variant"], + "meta.value": ["A"] + ), + build(:event, + user_id: @user_id, + name: "Signup", + "meta.key": ["variant"], + "meta.value": ["B"] + ), + build(:event, name: "Signup") + ]) + + insert(:goal, %{site: site, page_path: "/register"}) + insert(:goal, %{site: site, event_name: "Signup"}) + + response = query_conversions(conn, site, date_range: "day") + + assert response["results"] == [ + %{"dimensions" => ["Signup"], "metrics" => [3, 4, 42.86]}, + %{"dimensions" => ["Visit /register"], "metrics" => [2, 2, 28.57]} + ] + end + + test "returns page scroll goals ordered by count", %{conn: conn, site: site} do + populate_stats(site, [ + # user 1: /blog -> /another -> blog/posts/1 + build(:pageview, user_id: 1, pathname: "/blog", timestamp: ~N[2020-01-01 00:00:00]), + build(:engagement, + user_id: 1, + pathname: "/blog", + timestamp: ~N[2020-01-01 00:01:00], + scroll_depth: 20 + ), + build(:pageview, user_id: 1, pathname: "/another", timestamp: ~N[2020-01-01 00:01:00]), + build(:engagement, + user_id: 1, + pathname: "/another", + timestamp: ~N[2020-01-01 00:02:00], + scroll_depth: 100 + ), + build(:pageview, + user_id: 1, + pathname: "/blog/posts/1", + timestamp: ~N[2020-01-01 00:02:00] + ), + build(:engagement, + user_id: 1, + pathname: "/blog/posts/1", + timestamp: ~N[2020-01-01 00:03:00], + scroll_depth: 55 + ), + # user 2: /blog -> /blog/posts/1 -> /blog/posts/2 + build(:pageview, user_id: 2, pathname: "/blog", timestamp: ~N[2020-01-01 00:00:00]), + build(:engagement, + user_id: 2, + pathname: "/blog", + timestamp: ~N[2020-01-01 00:01:00], + scroll_depth: 60 + ), + build(:pageview, + user_id: 2, + pathname: "/blog/posts/1", + timestamp: ~N[2020-01-01 00:02:00] + ), + build(:engagement, + user_id: 2, + pathname: "/blog/posts/1", + timestamp: ~N[2020-01-01 00:03:00], + scroll_depth: 100 + ), + build(:pageview, + user_id: 2, + pathname: "/blog/posts/2", + timestamp: ~N[2020-01-01 00:02:00] + ), + build(:engagement, + user_id: 2, + pathname: "/blog/posts/2", + timestamp: ~N[2020-01-01 00:03:00], + scroll_depth: 100 + ) + ]) + + insert(:goal, %{ + site: site, + page_path: "/blog/**", + scroll_threshold: 50, + display_name: "Scroll 50 /blog/**" + }) + + insert(:goal, %{ + site: site, + page_path: "/blog/posts/1", + scroll_threshold: 75, + display_name: "Scroll 75 /blog/posts/1" + }) + + response = query_conversions(conn, site, date_range: ["2020-01-01", "2020-01-01"]) + + assert response["results"] == [ + %{"dimensions" => ["Scroll 50 /blog/**"], "metrics" => [2, nil, 100.0]}, + %{"dimensions" => ["Scroll 75 /blog/posts/1"], "metrics" => [1, nil, 50.0]} + ] + end + + test "returns conversions when a direct :is filter on event prop", %{conn: conn, site: site} do + populate_stats(site, [ + build(:event, + user_id: @user_id, + name: "Payment", + "meta.key": ["amount", "logged_in"], + "meta.value": ["100", "true"] + ), + build(:event, + user_id: @user_id, + name: "Payment", + "meta.key": ["amount", "logged_in"], + "meta.value": ["500", "true"] + ), + build(:event, + name: "Payment", + "meta.key": ["amount", "logged_in"], + "meta.value": ["100", "false"] + ), + build(:event, + name: "Payment", + "meta.key": ["amount"], + "meta.value": ["200"] + ) + ]) + + insert(:goal, %{site: site, event_name: "Payment"}) + + response = + query_conversions(conn, site, + date_range: "day", + filters: [["is", "event:props:logged_in", ["true"]]] + ) + + assert response["results"] == [ + %{"dimensions" => ["Payment"], "metrics" => [1, 2, 33.33]} + ] + end + + test "returns conversions when a direct :is_not filter on event prop", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:event, + user_id: @user_id, + name: "Payment", + "meta.key": ["amount", "logged_in"], + "meta.value": ["100", "true"] + ), + build(:event, + user_id: @user_id, + name: "Payment", + "meta.key": ["amount", "logged_in"], + "meta.value": ["500", "true"] + ), + build(:event, + name: "Payment", + "meta.key": ["amount", "logged_in"], + "meta.value": ["100", "false"] + ), + build(:event, name: "Payment") + ]) + + insert(:goal, %{site: site, event_name: "Payment"}) + + response = + query_conversions(conn, site, + date_range: "day", + filters: [["is_not", "event:props:logged_in", ["true"]]] + ) + + assert response["results"] == [ + %{"dimensions" => ["Payment"], "metrics" => [2, 2, 66.67]} + ] + end + + test "returns conversions when a direct :is (none) filter on event prop", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:event, + user_id: @user_id, + name: "Payment" + ), + build(:event, + user_id: @user_id, + name: "Payment", + "meta.key": ["amount"], + "meta.value": ["500"] + ), + build(:event, + name: "Payment", + "meta.key": ["amount", "logged_in"], + "meta.value": ["100", "false"] + ), + build(:event, name: "Payment") + ]) + + insert(:goal, %{site: site, event_name: "Payment"}) + + response = + query_conversions(conn, site, + date_range: "day", + filters: [["is", "event:props:logged_in", ["(none)"]]] + ) + + assert response["results"] == [ + %{"dimensions" => ["Payment"], "metrics" => [2, 3, 66.67]} + ] + end + + test "returns conversions when a direct :is_not (none) filter on event prop", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:event, + user_id: @user_id, + name: "Payment", + "meta.key": ["amount", "logged_in"], + "meta.value": ["500", "false"] + ), + build(:event, + user_id: @user_id, + name: "Payment", + "meta.key": ["amount", "logged_in"], + "meta.value": ["500", "true"] + ), + build(:event, + name: "Payment", + "meta.key": ["amount", "logged_in"], + "meta.value": ["100", "false"] + ), + build(:event, name: "Payment") + ]) + + insert(:goal, %{site: site, event_name: "Payment"}) + + response = + query_conversions(conn, site, + date_range: "day", + filters: [["is_not", "event:props:logged_in", ["(none)"]]] + ) + + assert response["results"] == [ + %{"dimensions" => ["Payment"], "metrics" => [2, 3, 66.67]} + ] + end + + test "garbage filters result in a 400 response", %{conn: conn, site: site} do + params = %{ + "date_range" => "all", + "metrics" => ["visitors", "events", "conversion_rate"], + "filters" => [ + ["is", "visit:city AND 2*3*8=6*8 AND 'L9sv'!='L9sv%", ["a"]] + ] + } + + response = + conn + |> post("/api/stats/#{site.domain}/query", params) + |> json_response(400) + + assert response["error"] =~ "filter" + end + + test "filtering by session attribute and multiple goals", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:event, + user_id: @user_id, + name: "Payment", + browser: "Firefox" + ), + build(:event, + user_id: @user_id, + name: "Payment", + browser: "Firefox" + ), + build(:event, + name: "Payment", + browser: "Chrome" + ), + build(:event, name: "Payment"), + build(:pageview, browser: "Firefox", pathname: "/"), + build(:pageview, browser: "Firefox", pathname: "/register") + ]) + + insert(:goal, %{site: site, event_name: "Payment"}) + insert(:goal, %{site: site, page_path: "/register"}) + + response = + query_conversions(conn, site, + date_range: "day", + filters: [["is", "visit:browser", ["Firefox"]]] + ) + + assert response["results"] == [ + %{"dimensions" => ["Payment"], "metrics" => [1, 2, 33.33]}, + %{"dimensions" => ["Visit /register"], "metrics" => [1, 1, 33.33]} + ] + end + + @tag :ee_only + test "returns formatted average and total values for a conversion with revenue value", %{ + conn: conn, + site: site + } do + site_import = insert(:site_import, site: site) + + populate_stats(site, site_import.id, [ + build(:event, + name: "Payment", + revenue_reporting_amount: Decimal.new("200100300.123"), + revenue_reporting_currency: "USD" + ), + build(:event, + name: "Payment", + revenue_reporting_amount: Decimal.new("300100400.123"), + revenue_reporting_currency: "USD" + ), + build(:event, + name: "Payment", + revenue_reporting_amount: Decimal.new("0"), + revenue_reporting_currency: "USD" + ), + build(:event, name: "Payment", revenue_reporting_amount: nil), + build(:event, name: "Payment", revenue_reporting_amount: nil) + ]) + + insert(:goal, %{site: site, event_name: "Payment", currency: :EUR}) + + response = + query_conversions(conn, site, + date_range: "day", + metrics: ["visitors", "events", "conversion_rate", "average_revenue", "total_revenue"], + include: %{"imports" => true} + ) + + assert response["results"] == [ + %{ + "dimensions" => ["Payment"], + "metrics" => [ + 5, + 5, + 100.0, + %{ + "short" => "€166.7M", + "long" => "€166,733,566.75", + "value" => 166_733_566.748, + "currency" => "EUR" + }, + %{ + "short" => "€500.2M", + "long" => "€500,200,700.25", + "value" => 500_200_700.246, + "currency" => "EUR" + } + ] + } + ] + end + + @tag :ee_only + test "returns revenue goals as custom events if the plan doesn't cover the feature", %{ + conn: conn, + site: site, + user: user + } do + user + |> team_of() + |> Plausible.Teams.Team.end_trial() + |> Plausible.Repo.update!() + + populate_stats(site, [ + build(:event, + name: "Payment", + revenue_reporting_amount: Decimal.new("200100300.123"), + revenue_reporting_currency: "USD" + ), + build(:event, + name: "Payment", + revenue_reporting_amount: Decimal.new("300100400.123"), + revenue_reporting_currency: "USD" + ), + build(:event, + name: "Payment", + revenue_reporting_amount: Decimal.new("0"), + revenue_reporting_currency: "USD" + ), + build(:event, name: "Payment", revenue_reporting_amount: nil), + build(:event, name: "Payment", revenue_reporting_amount: nil) + ]) + + insert(:goal, %{site: site, event_name: "Payment", currency: :EUR}) + + response = + query_conversions(conn, site, + date_range: "day", + metrics: ["visitors", "events", "conversion_rate", "average_revenue", "total_revenue"] + ) + + assert response["query"]["metrics"] == ["visitors", "events", "conversion_rate"] + + assert response["results"] == [ + %{"dimensions" => ["Payment"], "metrics" => [5, 5, 100.0]} + ] + end + + @tag :ee_only + test "excludes goals with custom props when Props feature is unavailable", %{ + conn: conn, + site: site, + user: user + } do + user + |> team_of() + |> Plausible.Teams.Team.end_trial() + |> Plausible.Repo.update!() + + populate_stats(site, [ + build(:event, name: "Signup"), + build(:event, name: "Signup"), + build(:event, name: "Signup"), + build(:event, name: "Purchase", "meta.key": ["product"], "meta.value": ["Shirt"]) + ]) + + {:ok, _goal_with_props} = + Plausible.Goals.create(site, %{ + "event_name" => "Purchase", + "custom_props" => %{"product" => "Shirt"} + }) + + insert(:goal, %{site: site, event_name: "Signup"}) + + response = query_conversions(conn, site, date_range: "day") + + assert response["results"] == [ + %{"dimensions" => ["Signup"], "metrics" => [3, 3, 75.0]} + ] + end + + @tag :ee_only + test "includes goals with custom props when Props feature is available", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:event, name: "Purchase", "meta.key": ["product"], "meta.value": ["Shirt"]), + build(:event, name: "Purchase", "meta.key": ["product"], "meta.value": ["Shirt"]) + ]) + + {:ok, _goal_with_props} = + Plausible.Goals.create(site, %{ + "event_name" => "Purchase", + "custom_props" => %{"product" => "Shirt"} + }) + + response = query_conversions(conn, site, date_range: "day") + + assert response["results"] == [ + %{"dimensions" => ["Purchase"], "metrics" => [2, 2, 100.0]} + ] + end + + test "returns correct conversion stats for goals with and without custom properties", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:event, name: "Purchase", "meta.key": ["product"], "meta.value": ["Shirt"]), + build(:event, name: "Purchase", "meta.key": ["product"], "meta.value": ["Shirt"]), + build(:event, name: "Purchase", "meta.key": ["product"], "meta.value": ["Jacket"]) + ]) + + {:ok, _} = + Plausible.Goals.create( + site, + %{ + "event_name" => "Purchase", + "display_name" => "Purchase - Shirt", + "custom_props" => %{"product" => "Shirt"} + } + ) + + {:ok, _} = + Plausible.Goals.create( + site, + %{ + "event_name" => "Purchase", + "display_name" => "Purchase - Jacket", + "custom_props" => %{"product" => "Jacket"} + } + ) + + {:ok, _} = + Plausible.Goals.create( + site, + %{ + "event_name" => "Purchase", + "display_name" => "Purchase - All" + } + ) + + response = query_conversions(conn, site, date_range: "day") + + assert response["results"] == [ + %{"dimensions" => ["Purchase - All"], "metrics" => [3, 3, 100.0]}, + %{"dimensions" => ["Purchase - Shirt"], "metrics" => [2, 2, 66.67]}, + %{"dimensions" => ["Purchase - Jacket"], "metrics" => [1, 1, 33.33]} + ] + end + + @tag :ee_only + test "handles mixed goals with and without custom props (2)", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:event, name: "Signup"), + build(:event, name: "Purchase", "meta.key": ["product"], "meta.value": ["Shirt"]), + build(:event, name: "Purchase", "meta.key": ["product"], "meta.value": ["Shirt"]) + ]) + + {:ok, _goal_with_props} = + Plausible.Goals.create( + site, + %{ + "event_name" => "Purchase", + "custom_props" => %{"product" => "Shirt"} + } + ) + + insert(:goal, %{site: site, event_name: "Signup"}) + + response = query_conversions(conn, site, date_range: "day") + + assert [ + %{"dimensions" => ["Purchase"], "metrics" => [2, 2, 66.67]}, + %{"dimensions" => ["Signup"], "metrics" => [1, 1, 33.33]} + ] = response["results"] + end + + @tag :ee_only + test "returns revenue metrics as nil for non-revenue goals", %{ + conn: conn, + site: site + } do + [ + build(:event, + name: "Payment", + pathname: "/checkout", + revenue_reporting_amount: Decimal.new("10.00"), + revenue_reporting_currency: "EUR" + ) + ] + |> Enum.concat(build_list(2, :event, name: "Signup")) + |> Enum.concat(build_list(3, :pageview, pathname: "/checkout")) + |> then(fn events -> populate_stats(site, events) end) + + insert(:goal, %{site: site, event_name: "Signup"}) + insert(:goal, %{site: site, page_path: "/checkout"}) + insert(:goal, %{site: site, event_name: "Payment", currency: :EUR}) + + response = + query_conversions(conn, site, + date_range: "day", + metrics: ["visitors", "events", "conversion_rate", "average_revenue", "total_revenue"], + order_by: [["visitors", "asc"]] + ) + + assert response["results"] == [ + %{ + "dimensions" => ["Payment"], + "metrics" => [ + 1, + 1, + 16.67, + %{ + "long" => "€10.00", + "short" => "€10.0", + "value" => 10.0, + "currency" => "EUR" + }, + %{ + "long" => "€10.00", + "short" => "€10.0", + "value" => 10.0, + "currency" => "EUR" + } + ] + }, + %{"dimensions" => ["Signup"], "metrics" => [2, 2, 33.33, nil, nil]}, + %{"dimensions" => ["Visit /checkout"], "metrics" => [3, 3, 50.0, nil, nil]} + ] + end + + @tag :ee_only + test "does not return revenue metrics if no revenue goals are returned", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:event, name: "Signup") + ]) + + insert(:goal, %{site: site, event_name: "Signup"}) + + response = + query_conversions(conn, site, + date_range: "day", + metrics: [ + "visitors", + "events", + "conversion_rate", + "average_revenue", + "total_revenue" + ] + ) + + assert response["query"]["metrics"] == ["visitors", "events", "conversion_rate"] + + assert response["results"] == [ + %{"dimensions" => ["Signup"], "metrics" => [1, 1, 100.0]} + ] + end + end + + describe "GET /api/stats/:domain/conversions - with goal filter" do + setup [:create_user, :log_in, :create_site] + + test "does not consider custom event pathname as a pageview goal completion", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, pathname: "/"), + build(:pageview, pathname: "/register"), + build(:event, pathname: "/register", name: "Signup") + ]) + + insert(:goal, %{site: site, page_path: "/register"}) + insert(:goal, %{site: site, event_name: "Signup"}) + + get_with_filters = fn filters -> + query_conversions(conn, site, date_range: "day", filters: filters) + |> Map.get("results") + end + + expected = [ + %{"dimensions" => ["Signup"], "metrics" => [1, 1, 33.33]} + ] + + # {:is, {:event, event}} filter type + assert get_with_filters.([["is", "event:goal", ["Signup"]]]) == expected + + # {:member, clauses} filter type + assert get_with_filters.([["is", "event:goal", ["Signup", "Whatever"]]]) == expected + + # {:matches_member, clauses} filter type + assert get_with_filters.([["is", "event:goal", ["Signup", "Visit /whatever*"]]]) == expected + end + + test "does not return custom events with the filtered pageview goal pathname", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, pathname: "/"), + build(:pageview, pathname: "/register"), + build(:event, pathname: "/register", name: "Signup") + ]) + + insert(:goal, %{site: site, page_path: "/register"}) + insert(:goal, %{site: site, event_name: "Signup"}) + + response = + query_conversions(conn, site, + date_range: "day", + filters: [["is", "event:goal", ["Visit /register"]]] + ) + + assert response["results"] == [ + %{"dimensions" => ["Visit /register"], "metrics" => [1, 1, 33.33]} + ] + end + + test "can filter by multiple mixed goals", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/"), + build(:pageview, pathname: "/"), + build(:pageview, pathname: "/another"), + build(:pageview, pathname: "/register"), + build(:event, name: "Signup"), + build(:event, name: "Signup") + ]) + + insert(:goal, %{site: site, page_path: "/register"}) + insert(:goal, %{site: site, event_name: "Signup"}) + + response = + query_conversions(conn, site, + date_range: "day", + filters: [["is", "event:goal", ["Signup", "Visit /register"]]] + ) + + assert response["results"] == [ + %{"dimensions" => ["Signup"], "metrics" => [2, 2, 33.33]}, + %{"dimensions" => ["Visit /register"], "metrics" => [1, 1, 16.67]} + ] + end + + test "can combine wildcard and no wildcard in matches_member", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/blog/post-1"), + build(:pageview, pathname: "/blog/post-2"), + build(:pageview, pathname: "/billing/upgrade") + ]) + + insert(:goal, %{site: site, page_path: "/blog/**"}) + insert(:goal, %{site: site, page_path: "/billing/upgrade"}) + + response = + query_conversions(conn, site, + date_range: "day", + filters: [["is", "event:goal", ["Visit /blog/**", "Visit /billing/upgrade"]]] + ) + + assert response["results"] == [ + %{"dimensions" => ["Visit /blog/**"], "metrics" => [2, 2, 66.67]}, + %{"dimensions" => ["Visit /billing/upgrade"], "metrics" => [1, 1, 33.33]} + ] + end + + test "can filter by matches_member filter type on goals", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/"), + build(:pageview, pathname: "/another"), + build(:pageview, pathname: "/blog/post-1"), + build(:pageview, pathname: "/blog/post-2"), + build(:event, name: "CTA"), + build(:event, name: "Signup") + ]) + + insert(:goal, %{site: site, page_path: "/blog**"}) + insert(:goal, %{site: site, event_name: "CTA"}) + insert(:goal, %{site: site, event_name: "Signup"}) + + response = + query_conversions(conn, site, + date_range: "day", + filters: [["is", "event:goal", ["Signup", "Visit /blog**"]]] + ) + + assert response["results"] == [ + %{"dimensions" => ["Visit /blog**"], "metrics" => [2, 2, 33.33]}, + %{"dimensions" => ["Signup"], "metrics" => [1, 1, 16.67]} + ] + end + end + + describe "GET /api/stats/:domain/conversions - with goal and prop=(none) filter" do + setup [:create_user, :log_in, :create_site] + + test "returns only the conversion that is filtered for", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/", user_id: 1), + build(:pageview, pathname: "/", user_id: 2), + build(:event, name: "Signup", user_id: 1, "meta.key": ["variant"], "meta.value": ["A"]), + build(:event, name: "Signup", user_id: 2) + ]) + + insert(:goal, %{site: site, event_name: "Signup"}) + + response = + query_conversions(conn, site, + date_range: "day", + filters: [ + ["is", "event:goal", ["Signup"]], + ["is", "event:props:variant", ["(none)"]] + ] + ) + + assert response["results"] == [ + %{"dimensions" => ["Signup"], "metrics" => [1, 1, 50.0]} + ] + end + end + + describe "GET /api/stats/:domain/conversions - with glob goals" do + setup [:create_user, :log_in, :create_site] + + test "returns correct and sorted glob goal counts", %{conn: conn, site: site} do + insert(:goal, %{site: site, page_path: "/register"}) + insert(:goal, %{site: site, page_path: "/reg*"}) + insert(:goal, %{site: site, page_path: "/*/register"}) + insert(:goal, %{site: site, page_path: "/billing*/success"}) + insert(:goal, %{site: site, page_path: "/signup"}) + insert(:goal, %{site: site, page_path: "/signup/*"}) + insert(:goal, %{site: site, page_path: "/*"}) + + populate_stats(site, [ + build(:pageview, + pathname: "/hum", + timestamp: ~N[2019-07-01 23:00:00] + ), + build(:pageview, + pathname: "/register", + timestamp: ~N[2019-07-01 23:00:00] + ), + build(:pageview, + user_id: @user_id, + pathname: "/reg", + timestamp: ~N[2019-07-01 23:00:00] + ), + build(:pageview, + user_id: @user_id, + pathname: "/reg123", + timestamp: ~N[2019-07-01 23:10:00] + ), + build(:pageview, + pathname: "/billing/success", + timestamp: ~N[2019-07-01 23:00:00] + ), + build(:pageview, + pathname: "/billing/upgrade/success", + timestamp: ~N[2019-07-01 23:00:00] + ), + build(:pageview, + pathname: "/signup/new", + timestamp: ~N[2019-07-01 23:00:00] + ), + build(:pageview, + pathname: "/signup/new/2", + timestamp: ~N[2019-07-01 23:00:00] + ), + build(:pageview, + pathname: "/signup/new/3", + timestamp: ~N[2019-07-01 23:00:00] + ) + ]) + + response = + query_conversions(conn, site, + date_range: ["2019-07-01", "2019-07-01"], + order_by: [["visitors", "desc"], ["events", "asc"]] + ) + + assert response["results"] == [ + %{"dimensions" => ["Visit /*"], "metrics" => [8, 9, 100.0]}, + %{"dimensions" => ["Visit /signup/*"], "metrics" => [3, 3, 37.5]}, + %{"dimensions" => ["Visit /billing*/success"], "metrics" => [2, 2, 25.0]}, + %{"dimensions" => ["Visit /reg*"], "metrics" => [2, 3, 25.0]}, + %{"dimensions" => ["Visit /register"], "metrics" => [1, 1, 12.5]} + ] + end + end + + describe "GET /api/stats/:domain/conversions - with imported data" do + setup [:create_user, :log_in, :create_site] + + test "returns custom event goals and pageview goals", %{conn: conn, site: site} do + insert(:goal, site: site, event_name: "Purchase") + insert(:goal, site: site, page_path: "/test") + + site_import = insert(:site_import, site: site) + + populate_stats(site, site_import.id, [ + build(:pageview, + timestamp: ~N[2021-01-01 00:00:01], + pathname: "/test" + ), + build(:event, + name: "Purchase", + timestamp: ~N[2021-01-01 00:00:03] + ), + build(:event, + name: "Purchase", + timestamp: ~N[2021-01-01 00:00:03] + ), + build(:imported_custom_events, + name: "Purchase", + visitors: 3, + events: 5, + date: ~D[2021-01-01] + ), + build(:imported_pages, + page: "/test", + visitors: 2, + pageviews: 2, + date: ~D[2021-01-01] + ), + build(:imported_visitors, visitors: 5, date: ~D[2021-01-01]) + ]) + + response = + query_conversions(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + include: %{"imports" => true} + ) + + assert [ + %{"dimensions" => ["Purchase"], "metrics" => [5, 7, 62.5]}, + %{"dimensions" => ["Visit /test"], "metrics" => [3, 3, 37.5]} + ] = response["results"] + end + + test "returns only custom event goals with a custom event goal filter", %{ + conn: conn, + site: site + } do + insert(:goal, site: site, event_name: "Purchase") + insert(:goal, site: site, event_name: "Activation") + insert(:goal, site: site, page_path: "/test") + + site_import = insert(:site_import, site: site) + + populate_stats(site, site_import.id, [ + build(:pageview, + timestamp: ~N[2021-01-01 00:00:01], + pathname: "/test" + ), + build(:event, + name: "Purchase", + timestamp: ~N[2021-01-01 00:00:03] + ), + build(:event, + name: "Purchase", + timestamp: ~N[2021-01-01 00:00:03] + ), + build(:imported_custom_events, + name: "Purchase", + visitors: 3, + events: 5, + date: ~D[2021-01-01] + ), + build(:imported_pages, + page: "/test", + visitors: 2, + pageviews: 2, + date: ~D[2021-01-01] + ), + build(:imported_visitors, visitors: 5, date: ~D[2021-01-01]) + ]) + + response = + query_conversions(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + filters: [["is", "event:goal", ["Purchase"]]], + include: %{"imports" => true} + ) + + assert [ + %{"dimensions" => ["Purchase"], "metrics" => [5, 7, 62.5]} + ] = response["results"] + end + + test "returns custom event goals with more than one option in goal filter", %{ + conn: conn, + site: site + } do + insert(:goal, site: site, event_name: "Purchase") + insert(:goal, site: site, event_name: "Activation") + insert(:goal, site: site, page_path: "/test") + + site_import = insert(:site_import, site: site) + + populate_stats(site, site_import.id, [ + build(:pageview, + timestamp: ~N[2021-01-01 00:00:01], + pathname: "/test" + ), + build(:event, + name: "Purchase", + timestamp: ~N[2021-01-01 00:00:03] + ), + build(:event, + name: "Purchase", + timestamp: ~N[2021-01-01 00:00:03] + ), + build(:event, + name: "Activation", + timestamp: ~N[2021-01-01 00:00:03] + ), + build(:imported_custom_events, + name: "Purchase", + visitors: 3, + events: 5, + date: ~D[2021-01-01] + ), + build(:imported_custom_events, + name: "Activation", + visitors: 2, + events: 4, + date: ~D[2021-01-01] + ), + build(:imported_pages, + page: "/test", + visitors: 2, + pageviews: 2, + date: ~D[2021-01-01] + ), + build(:imported_visitors, visitors: 5, date: ~D[2021-01-01]) + ]) + + response = + query_conversions(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + filters: [["is", "event:goal", ["Purchase", "Activation"]]], + include: %{"imports" => true} + ) + + assert [ + %{"dimensions" => ["Purchase"], "metrics" => [5, 7, 55.56]}, + %{"dimensions" => ["Activation"], "metrics" => [3, 5, 33.33]} + ] = response["results"] + end + + test "returns only pageview goals with a pageview goal filter", %{ + conn: conn, + site: site + } do + insert(:goal, site: site, event_name: "Purchase") + insert(:goal, site: site, event_name: "Activation") + insert(:goal, site: site, page_path: "/test") + + site_import = insert(:site_import, site: site) + + populate_stats(site, site_import.id, [ + build(:pageview, + timestamp: ~N[2021-01-01 00:00:01], + pathname: "/test" + ), + build(:event, + name: "Purchase", + timestamp: ~N[2021-01-01 00:00:03] + ), + build(:event, + name: "Purchase", + timestamp: ~N[2021-01-01 00:00:03] + ), + build(:imported_custom_events, + name: "Purchase", + visitors: 3, + events: 5, + date: ~D[2021-01-01] + ), + build(:imported_pages, + page: "/test", + visitors: 2, + pageviews: 2, + date: ~D[2021-01-01] + ), + build(:imported_visitors, visitors: 5, date: ~D[2021-01-01]) + ]) + + response = + query_conversions(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + filters: [["is", "event:goal", ["Visit /test"]]], + include: %{"imports" => true} + ) + + assert [ + %{"dimensions" => ["Visit /test"], "metrics" => [3, 3, 37.5]} + ] = response["results"] + end + + test "returns pageview goals with more than one option in pageview goal filter", %{ + conn: conn, + site: site + } do + insert(:goal, site: site, event_name: "Purchase") + insert(:goal, site: site, event_name: "Activation") + insert(:goal, site: site, page_path: "/test") + insert(:goal, site: site, page_path: "/blog") + + site_import = insert(:site_import, site: site) + + populate_stats(site, site_import.id, [ + build(:pageview, + timestamp: ~N[2021-01-01 00:00:01], + pathname: "/test" + ), + build(:pageview, + timestamp: ~N[2021-01-01 00:00:01], + pathname: "/blog" + ), + build(:event, + name: "Purchase", + timestamp: ~N[2021-01-01 00:00:03] + ), + build(:event, + name: "Purchase", + timestamp: ~N[2021-01-01 00:00:03] + ), + build(:imported_custom_events, + name: "Purchase", + visitors: 3, + events: 5, + date: ~D[2021-01-01] + ), + build(:imported_pages, + page: "/test", + visitors: 2, + pageviews: 2, + date: ~D[2021-01-01] + ), + build(:imported_pages, + page: "/blog", + visitors: 1, + pageviews: 1, + date: ~D[2021-01-01] + ), + build(:imported_visitors, visitors: 5, date: ~D[2021-01-01]) + ]) + + response = + query_conversions(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + filters: [["is", "event:goal", ["Visit /test", "Visit /blog"]]], + include: %{"imports" => true} + ) + + assert [ + %{"dimensions" => ["Visit /test"], "metrics" => [3, 3, 33.33]}, + %{"dimensions" => ["Visit /blog"], "metrics" => [2, 2, 22.22]} + ] = response["results"] + end + + test "returns pageview goals with a page filter", %{ + conn: conn, + site: site + } do + insert(:goal, site: site, page_path: "/blog/two") + insert(:goal, site: site, page_path: "/blog/thr**") + insert(:goal, site: site, page_path: "/blog/*") + + site_import = insert(:site_import, site: site) + + populate_stats(site, site_import.id, [ + build(:imported_pages, page: "/", visitors: 1, pageviews: 1, date: ~D[2021-01-01]), + build(:imported_pages, + page: "/blog/one", + visitors: 2, + pageviews: 2, + date: ~D[2021-01-01] + ), + build(:imported_pages, + page: "/blog/two", + visitors: 3, + pageviews: 3, + date: ~D[2021-01-01] + ), + build(:imported_pages, + page: "/blog/three", + visitors: 4, + pageviews: 4, + date: ~D[2021-01-01] + ), + build(:imported_visitors, visitors: 10, date: ~D[2021-01-01]) + ]) + + response = + query_conversions(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + filters: [["is", "event:page", ["/blog/one", "/blog/two"]]], + include: %{"imports" => true} + ) + + assert [ + %{"dimensions" => ["Visit /blog/*"], "metrics" => [5, 5, 100.0]}, + %{"dimensions" => ["Visit /blog/two"], "metrics" => [3, 3, 60.0]} + ] = response["results"] + end + + test "calculates conversion_rate for goals with glob pattern with imported data", %{ + conn: conn, + site: site + } do + site_import = + insert(:site_import, + site: site, + start_date: ~D[2005-01-01], + end_date: Date.utc_today(), + source: :universal_analytics + ) + + populate_stats(site, site_import.id, [ + build(:pageview, pathname: "/"), + build(:pageview, pathname: "/another"), + build(:pageview, pathname: "/blog/post-1"), + build(:pageview, pathname: "/blog/post-2"), + build(:imported_pages, page: "/blog/post-1"), + build(:imported_visitors) + ]) + + insert(:goal, %{site: site, page_path: "/blog**"}) + + response = query_conversions(conn, site, date_range: "day", include: %{"imports" => true}) + + assert response["meta"]["imports_included"] + + assert response["results"] == [ + %{"dimensions" => ["Visit /blog**"], "metrics" => [3, 3, 60.0]} + ] + end + + test "filtering with goal contains filter", %{ + conn: conn, + site: site + } do + site_import = + insert(:site_import, + site: site, + start_date: ~D[2005-01-01], + end_date: Date.utc_today(), + source: :universal_analytics + ) + + insert(:goal, site: site, event_name: "Onboarding: Step 1") + insert(:goal, site: site, event_name: "Onboarding: Step 2") + insert(:goal, site: site, event_name: "Unrelated") + + populate_stats(site, site_import.id, [ + build(:event, name: "Onboarding: Step 1"), + build(:event, name: "Onboarding: Step 1"), + build(:event, name: "Onboarding: Step 2"), + build(:event, name: "Unrelated"), + build(:imported_custom_events, name: "Onboarding: Step 1", visitors: 2, events: 2), + build(:imported_custom_events, name: "Onboarding: Step 2"), + build(:imported_custom_events, name: "Unrelated"), + build(:imported_visitors, visitors: 4) + ]) + + response = + query_conversions(conn, site, + date_range: "day", + filters: [["contains", "event:goal", ["Onboarding"]]], + include: %{"imports" => true} + ) + + assert response["results"] == [ + %{"dimensions" => ["Onboarding: Step 1"], "metrics" => [4, 4, 50.0]}, + %{"dimensions" => ["Onboarding: Step 2"], "metrics" => [2, 2, 25.0]} + ] + end + end +end diff --git a/test/plausible_web/controllers/api/stats_controller/countries_test.exs b/test/plausible_web/controllers/api/stats_controller/countries_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..91fd0a57d10b06d7628f5030e1c32b41b526536b --- /dev/null +++ b/test/plausible_web/controllers/api/stats_controller/countries_test.exs @@ -0,0 +1,511 @@ +defmodule PlausibleWeb.Api.StatsController.CountriesTest do + use PlausibleWeb.ConnCase + + defp query_countries(conn, site, opts) do + always_on_filters = ["is_not", "visit:country", [<<0, 0>>, "ZZ"]] + + params = %{ + "dimensions" => Keyword.get(opts, :dimensions, ["visit:country", "visit:country_name"]), + "date_range" => Keyword.get(opts, :date_range, "all"), + "filters" => [ + always_on_filters + | Keyword.get(opts, :filters, []) + ], + "metrics" => Keyword.get(opts, :metrics, ["visitors", "percentage"]), + "include" => Keyword.get(opts, :include, nil), + "pagination" => Keyword.get(opts, :pagination, nil), + "order_by" => Keyword.get(opts, :order_by, nil) + } + + conn + |> post("/api/stats/#{site.domain}/query", params) + |> json_response(200) + end + + describe "querying for countries with POST /query" do + setup [:create_user, :log_in, :create_site, :create_legacy_site_import] + + test "returns top countries by new visitors", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, country_code: "EE"), + build(:pageview, country_code: "EE"), + build(:pageview, country_code: "GB"), + build(:imported_locations, country: "EE"), + build(:imported_locations, country: "GB"), + build(:imported_visitors, visitors: 2) + ]) + + response1 = query_countries(conn, site, date_range: "day") + + assert response1["results"] == [ + %{"dimensions" => ["EE", "Estonia"], "metrics" => [2, 66.67]}, + %{"dimensions" => ["GB", "United Kingdom"], "metrics" => [1, 33.33]} + ] + + response2 = + query_countries(conn, site, date_range: "day", include: %{"imports" => true}) + + assert response2["results"] == [ + %{"dimensions" => ["EE", "Estonia"], "metrics" => [3, 60.0]}, + %{"dimensions" => ["GB", "United Kingdom"], "metrics" => [2, 40.0]} + ] + end + + test "ignores unknown country code ZZ", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, country_code: "ZZ"), + build(:imported_locations, country: "ZZ") + ]) + + response = + query_countries(conn, site, date_range: "day", include: %{"imports" => true}) + + assert response["results"] == [] + end + + test "includes anonymous VPN country code A1", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, country_code: "A1"), + build(:pageview, country_code: "A1"), + build(:pageview, country_code: "EE") + ]) + + response = + query_countries(conn, site, + date_range: "day", + order_by: [["visitors", "desc"]] + ) + + assert response["results"] == [ + %{"dimensions" => ["A1", "Anonymous VPN Service"], "metrics" => [2, 66.67]}, + %{"dimensions" => ["EE", "Estonia"], "metrics" => [1, 33.33]} + ] + end + + test "searching for 'Anonymous' returns VPN visitors", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, country_code: "A1"), + build(:pageview, country_code: "A1"), + build(:pageview, country_code: "EE"), + build(:pageview, country_code: "GB") + ]) + + response = + query_countries(conn, site, + date_range: "day", + filters: [["contains", "visit:country_name", ["Anonymous"]]] + ) + + assert response["results"] == [ + %{"dimensions" => ["A1", "Anonymous VPN Service"], "metrics" => [2, 100.0]} + ] + end + + test "calculates conversion_rate when filtering for goal", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + user_id: 1, + country_code: "EE" + ), + build(:event, user_id: 1, name: "Signup"), + build(:pageview, + user_id: 2, + country_code: "EE" + ), + build(:pageview, + user_id: 3, + country_code: "GB" + ), + build(:event, user_id: 3, name: "Signup") + ]) + + insert(:goal, site: site, event_name: "Signup") + + response = + query_countries(conn, site, + date_range: "day", + filters: [["is", "event:goal", ["Signup"]]], + metrics: ["visitors", "total_visitors", "group_conversion_rate"], + order_by: [["visit:country", "asc"]] + ) + + assert response["results"] == [ + %{"dimensions" => ["EE", "Estonia"], "metrics" => [1, 2, 50.0]}, + %{"dimensions" => ["GB", "United Kingdom"], "metrics" => [1, 1, 100.0]} + ] + end + + test "handles conversion_rate sort directive", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + user_id: 1, + country_code: "EE" + ), + build(:event, user_id: 1, name: "Signup"), + build(:pageview, + user_id: 2, + country_code: "EE" + ), + build(:pageview, + user_id: 3, + country_code: "GB" + ), + build(:event, user_id: 3, name: "Signup") + ]) + + insert(:goal, site: site, event_name: "Signup") + + response = + query_countries(conn, site, + date_range: "day", + filters: [["is", "event:goal", ["Signup"]]], + metrics: ["visitors", "total_visitors", "group_conversion_rate"], + order_by: [["group_conversion_rate", "desc"]] + ) + + assert response["results"] == [ + %{"dimensions" => ["GB", "United Kingdom"], "metrics" => [1, 1, 100.0]}, + %{"dimensions" => ["EE", "Estonia"], "metrics" => [1, 2, 50.0]} + ] + end + + test "returns top countries with :is filter on custom pageview props", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, + user_id: 123, + country_code: "EE" + ), + build(:pageview, + user_id: 123, + country_code: "EE", + "meta.key": ["author"], + "meta.value": ["John Doe"] + ), + build(:pageview, + country_code: "GB", + "meta.key": ["author"], + "meta.value": ["other"] + ), + build(:pageview, + country_code: "US" + ) + ]) + + response = + query_countries(conn, site, + date_range: "day", + filters: [["is", "event:props:author", ["John Doe"]]] + ) + + assert response["results"] == [ + %{"dimensions" => ["EE", "Estonia"], "metrics" => [1, 100.0]} + ] + end + + test "returns top countries with :is_not filter on custom pageview props", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, + user_id: 123, + country_code: "EE", + "meta.key": ["author"], + "meta.value": ["John Doe"] + ), + build(:pageview, + user_id: 123, + country_code: "EE", + "meta.key": ["author"], + "meta.value": ["John Doe"] + ), + build(:pageview, + country_code: "GB", + "meta.key": ["author"], + "meta.value": ["other"] + ), + build(:pageview, + country_code: "GB" + ) + ]) + + response = + query_countries(conn, site, + date_range: "day", + filters: [["is_not", "event:props:author", ["John Doe"]]] + ) + + assert response["results"] == [ + %{"dimensions" => ["GB", "United Kingdom"], "metrics" => [2, 100.0]} + ] + end + + test "returns top countries with :is (none) filter on custom pageview props", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, + country_code: "EE", + "meta.key": ["author"], + "meta.value": ["John Doe"] + ), + build(:pageview, + country_code: "GB", + "meta.key": ["logged_in"], + "meta.value": ["true"] + ), + build(:pageview, + country_code: "GB" + ) + ]) + + response = + query_countries(conn, site, + date_range: "day", + filters: [["is", "event:props:author", ["(none)"]]] + ) + + assert response["results"] == [ + %{"dimensions" => ["GB", "United Kingdom"], "metrics" => [2, 100.0]} + ] + end + + test "returns top countries with :is_not (none) filter on custom pageview props", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, + country_code: "EE", + "meta.key": ["author"], + "meta.value": ["John Doe"] + ), + build(:pageview, + country_code: "EE", + "meta.key": ["author"], + "meta.value": [""] + ), + build(:pageview, + country_code: "GB", + "meta.key": ["logged_in"], + "meta.value": ["true"] + ), + build(:pageview, + country_code: "GB" + ) + ]) + + response = + query_countries(conn, site, + date_range: "day", + filters: [["is_not", "event:props:author", ["(none)"]]] + ) + + assert response["results"] == [ + %{"dimensions" => ["EE", "Estonia"], "metrics" => [2, 100.0]} + ] + end + + test "when list is filtered by country returns one country only", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, country_code: "EE"), + build(:pageview, country_code: "EE"), + build(:pageview, country_code: "GB") + ]) + + response = + query_countries(conn, site, + date_range: "day", + filters: [["is", "visit:country", ["GB"]]] + ) + + assert response["results"] == [ + %{"dimensions" => ["GB", "United Kingdom"], "metrics" => [1, 100.0]} + ] + end + + test "handles multiple segment filters", %{conn: conn, site: site, user: user} do + %{id: segment_alfa} = + insert(:segment, + site_id: site.id, + owner_id: user.id, + name: "Ireland and Britain (excl London)", + type: :site, + segment_data: %{ + "filters" => [ + ["is", "visit:country", ["IE", "GB"]], + ["is_not", "visit:city", [2_643_743]] + ] + } + ) + + %{id: segment_beta} = + insert(:segment, + site_id: site.id, + owner_id: user.id, + name: "Entered on root or blog", + type: :personal, + segment_data: %{ + "filters" => [ + ["is", "visit:entry_page", ["/", "/blog"]] + ] + } + ) + + populate_stats(site, [ + build(:pageview, country_code: "EE"), + build(:pageview, + country_code: "GB", + # London + city_geoname_id: 2_643_743 + ), + build(:pageview, + country_code: "GB", + # London + city_geoname_id: 2_643_743 + ), + build(:pageview, country_code: "GB"), + build(:pageview, country_code: "IE", pathname: "/other"), + build(:pageview, country_code: "IE") + ]) + + response = + query_countries(conn, site, + date_range: "day", + filters: [ + ["is", "segment", [segment_alfa]], + ["is", "segment", [segment_beta]] + ], + order_by: [["visitors", "desc"], ["visit:country", "asc"]] + ) + + assert response["results"] == [ + %{"dimensions" => ["GB", "United Kingdom"], "metrics" => [1, 50.0]}, + %{"dimensions" => ["IE", "Ireland"], "metrics" => [1, 50.0]} + ] + end + + @tag :ee_only + test "return revenue metrics for countries breakdown", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + user_id: 1, + country_code: "EE" + ), + build(:event, + name: "Payment", + user_id: 1, + revenue_reporting_amount: Decimal.new("1000"), + revenue_reporting_currency: "USD" + ), + build(:pageview, + user_id: 2, + country_code: "EE" + ), + build(:event, + name: "Payment", + user_id: 2, + revenue_reporting_amount: Decimal.new("2000"), + revenue_reporting_currency: "USD" + ), + build(:pageview, + user_id: 3, + country_code: "EE" + ), + build(:pageview, + user_id: 4, + country_code: "GB" + ), + build(:event, + name: "Payment", + user_id: 4, + revenue_reporting_amount: Decimal.new("500"), + revenue_reporting_currency: "USD" + ), + build(:pageview, + user_id: 5, + country_code: "GB" + ), + build(:pageview, user_id: 6), + build(:event, + name: "Payment", + user_id: 6, + revenue_reporting_amount: Decimal.new("600"), + revenue_reporting_currency: "USD" + ), + build(:pageview, user_id: 7), + build(:event, + name: "Payment", + user_id: 7, + revenue_reporting_amount: nil + ) + ]) + + insert(:goal, %{site: site, event_name: "Payment", currency: :USD}) + + response = + query_countries(conn, site, + date_range: "day", + filters: [["is", "event:goal", ["Payment"]]], + metrics: [ + "visitors", + "total_visitors", + "group_conversion_rate", + "average_revenue", + "total_revenue" + ], + order_by: [["visitors", "desc"], ["visit:country", "asc"]] + ) + + assert response["results"] == [ + %{ + "dimensions" => ["EE", "Estonia"], + "metrics" => [ + 2, + 3, + 66.67, + %{ + "currency" => "USD", + "long" => "$1,500.00", + "short" => "$1.5K", + "value" => 1500.0 + }, + %{ + "currency" => "USD", + "long" => "$3,000.00", + "short" => "$3.0K", + "value" => 3000.0 + } + ] + }, + %{ + "dimensions" => ["GB", "United Kingdom"], + "metrics" => [ + 1, + 2, + 50.0, + %{ + "currency" => "USD", + "long" => "$500.00", + "short" => "$500.0", + "value" => 500.0 + }, + %{ + "currency" => "USD", + "long" => "$500.00", + "short" => "$500.0", + "value" => 500.0 + } + ] + } + ] + end + end +end diff --git a/test/plausible_web/controllers/api/stats_controller/current_visitors_test.exs b/test/plausible_web/controllers/api/stats_controller/current_visitors_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..f68b19ce1be7e191d7961a004515d99405feb89a --- /dev/null +++ b/test/plausible_web/controllers/api/stats_controller/current_visitors_test.exs @@ -0,0 +1,23 @@ +defmodule PlausibleWeb.Api.StatsController.CurrentVisitorsTest do + use PlausibleWeb.ConnCase + + describe "GET /api/stats/:domain/current-visitors" do + setup [:create_user, :log_in, :create_site] + + test "returns unique users in the last 5 minutes", %{conn: conn, site: site} do + now = DateTime.utc_now() + + populate_stats(site, [ + build(:pageview, user_id: 123, timestamp: now |> DateTime.shift(minute: -3)), + build(:pageview, user_id: 456, timestamp: now |> DateTime.shift(minute: -3)), + build(:pageview, user_id: 123, timestamp: now |> DateTime.shift(minute: -1)), + build(:pageview, user_id: 789, timestamp: now |> DateTime.shift(minute: -7)), + build(:engagement, user_id: 789, timestamp: now |> DateTime.shift(minute: -3)) + ]) + + conn = get(conn, "/api/stats/#{site.domain}/current-visitors") + + assert json_response(conn, 200) == 2 + end + end +end diff --git a/test/plausible_web/controllers/api/stats_controller/custom_prop_breakdown_test.exs b/test/plausible_web/controllers/api/stats_controller/custom_prop_breakdown_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..b806e3750b260b84a1a0edf18138339bd7e85da0 --- /dev/null +++ b/test/plausible_web/controllers/api/stats_controller/custom_prop_breakdown_test.exs @@ -0,0 +1,1113 @@ +defmodule PlausibleWeb.Api.StatsController.CustomPropBreakdownTest do + use PlausibleWeb.ConnCase + + defp query_props(conn, site, prop_key, opts \\ []) do + params = %{ + "dimensions" => ["event:props:#{prop_key}"], + "date_range" => Keyword.get(opts, :date_range, "day"), + "filters" => Keyword.get(opts, :filters, []), + "metrics" => Keyword.get(opts, :metrics, ["visitors", "events", "percentage"]), + "include" => Keyword.get(opts, :include, nil), + "pagination" => Keyword.get(opts, :pagination, nil), + "order_by" => Keyword.get(opts, :order_by, nil) + } + + conn + |> post("/api/stats/#{site.domain}/query", params) + |> json_response(200) + end + + describe "GET /api/stats/:domain/custom-prop-values/:prop_key" do + setup [:create_user, :log_in, :create_site, :create_legacy_site_import] + + test "returns breakdown by a custom property", %{conn: conn, site: site} do + prop_key = "parim_s6ber" + + populate_stats(site, [ + build(:pageview, "meta.key": [prop_key], "meta.value": ["K2sna Kalle"]), + build(:pageview, "meta.key": [prop_key], "meta.value": ["K2sna Kalle"]), + build(:pageview, user_id: 123, "meta.key": [prop_key], "meta.value": ["Lotte"]), + build(:pageview, user_id: 123, "meta.key": [prop_key], "meta.value": ["Lotte"]), + build(:pageview, "meta.key": [prop_key], "meta.value": ["Sipsik"]) + ]) + + response = + query_props(conn, site, prop_key, + order_by: [["visitors", "desc"], ["event:props:#{prop_key}", "asc"]] + ) + + assert response["results"] == [ + %{"dimensions" => ["K2sna Kalle"], "metrics" => [2, 2, 50.0]}, + %{"dimensions" => ["Lotte"], "metrics" => [1, 2, 25.0]}, + %{"dimensions" => ["Sipsik"], "metrics" => [1, 1, 25.0]} + ] + end + + test "ignores imported data when calculating percentage", %{conn: conn, site: site} do + prop_key = "parim_s6ber" + + populate_stats(site, [ + build(:pageview, "meta.key": [prop_key], "meta.value": ["K2sna Kalle"]), + build(:imported_visitors, visitors: 2) + ]) + + response = query_props(conn, site, prop_key, include: %{"imports" => true}) + + assert response["results"] == [ + %{"dimensions" => ["K2sna Kalle"], "metrics" => [1, 1, 100.0]} + ] + end + + test "returns (none) values in the breakdown", %{conn: conn, site: site} do + prop_key = "parim_s6ber" + + populate_stats(site, [ + build(:pageview, "meta.key": [prop_key], "meta.value": ["K2sna Kalle"]), + build(:pageview, "meta.key": [prop_key], "meta.value": ["K2sna Kalle"]), + build(:pageview) + ]) + + response = query_props(conn, site, prop_key) + + assert response["results"] == [ + %{"dimensions" => ["K2sna Kalle"], "metrics" => [2, 2, 66.67]}, + %{"dimensions" => ["(none)"], "metrics" => [1, 1, 33.33]} + ] + end + + test "(none) value is included in pagination", %{conn: conn, site: site} do + prop_key = "kaksik" + + populate_stats(site, [ + build(:pageview, "meta.key": [prop_key], "meta.value": ["Teet"]), + build(:pageview, "meta.key": [prop_key], "meta.value": ["Teet"]), + build(:pageview, "meta.key": [prop_key], "meta.value": ["Tiit"]), + build(:pageview, "meta.key": [prop_key], "meta.value": ["Tiit"]), + build(:pageview, "meta.key": [prop_key], "meta.value": ["Tiit"]), + build(:pageview) + ]) + + response1 = query_props(conn, site, prop_key, pagination: %{"limit" => 2, "offset" => 0}) + response2 = query_props(conn, site, prop_key, pagination: %{"limit" => 2, "offset" => 2}) + + assert response1["results"] == [ + %{"dimensions" => ["Tiit"], "metrics" => [3, 3, 50.0]}, + %{"dimensions" => ["Teet"], "metrics" => [2, 2, 33.33]} + ] + + assert response2["results"] == [ + %{"dimensions" => ["(none)"], "metrics" => [1, 1, 16.67]} + ] + end + end + + describe "GET /api/stats/:domain/custom-prop-values/:prop_key - with goal filter" do + setup [:create_user, :log_in, :create_site] + + test "returns property breakdown for goal", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/"), + build(:pageview, pathname: "/"), + build(:pageview, pathname: "/register"), + build(:event, name: "Signup", "meta.key": ["variant"], "meta.value": ["A"]), + build(:event, name: "Signup", "meta.key": ["variant"], "meta.value": ["B"]), + build(:event, name: "Signup", "meta.key": ["variant"], "meta.value": ["B"]) + ]) + + insert(:goal, %{site: site, event_name: "Signup"}) + + response = + query_props(conn, site, "variant", + filters: [["is", "event:goal", ["Signup"]]], + metrics: ["visitors", "events", "conversion_rate"] + ) + + assert response["results"] == [ + %{"dimensions" => ["B"], "metrics" => [2, 2, 33.33]}, + %{"dimensions" => ["A"], "metrics" => [1, 1, 16.67]} + ] + end + + test "returns (none) values in property breakdown for goal", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/"), + build(:pageview, pathname: "/"), + build(:pageview, pathname: "/register"), + build(:event, name: "Signup"), + build(:event, name: "Signup"), + build(:event, name: "Signup", "meta.key": ["variant"], "meta.value": ["A"]) + ]) + + insert(:goal, %{site: site, event_name: "Signup"}) + + response = + query_props(conn, site, "variant", + filters: [["is", "event:goal", ["Signup"]]], + metrics: ["visitors", "events", "conversion_rate"] + ) + + assert response["results"] == [ + %{"dimensions" => ["(none)"], "metrics" => [2, 2, 33.33]}, + %{"dimensions" => ["A"], "metrics" => [1, 1, 16.67]} + ] + end + + test "does not return (none) value in property breakdown with is filter on prop_value", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:event, + name: "Purchase", + "meta.key": ["cost"], + "meta.value": ["0"] + ), + build(:event, name: "Purchase") + ]) + + insert(:goal, %{site: site, event_name: "Purchase"}) + + response = + query_props(conn, site, "cost", + filters: [ + ["is", "event:goal", ["Purchase"]], + ["is", "event:props:cost", ["0"]] + ], + metrics: ["visitors", "events", "conversion_rate"] + ) + + assert response["results"] == [ + %{"dimensions" => ["0"], "metrics" => [1, 1, 50.0]} + ] + end + + test "returns only (none) value in property breakdown with is (none) filter", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:event, + name: "Purchase", + "meta.key": ["cost"], + "meta.value": ["0"] + ), + build(:event, name: "Purchase") + ]) + + insert(:goal, %{site: site, event_name: "Purchase"}) + + response = + query_props(conn, site, "cost", + filters: [ + ["is", "event:goal", ["Purchase"]], + ["is", "event:props:cost", ["(none)"]] + ], + metrics: ["visitors", "events", "conversion_rate"] + ) + + assert response["results"] == [ + %{"dimensions" => ["(none)"], "metrics" => [1, 1, 50.0]} + ] + end + + test "returns (none) value in property breakdown with is_not filter on prop_value", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:event, + name: "Purchase", + "meta.key": ["cost"], + "meta.value": ["0"] + ), + build(:event, + name: "Purchase", + "meta.key": ["cost"], + "meta.value": ["20"] + ), + build(:event, + name: "Purchase", + "meta.key": ["cost"], + "meta.value": ["20"] + ), + build(:event, name: "Purchase") + ]) + + insert(:goal, %{site: site, event_name: "Purchase"}) + + response = + query_props(conn, site, "cost", + filters: [ + ["is", "event:goal", ["Purchase"]], + ["is_not", "event:props:cost", ["0"]] + ], + metrics: ["visitors", "events", "conversion_rate"] + ) + + assert response["results"] == [ + %{"dimensions" => ["20"], "metrics" => [2, 2, 50.0]}, + %{"dimensions" => ["(none)"], "metrics" => [1, 1, 25.0]} + ] + end + + test "does not return (none) value in property breakdown with is_not (none) filter", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:event, + name: "Purchase", + "meta.key": ["cost"], + "meta.value": ["0"] + ), + build(:event, name: "Purchase") + ]) + + insert(:goal, %{site: site, event_name: "Purchase"}) + + response = + query_props(conn, site, "cost", + filters: [ + ["is", "event:goal", ["Purchase"]], + ["is_not", "event:props:cost", ["(none)"]] + ], + metrics: ["visitors", "events", "conversion_rate"] + ) + + assert response["results"] == [ + %{"dimensions" => ["0"], "metrics" => [1, 1, 50.0]} + ] + end + + test "does not return (none) value in property breakdown with member filter on prop_value", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:event, + name: "Purchase", + "meta.key": ["cost"], + "meta.value": ["0"] + ), + build(:event, + name: "Purchase", + "meta.key": ["cost"], + "meta.value": ["1"] + ), + build(:event, + name: "Purchase", + "meta.key": ["cost"], + "meta.value": ["1"] + ), + build(:event, name: "Purchase") + ]) + + insert(:goal, %{site: site, event_name: "Purchase"}) + + response = + query_props(conn, site, "cost", + filters: [ + ["is", "event:goal", ["Purchase"]], + ["is", "event:props:cost", ["0", "1"]] + ], + metrics: ["visitors", "events", "conversion_rate"] + ) + + assert response["results"] == [ + %{"dimensions" => ["1"], "metrics" => [2, 2, 50.0]}, + %{"dimensions" => ["0"], "metrics" => [1, 1, 25.0]} + ] + end + + test "returns (none) value in property breakdown with member filter including a (none) value", + %{conn: conn, site: site} do + populate_stats(site, [ + build(:event, + name: "Purchase", + "meta.key": ["cost"], + "meta.value": ["0"] + ), + build(:event, + name: "Purchase", + "meta.key": ["cost"], + "meta.value": ["1"] + ), + build(:event, + name: "Purchase", + "meta.key": ["cost"], + "meta.value": ["1"] + ), + build(:event, name: "Purchase") + ]) + + insert(:goal, %{site: site, event_name: "Purchase"}) + + response = + query_props(conn, site, "cost", + filters: [ + ["is", "event:goal", ["Purchase"]], + ["is", "event:props:cost", ["1", "(none)"]] + ], + metrics: ["visitors", "events", "conversion_rate"] + ) + + assert response["results"] == [ + %{"dimensions" => ["1"], "metrics" => [2, 2, 50.0]}, + %{"dimensions" => ["(none)"], "metrics" => [1, 1, 25.0]} + ] + end + + test "returns (none) value in property breakdown with not_member filter on prop_value", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:event, + name: "Purchase", + "meta.key": ["cost"], + "meta.value": ["0"] + ), + build(:event, + name: "Purchase", + "meta.key": ["cost"], + "meta.value": ["0.01"] + ), + build(:event, + name: "Purchase", + "meta.key": ["cost"], + "meta.value": ["20"] + ), + build(:event, + name: "Purchase", + "meta.key": ["cost"], + "meta.value": ["20"] + ), + build(:event, name: "Purchase") + ]) + + insert(:goal, %{site: site, event_name: "Purchase"}) + + response = + query_props(conn, site, "cost", + filters: [ + ["is", "event:goal", ["Purchase"]], + ["is_not", "event:props:cost", ["0", "0.01"]] + ], + metrics: ["visitors", "events", "conversion_rate"] + ) + + assert response["results"] == [ + %{"dimensions" => ["20"], "metrics" => [2, 2, 40.0]}, + %{"dimensions" => ["(none)"], "metrics" => [1, 1, 20.0]} + ] + end + + test "does not return (none) value in property breakdown with not_member filter including a (none) value", + %{conn: conn, site: site} do + populate_stats(site, [ + build(:event, + name: "Purchase", + "meta.key": ["cost"], + "meta.value": ["0"] + ), + build(:event, + name: "Purchase", + "meta.key": ["cost"], + "meta.value": ["20"] + ), + build(:event, + name: "Purchase", + "meta.key": ["cost"], + "meta.value": ["20"] + ), + build(:event, name: "Purchase") + ]) + + insert(:goal, %{site: site, event_name: "Purchase"}) + + response = + query_props(conn, site, "cost", + filters: [ + ["is", "event:goal", ["Purchase"]], + ["is_not", "event:props:cost", ["0", "(none)"]] + ], + metrics: ["visitors", "events", "conversion_rate"] + ) + + assert response["results"] == [ + %{"dimensions" => ["20"], "metrics" => [2, 2, 50.0]} + ] + end + + test "returns property breakdown with a pageview goal filter", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/"), + build(:pageview, pathname: "/register"), + build(:pageview, pathname: "/register", "meta.key": ["variant"], "meta.value": ["A"]), + build(:pageview, pathname: "/register", "meta.key": ["variant"], "meta.value": ["A"]) + ]) + + insert(:goal, %{site: site, page_path: "/register"}) + + response = + query_props(conn, site, "variant", + filters: [["is", "event:goal", ["Visit /register"]]], + metrics: ["visitors", "events", "conversion_rate"] + ) + + assert response["results"] == [ + %{"dimensions" => ["A"], "metrics" => [2, 2, 50.0]}, + %{"dimensions" => ["(none)"], "metrics" => [1, 1, 25.0]} + ] + end + + test "property breakdown with prop filter", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, user_id: 1), + build(:event, user_id: 1, name: "Signup", "meta.key": ["variant"], "meta.value": ["A"]), + build(:pageview, user_id: 2), + build(:event, user_id: 2, name: "Signup", "meta.key": ["variant"], "meta.value": ["B"]) + ]) + + insert(:goal, %{site: site, event_name: "Signup"}) + + response = + query_props(conn, site, "variant", + filters: [ + ["is", "event:goal", ["Signup"]], + ["is", "event:props:variant", ["B"]] + ], + metrics: ["visitors", "events", "conversion_rate"] + ) + + assert response["results"] == [ + %{"dimensions" => ["B"], "metrics" => [1, 1, 50.0]} + ] + end + + test "Property breakdown with prop and goal filter", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, user_id: 1, utm_campaign: "campaignA"), + build(:event, + user_id: 1, + name: "ButtonClick", + "meta.key": ["variant"], + "meta.value": ["A"] + ), + build(:pageview, user_id: 2, utm_campaign: "campaignA"), + build(:event, + user_id: 2, + name: "ButtonClick", + "meta.key": ["variant"], + "meta.value": ["B"] + ) + ]) + + insert(:goal, %{site: site, event_name: "ButtonClick"}) + + response = + query_props(conn, site, "variant", + filters: [ + ["is", "event:goal", ["ButtonClick"]], + ["is", "visit:utm_campaign", ["campaignA"]], + ["is", "event:props:variant", ["A"]] + ], + metrics: ["visitors", "events", "conversion_rate"] + ) + + assert response["results"] == [ + %{"dimensions" => ["A"], "metrics" => [1, 1, 50.0]} + ] + end + + test "Property breakdown with goal and source filter", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, user_id: 1, referrer_source: "Google"), + build(:event, + user_id: 1, + name: "ButtonClick", + "meta.key": ["variant"], + "meta.value": ["A"] + ), + build(:pageview, user_id: 2, referrer_source: "Google"), + build(:pageview, user_id: 3, referrer_source: "ignore"), + build(:event, + user_id: 3, + name: "ButtonClick", + "meta.key": ["variant"], + "meta.value": ["B"] + ) + ]) + + insert(:goal, %{site: site, event_name: "ButtonClick"}) + + response = + query_props(conn, site, "variant", + filters: [ + ["is", "event:goal", ["ButtonClick"]], + ["is", "visit:source", ["Google"]] + ], + metrics: ["visitors", "events", "conversion_rate"] + ) + + assert response["results"] == [ + %{"dimensions" => ["A"], "metrics" => [1, 1, 50.0]} + ] + end + + @tag :ee_only + test "returns revenue metrics when filtering by a revenue goal", %{conn: conn, site: site} do + prop_key = "logged_in" + + populate_stats(site, [ + build(:event, + name: "Payment", + "meta.key": [prop_key], + "meta.value": ["true"], + revenue_reporting_amount: Decimal.new("12"), + revenue_reporting_currency: "EUR" + ), + build(:event, + name: "Payment", + "meta.key": [prop_key], + "meta.value": ["true"], + revenue_reporting_amount: Decimal.new("100"), + revenue_reporting_currency: "EUR" + ), + build(:event, + name: "Payment", + "meta.key": [prop_key], + "meta.value": ["false"], + revenue_reporting_amount: Decimal.new("8"), + revenue_reporting_currency: "EUR" + ) + ]) + + insert(:goal, %{ + site: site, + event_name: "Payment", + currency: :EUR, + display_name: "PaymentEUR" + }) + + response = + query_props(conn, site, prop_key, + filters: [["is", "event:goal", ["PaymentEUR"]]], + metrics: [ + "visitors", + "events", + "conversion_rate", + "average_revenue", + "total_revenue" + ] + ) + + assert response["results"] == [ + %{ + "dimensions" => ["true"], + "metrics" => [ + 2, + 2, + 66.67, + %{ + "long" => "€56.00", + "short" => "€56.0", + "value" => 56.0, + "currency" => "EUR" + }, + %{ + "long" => "€112.00", + "short" => "€112.0", + "value" => 112.0, + "currency" => "EUR" + } + ] + }, + %{ + "dimensions" => ["false"], + "metrics" => [ + 1, + 1, + 33.33, + %{"long" => "€8.00", "short" => "€8.0", "value" => 8.0, "currency" => "EUR"}, + %{"long" => "€8.00", "short" => "€8.0", "value" => 8.0, "currency" => "EUR"} + ] + } + ] + end + + @tag :ee_only + test "returns revenue metrics when filtering by many revenue goals with same currency", %{ + conn: conn, + site: site + } do + prop_key = "logged_in" + insert(:goal, site: site, event_name: "Payment", currency: "EUR") + insert(:goal, site: site, event_name: "Payment2", currency: "EUR") + + populate_stats(site, [ + build(:event, + name: "Payment", + "meta.key": [prop_key], + "meta.value": ["false"], + revenue_reporting_amount: Decimal.new("10"), + revenue_reporting_currency: "EUR" + ), + build(:event, + name: "Payment", + "meta.key": [prop_key], + "meta.value": ["true"], + revenue_reporting_amount: Decimal.new("30"), + revenue_reporting_currency: "EUR" + ), + build(:event, + name: "Payment2", + "meta.key": [prop_key], + "meta.value": ["true"], + revenue_reporting_amount: Decimal.new("50"), + revenue_reporting_currency: "EUR" + ) + ]) + + response = + query_props(conn, site, prop_key, + filters: [["is", "event:goal", ["Payment", "Payment2"]]], + metrics: [ + "visitors", + "events", + "conversion_rate", + "average_revenue", + "total_revenue" + ] + ) + + assert response["results"] == [ + %{ + "dimensions" => ["true"], + "metrics" => [ + 2, + 2, + 66.67, + %{ + "long" => "€40.00", + "short" => "€40.0", + "value" => 40.0, + "currency" => "EUR" + }, + %{"long" => "€80.00", "short" => "€80.0", "value" => 80.0, "currency" => "EUR"} + ] + }, + %{ + "dimensions" => ["false"], + "metrics" => [ + 1, + 1, + 33.33, + %{ + "long" => "€10.00", + "short" => "€10.0", + "value" => 10.0, + "currency" => "EUR" + }, + %{"long" => "€10.00", "short" => "€10.0", "value" => 10.0, "currency" => "EUR"} + ] + } + ] + end + + @tag :ee_only + test "does not return revenue metrics when filtering by many revenue goals with different currencies", + %{conn: conn, site: site} do + insert(:goal, site: site, event_name: "Payment", currency: "USD") + insert(:goal, site: site, event_name: "AddToCart", currency: "EUR") + + populate_stats(site, [ + build(:event, + name: "Payment", + "meta.key": ["logged_in"], + "meta.value": ["false"], + revenue_reporting_amount: Decimal.new("10"), + revenue_reporting_currency: "EUR" + ) + ]) + + response = + query_props(conn, site, "whatever-prop", + filters: [["is", "event:goal", ["Payment", "AddToCart"]]], + metrics: [ + "visitors", + "events", + "conversion_rate", + "average_revenue", + "total_revenue" + ] + ) + + refute "average_revenue" in response["query"]["metrics"] + refute "total_revenue" in response["query"]["metrics"] + end + end + + describe "GET /api/stats/:domain/custom-prop-values/:prop_key - other filters" do + setup [:create_user, :log_in, :create_site] + + test "returns prop-breakdown with a page filter", %{conn: conn, site: site} do + prop_key = "parim_s6ber" + + populate_stats(site, [ + build(:pageview, "meta.key": [prop_key], "meta.value": ["K2sna Kalle"]), + build(:pageview, pathname: "/sipsik", "meta.key": [prop_key], "meta.value": ["Sipsik"]) + ]) + + response = + query_props(conn, site, prop_key, filters: [["is", "event:page", ["/sipsik"]]]) + + assert response["results"] == [ + %{"dimensions" => ["Sipsik"], "metrics" => [1, 1, 100.0]} + ] + end + + test "returns prop-breakdown with a session-level filter", %{conn: conn, site: site} do + prop_key = "parim_s6ber" + + populate_stats(site, [ + build(:pageview, "meta.key": [prop_key], "meta.value": ["K2sna Kalle"]), + build(:pageview, + browser: "Chrome", + "meta.key": [prop_key], + "meta.value": ["Sipsik"] + ) + ]) + + response = + query_props(conn, site, prop_key, filters: [["is", "visit:browser", ["Chrome"]]]) + + assert response["results"] == [ + %{"dimensions" => ["Sipsik"], "metrics" => [1, 1, 100.0]} + ] + end + + test "returns prop-breakdown with a prop_value filter", %{conn: conn, site: site} do + prop_key = "parim_s6ber" + + populate_stats(site, [ + build(:pageview, "meta.key": [prop_key], "meta.value": ["K2sna Kalle"]), + build(:pageview, "meta.key": [prop_key], "meta.value": ["K2sna Kalle"]), + build(:pageview, "meta.key": [prop_key], "meta.value": ["Sipsik"]) + ]) + + response = + query_props(conn, site, prop_key, + filters: [["is", "event:props:parim_s6ber", ["Sipsik"]]] + ) + + assert response["results"] == [ + %{"dimensions" => ["Sipsik"], "metrics" => [1, 1, 100.0]} + ] + end + + test "returns prop-breakdown with a prop_value is_not (none) filter", %{ + conn: conn, + site: site + } do + prop_key = "parim_s6ber" + + populate_stats(site, [ + build(:pageview, "meta.key": [prop_key], "meta.value": ["K2sna Kalle"]), + build(:pageview, "meta.key": [prop_key], "meta.value": ["K2sna Kalle"]), + build(:pageview, "meta.key": [prop_key], "meta.value": ["Sipsik"]), + build(:pageview) + ]) + + response = + query_props(conn, site, prop_key, + filters: [["is_not", "event:props:parim_s6ber", ["(none)"]]] + ) + + assert response["results"] == [ + %{"dimensions" => ["K2sna Kalle"], "metrics" => [2, 2, 66.67]}, + %{"dimensions" => ["Sipsik"], "metrics" => [1, 1, 33.33]} + ] + end + + test "returns prop-breakdown with a prop_value matching filter", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, "meta.key": ["key"], "meta.value": ["foo"]), + build(:pageview, "meta.key": ["key"], "meta.value": ["bar"]), + build(:pageview, "meta.key": ["key"], "meta.value": ["bar"]), + build(:pageview, "meta.key": ["key"], "meta.value": ["foobar"]), + build(:pageview) + ]) + + response = + query_props(conn, site, "key", filters: [["contains", "event:props:key", ["bar"]]]) + + assert response["results"] == [ + %{"dimensions" => ["bar"], "metrics" => [2, 2, 66.67]}, + %{"dimensions" => ["foobar"], "metrics" => [1, 1, 33.33]} + ] + end + + test "returns prop-breakdown with multiple matching custom property filters", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, "meta.key": ["key", "other"], "meta.value": ["foo", "1"]), + build(:pageview, "meta.key": ["key", "other"], "meta.value": ["bar", "1"]), + build(:pageview, "meta.key": ["key", "other"], "meta.value": ["bar", "2"]), + build(:pageview, "meta.key": ["key"], "meta.value": ["bar"]), + build(:pageview, "meta.key": ["key", "other"], "meta.value": ["foobar", "1"]), + build(:pageview, "meta.key": ["key", "other"], "meta.value": ["foobar", "3"]), + build(:pageview) + ]) + + response = + query_props(conn, site, "key", + filters: [ + ["contains", "event:props:key", ["bar"]], + ["is", "event:props:other", ["1"]] + ] + ) + + assert response["results"] == [ + %{"dimensions" => ["bar"], "metrics" => [1, 1, 50.0]}, + %{"dimensions" => ["foobar"], "metrics" => [1, 1, 50.0]} + ] + end + end + + describe "GET /api/stats/:domain/custom-prop-values/:prop_key - for a Growth subscription" do + setup [:create_user, :log_in, :create_site] + + setup %{user: user} do + subscribe_to_growth_plan(user) + :ok + end + + for special_prop <- Plausible.Props.internal_keys() do + test "returns breakdown for the internally used #{special_prop} prop key", %{ + site: site, + conn: conn + } do + populate_stats(site, [ + build(:pageview, + "meta.key": [unquote(special_prop)], + "meta.value": ["some_value"] + ) + ]) + + response = query_props(conn, site, unquote(special_prop)) + + assert [ + %{ + "dimensions" => ["some_value"], + "metrics" => [1, 1, 100.0] + } + ] = response["results"] + end + end + + test "returns 400 for a non-internal prop key when on a Growth plan", %{ + conn: conn, + site: site + } do + conn = + conn + |> put_req_header("content-type", "application/json") + |> post( + "/api/stats/#{site.domain}/query", + Jason.encode!(%{ + "dimensions" => ["event:props:prop"], + "date_range" => "day", + "metrics" => ["visitors", "events", "percentage"] + }) + ) + + assert json_response(conn, 400) == %{ + "error" => + "The owner of this site does not have access to the custom properties feature." + } + end + end + + describe "with imported data" do + setup [:create_user, :log_in, :create_site] + + test "gracefully ignores unsupported WP Search Queries goal for imported data", %{ + conn: conn, + site: site + } do + insert(:goal, event_name: "WP Search Queries", site: site) + site_import = insert(:site_import, site: site) + + populate_stats(site, site_import.id, [ + build(:event, + name: "WP Search Queries", + "meta.key": ["search_query", "result_count"], + "meta.value": ["some phrase", "12"] + ), + build(:imported_custom_events, + name: "view_search_results", + visitors: 100, + events: 200 + ), + build(:imported_visitors, visitors: 9) + ]) + + response = + query_props(conn, site, "search_query", + filters: [["is", "event:goal", ["WP Search Queries"]]], + metrics: ["visitors", "events", "conversion_rate"], + include: %{"imports" => true} + ) + + assert response["results"] == [ + %{"dimensions" => ["some phrase"], "metrics" => [1, 1, 100.0]} + ] + end + + for event_name <- Plausible.Event.SystemEvents.events_with_path_prop() do + test "returns path breakdown for #{event_name} goal", %{conn: conn, site: site} do + insert(:goal, event_name: unquote(event_name), site: site) + site_import = insert(:site_import, site: site) + + populate_stats(site, site_import.id, [ + build(:event, + name: unquote(event_name), + "meta.key": ["path"], + "meta.value": ["/some/path/first.bin"] + ), + build(:imported_custom_events, + name: unquote(event_name), + visitors: 2, + events: 5, + path: "/some/path/first.bin" + ), + build(:imported_custom_events, + name: unquote(event_name), + visitors: 5, + events: 10, + path: "/some/path/second.bin" + ), + build(:imported_custom_events, + name: "view_search_results", + visitors: 100, + events: 200 + ), + build(:imported_visitors, visitors: 9) + ]) + + response = + query_props(conn, site, "path", + filters: [["is", "event:goal", [unquote(event_name)]]], + metrics: ["visitors", "events", "conversion_rate"], + include: %{"imports" => true} + ) + + assert response["results"] == [ + %{"dimensions" => ["/some/path/second.bin"], "metrics" => [5, 10, 50.0]}, + %{"dimensions" => ["/some/path/first.bin"], "metrics" => [3, 6, 30.0]} + ] + end + end + + for event_name <- Plausible.Event.SystemEvents.events_with_url_prop() do + test "returns url breakdown for #{event_name} goal", %{conn: conn, site: site} do + insert(:goal, event_name: unquote(event_name), site: site) + site_import = insert(:site_import, site: site) + + populate_stats(site, site_import.id, [ + build(:event, + name: unquote(event_name), + "meta.key": ["url"], + "meta.value": ["https://one.com"] + ), + build(:imported_custom_events, + name: unquote(event_name), + visitors: 2, + events: 5, + link_url: "https://one.com" + ), + build(:imported_custom_events, + name: unquote(event_name), + visitors: 5, + events: 10, + link_url: "https://two.com" + ), + build(:imported_custom_events, + name: "view_search_results", + visitors: 100, + events: 200 + ), + build(:imported_visitors, visitors: 9) + ]) + + response = + query_props(conn, site, "url", + filters: [["is", "event:goal", [unquote(event_name)]]], + metrics: ["visitors", "events", "conversion_rate"], + include: %{"imports" => true} + ) + + assert response["results"] == [ + %{"dimensions" => ["https://two.com"], "metrics" => [5, 10, 50.0]}, + %{"dimensions" => ["https://one.com"], "metrics" => [3, 6, 30.0]} + ] + end + end + + for event_name <- Plausible.Event.SystemEvents.events_with_url_prop() do + test "returns url breakdown for #{event_name} goal with a url filter", %{ + conn: conn, + site: site + } do + insert(:goal, event_name: unquote(event_name), site: site) + site_import = insert(:site_import, site: site) + + populate_stats(site, site_import.id, [ + build(:event, + name: unquote(event_name), + "meta.key": ["url"], + "meta.value": ["https://one.com"] + ), + build(:imported_custom_events, + name: unquote(event_name), + visitors: 2, + events: 5, + link_url: "https://one.com" + ), + build(:imported_custom_events, + name: unquote(event_name), + visitors: 5, + events: 10, + link_url: "https://two.com" + ), + build(:imported_custom_events, + name: "view_search_results", + visitors: 100, + events: 200 + ), + build(:imported_visitors, visitors: 9) + ]) + + response = + query_props(conn, site, "url", + filters: [ + ["is", "event:goal", [unquote(event_name)]], + ["is", "event:props:url", ["https://two.com"]] + ], + metrics: ["visitors", "events", "conversion_rate"], + include: %{"imports" => true} + ) + + assert response["results"] == [ + %{"dimensions" => ["https://two.com"], "metrics" => [5, 10, 50.0]} + ] + end + end + end +end diff --git a/test/plausible_web/controllers/api/stats_controller/dashboard_csv_export_test.exs b/test/plausible_web/controllers/api/stats_controller/dashboard_csv_export_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..fee06be0c2a207c2b5d31d092215a38b21be1f1a --- /dev/null +++ b/test/plausible_web/controllers/api/stats_controller/dashboard_csv_export_test.exs @@ -0,0 +1,1134 @@ +defmodule PlausibleWeb.Api.StatsController.DashboardCsvExportTest do + use PlausibleWeb.ConnCase, async: false + use Plausible.Repo + + @default_reports %{ + "visitors.csv" => %{ + dimensions: ["time:day"], + metrics: [ + "visitors", + "pageviews", + "visits", + "views_per_visit", + "bounce_rate", + "visit_duration" + ] + }, + "sources.csv" => %{ + dimensions: ["visit:source"], + metrics: ["visitors", "bounce_rate", "visit_duration"] + }, + "channels.csv" => %{ + dimensions: ["visit:channel"], + metrics: ["visitors", "bounce_rate", "visit_duration"] + }, + "referrers.csv" => %{ + dimensions: ["visit:referrer"], + metrics: ["visitors", "bounce_rate", "visit_duration"] + }, + "utm_mediums.csv" => %{ + dimensions: ["visit:utm_medium"], + always_on_filters: [["is_not", "visit:utm_medium", [""]]], + metrics: ["visitors", "bounce_rate", "visit_duration"] + }, + "utm_sources.csv" => %{ + dimensions: ["visit:utm_source"], + always_on_filters: [["is_not", "visit:utm_source", [""]]], + metrics: ["visitors", "bounce_rate", "visit_duration"] + }, + "utm_campaigns.csv" => %{ + dimensions: ["visit:utm_campaign"], + always_on_filters: [["is_not", "visit:utm_campaign", [""]]], + metrics: ["visitors", "bounce_rate", "visit_duration"] + }, + "utm_contents.csv" => %{ + dimensions: ["visit:utm_content"], + always_on_filters: [["is_not", "visit:utm_content", [""]]], + metrics: ["visitors", "bounce_rate", "visit_duration"] + }, + "utm_terms.csv" => %{ + dimensions: ["visit:utm_term"], + always_on_filters: [["is_not", "visit:utm_term", [""]]], + metrics: ["visitors", "bounce_rate", "visit_duration"] + }, + "pages.csv" => %{ + dimensions: ["event:page"], + metrics: ["visitors", "pageviews", "bounce_rate", "time_on_page", "scroll_depth"] + }, + "entry_pages.csv" => %{ + dimensions: ["visit:entry_page"], + always_on_filters: [["is_not", "visit:entry_page", [""]]], + metrics: ["visitors", "visits", "bounce_rate", "visit_duration"] + }, + "exit_pages.csv" => %{ + dimensions: ["visit:exit_page"], + always_on_filters: [["is_not", "visit:exit_page", [""]]], + metrics: ["visitors", "visits", "exit_rate"] + }, + "browsers.csv" => %{ + dimensions: ["visit:browser"], + metrics: ["visitors"] + }, + "browser_versions.csv" => %{ + dimensions: ["visit:browser_version", "visit:browser"], + metrics: ["visitors"] + }, + "operating_systems.csv" => %{ + dimensions: ["visit:os"], + metrics: ["visitors"] + }, + "operating_system_versions.csv" => %{ + dimensions: ["visit:os_version", "visit:os"], + metrics: ["visitors"] + }, + "devices.csv" => %{ + dimensions: ["visit:device"], + metrics: ["visitors"] + }, + "countries.csv" => %{ + dimensions: ["visit:country_name"], + always_on_filters: [["is_not", "visit:country", ["\0\0", "ZZ"]]], + metrics: ["visitors"] + }, + "regions.csv" => %{ + dimensions: ["visit:region_name"], + always_on_filters: [["is_not", "visit:region", [""]]], + metrics: ["visitors"] + }, + "cities.csv" => %{ + dimensions: ["visit:city_name"], + always_on_filters: [["is_not", "visit:city", [0]]], + metrics: ["visitors"] + }, + "custom_props.csv" => %{ + dimensions: ["event:props:*"], + metrics: ["visitors", "events", "percentage"] + }, + "conversions.csv" => %{ + dimensions: ["event:goal"], + metrics: ["visitors", "events"] + } + } + + @page_filtered_reports put_in(@default_reports, ["visitors.csv", :metrics], [ + "visitors", + "pageviews", + "visits", + "bounce_rate", + "time_on_page", + "scroll_depth" + ]) + |> put_in( + ["exit_pages.csv", :metrics], + get_in(@default_reports, ["exit_pages.csv", :metrics]) -- ["exit_rate"] + ) + @prop_filtered_reports put_in( + @default_reports, + ["exit_pages.csv", :metrics], + get_in(@default_reports, ["exit_pages.csv", :metrics]) -- ["exit_rate"] + ) + + @goal_filtered_reports Map.new(@default_reports, fn + {"visitors.csv" = filename, params} -> + {filename, + %{params | metrics: ["visitors", "events", "group_conversion_rate"]}} + + {"custom_props.csv" = filename, params} -> + {filename, + %{params | metrics: ["visitors", "events", "conversion_rate"]}} + + {"conversions.csv" = filename, params} -> + {filename, params} + + {filename, params} -> + {filename, + %{params | metrics: ["visitors", "group_conversion_rate"]}} + end) + + @base_params %{ + date_range: "30d", + relative_date: nil, + filters: [], + include: %{imports: false}, + reports: @default_reports + } + + describe "POST /api/stats/:domain/export" do + setup [:create_user, :create_site, :log_in] + + test "exports all the necessary CSV files", %{conn: conn, site: site} do + conn = do_export(conn, site, @base_params) + + assert {"content-type", "application/zip; charset=utf-8"} = + List.keyfind(conn.resp_headers, "content-type", 0) + + {:ok, zip} = :zip.unzip(response(conn, 200), [:memory]) + + zip = Enum.map(zip, fn {filename, _} -> filename end) + + assert ~c"visitors.csv" in zip + assert ~c"browsers.csv" in zip + assert ~c"browser_versions.csv" in zip + assert ~c"cities.csv" in zip + assert ~c"conversions.csv" in zip + assert ~c"countries.csv" in zip + assert ~c"devices.csv" in zip + assert ~c"entry_pages.csv" in zip + assert ~c"exit_pages.csv" in zip + assert ~c"operating_systems.csv" in zip + assert ~c"operating_system_versions.csv" in zip + assert ~c"pages.csv" in zip + assert ~c"regions.csv" in zip + assert ~c"sources.csv" in zip + assert ~c"channels.csv" in zip + assert ~c"utm_campaigns.csv" in zip + assert ~c"utm_contents.csv" in zip + assert ~c"utm_mediums.csv" in zip + assert ~c"utm_sources.csv" in zip + assert ~c"utm_terms.csv" in zip + end + + test "limits pages.csv and exit_pages.csv to 100 rows", %{conn: conn, site: site} do + events = for i <- 1..101, do: build(:pageview, pathname: "/page-#{i}") + populate_stats(site, events) + + zip_content = + conn + |> do_export(site, %{@base_params | date_range: "day"}) + |> response(200) + + pages = unzip_and_parse_csv(zip_content, ~c"pages.csv") + # header + 100 data rows + trailing empty row + assert length(pages) == 102 + + exit_pages = unzip_and_parse_csv(zip_content, ~c"exit_pages.csv") + assert length(exit_pages) == 102 + end + + test "limits custom_props.csv to 25 prop keys", %{conn: conn, site: site} do + keys = for i <- 1..26, do: "key_#{i}" + {:ok, site} = Plausible.Props.allow(site, keys) + + populate_stats(site, [ + build(:pageview, + "meta.key": keys, + "meta.value": List.duplicate("v", 26) + ) + ]) + + custom_props = + conn + |> do_export(site, %{@base_params | date_range: "day"}) + |> response(200) + |> unzip_and_parse_csv(~c"custom_props.csv") + + prop_keys = + custom_props + |> Enum.drop(1) + |> Enum.reject(&(&1 == [""])) + |> Enum.map(&hd/1) + |> Enum.uniq() + + assert length(prop_keys) == 25 + end + + test "limits other breakdown reports to 300 rows", %{conn: conn, site: site} do + events = for i <- 1..301, do: build(:pageview, referrer_source: "Source #{i}") + populate_stats(site, events) + + sources = + conn + |> do_export(site, %{@base_params | date_range: "day"}) + |> response(200) + |> unzip_and_parse_csv(~c"sources.csv") + + # header + 300 data rows + trailing empty row + assert length(sources) == 302 + end + + test "exports scroll depth metric in pages.csv", %{conn: conn, site: site} do + t0 = ~N[2020-01-01 00:00:00] + [t1, t2, t3] = for i <- 1..3, do: NaiveDateTime.add(t0, i, :minute) + + populate_stats(site, [ + build(:pageview, user_id: 12, pathname: "/blog", timestamp: t0), + build(:engagement, + user_id: 12, + pathname: "/blog", + timestamp: t1, + scroll_depth: 20, + engagement_time: 60_000 + ), + build(:pageview, user_id: 12, pathname: "/another", timestamp: t1), + build(:engagement, + user_id: 12, + pathname: "/another", + timestamp: t2, + scroll_depth: 24, + engagement_time: 60_000 + ), + build(:pageview, user_id: 34, pathname: "/blog", timestamp: t0), + build(:engagement, + user_id: 34, + pathname: "/blog", + timestamp: t1, + scroll_depth: 17, + engagement_time: 60_000 + ), + build(:pageview, user_id: 34, pathname: "/another", timestamp: t1), + build(:engagement, + user_id: 34, + pathname: "/another", + timestamp: t2, + scroll_depth: 26, + engagement_time: 60_000 + ), + build(:pageview, user_id: 34, pathname: "/blog", timestamp: t2), + build(:engagement, + user_id: 34, + pathname: "/blog", + timestamp: t3, + scroll_depth: 60, + engagement_time: 60_000 + ), + build(:pageview, user_id: 56, pathname: "/blog", timestamp: t0), + build(:engagement, + user_id: 56, + pathname: "/blog", + timestamp: t1, + scroll_depth: 100, + engagement_time: 60_000 + ) + ]) + + pages = + conn + |> do_export(site, %{@base_params | date_range: "day", relative_date: "2020-01-01"}) + |> response(200) + |> unzip_and_parse_csv(~c"pages.csv") + + assert pages == [ + ["name", "visitors", "pageviews", "bounce_rate", "time_on_page", "scroll_depth"], + ["/blog", "3", "4", "33", "80", "60"], + ["/another", "2", "2", "0", "60", "25"], + [""] + ] + end + + test "exports only internally used props in custom_props.csv for a growth plan", %{ + conn: conn, + site: site + } do + {:ok, site} = Plausible.Props.allow(site, ["author"]) + + [owner | _] = Repo.preload(site, :owners).owners + subscribe_to_growth_plan(owner) + + populate_stats(site, [ + build(:pageview, "meta.key": ["author"], "meta.value": ["a"]), + build(:event, name: "File Download", "meta.key": ["url"], "meta.value": ["b"]) + ]) + + result = + conn + |> do_export(site, %{@base_params | date_range: "day"}) + |> response(200) + |> unzip_and_parse_csv(~c"custom_props.csv") + + assert result == [ + ["property", "value", "visitors", "events", "percentage"], + ["url", "(none)", "1", "1", "50.0"], + ["url", "b", "1", "1", "50.0"], + [""] + ] + end + + test "does not include custom_props.csv for a growth plan if no internal props used", %{ + conn: conn, + site: site + } do + {:ok, site} = Plausible.Props.allow(site, ["author"]) + + [owner | _] = Repo.preload(site, :owners).owners + subscribe_to_growth_plan(owner) + + populate_stats(site, [ + build(:pageview, "meta.key": ["author"], "meta.value": ["a"]) + ]) + + {:ok, zip} = + conn + |> do_export(site, %{@base_params | date_range: "day"}) + |> response(200) + |> :zip.unzip([:memory]) + + files = Map.new(zip) + + refute Map.has_key?(files, ~c"custom_props.csv") + end + + test "exports data in zipped csvs", %{conn: conn, site: site} do + populate_exported_stats(site) + + conn = + do_export(conn, site, %{@base_params | date_range: ["2021-09-20", "2021-10-20"]}) + + assert_zip(conn, "30d") + end + + test "exports allowed event props for a trial account", %{conn: conn, site: site} do + {:ok, site} = Plausible.Props.allow(site, ["author", "logged_in"]) + + populate_stats(site, [ + build(:pageview, "meta.key": ["author"], "meta.value": ["uku"]), + build(:pageview, "meta.key": ["author"], "meta.value": ["uku"]), + build(:event, "meta.key": ["author"], "meta.value": ["marko"], name: "Newsletter Signup"), + build(:pageview, user_id: 999, "meta.key": ["logged_in"], "meta.value": ["true"]), + build(:pageview, user_id: 999, "meta.key": ["logged_in"], "meta.value": ["true"]), + build(:pageview, "meta.key": ["disallowed"], "meta.value": ["whatever"]), + build(:pageview) + ]) + + result = + conn + |> do_export(site, %{@base_params | date_range: "day"}) + |> response(200) + |> unzip_and_parse_csv(~c"custom_props.csv") + + assert result == [ + ["property", "value", "visitors", "events", "percentage"], + ["author", "(none)", "3", "4", "50.0"], + ["author", "uku", "2", "2", "33.33"], + ["author", "marko", "1", "1", "16.67"], + ["logged_in", "(none)", "5", "5", "83.33"], + ["logged_in", "true", "1", "2", "16.67"], + [""] + ] + end + + test "exports data grouped by interval", %{conn: conn, site: site} do + populate_exported_stats(site) + + params = + @base_params + |> Map.put(:date_range, ["2021-09-20", "2021-10-20"]) + |> put_in([:reports, "visitors.csv", :dimensions], ["time:week"]) + + visitors = + conn + |> do_export(site, params) + |> response(200) + |> unzip_and_parse_csv(~c"visitors.csv") + + assert visitors == [ + [ + "date", + "visitors", + "pageviews", + "visits", + "views_per_visit", + "bounce_rate", + "visit_duration" + ], + ["2021-09-20", "1", "1", "1", "1.0", "100", "0"], + ["2021-09-27", "0", "0", "0", "0.0", "0.0", ""], + ["2021-10-04", "0", "0", "0", "0.0", "0.0", ""], + ["2021-10-11", "0", "0", "0", "0.0", "0.0", ""], + ["2021-10-18", "3", "4", "3", "1.33", "33", "40"], + [""] + ] + end + + test "exports operating system versions", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, operating_system: "Mac", operating_system_version: "14"), + build(:pageview, operating_system: "Mac", operating_system_version: "14"), + build(:pageview, operating_system: "Mac", operating_system_version: "14"), + build(:pageview, operating_system: "Ubuntu", operating_system_version: "20.04"), + build(:pageview, operating_system: "Ubuntu", operating_system_version: "20.04"), + build(:pageview, operating_system: "Mac", operating_system_version: "13") + ]) + + os_versions = + conn + |> do_export(site, %{@base_params | date_range: "day"}) + |> response(200) + |> unzip_and_parse_csv(~c"operating_system_versions.csv") + + assert os_versions == [ + ["name", "version", "visitors"], + ["Mac", "14", "3"], + ["Ubuntu", "20.04", "2"], + ["Mac", "13", "1"], + [""] + ] + end + + test "exports imported data when requested", %{conn: conn, site: site} do + site_import = insert(:site_import, site: site) + + insert(:goal, site: site, event_name: "Outbound Link: Click") + + populate_stats(site, site_import.id, [ + build(:imported_visitors, visitors: 9), + build(:imported_browsers, browser: "Chrome", pageviews: 1), + build(:imported_devices, device: "Desktop", pageviews: 1), + build(:imported_entry_pages, entry_page: "/test", pageviews: 1), + build(:imported_exit_pages, exit_page: "/test", pageviews: 1), + build(:imported_locations, country: "PL", region: "PL-22", city: 3_099_434, pageviews: 1), + build(:imported_operating_systems, operating_system: "Mac", pageviews: 1), + build(:imported_pages, page: "/test", pageviews: 1), + build(:imported_sources, + source: "Google", + channel: "Paid Search", + utm_medium: "search", + utm_campaign: "ads", + utm_source: "google", + utm_content: "content", + utm_term: "term", + pageviews: 1 + ), + build(:imported_custom_events, + name: "Outbound Link: Click", + link_url: "https://example.com", + visitors: 5, + events: 10 + ) + ]) + + tomorrow = Date.utc_today() |> Date.add(1) |> Date.to_iso8601() + + params = %{@base_params | relative_date: tomorrow, include: %{imports: true}} + + assert response = do_export(conn, site, params) |> response(200) + {:ok, zip} = :zip.unzip(response, [:memory]) + + filenames = zip |> Enum.map(fn {filename, _} -> to_string(filename) end) + + # NOTE: currently, custom_props.csv is not populated from imported data + expected_filenames = [ + "visitors.csv", + "sources.csv", + "channels.csv", + "utm_mediums.csv", + "utm_sources.csv", + "utm_campaigns.csv", + "utm_contents.csv", + "utm_terms.csv", + "pages.csv", + "entry_pages.csv", + "exit_pages.csv", + "countries.csv", + "regions.csv", + "cities.csv", + "browsers.csv", + "browser_versions.csv", + "operating_systems.csv", + "operating_system_versions.csv", + "devices.csv", + "conversions.csv", + "referrers.csv" + ] + + Enum.each(expected_filenames, fn expected -> assert expected in filenames end) + + Enum.each(zip, fn + {~c"visitors.csv", data} -> + csv = parse_csv(data) + + assert List.first(csv) == [ + "date", + "visitors", + "pageviews", + "visits", + "views_per_visit", + "bounce_rate", + "visit_duration" + ] + + assert Enum.at(csv, -2) == + [Date.to_iso8601(Date.utc_today()), "9", "1", "1", "1.0", "0.0", "10.0"] + + {~c"sources.csv", data} -> + assert parse_csv(data) == [ + ["name", "visitors", "bounce_rate", "visit_duration"], + ["Google", "1", "0.0", "10.0"], + [""] + ] + + {~c"channels.csv", data} -> + assert parse_csv(data) == [ + ["name", "visitors", "bounce_rate", "visit_duration"], + ["Paid Search", "1", "0.0", "10.0"], + [""] + ] + + {~c"utm_mediums.csv", data} -> + assert parse_csv(data) == [ + ["name", "visitors", "bounce_rate", "visit_duration"], + ["search", "1", "0.0", "10.0"], + [""] + ] + + {~c"utm_sources.csv", data} -> + assert parse_csv(data) == [ + ["name", "visitors", "bounce_rate", "visit_duration"], + ["google", "1", "0.0", "10.0"], + [""] + ] + + {~c"utm_campaigns.csv", data} -> + assert parse_csv(data) == [ + ["name", "visitors", "bounce_rate", "visit_duration"], + ["ads", "1", "0.0", "10.0"], + [""] + ] + + {~c"utm_contents.csv", data} -> + assert parse_csv(data) == [ + ["name", "visitors", "bounce_rate", "visit_duration"], + ["content", "1", "0.0", "10.0"], + [""] + ] + + {~c"utm_terms.csv", data} -> + assert parse_csv(data) == [ + ["name", "visitors", "bounce_rate", "visit_duration"], + ["term", "1", "0.0", "10.0"], + [""] + ] + + {~c"pages.csv", data} -> + assert parse_csv(data) == [ + [ + "name", + "visitors", + "pageviews", + "bounce_rate", + "time_on_page", + "scroll_depth" + ], + ["/test", "1", "1", "0.0", "10", ""], + [""] + ] + + {~c"entry_pages.csv", data} -> + assert parse_csv(data) == [ + [ + "name", + "unique_entrances", + "total_entrances", + "bounce_rate", + "visit_duration" + ], + ["/test", "1", "1", "0.0", "10.0"], + [""] + ] + + {~c"exit_pages.csv", data} -> + assert parse_csv(data) == [ + ["name", "unique_exits", "total_exits", "exit_rate"], + ["/test", "1", "1", "100.0"], + [""] + ] + + {~c"countries.csv", data} -> + assert parse_csv(data) == [["name", "visitors"], ["Poland", "1"], [""]] + + {~c"regions.csv", data} -> + assert parse_csv(data) == [["name", "visitors"], ["Pomerania", "1"], [""]] + + {~c"cities.csv", data} -> + assert parse_csv(data) == [["name", "visitors"], ["Gdańsk", "1"], [""]] + + {~c"browsers.csv", data} -> + assert parse_csv(data) == [["name", "visitors"], ["Chrome", "1"], [""]] + + {~c"browser_versions.csv", data} -> + assert parse_csv(data) == [ + ["name", "version", "visitors"], + ["Chrome", "(not set)", "1"], + [""] + ] + + {~c"operating_systems.csv", data} -> + assert parse_csv(data) == [["name", "visitors"], ["Mac", "1"], [""]] + + {~c"operating_system_versions.csv", data} -> + assert parse_csv(data) == [ + ["name", "version", "visitors"], + ["Mac", "(not set)", "1"], + [""] + ] + + {~c"devices.csv", data} -> + assert parse_csv(data) == [["name", "visitors"], ["Desktop", "1"], [""]] + + {~c"conversions.csv", data} -> + assert parse_csv(data) == [ + ["name", "unique_conversions", "total_conversions"], + ["Outbound Link: Click", "5", "10"], + [""] + ] + + {~c"referrers.csv", data} -> + assert parse_csv(data) == [ + ["name", "visitors", "bounce_rate", "visit_duration"], + ["Direct / None", "1", "0.0", "10.0"], + [""] + ] + + {~c"custom_props.csv", _data} -> + :ok + end) + end + end + + defp parse_csv(file_content) when is_binary(file_content) do + file_content + |> String.split("\r\n") + |> Enum.map(&String.split(&1, ",")) + end + + describe "POST /api/stats/:domain/export - via shared link" do + setup [:create_user, :create_site] + + test "exports data in zipped csvs", %{conn: conn, site: site} do + link = insert(:shared_link, site: site) + + populate_exported_stats(site) + + params = + %{@base_params | date_range: ["2021-09-20", "2021-10-20"], include: %{imports: false}} + |> Map.put(:auth, link.slug) + + conn = do_export(conn, site, params) + + assert_zip(conn, "30d") + end + end + + describe "POST /api/stats/:domain/export - for past 6 months" do + setup [:create_user, :create_site, :log_in] + + test "exports 6 months of data in zipped csvs", %{conn: conn, site: site} do + populate_exported_stats(site) + + conn = + do_export(conn, site, %{ + @base_params + | date_range: "6mo", + relative_date: "2021-11-20", + reports: put_in(@default_reports, ["visitors.csv", :dimensions], ["time:month"]) + }) + + assert_zip(conn, "6m") + end + end + + describe "POST /api/stats/:domain/export - with path filter" do + setup [:create_user, :create_site, :log_in] + + test "exports filtered data in zipped csvs", %{conn: conn, site: site} do + populate_exported_stats(site) + + conn = + do_export(conn, site, %{ + @base_params + | date_range: ["2021-09-20", "2021-10-20"], + filters: [["is", "event:page", ["/some-other-page"]]], + reports: @page_filtered_reports + }) + + assert_zip(conn, "30d-filter-path") + end + + test "exports scroll depth in visitors.csv", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, user_id: 12, pathname: "/blog", timestamp: ~N[2020-01-05 00:00:00]), + build(:engagement, + user_id: 12, + pathname: "/blog", + timestamp: ~N[2020-01-05 00:01:00], + scroll_depth: 40 + ), + build(:pageview, user_id: 12, pathname: "/blog", timestamp: ~N[2020-01-05 10:00:00]), + build(:engagement, + user_id: 12, + pathname: "/blog", + timestamp: ~N[2020-01-05 10:01:00], + scroll_depth: 17 + ), + build(:pageview, user_id: 34, pathname: "/blog", timestamp: ~N[2020-01-07 00:00:00]), + build(:engagement, + user_id: 34, + pathname: "/blog", + timestamp: ~N[2020-01-07 00:01:00], + scroll_depth: 90 + ) + ]) + + pages = + conn + |> do_export(site, %{ + @base_params + | date_range: "7d", + relative_date: "2020-01-08", + filters: [["is", "event:page", ["/blog"]]], + reports: @page_filtered_reports + }) + |> response(200) + |> unzip_and_parse_csv(~c"visitors.csv") + + assert pages == [ + [ + "date", + "visitors", + "pageviews", + "visits", + "bounce_rate", + "time_on_page", + "scroll_depth" + ], + ["2020-01-01", "0", "0", "0", "0.0", "", ""], + ["2020-01-02", "0", "0", "0", "0.0", "", ""], + ["2020-01-03", "0", "0", "0", "0.0", "", ""], + ["2020-01-04", "0", "0", "0", "0.0", "", ""], + ["2020-01-05", "1", "2", "2", "100", "0", "28"], + ["2020-01-06", "0", "0", "0", "0.0", "", ""], + ["2020-01-07", "1", "1", "1", "100", "0", "90"], + [""] + ] + end + end + + describe "POST /api/stats/:domain/export - with a custom prop filter" do + setup [:create_user, :create_site, :log_in] + + test "custom-props.csv only returns the prop and its value in filter", %{ + conn: conn, + site: site + } do + {:ok, site} = Plausible.Props.allow(site, ["author", "logged_in"]) + + populate_stats(site, [ + build(:pageview, "meta.key": ["author"], "meta.value": ["uku"]), + build(:pageview, "meta.key": ["author"], "meta.value": ["marko"]), + build(:pageview, "meta.key": ["logged_in"], "meta.value": ["true"]) + ]) + + result = + conn + |> do_export(site, %{ + @base_params + | date_range: "day", + filters: [["is", "event:props:author", ["marko"]]], + reports: @prop_filtered_reports + }) + |> response(200) + |> unzip_and_parse_csv(~c"custom_props.csv") + + assert result == [ + ["property", "value", "visitors", "events", "percentage"], + ["author", "marko", "1", "1", "100.0"], + [""] + ] + end + end + + defp unzip_and_parse_csv(archive, filename) do + {:ok, zip} = :zip.unzip(archive, [:memory]) + {_filename, data} = Enum.find(zip, &(elem(&1, 0) == filename)) + parse_csv(data) + end + + defp assert_zip(conn, folder) do + assert conn.status == 200 + + assert {"content-type", "application/zip; charset=utf-8"} = + List.keyfind(conn.resp_headers, "content-type", 0) + + {:ok, zip} = :zip.unzip(response(conn, 200), [:memory]) + + folder = Path.expand(folder, "test/plausible_web/controllers/CSVs") + + Enum.map(zip, &assert_csv_by_fixture(&1, folder)) + end + + defp assert_csv_by_fixture({file, downloaded}, folder) do + file = Path.expand(file, folder) + + {:ok, content} = File.read(file) + msg = "CSV file comparison failed (#{file})" + assert downloaded == content, message: msg, left: downloaded, right: content + end + + defp do_export(conn, site, params) do + conn + |> put_req_header("content-type", "application/json") + |> post("/api/stats/#{site.domain}/export", Jason.encode!(params)) + end + + defp populate_exported_stats(site) do + populate_stats(site, [ + build(:pageview, + user_id: 123, + pathname: "/", + timestamp: + NaiveDateTime.shift(~N[2021-10-20 12:00:00], minute: -1) + |> NaiveDateTime.truncate(:second), + country_code: "EE", + subdivision1_code: "EE-37", + city_geoname_id: 588_409, + referrer_source: "Google" + ), + build(:engagement, + user_id: 123, + pathname: "/", + timestamp: ~N[2021-10-20 12:00:00] |> NaiveDateTime.truncate(:second), + engagement_time: 30_000, + scroll_depth: 30, + country_code: "EE", + subdivision1_code: "EE-37", + city_geoname_id: 588_409, + referrer_source: "Google" + ), + build(:pageview, + user_id: 123, + pathname: "/some-other-page", + timestamp: + NaiveDateTime.shift(~N[2021-10-20 12:00:00], minute: -2) + |> NaiveDateTime.truncate(:second), + country_code: "EE", + subdivision1_code: "EE-37", + city_geoname_id: 588_409, + referrer_source: "Google" + ), + build(:engagement, + user_id: 123, + pathname: "/some-other-page", + timestamp: + NaiveDateTime.shift(~N[2021-10-20 12:00:00], minute: -1) + |> NaiveDateTime.truncate(:second), + engagement_time: 60_000, + scroll_depth: 30, + country_code: "EE", + subdivision1_code: "EE-37", + city_geoname_id: 588_409, + referrer_source: "Google" + ), + build(:pageview, + user_id: 100, + pathname: "/", + timestamp: + NaiveDateTime.shift(~N[2021-10-20 12:00:00], day: -1) |> NaiveDateTime.truncate(:second), + utm_medium: "search", + utm_campaign: "ads", + utm_source: "google", + utm_content: "content", + utm_term: "term", + browser: "Firefox", + browser_version: "120", + operating_system: "Mac", + operating_system_version: "14" + ), + build(:engagement, + user_id: 100, + pathname: "/", + timestamp: + NaiveDateTime.shift(~N[2021-10-20 12:00:00], day: -1, minute: 1) + |> NaiveDateTime.truncate(:second), + engagement_time: 30_000, + scroll_depth: 30, + utm_medium: "search", + utm_campaign: "ads", + utm_source: "google", + utm_content: "content", + utm_term: "term", + browser: "Firefox", + browser_version: "120", + operating_system: "Mac", + operating_system_version: "14" + ), + build(:pageview, + user_id: 200, + timestamp: + NaiveDateTime.shift(~N[2021-10-20 12:00:00], month: -1) + |> NaiveDateTime.truncate(:second), + country_code: "EE", + browser: "Firefox", + browser_version: "120", + operating_system: "Mac", + operating_system_version: "14" + ), + build(:engagement, + user_id: 200, + timestamp: + NaiveDateTime.shift(~N[2021-10-20 12:00:00], month: -1, minute: 1) + |> NaiveDateTime.truncate(:second), + engagement_time: 30_000, + scroll_depth: 20, + country_code: "EE", + browser: "Firefox", + browser_version: "120", + operating_system: "Mac", + operating_system_version: "14" + ), + build(:pageview, + user_id: 300, + timestamp: + NaiveDateTime.shift(~N[2021-10-20 12:00:00], month: -5) + |> NaiveDateTime.truncate(:second), + utm_campaign: "ads", + country_code: "EE", + referrer_source: "Google", + click_id_param: "gclid", + browser: "FirefoxNoVersion", + operating_system: "MacNoVersion" + ), + build(:engagement, + user_id: 300, + timestamp: + NaiveDateTime.shift(~N[2021-10-20 12:00:00], month: -5, minute: 1) + |> NaiveDateTime.truncate(:second), + engagement_time: 30_000, + scroll_depth: 20, + utm_campaign: "ads", + country_code: "EE", + referrer_source: "Google", + click_id_param: "gclid", + browser: "FirefoxNoVersion", + operating_system: "MacNoVersion" + ), + build(:pageview, + user_id: 456, + timestamp: + NaiveDateTime.shift(~N[2021-10-20 12:00:00], day: -1, minute: -1) + |> NaiveDateTime.truncate(:second), + pathname: "/signup", + "meta.key": ["variant"], + "meta.value": ["A"] + ), + build(:engagement, + user_id: 456, + timestamp: + NaiveDateTime.shift(~N[2021-10-20 12:00:00], day: -1) |> NaiveDateTime.truncate(:second), + pathname: "/signup", + engagement_time: 60_000, + scroll_depth: 20, + "meta.key": ["variant"], + "meta.value": ["A"] + ), + build(:event, + user_id: 456, + timestamp: + NaiveDateTime.shift(~N[2021-10-20 12:00:00], day: -1) |> NaiveDateTime.truncate(:second), + name: "Signup", + "meta.key": ["variant"], + "meta.value": ["A"] + ) + ]) + + insert(:goal, %{site: site, event_name: "Signup"}) + end + + describe "POST /api/stats/:domain/export - with goal filter" do + setup [:create_user, :create_site, :log_in] + + test "exports goal-filtered data in zipped csvs", %{conn: conn, site: site} do + populate_exported_stats(site) + + conn = + do_export(conn, site, %{ + @base_params + | date_range: ["2021-09-20", "2021-10-20"], + filters: [["is", "event:goal", ["Signup"]]], + reports: @goal_filtered_reports + }) + + assert_zip(conn, "30d-filter-goal") + end + + test "custom-props.csv only returns the prop names for the goal in filter", %{ + conn: conn, + site: site + } do + {:ok, site} = Plausible.Props.allow(site, ["author", "logged_in"]) + + populate_stats(site, [ + build(:event, name: "Newsletter Signup", "meta.key": ["author"], "meta.value": ["uku"]), + build(:event, name: "Newsletter Signup", "meta.key": ["author"], "meta.value": ["marko"]), + build(:event, name: "Newsletter Signup", "meta.key": ["author"], "meta.value": ["marko"]), + build(:pageview, "meta.key": ["logged_in"], "meta.value": ["true"]) + ]) + + insert(:goal, site: site, event_name: "Newsletter Signup") + + result = + conn + |> do_export(site, %{ + @base_params + | date_range: "day", + filters: [["is", "event:goal", ["Newsletter Signup"]]], + reports: @goal_filtered_reports + }) + |> response(200) + |> unzip_and_parse_csv(~c"custom_props.csv") + + assert result == [ + ["property", "value", "visitors", "events", "conversion_rate"], + ["author", "marko", "2", "2", "50.0"], + ["author", "uku", "1", "1", "25.0"], + [""] + ] + end + + test "exports conversions and conversion rate for operating system versions", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, operating_system: "Mac", operating_system_version: "14"), + build(:event, name: "Signup", operating_system: "Mac", operating_system_version: "14"), + build(:event, name: "Signup", operating_system: "Mac", operating_system_version: "14"), + build(:event, name: "Signup", operating_system: "Mac", operating_system_version: "14"), + build(:event, + name: "Signup", + operating_system: "Ubuntu", + operating_system_version: "20.04" + ), + build(:event, + name: "Signup", + operating_system: "Ubuntu", + operating_system_version: "20.04" + ), + build(:event, + name: "Signup", + operating_system: "Lubuntu", + operating_system_version: "20.04" + ) + ]) + + insert(:goal, site: site, event_name: "Signup") + + os_versions = + conn + |> do_export(site, %{ + @base_params + | date_range: "day", + filters: [["is", "event:goal", ["Signup"]]], + reports: @goal_filtered_reports + }) + |> response(200) + |> unzip_and_parse_csv(~c"operating_system_versions.csv") + + assert os_versions == [ + ["name", "version", "conversions", "conversion_rate"], + ["Mac", "14", "3", "75.0"], + ["Ubuntu", "20.04", "2", "100.0"], + ["Lubuntu", "20.04", "1", "100.0"], + [""] + ] + end + end +end diff --git a/test/plausible_web/controllers/api/stats_controller/debug_metadata_test.exs b/test/plausible_web/controllers/api/stats_controller/debug_metadata_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..982d31285dfb9a786361caef3765d479fc2cfcbb --- /dev/null +++ b/test/plausible_web/controllers/api/stats_controller/debug_metadata_test.exs @@ -0,0 +1,124 @@ +defmodule PlausibleWeb.Api.StatsController.DebugMetadataTest do + alias Plausible.{IngestRepo, ClickhouseRepo} + use PlausibleWeb.ConnCase + + describe "Debug metadata for logged in requests" do + setup [:create_user, :log_in] + + test "for /api/stats/:domain/query", %{conn: conn, user: user} do + domain = :rand.bytes(20) |> Base.url_encode64() + site = new_site(domain: domain, owner: user) + + conn = + post(conn, "/api/stats/#{site.domain}/query", %{ + "date_range" => "day", + "metrics" => ["visitors"] + }) + + assert json_response(conn, 200) + + IngestRepo.query!("SYSTEM FLUSH LOGS") + + %{rows: [r1, r2]} = + ClickhouseRepo.query!( + "FROM system.query_log SELECT log_comment WHERE JSONExtractString(log_comment, 'site_domain') = {$0:String}", + [site.domain] + ) + + for [unparsed_log_comment] <- [r1, r2] do + decoded = Jason.decode!(unparsed_log_comment) + + assert_matches ^strict_map(%{ + "params" => + ^strict_map(%{ + "domain" => ^site.domain, + "date_range" => "day", + "metrics" => ["visitors"] + }), + "phoenix_action" => "query", + "phoenix_controller" => "Elixir.PlausibleWeb.Api.StatsController", + "request_method" => "POST", + "request_path" => ^"/api/stats/#{site.domain}/query", + "site_domain" => ^site.domain, + "site_id" => ^site.id, + "team_id" => ^team_of(user).id, + "trace_id" => _, + "user_id" => ^user.id + }) = decoded + end + end + end + + defp setup_dashboard_case(domain, type) do + site_owner = new_user() + + case type do + "public" -> + site = new_site(domain: domain, owner: site_owner, public: true) + {site, "", %{"domain" => domain}} + + "shared" -> + site = new_site(domain: domain, owner: site_owner) + link = insert(:shared_link, site: site) + + {site, "?auth=#{link.slug}", %{"domain" => domain, "auth" => link.slug}} + end + end + + describe "Debug metadata for non-private dashboard requests" do + setup [:create_user, :log_in] + + for type <- ["public", "shared"] do + test "for /query request (#{type})", %{ + conn: conn, + user: user + } do + domain = :rand.bytes(20) |> Base.url_encode64() + {site, query_string, expected_params} = setup_dashboard_case(domain, unquote(type)) + + query_params = %{ + "date_range" => "day", + "dimensions" => ["event:page"], + "metrics" => ["visitors"] + } + + expected_params = Map.merge(expected_params, query_params) + + conn = + post(conn, "/api/stats/#{site.domain}/query#{query_string}", query_params) + + assert json_response(conn, 200) + + IngestRepo.query!("SYSTEM FLUSH LOGS") + + %{rows: [r1, r2]} = + ClickhouseRepo.query!( + "FROM system.query_log SELECT log_comment WHERE JSONExtractString(log_comment, 'site_domain') = {$0:String}", + [site.domain] + ) + + for [unparsed_log_comment] <- [r1, r2] do + decoded = Jason.decode!(unparsed_log_comment) + + assert_matches ^strict_map(%{ + # params are asserted below + "params" => %{}, + "phoenix_action" => "query", + "phoenix_controller" => "Elixir.PlausibleWeb.Api.StatsController", + "request_method" => "POST", + "request_path" => ^"/api/stats/#{site.domain}/query", + "site_domain" => ^site.domain, + "site_id" => ^site.id, + # nil team_id because viewing a public/shared dashboard + "team_id" => nil, + "trace_id" => _, + # the logged in user ID is included even when viewing a random public dashboard + "user_id" => ^user.id + }) = decoded + + assert decoded["params"] == expected_params + end + end + end + end +end diff --git a/test/plausible_web/controllers/api/stats_controller/exploration_test.exs b/test/plausible_web/controllers/api/stats_controller/exploration_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..d5c7e4a44c17e3da532ffc313de9fd1d93e76470 --- /dev/null +++ b/test/plausible_web/controllers/api/stats_controller/exploration_test.exs @@ -0,0 +1,508 @@ +defmodule PlausibleWeb.Api.StatsController.ExplorationTest do + use PlausibleWeb.ConnCase, async: false + use Plausible + + on_ee do + alias Plausible.Stats.Exploration.Journey + + setup [:create_user, :log_in, :create_site] + + setup %{user: user, site: site} do + patch_env(:super_admin_user_ids, [user.id]) + + now = DateTime.utc_now() + + populate_stats(site, [ + build(:pageview, + user_id: 123, + pathname: "/home", + timestamp: DateTime.shift(now, minute: -300) + ), + build(:pageview, + user_id: 123, + pathname: "/login", + timestamp: DateTime.shift(now, minute: -270) + ), + build(:pageview, + user_id: 123, + pathname: "/home", + timestamp: DateTime.shift(now, minute: -30) + ), + build(:pageview, + user_id: 123, + pathname: "/login", + timestamp: DateTime.shift(now, minute: -25) + ), + build(:pageview, + user_id: 123, + pathname: "/logout", + timestamp: DateTime.shift(now, minute: -20) + ), + build(:pageview, + user_id: 124, + pathname: "/home", + timestamp: DateTime.shift(now, minute: -30) + ), + build(:pageview, + user_id: 124, + pathname: "/login", + timestamp: DateTime.shift(now, minute: -25) + ), + build(:pageview, + user_id: 124, + pathname: "/docs", + timestamp: DateTime.shift(now, minute: -20) + ), + build(:pageview, + user_id: 124, + pathname: "/logout", + timestamp: DateTime.shift(now, minute: -15) + ) + ]) + + {:ok, site: site} + end + + describe "exploration_next/2" do + test "returns suggestions for the next step", %{conn: conn, site: site} do + journey = + Jason.encode!([ + Journey.Step.new("pageview", "/home"), + Journey.Step.new("pageview", "/login") + ]) + + resp = + conn + |> post("/api/stats/#{site.domain}/exploration/next/", %{ + "journey" => journey, + "period" => "24h" + }) + |> json_response(200) + + assert [next_step1, next_step2, next_step3] = resp + assert next_step1["step"]["label"] == "/docs" + assert next_step1["step"]["pathname"] == "/docs" + assert next_step1["visitors"] == 1 + assert next_step2["step"]["label"] == "/home" + assert next_step2["step"]["pathname"] == "/home" + assert next_step2["visitors"] == 1 + assert next_step3["step"]["label"] == "/logout" + assert next_step3["step"]["pathname"] == "/logout" + assert next_step3["visitors"] == 1 + end + + test "returns error on too long journey", %{conn: conn, site: site} do + journey = + Jason.encode!( + Enum.map(1..20, fn idx -> Journey.Step.new("pageview", "/page#{idx}") end) + ) + + resp = + conn + |> post("/api/stats/#{site.domain}/exploration/next/", %{ + "journey" => journey, + "period" => "24h" + }) + |> json_response(400) + + assert resp["error"] == "The journey is too long" + end + + test "allows filtering suggestions", %{conn: conn, site: site} do + journey = + Jason.encode!([ + Journey.Step.new("pageview", "/home"), + Journey.Step.new("pageview", "/login") + ]) + + resp = + conn + |> post("/api/stats/#{site.domain}/exploration/next/", %{ + "journey" => journey, + "search_term" => "doc", + "period" => "24h" + }) + |> json_response(200) + + assert [next_step] = resp + assert next_step["step"]["pathname"] == "/docs" + assert next_step["visitors"] == 1 + end + + test "supports backward direction", %{conn: conn, site: site} do + journey = Jason.encode!([Journey.Step.new("pageview", "/logout")]) + + resp = + conn + |> post("/api/stats/#{site.domain}/exploration/next/", %{ + "journey" => journey, + "direction" => "backward", + "period" => "24h" + }) + |> json_response(200) + + assert [next_step1, next_step2] = resp + assert next_step1["step"]["pathname"] == "/docs" + assert next_step1["visitors"] == 1 + assert next_step2["step"]["pathname"] == "/login" + assert next_step2["visitors"] == 1 + end + end + + describe "exploration_funnel/2" do + test "returns a funnel", %{conn: conn, site: site} do + journey = + Jason.encode!([ + Journey.Step.new("pageview", "/home"), + Journey.Step.new("pageview", "/login"), + Journey.Step.new("pageview", "/logout") + ]) + + resp = + conn + |> post("/api/stats/#{site.domain}/exploration/funnel/", %{ + "journey" => journey, + "period" => "24h" + }) + |> json_response(200) + + assert [step1, step2, step3] = resp + + assert step1["step"]["label"] == "/home" + assert step1["step"]["pathname"] == "/home" + assert step1["visitors"] == 2 + assert step1["dropoff"] == 0 + assert step1["dropoff_percentage"] == "0" + assert step1["conversion_rate"] == "100" + assert step1["conversion_rate_step"] == "0" + assert step2["step"]["label"] == "/login" + assert step2["step"]["pathname"] == "/login" + assert step2["visitors"] == 2 + assert step2["dropoff"] == 0 + assert step2["dropoff_percentage"] == "0" + assert step2["conversion_rate"] == "100" + assert step2["conversion_rate_step"] == "100" + assert step3["step"]["label"] == "/logout" + assert step3["step"]["pathname"] == "/logout" + assert step3["visitors"] == 1 + assert step3["dropoff"] == 1 + assert step3["dropoff_percentage"] == "50" + assert step3["conversion_rate"] == "50" + assert step3["conversion_rate_step"] == "50" + end + + test "returns error on too long journey", %{conn: conn, site: site} do + journey = + Jason.encode!( + Enum.map(1..21, fn idx -> Journey.Step.new("pageview", "/page#{idx}") end) + ) + + resp = + conn + |> post("/api/stats/#{site.domain}/exploration/funnel/", %{ + "journey" => journey, + "period" => "24h" + }) + |> json_response(400) + + assert resp["error"] == "The journey is too long" + end + + test "returns error on empty journey", %{conn: conn, site: site} do + resp = + conn + |> post("/api/stats/#{site.domain}/exploration/funnel/", %{ + "journey" => Jason.encode!([]), + "period" => "24h" + }) + |> json_response(400) + + assert resp["error"] == "We are unable to show funnels when journey is empty" + end + + test "supports backward direction", %{conn: conn, site: site} do + journey = + Jason.encode!([ + Journey.Step.new("pageview", "/logout"), + Journey.Step.new("pageview", "/login"), + Journey.Step.new("pageview", "/home") + ]) + + resp = + conn + |> post("/api/stats/#{site.domain}/exploration/funnel/", %{ + "journey" => journey, + "direction" => "backward", + "period" => "24h" + }) + |> json_response(200) + + assert [step1, step2, step3] = resp + + assert step1["step"]["pathname"] == "/logout" + assert step1["visitors"] == 2 + assert step1["dropoff"] == 0 + assert step1["dropoff_percentage"] == "0" + assert step1["conversion_rate"] == "100" + assert step1["conversion_rate_step"] == "0" + assert step2["step"]["pathname"] == "/login" + assert step2["visitors"] == 1 + assert step2["dropoff"] == 1 + assert step2["dropoff_percentage"] == "50" + assert step2["conversion_rate"] == "50" + assert step2["conversion_rate_step"] == "50" + assert step3["step"]["pathname"] == "/home" + assert step3["visitors"] == 1 + assert step3["dropoff"] == 0 + assert step3["dropoff_percentage"] == "0" + assert step3["conversion_rate"] == "50" + assert step3["conversion_rate_step"] == "100" + end + end + + describe "exploration_next_with_funnel/2" do + test "returns next steps without funnel by default", %{conn: conn, site: site} do + journey = + Jason.encode!([ + Journey.Step.new("pageview", "/home"), + Journey.Step.new("pageview", "/login") + ]) + + resp = + conn + |> post("/api/stats/#{site.domain}/exploration/next-with-funnel", %{ + "journey" => journey, + "period" => "24h" + }) + |> json_response(200) + + assert [next_step1, next_step2, next_step3] = resp["next"] + assert next_step1["step"]["label"] == "/docs" + assert next_step1["step"]["pathname"] == "/docs" + assert next_step1["visitors"] == 1 + assert next_step2["step"]["label"] == "/home" + assert next_step2["step"]["pathname"] == "/home" + assert next_step2["visitors"] == 1 + assert next_step3["step"]["label"] == "/logout" + assert next_step3["step"]["pathname"] == "/logout" + assert next_step3["visitors"] == 1 + + assert resp["funnel"] == [] + end + + test "filters next steps by search_term", %{conn: conn, site: site} do + journey = + Jason.encode!([ + Journey.Step.new("pageview", "/home"), + Journey.Step.new("pageview", "/login") + ]) + + resp = + conn + |> post("/api/stats/#{site.domain}/exploration/next-with-funnel", %{ + "journey" => journey, + "search_term" => "doc", + "period" => "24h" + }) + |> json_response(200) + + assert [next_step] = resp["next"] + assert next_step["step"]["pathname"] == "/docs" + assert next_step["visitors"] == 1 + assert resp["funnel"] == [] + end + + test "supports backward direction for next steps", %{conn: conn, site: site} do + journey = Jason.encode!([Journey.Step.new("pageview", "/logout")]) + + resp = + conn + |> post("/api/stats/#{site.domain}/exploration/next-with-funnel", %{ + "journey" => journey, + "direction" => "backward", + "period" => "24h" + }) + |> json_response(200) + + assert [next_step1, next_step2] = resp["next"] + assert next_step1["step"]["pathname"] == "/docs" + assert next_step1["visitors"] == 1 + assert next_step2["step"]["pathname"] == "/login" + assert next_step2["visitors"] == 1 + assert resp["funnel"] == [] + end + + test "includes funnel when include_funnel is true", %{conn: conn, site: site} do + journey = + Jason.encode!([ + Journey.Step.new("pageview", "/home"), + Journey.Step.new("pageview", "/login"), + Journey.Step.new("pageview", "/logout") + ]) + + resp = + conn + |> post("/api/stats/#{site.domain}/exploration/next-with-funnel", %{ + "journey" => journey, + "include_funnel" => true, + "period" => "24h" + }) + |> json_response(200) + + assert [step1, step2, step3] = resp["funnel"] + + assert step1["step"]["label"] == "/home" + assert step1["step"]["pathname"] == "/home" + assert step1["visitors"] == 2 + assert step1["dropoff"] == 0 + assert step1["dropoff_percentage"] == "0" + assert step1["conversion_rate"] == "100" + assert step1["conversion_rate_step"] == "0" + assert step2["step"]["label"] == "/login" + assert step2["step"]["pathname"] == "/login" + assert step2["visitors"] == 2 + assert step2["dropoff"] == 0 + assert step2["dropoff_percentage"] == "0" + assert step2["conversion_rate"] == "100" + assert step2["conversion_rate_step"] == "100" + assert step3["step"]["label"] == "/logout" + assert step3["step"]["pathname"] == "/logout" + assert step3["visitors"] == 1 + assert step3["dropoff"] == 1 + assert step3["dropoff_percentage"] == "50" + assert step3["conversion_rate"] == "50" + assert step3["conversion_rate_step"] == "50" + end + + test "includes funnel with backward direction when include_funnel is true", %{ + conn: conn, + site: site + } do + journey = + Jason.encode!([ + Journey.Step.new("pageview", "/logout"), + Journey.Step.new("pageview", "/login"), + Journey.Step.new("pageview", "/home") + ]) + + resp = + conn + |> post("/api/stats/#{site.domain}/exploration/next-with-funnel", %{ + "journey" => journey, + "include_funnel" => true, + "direction" => "backward", + "period" => "24h" + }) + |> json_response(200) + + assert [step1, step2, step3] = resp["funnel"] + + assert step1["step"]["pathname"] == "/logout" + assert step1["visitors"] == 2 + assert step1["dropoff"] == 0 + assert step1["dropoff_percentage"] == "0" + assert step1["conversion_rate"] == "100" + assert step1["conversion_rate_step"] == "0" + assert step2["step"]["pathname"] == "/login" + assert step2["visitors"] == 1 + assert step2["dropoff"] == 1 + assert step2["dropoff_percentage"] == "50" + assert step2["conversion_rate"] == "50" + assert step2["conversion_rate_step"] == "50" + assert step3["step"]["pathname"] == "/home" + assert step3["visitors"] == 1 + assert step3["dropoff"] == 0 + assert step3["dropoff_percentage"] == "0" + assert step3["conversion_rate"] == "50" + assert step3["conversion_rate_step"] == "100" + end + + test "returns empty funnel when include_funnel is true but journey is empty", %{ + conn: conn, + site: site + } do + journey = Jason.encode!([]) + + resp = + conn + |> post("/api/stats/#{site.domain}/exploration/next-with-funnel", %{ + "journey" => journey, + "include_funnel" => true, + "period" => "24h" + }) + |> json_response(200) + + # An empty journey still returns starting-point candidates for `next` + assert is_list(resp["next"]) + # Funnel requires at least one step; empty journey yields empty funnel + assert resp["funnel"] == [] + end + end + + describe "rate limiting" do + defp exhaust_exploration_rate_limit(site) do + Plausible.RateLimit.check_rate( + Plausible.RateLimit, + "exploration:#{site.id}", + :timer.hours(1), + 600, + 601 + ) + end + + test "returns 429 for exploration_next when rate limit exceeded", %{ + conn: conn, + site: site + } do + exhaust_exploration_rate_limit(site) + + journey = Jason.encode!([Journey.Step.new("pageview", "/home")]) + + resp = + conn + |> post("/api/stats/#{site.domain}/exploration/next/", %{ + "journey" => journey, + "period" => "all" + }) + + assert json_response(resp, 429) == %{"error" => "Too many exploration requests"} + end + + test "returns 429 for exploration_funnel when rate limit exceeded", %{ + conn: conn, + site: site + } do + exhaust_exploration_rate_limit(site) + + journey = Jason.encode!([Journey.Step.new("pageview", "/home")]) + + resp = + conn + |> post("/api/stats/#{site.domain}/exploration/funnel/", %{ + "journey" => journey, + "period" => "all" + }) + + assert json_response(resp, 429) == %{"error" => "Too many exploration requests"} + end + + test "returns 429 for exploration_next_with_funnel when rate limit exceeded", %{ + conn: conn, + site: site + } do + exhaust_exploration_rate_limit(site) + + journey = Jason.encode!([Journey.Step.new("pageview", "/home")]) + + resp = + conn + |> post("/api/stats/#{site.domain}/exploration/next-with-funnel/", %{ + "journey" => journey, + "period" => "all" + }) + + assert json_response(resp, 429) == %{"error" => "Too many exploration requests"} + end + end + end +end diff --git a/test/plausible_web/controllers/api/stats_controller/funnels_test.exs b/test/plausible_web/controllers/api/stats_controller/funnels_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..7bdee7d0794389792041517a6345b303c4c3b711 --- /dev/null +++ b/test/plausible_web/controllers/api/stats_controller/funnels_test.exs @@ -0,0 +1,428 @@ +defmodule PlausibleWeb.Api.StatsController.FunnelsTest do + use PlausibleWeb.ConnCase, async: true + @moduletag :ee_only + + on_ee do + @user_id Enum.random(1000..9999) + @other_user_id @user_id + 1 + + @build_funnel_with [ + {"page_path", "/blog/announcement"}, + {"event_name", "Signup"}, + {"page_path", "/cart/add/product"}, + {"event_name", "Purchase"} + ] + + describe "GET /api/stats/funnel - default" do + setup [:create_user, :log_in, :create_site] + + test "computes funnel for a day", %{conn: conn, site: site} do + {:ok, funnel} = setup_funnel(site, @build_funnel_with) + + populate_stats(site, [ + build(:pageview, pathname: "/some/irrelevant", user_id: 9_999_999), + build(:pageview, pathname: "/blog/announcement", user_id: @user_id), + build(:pageview, pathname: "/blog/announcement", user_id: @other_user_id), + build(:event, name: "Signup", user_id: @user_id), + build(:event, name: "Signup", user_id: @other_user_id), + build(:pageview, pathname: "/cart/add/product", user_id: @user_id), + build(:event, name: "Purchase", user_id: @user_id) + ]) + + resp = + conn + |> get("/api/stats/#{site.domain}/funnels/#{funnel.id}/?period=day") + |> json_response(200) + + assert %{ + "name" => "Test funnel", + "all_visitors" => 3, + "entering_visitors" => 2, + "entering_visitors_percentage" => "66.67", + "never_entering_visitors" => 1, + "never_entering_visitors_percentage" => "33.33", + "steps" => [ + %{ + "conversion_rate" => "100", + "conversion_rate_step" => "0", + "dropoff" => 0, + "dropoff_percentage" => "0", + "label" => "Visit /blog/announcement", + "visitors" => 2 + }, + %{ + "conversion_rate" => "100", + "conversion_rate_step" => "100", + "dropoff" => 0, + "dropoff_percentage" => "0", + "label" => "Signup", + "visitors" => 2 + }, + %{ + "conversion_rate" => "50", + "conversion_rate_step" => "50", + "dropoff" => 1, + "dropoff_percentage" => "50", + "label" => "Visit /cart/add/product", + "visitors" => 1 + }, + %{ + "conversion_rate" => "50", + "conversion_rate_step" => "100", + "dropoff" => 0, + "dropoff_percentage" => "0", + "label" => "Purchase", + "visitors" => 1 + } + ] + } = resp + end + + test "404 for unknown funnel", %{site: site, conn: conn} do + resp = + conn + |> get("/api/stats/#{site.domain}/funnels/122873/?period=day") + |> json_response(404) + + assert resp == %{"error" => "Funnel not found"} + end + + test "400 for bad funnel ID", %{site: site, conn: conn} do + resp = + conn + |> get("/api/stats/#{site.domain}/funnels/foobar/?period=day") + |> json_response(400) + + assert resp == %{"error" => "There was an error with your request"} + end + + test "computes all-time funnel with filters", %{conn: conn, user: user} do + site = new_site(stats_start_date: ~D[2020-01-01], owner: user) + {:ok, funnel} = setup_funnel(site, @build_funnel_with) + + populate_stats(site, [ + build(:pageview, pathname: "/blog/announcement", user_id: @user_id), + build(:pageview, + pathname: "/blog/announcement", + user_id: @other_user_id, + timestamp: ~N[2021-01-01 12:00:00], + utm_medium: "social" + ), + build(:event, name: "Signup", user_id: @user_id), + build(:event, + name: "Signup", + user_id: @other_user_id, + timestamp: ~N[2021-01-01 12:01:00], + utm_medium: "social" + ), + build(:pageview, pathname: "/cart/add/product", user_id: @user_id), + build(:event, name: "Purchase", user_id: @user_id) + ]) + + filters = Jason.encode!([[:is, "visit:utm_medium", ["social"]]]) + + resp = + conn + |> get("/api/stats/#{site.domain}/funnels/#{funnel.id}/?period=all&filters=#{filters}") + |> json_response(200) + + assert %{ + "name" => "Test funnel", + "all_visitors" => 1, + "entering_visitors" => 1, + "entering_visitors_percentage" => "100", + "never_entering_visitors" => 0, + "never_entering_visitors_percentage" => "0", + "steps" => [ + %{ + "conversion_rate" => "100", + "conversion_rate_step" => "0", + "dropoff" => 0, + "dropoff_percentage" => "0", + "label" => "Visit /blog/announcement", + "visitors" => 1 + }, + %{ + "conversion_rate" => "100", + "conversion_rate_step" => "100", + "dropoff" => 0, + "dropoff_percentage" => "0", + "label" => "Signup", + "visitors" => 1 + }, + %{ + "conversion_rate" => "0", + "conversion_rate_step" => "0", + "dropoff" => 1, + "dropoff_percentage" => "100", + "label" => "Visit /cart/add/product", + "visitors" => 0 + }, + %{ + "conversion_rate" => "0", + "conversion_rate_step" => "0", + "dropoff" => 0, + "dropoff_percentage" => "0", + "label" => "Purchase", + "visitors" => 0 + } + ] + } = resp + end + + test "computes an empty funnel", %{conn: conn, site: site} do + {:ok, funnel} = setup_funnel(site, @build_funnel_with) + + resp = + conn + |> get("/api/stats/#{site.domain}/funnels/#{funnel.id}/?period=day") + |> json_response(200) + + assert %{ + "name" => "Test funnel", + "all_visitors" => 0, + "entering_visitors" => 0, + "entering_visitors_percentage" => "0", + "never_entering_visitors" => 0, + "never_entering_visitors_percentage" => "0", + "steps" => [ + %{ + "conversion_rate" => "0", + "conversion_rate_step" => "0", + "dropoff" => 0, + "dropoff_percentage" => "0", + "label" => "Visit /blog/announcement", + "visitors" => 0 + }, + %{ + "conversion_rate" => "0", + "conversion_rate_step" => "0", + "dropoff" => 0, + "dropoff_percentage" => "0", + "label" => "Signup", + "visitors" => 0 + }, + %{ + "conversion_rate" => "0", + "conversion_rate_step" => "0", + "dropoff" => 0, + "dropoff_percentage" => "0", + "label" => "Visit /cart/add/product", + "visitors" => 0 + }, + %{ + "conversion_rate" => "0", + "conversion_rate_step" => "0", + "dropoff" => 0, + "dropoff_percentage" => "0", + "label" => "Purchase", + "visitors" => 0 + } + ] + } = resp + end + + test "returns HTTP 402 when site owner is on a growth plan", %{ + conn: conn, + user: user, + site: site + } do + {:ok, funnel} = setup_funnel(site, @build_funnel_with) + subscribe_to_growth_plan(user) + + resp = + conn + |> get("/api/stats/#{site.domain}/funnels/#{funnel.id}/?period=day") + |> json_response(402) + + assert %{ + "error" => + "Funnels and user journeys is part of the Plausible Business plan. To get access to this feature, please upgrade your account." + } == resp + end + end + + describe "GET /api/stats/funnel - disallowed filters" do + setup [:create_user, :log_in, :create_site] + + test "event:page", %{conn: conn, site: site} do + {:ok, funnel} = setup_funnel(site, @build_funnel_with) + + filters = Jason.encode!([[:is, "event:page", ["/pageA"]]]) + + resp = + conn + |> get("/api/stats/#{site.domain}/funnels/#{funnel.id}/?period=day&filters=#{filters}") + |> json_response(400) + + assert resp == %{ + "error" => + "We are unable to show funnels when the dashboard is filtered by pages", + "level" => "normal" + } + end + + test "event:goal", %{conn: conn, site: site} do + {:ok, funnel} = setup_funnel(site, @build_funnel_with) + + filters = + Jason.encode!([[:is, "event:goal", ["Signup"]], [:is, "event:page", ["/pageA"]]]) + + resp = + conn + |> get("/api/stats/#{site.domain}/funnels/#{funnel.id}/?period=day&filters=#{filters}") + |> json_response(400) + + assert resp == %{ + "error" => + "We are unable to show funnels when the dashboard is filtered by goals", + "level" => "normal" + } + end + + test "period: realtime", %{conn: conn, site: site} do + {:ok, funnel} = setup_funnel(site, @build_funnel_with) + + resp = + conn + |> get("/api/stats/#{site.domain}/funnels/#{funnel.id}/?period=realtime") + |> json_response(400) + + assert resp == %{ + "error" => + "We are unable to show funnels when the dashboard is filtered by realtime period", + "level" => "normal" + } + end + end + + describe "GET /api/stats/funnel - page scroll goals" do + setup [:create_user, :log_in, :create_site] + + test "computes a funnel with page scroll goals", %{conn: conn, site: site} do + goals = [ + insert(:goal, site: site, event_name: "Onboarding Start"), + insert(:goal, + site: site, + page_path: "/onboard", + scroll_threshold: 25, + display_name: "Scroll 25% on /onboard" + ), + insert(:goal, + site: site, + page_path: "/onboard", + scroll_threshold: 50, + display_name: "Scroll 50% on /onboard" + ), + insert(:goal, + site: site, + page_path: "/onboard", + scroll_threshold: 75, + display_name: "Scroll 75% on /onboard" + ), + insert(:goal, site: site, page_path: "/onboard-completed") + ] + + {:ok, funnel} = + Plausible.Funnels.create(site, "Onboarding", Enum.map(goals, &%{"goal_id" => &1.id})) + + populate_stats(site, [ + # user 1 - completes the whole funnel + build(:event, user_id: 1, name: "Onboarding Start", timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, user_id: 1, pathname: "/onboard", timestamp: ~N[2021-01-01 00:00:10]), + build(:engagement, + user_id: 1, + pathname: "/onboard", + scroll_depth: 80, + timestamp: ~N[2021-01-01 00:00:20] + ), + build(:pageview, + user_id: 1, + pathname: "/onboard-completed", + timestamp: ~N[2021-01-01 00:00:30] + ), + # user 2 - drops off after scrolling 25% on /onboard + build(:event, user_id: 2, name: "Onboarding Start", timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, user_id: 2, pathname: "/onboard", timestamp: ~N[2021-01-01 00:00:10]), + build(:engagement, + user_id: 2, + pathname: "/onboard", + scroll_depth: 25, + timestamp: ~N[2021-01-01 00:00:20] + ) + ]) + + resp = + conn + |> get("/api/stats/#{site.domain}/funnels/#{funnel.id}/?period=day&date=2021-01-01") + |> json_response(200) + + assert %{ + "all_visitors" => 2, + "entering_visitors" => 2, + "entering_visitors_percentage" => "100", + "name" => "Onboarding", + "never_entering_visitors" => 0, + "never_entering_visitors_percentage" => "0", + "steps" => [ + %{ + "conversion_rate" => "100", + "conversion_rate_step" => "0", + "dropoff" => 0, + "dropoff_percentage" => "0", + "label" => "Onboarding Start", + "visitors" => 2 + }, + %{ + "conversion_rate" => "100", + "conversion_rate_step" => "100", + "dropoff" => 0, + "dropoff_percentage" => "0", + "label" => "Scroll 25% on /onboard", + "visitors" => 2 + }, + %{ + "conversion_rate" => "50", + "conversion_rate_step" => "50", + "dropoff" => 1, + "dropoff_percentage" => "50", + "label" => "Scroll 50% on /onboard", + "visitors" => 1 + }, + %{ + "conversion_rate" => "50", + "conversion_rate_step" => "100", + "dropoff" => 0, + "dropoff_percentage" => "0", + "label" => "Scroll 75% on /onboard", + "visitors" => 1 + }, + %{ + "conversion_rate" => "50", + "conversion_rate_step" => "100", + "dropoff" => 0, + "dropoff_percentage" => "0", + "label" => "Visit /onboard-completed", + "visitors" => 1 + } + ] + } = resp + end + end + + defp setup_goals(site, goals) when is_list(goals) do + goals = + Enum.map(goals, fn {type, value} -> + {:ok, g} = Plausible.Goals.create(site, %{type => value}) + g + end) + + {:ok, goals} + end + + defp setup_funnel(site, goal_names) do + {:ok, goals} = setup_goals(site, goal_names) + Plausible.Funnels.create(site, "Test funnel", Enum.map(goals, &%{"goal_id" => &1.id})) + end + end +end diff --git a/test/plausible_web/controllers/api/stats_controller/imported_test.exs b/test/plausible_web/controllers/api/stats_controller/imported_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..b93fa68a3a06b7a044e8e3b8b2b4c34168379e61 --- /dev/null +++ b/test/plausible_web/controllers/api/stats_controller/imported_test.exs @@ -0,0 +1,1358 @@ +defmodule PlausibleWeb.Api.StatsController.ImportedTest do + use PlausibleWeb.ConnCase + + @user_id Enum.random(1000..9999) + + defp do_query(conn, site, params) do + conn + |> post("/api/stats/#{site.domain}/query", params) + |> json_response(200) + end + + defp import_data(ga_data, site_id, import_id, table_name) do + ga_data + |> Plausible.Imported.GoogleAnalytics4.from_report(site_id, import_id, table_name) + |> then(&Plausible.Imported.Buffer.insert_all(table_name, &1)) + end + + for import_type <- [:new_and_legacy, :new] do + describe "Parse and import third party data fetched from Google Analytics as #{import_type} import" do + setup [:create_user, :log_in, :create_site] + + setup %{user: user, site: site} do + import_params = + %{ + source: :google_analytics_4, + start_date: ~D[2005-01-01], + end_date: Date.utc_today(), + legacy: unquote(import_type) == :new_and_legacy + } + + site_import = + site + |> Plausible.Imported.SiteImport.create_changeset( + user, + import_params + ) + |> Plausible.Repo.insert!() + |> Plausible.Imported.SiteImport.complete_changeset() + |> Plausible.Repo.update!() + + {:ok, %{import_id: site_import.id}} + end + + test "Visitors data imported from Google Analytics", %{ + conn: conn, + site: site, + import_id: import_id + } do + populate_stats(site, [ + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-31 00:00:00]) + ]) + + import_data( + [ + %{ + dimensions: %{"date" => "20210101"}, + metrics: %{ + "totalUsers" => "1", + "screenPageViews" => "1", + "bounces" => "0", + "sessions" => "1", + "userEngagementDuration" => "60" + } + }, + %{ + dimensions: %{"date" => "20210131"}, + metrics: %{ + "totalUsers" => "1", + "screenPageViews" => "1", + "bounces" => "0", + "sessions" => "1", + "userEngagementDuration" => "60" + } + } + ], + site.id, + import_id, + "imported_visitors" + ) + + params = %{ + "date_range" => "month", + "metrics" => ["visitors"], + "relative_date" => "2021-01-01", + "dimensions" => ["time:day"], + "include" => %{"imports" => true, "time_labels" => true} + } + + response = do_query(conn, site, params) + + assert length(response["meta"]["time_labels"]) == 31 + + assert response["results"] == [ + %{"dimensions" => ["2021-01-01"], "metrics" => [2]}, + %{"dimensions" => ["2021-01-31"], "metrics" => [2]} + ] + end + + test "returns data grouped by week", %{conn: conn, site: site, import_id: import_id} do + populate_stats(site, [ + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-31 00:00:00]) + ]) + + import_data( + [ + %{ + dimensions: %{"date" => "20210101"}, + metrics: %{ + "totalUsers" => "1", + "screenPageViews" => "1", + "bounces" => "0", + "sessions" => "1", + "userEngagementDuration" => "60" + } + }, + %{ + dimensions: %{"date" => "20210131"}, + metrics: %{ + "totalUsers" => "1", + "screenPageViews" => "1", + "bounces" => "0", + "sessions" => "1", + "userEngagementDuration" => "60" + } + } + ], + site.id, + import_id, + "imported_visitors" + ) + + params = %{ + "date_range" => "month", + "metrics" => ["visitors"], + "relative_date" => "2021-01-01", + "dimensions" => ["time:week"], + "include" => %{"imports" => true, "time_labels" => true} + } + + response = do_query(conn, site, params) + + assert length(response["meta"]["time_labels"]) == 5 + + assert response["results"] == [ + %{"dimensions" => ["2021-01-01"], "metrics" => [2]}, + %{"dimensions" => ["2021-01-25"], "metrics" => [2]} + ] + end + + test "Sources are imported", %{conn: conn, site: site, import_id: import_id} do + populate_stats(site, [ + build(:pageview, + referrer_source: "Google", + referrer: "google.com", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + referrer_source: "Google", + referrer: "google.com", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + referrer_source: "DuckDuckGo", + referrer: "duckduckgo.com", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + populate_stats(site, import_id, [ + build(:imported_visitors, date: ~D[2021-01-01]), + build(:imported_visitors, date: ~D[2021-01-01]), + build(:imported_visitors, date: ~D[2021-01-01]), + build(:imported_visitors, date: ~D[2021-01-01]), + build(:imported_visitors, date: ~D[2021-01-31]), + build(:imported_visitors, date: ~D[2021-01-31]) + ]) + + import_data( + [ + %{ + dimensions: %{ + "sessionManualAdContent" => "", + "sessionCampaignName" => "", + "date" => "20210101", + "sessionGoogleAdsKeyword" => "", + "sessionMedium" => "organic", + "sessionSource" => "duckduckgo.com", + "sessionDefaultChannelGroup" => "" + }, + metrics: %{ + "bounces" => "0", + "userEngagementDuration" => "60", + "sessions" => "1", + "totalUsers" => "1", + "screenPageViews" => "1" + } + }, + %{ + dimensions: %{ + "sessionManualAdContent" => "", + "sessionCampaignName" => "", + "date" => "20210131", + "sessionGoogleAdsKeyword" => "", + "sessionMedium" => "organic", + "sessionSource" => "google.com", + "sessionDefaultChannelGroup" => "" + }, + metrics: %{ + "bounces" => "1", + "userEngagementDuration" => "60", + "sessions" => "1", + "totalUsers" => "1", + "screenPageViews" => "1" + } + }, + %{ + dimensions: %{ + "sessionManualAdContent" => "", + "sessionCampaignName" => "", + "date" => "20210101", + "sessionGoogleAdsKeyword" => "", + "sessionMedium" => "paid", + "sessionSource" => "google.com", + "sessionDefaultChannelGroup" => "" + }, + metrics: %{ + "bounces" => "1", + "userEngagementDuration" => "60", + "sessions" => "1", + "totalUsers" => "1", + "screenPageViews" => "1" + } + }, + %{ + dimensions: %{ + "sessionManualAdContent" => "", + "sessionCampaignName" => "", + "date" => "20210101", + "sessionGoogleAdsKeyword" => "", + "sessionMedium" => "social", + "sessionSource" => "Twitter", + "sessionDefaultChannelGroup" => "" + }, + metrics: %{ + "bounces" => "1", + "userEngagementDuration" => "60", + "sessions" => "1", + "totalUsers" => "1", + "screenPageViews" => "1" + } + }, + %{ + dimensions: %{ + "sessionManualAdContent" => "", + "sessionCampaignName" => "newsletter", + "date" => "20210131", + "sessionGoogleAdsKeyword" => "", + "sessionMedium" => "email", + "sessionSource" => "A Nice Newsletter", + "sessionDefaultChannelGroup" => "" + }, + metrics: %{ + "bounces" => "1", + "userEngagementDuration" => "60", + "sessions" => "1", + "totalUsers" => "1", + "screenPageViews" => "1" + } + }, + %{ + dimensions: %{ + "sessionManualAdContent" => "", + "sessionCampaignName" => "", + "date" => "20210101", + "sessionGoogleAdsKeyword" => "", + "sessionMedium" => "(none)", + "sessionSource" => "(direct)", + "sessionDefaultChannelGroup" => "" + }, + metrics: %{ + "bounces" => "1", + "userEngagementDuration" => "60", + "sessions" => "1", + "totalUsers" => "1", + "screenPageViews" => "1" + } + } + ], + site.id, + import_id, + "imported_sources" + ) + + response = + do_query(conn, site, %{ + "date_range" => ["2021-01-01", "2021-01-31"], + "dimensions" => ["visit:source"], + "metrics" => ["visitors", "percentage"], + "order_by" => [["visit:source", "asc"]], + "include" => %{"imports" => true} + }) + + assert response["results"] == [ + %{"dimensions" => ["A Nice Newsletter"], "metrics" => [1, 11.11]}, + %{"dimensions" => ["Direct / None"], "metrics" => [1, 11.11]}, + %{"dimensions" => ["DuckDuckGo"], "metrics" => [2, 22.22]}, + %{"dimensions" => ["Google"], "metrics" => [4, 44.44]}, + %{"dimensions" => ["X (Twitter)"], "metrics" => [1, 11.11]} + ] + end + + test "Channels are imported", %{conn: conn, site: site, import_id: import_id} do + populate_stats(site, [ + # Organic Search + build(:pageview, + referrer_source: "Bing", + timestamp: ~N[2021-01-01 00:00:00] + ), + # Paid Search + build(:pageview, + referrer_source: "Google", + utm_medium: "paid", + timestamp: ~N[2021-01-01 00:00:00] + ), + # Direct + build(:pageview, + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + import_data( + [ + %{ + dimensions: %{ + "sessionManualAdContent" => "", + "sessionCampaignName" => "", + "date" => "20210101", + "sessionGoogleAdsKeyword" => "", + "sessionMedium" => "organic", + "sessionSource" => "duckduckgo.com", + "sessionDefaultChannelGroup" => "Organic Search" + }, + metrics: %{ + "bounces" => "0", + "userEngagementDuration" => "60", + "sessions" => "1", + "totalUsers" => "1", + "screenPageViews" => "1" + } + }, + %{ + dimensions: %{ + "sessionManualAdContent" => "", + "sessionCampaignName" => "", + "date" => "20210131", + "sessionGoogleAdsKeyword" => "", + "sessionMedium" => "organic", + "sessionSource" => "google.com", + "sessionDefaultChannelGroup" => "Organic Search" + }, + metrics: %{ + "bounces" => "1", + "userEngagementDuration" => "60", + "sessions" => "1", + "totalUsers" => "1", + "screenPageViews" => "1" + } + }, + %{ + dimensions: %{ + "sessionManualAdContent" => "", + "sessionCampaignName" => "", + "date" => "20210101", + "sessionGoogleAdsKeyword" => "", + "sessionMedium" => "paid", + "sessionSource" => "google.com", + "sessionDefaultChannelGroup" => "Paid Search" + }, + metrics: %{ + "bounces" => "1", + "userEngagementDuration" => "60", + "sessions" => "1", + "totalUsers" => "1", + "screenPageViews" => "1" + } + }, + %{ + dimensions: %{ + "sessionManualAdContent" => "", + "sessionCampaignName" => "", + "date" => "20210101", + "sessionGoogleAdsKeyword" => "", + "sessionMedium" => "(none)", + "sessionSource" => "(direct)", + "sessionDefaultChannelGroup" => "Direct" + }, + metrics: %{ + "bounces" => "1", + "userEngagementDuration" => "60", + "sessions" => "1", + "totalUsers" => "1", + "screenPageViews" => "1" + } + }, + %{ + dimensions: %{ + "sessionManualAdContent" => "", + "sessionCampaignName" => "", + "date" => "20210101", + "sessionGoogleAdsKeyword" => "", + "sessionMedium" => "(none)", + "sessionSource" => "(direct)", + "sessionDefaultChannelGroup" => "" + }, + metrics: %{ + "bounces" => "1", + "userEngagementDuration" => "60", + "sessions" => "1", + "totalUsers" => "1", + "screenPageViews" => "1" + } + } + ], + site.id, + import_id, + "imported_sources" + ) + + response = + do_query(conn, site, %{ + "date_range" => ["2021-01-01", "2021-01-31"], + "dimensions" => ["visit:channel"], + "metrics" => ["visitors", "percentage"], + "order_by" => [["visit:channel", "asc"]], + "include" => %{"imports" => true} + }) + + assert response["results"] == [ + %{"dimensions" => ["(not set)"], "metrics" => [1, 33.33]}, + %{"dimensions" => ["Direct"], "metrics" => [2, 66.67]}, + %{"dimensions" => ["Organic Search"], "metrics" => [3, 100.0]}, + %{"dimensions" => ["Paid Search"], "metrics" => [2, 66.67]} + ] + end + + test "UTM mediums data imported from Google Analytics", %{ + conn: conn, + site: site, + import_id: import_id + } do + populate_stats(site, [ + build(:pageview, + utm_medium: "social", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + utm_medium: "social", + timestamp: ~N[2021-01-01 12:00:00] + ) + ]) + + import_data( + [ + %{ + dimensions: %{ + "sessionManualAdContent" => "", + "sessionCampaignName" => "", + "date" => "20210101", + "sessionGoogleAdsKeyword" => "", + "sessionMedium" => "social", + "sessionSource" => "Twitter", + "sessionDefaultChannelGroup" => "" + }, + metrics: %{ + "bounces" => "1", + "userEngagementDuration" => "60", + "sessions" => "1", + "totalUsers" => "1", + "screenPageViews" => "1" + } + }, + %{ + dimensions: %{ + "sessionManualAdContent" => "", + "sessionCampaignName" => "", + "date" => "20210101", + "sessionGoogleAdsKeyword" => "", + "sessionMedium" => "(none)", + "sessionSource" => "(direct)", + "sessionDefaultChannelGroup" => "" + }, + metrics: %{ + "bounces" => "1", + "userEngagementDuration" => "60", + "sessions" => "1", + "totalUsers" => "1", + "screenPageViews" => "1" + } + } + ], + site.id, + import_id, + "imported_sources" + ) + + response = + do_query(conn, site, %{ + "date_range" => ["2021-01-01", "2021-01-01"], + "dimensions" => ["visit:utm_medium"], + "filters" => [["is_not", "visit:utm_medium", [""]]], + "metrics" => ["visitors", "bounce_rate", "visit_duration", "percentage"], + "include" => %{"imports" => true} + }) + + assert response["results"] == [ + %{ + "dimensions" => ["social"], + "metrics" => [3, 100, 20, 100.0] + } + ] + end + + test "UTM campaigns data imported from Google Analytics", %{ + conn: conn, + site: site, + import_id: import_id + } do + populate_stats(site, [ + build(:pageview, utm_campaign: "profile", timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, utm_campaign: "august", timestamp: ~N[2021-01-01 00:00:00]) + ]) + + import_data( + [ + %{ + dimensions: %{ + "sessionManualAdContent" => "", + "sessionCampaignName" => "profile", + "date" => "20210101", + "sessionGoogleAdsKeyword" => "", + "sessionMedium" => "social", + "sessionSource" => "Twitter", + "sessionDefaultChannelGroup" => "" + }, + metrics: %{ + "bounces" => "1", + "userEngagementDuration" => "100", + "sessions" => "1", + "totalUsers" => "1", + "screenPageViews" => "1" + } + }, + %{ + dimensions: %{ + "sessionManualAdContent" => "", + "sessionCampaignName" => "august", + "date" => "20210101", + "sessionGoogleAdsKeyword" => "", + "sessionMedium" => "email", + "sessionSource" => "Gmail", + "sessionDefaultChannelGroup" => "" + }, + metrics: %{ + "bounces" => "0", + "userEngagementDuration" => "100", + "sessions" => "1", + "totalUsers" => "1", + "screenPageViews" => "1" + } + }, + %{ + dimensions: %{ + "sessionManualAdContent" => "", + "sessionCampaignName" => "(not set)", + "date" => "20210101", + "sessionGoogleAdsKeyword" => "", + "sessionMedium" => "email", + "sessionSource" => "Gmail", + "sessionDefaultChannelGroup" => "" + }, + metrics: %{ + "bounces" => "0", + "userEngagementDuration" => "100", + "sessions" => "1", + "totalUsers" => "1", + "screenPageViews" => "1" + } + } + ], + site.id, + import_id, + "imported_sources" + ) + + response = + do_query(conn, site, %{ + "date_range" => ["2021-01-01", "2021-01-01"], + "dimensions" => ["visit:utm_campaign"], + "filters" => [["is_not", "visit:utm_campaign", [""]]], + "order_by" => [["visit:utm_campaign", "asc"]], + "metrics" => ["visitors", "bounce_rate", "visit_duration", "percentage"], + "include" => %{"imports" => true} + }) + + assert response["results"] == [ + %{ + "dimensions" => ["august"], + "metrics" => [2, 50, 50, 50.0] + }, + %{ + "dimensions" => ["profile"], + "metrics" => [2, 100, 50, 50.0] + } + ] + end + + test "UTM terms data imported from Google Analytics", %{ + conn: conn, + site: site, + import_id: import_id + } do + populate_stats(site, [ + build(:pageview, utm_term: "oat milk", timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, utm_term: "Sweden", timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, utm_term: "Sweden", timestamp: ~N[2021-01-01 00:00:00]) + ]) + + import_data( + [ + %{ + dimensions: %{ + "sessionManualAdContent" => "", + "sessionCampaignName" => "", + "date" => "20210101", + "sessionGoogleAdsKeyword" => "oat milk", + "sessionMedium" => "paid", + "sessionSource" => "Google", + "sessionDefaultChannelGroup" => "" + }, + metrics: %{ + "bounces" => "1", + "userEngagementDuration" => "100", + "sessions" => "1", + "totalUsers" => "1", + "screenPageViews" => "1" + } + }, + %{ + dimensions: %{ + "sessionManualAdContent" => "", + "sessionCampaignName" => "", + "date" => "20210101", + "sessionGoogleAdsKeyword" => "Sweden", + "sessionMedium" => "paid", + "sessionSource" => "Google", + "sessionDefaultChannelGroup" => "" + }, + metrics: %{ + "bounces" => "0", + "userEngagementDuration" => "100", + "sessions" => "1", + "totalUsers" => "1", + "screenPageViews" => "1" + } + }, + %{ + dimensions: %{ + "sessionManualAdContent" => "", + "sessionCampaignName" => "", + "date" => "20210101", + "sessionGoogleAdsKeyword" => "(not set)", + "sessionMedium" => "paid", + "sessionSource" => "Google", + "sessionDefaultChannelGroup" => "" + }, + metrics: %{ + "bounces" => "0", + "userEngagementDuration" => "100", + "sessions" => "1", + "totalUsers" => "1", + "screenPageViews" => "1" + } + } + ], + site.id, + import_id, + "imported_sources" + ) + + response = + do_query(conn, site, %{ + "date_range" => ["2021-01-01", "2021-01-01"], + "dimensions" => ["visit:utm_term"], + "filters" => [["is_not", "visit:utm_term", [""]]], + "metrics" => ["visitors", "bounce_rate", "visit_duration", "percentage"], + "include" => %{"imports" => true} + }) + + assert response["results"] == [ + %{ + "dimensions" => ["Sweden"], + "metrics" => [3, 67, 33, 60.0] + }, + %{ + "dimensions" => ["oat milk"], + "metrics" => [2, 100, 50, 40.0] + } + ] + end + + test "UTM contents data imported from Google Analytics", %{ + conn: conn, + site: site, + import_id: import_id + } do + populate_stats(site, [ + build(:pageview, utm_content: "ad", timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, utm_content: "blog", timestamp: ~N[2021-01-01 00:00:00]) + ]) + + import_data( + [ + %{ + dimensions: %{ + "sessionManualAdContent" => "ad", + "sessionCampaignName" => "", + "date" => "20210101", + "sessionGoogleAdsKeyword" => "", + "sessionMedium" => "paid", + "sessionSource" => "Google", + "sessionDefaultChannelGroup" => "" + }, + metrics: %{ + "bounces" => "1", + "userEngagementDuration" => "100", + "sessions" => "1", + "totalUsers" => "1", + "screenPageViews" => "1" + } + }, + %{ + dimensions: %{ + "sessionManualAdContent" => "blog", + "sessionCampaignName" => "", + "date" => "20210101", + "sessionGoogleAdsKeyword" => "", + "sessionMedium" => "paid", + "sessionSource" => "Google", + "sessionDefaultChannelGroup" => "" + }, + metrics: %{ + "bounces" => "0", + "userEngagementDuration" => "100", + "sessions" => "1", + "totalUsers" => "1", + "screenPageViews" => "1" + } + }, + %{ + dimensions: %{ + "sessionManualAdContent" => "(not set)", + "sessionCampaignName" => "", + "date" => "20210101", + "sessionGoogleAdsKeyword" => "", + "sessionMedium" => "paid", + "sessionSource" => "Google", + "sessionDefaultChannelGroup" => "" + }, + metrics: %{ + "bounces" => "0", + "userEngagementDuration" => "100", + "sessions" => "1", + "totalUsers" => "1", + "screenPageViews" => "1" + } + } + ], + site.id, + import_id, + "imported_sources" + ) + + response = + do_query(conn, site, %{ + "date_range" => ["2021-01-01", "2021-01-01"], + "dimensions" => ["visit:utm_content"], + "filters" => [["is_not", "visit:utm_content", [""]]], + "order_by" => [["bounce_rate", "desc"]], + "metrics" => ["visitors", "bounce_rate", "visit_duration", "percentage"], + "include" => %{"imports" => true} + }) + + assert response["results"] == [ + %{ + "dimensions" => ["ad"], + "metrics" => [2, 100, 50, 50.0] + }, + %{ + "dimensions" => ["blog"], + "metrics" => [2, 50, 50, 50.0] + } + ] + end + + test "Page event data imported from Google Analytics", %{ + conn: conn, + site: site, + import_id: import_id + } do + populate_stats(site, [ + build(:pageview, + pathname: "/", + hostname: "host-a.com", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/some-other-page", + hostname: "host-a.com", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:15:00] + ) + ]) + + populate_stats(site, import_id, [ + build(:imported_visitors, date: ~D[2021-01-01]), + build(:imported_visitors, date: ~D[2021-01-01]), + build(:imported_visitors, date: ~D[2021-01-01]), + build(:imported_visitors, date: ~D[2021-01-01]) + ]) + + import_data( + [ + %{ + dimensions: %{ + "date" => "20210101", + "hostName" => "host-a.com", + "pagePath" => "/" + }, + metrics: %{ + "screenPageViews" => "1", + "userEngagementDuration" => "700", + "totalUsers" => "1", + "activeUsers" => "1", + "sessions" => "1" + } + }, + %{ + dimensions: %{ + "date" => "20210101", + "hostName" => "host-b.com", + "pagePath" => "/some-other-page" + }, + metrics: %{ + "screenPageViews" => "2", + "userEngagementDuration" => "60", + "totalUsers" => "1", + "activeUsers" => "1", + "sessions" => "1" + } + }, + %{ + dimensions: %{ + "date" => "20210101", + "hostName" => "host-b.com", + "pagePath" => "/some-other-page?wat=wot" + }, + metrics: %{ + "screenPageViews" => "1", + "userEngagementDuration" => "60", + "totalUsers" => "1", + "activeUsers" => "1", + "sessions" => "1" + } + } + ], + site.id, + import_id, + "imported_pages" + ) + + import_data( + [ + %{ + dimensions: %{"date" => "20210101", "landingPage" => "/"}, + metrics: %{ + "bounces" => "1", + "sessions" => "3", + "userEngagementDuration" => "10", + "totalUsers" => "1", + "screenPageViews" => "1" + } + } + ], + site.id, + import_id, + "imported_entry_pages" + ) + + response = + do_query(conn, site, %{ + "date_range" => ["2021-01-01", "2021-01-01"], + "dimensions" => ["event:page"], + "metrics" => [ + "visitors", + "pageviews", + "bounce_rate", + "time_on_page", + "scroll_depth", + "percentage" + ], + "include" => %{"imports" => true} + }) + + assert response["results"] == [ + %{ + "dimensions" => ["/some-other-page"], + "metrics" => [3, 4, 0, 60, nil, 60.0] + }, + %{ + "dimensions" => ["/"], + "metrics" => [2, 2, 25, 700, nil, 40.0] + } + ] + end + + test "imports city data from Google Analytics", %{ + conn: conn, + site: site, + import_id: import_id + } do + populate_stats(site, [ + build(:pageview, + country_code: "EE", + timestamp: ~N[2021-01-01 00:15:00] + ), + build(:pageview, + country_code: "EE", + timestamp: ~N[2021-01-01 00:15:00] + ), + build(:pageview, + country_code: "GB", + timestamp: ~N[2021-01-01 00:15:00] + ) + ]) + + import_data( + [ + %{ + dimensions: %{ + "countryId" => "EE", + "city" => "Tartu", + "date" => "20210101", + "region" => "Tartumaa" + }, + metrics: %{ + "bounces" => "0", + "userEngagementDuration" => "10", + "sessions" => "1", + "totalUsers" => "1", + "screenPageViews" => "1" + } + }, + %{ + dimensions: %{ + "countryId" => "GB", + "city" => "Edinburgh", + "date" => "20210101", + "region" => "Midlothian" + }, + metrics: %{ + "bounces" => "0", + "userEngagementDuration" => "10", + "sessions" => "1", + "totalUsers" => "1", + "screenPageViews" => "1" + } + } + ], + site.id, + import_id, + "imported_locations" + ) + + response = + do_query(conn, site, %{ + "date_range" => ["2021-01-01", "2021-01-01"], + "dimensions" => ["visit:city_name", "visit:city", "visit:country"], + "metrics" => ["visitors", "percentage"], + "filters" => [["is_not", "visit:city", [0]]], + "order_by" => [["visit:city_name", "desc"]], + "include" => %{"imports" => true} + }) + + assert response["results"] == [ + %{"dimensions" => ["Tartu", 588_335, "EE"], "metrics" => [1, 50.0]}, + %{"dimensions" => ["Edinburgh", 2_650_225, "GB"], "metrics" => [1, 50.0]} + ] + end + + test "imports country data from Google Analytics", %{ + conn: conn, + site: site, + import_id: import_id + } do + populate_stats(site, import_id, [ + build(:pageview, + country_code: "EE", + timestamp: ~N[2021-01-01 00:15:00] + ), + build(:pageview, + country_code: "EE", + timestamp: ~N[2021-01-01 00:15:00] + ), + build(:pageview, + country_code: "GB", + timestamp: ~N[2021-01-01 00:15:00] + ), + build(:imported_visitors, date: ~D[2021-01-01], visitors: 2) + ]) + + import_data( + [ + %{ + dimensions: %{ + "countryId" => "EE", + "city" => "Tartu", + "date" => "20210101", + "region" => "Tartumaa" + }, + metrics: %{ + "bounces" => "0", + "userEngagementDuration" => "10", + "sessions" => "1", + "totalUsers" => "1", + "screenPageViews" => "1" + } + }, + %{ + dimensions: %{ + "countryId" => "GB", + "city" => "Edinburgh", + "date" => "20210101", + "region" => "Midlothian" + }, + metrics: %{ + "bounces" => "0", + "userEngagementDuration" => "10", + "sessions" => "1", + "totalUsers" => "1", + "screenPageViews" => "1" + } + } + ], + site.id, + import_id, + "imported_locations" + ) + + response = + do_query(conn, site, %{ + "date_range" => ["2021-01-01", "2021-01-01"], + "dimensions" => ["visit:country_name", "visit:country"], + "metrics" => ["visitors", "percentage"], + "include" => %{"imports" => true} + }) + + assert response["results"] == [ + %{"dimensions" => ["Estonia", "EE"], "metrics" => [3, 60.0]}, + %{"dimensions" => ["United Kingdom", "GB"], "metrics" => [2, 40.0]} + ] + end + + test "Devices data imported from Google Analytics", %{ + conn: conn, + site: site, + import_id: import_id + } do + populate_stats(site, import_id, [ + build(:pageview, screen_size: "Desktop", timestamp: ~N[2021-01-01 00:15:00]), + build(:pageview, screen_size: "Desktop", timestamp: ~N[2021-01-01 00:15:00]), + build(:pageview, screen_size: "Laptop", timestamp: ~N[2021-01-01 00:15:00]), + build(:imported_visitors, date: ~D[2021-01-01], visitors: 2) + ]) + + import_data( + [ + %{ + dimensions: %{"date" => "20210101", "deviceCategory" => "mobile"}, + metrics: %{ + "bounces" => "0", + "userEngagementDuration" => "10", + "sessions" => "1", + "totalUsers" => "1", + "screenPageViews" => "1" + } + }, + %{ + dimensions: %{"date" => "20210101", "deviceCategory" => "Laptop"}, + metrics: %{ + "bounces" => "0", + "userEngagementDuration" => "10", + "sessions" => "1", + "totalUsers" => "1", + "screenPageViews" => "1" + } + } + ], + site.id, + import_id, + "imported_devices" + ) + + response = + do_query(conn, site, %{ + "date_range" => ["2021-01-01", "2021-01-01"], + "dimensions" => ["visit:device"], + "order_by" => [["visitors", "desc"], ["visit:device", "asc"]], + "metrics" => ["visitors", "percentage"], + "include" => %{"imports" => true} + }) + + assert response["results"] == [ + %{"dimensions" => ["Desktop"], "metrics" => [2, 40.0]}, + %{"dimensions" => ["Laptop"], "metrics" => [2, 40.0]}, + %{"dimensions" => ["Mobile"], "metrics" => [1, 20.0]} + ] + end + + test "Browsers data imported from Google Analytics", %{ + conn: conn, + site: site, + import_id: import_id + } do + populate_stats(site, import_id, [ + build(:pageview, browser: "Chrome", timestamp: ~N[2021-01-01 00:15:00]), + build(:pageview, browser: "Firefox", timestamp: ~N[2021-01-01 00:15:00]), + build(:imported_visitors, visitors: 2, date: ~D[2021-01-01]) + ]) + + import_data( + [ + %{ + dimensions: %{ + "browser" => "User-Agent: Mozilla", + "date" => "20210101" + }, + metrics: %{ + "bounces" => "0", + "userEngagementDuration" => "10", + "sessions" => "1", + "totalUsers" => "1", + "screenPageViews" => "1" + } + }, + %{ + dimensions: %{"browser" => "Android Browser", "date" => "20210101"}, + metrics: %{ + "bounces" => "0", + "userEngagementDuration" => "10", + "sessions" => "1", + "totalUsers" => "1", + "screenPageViews" => "1" + } + } + ], + site.id, + import_id, + "imported_browsers" + ) + + response = + do_query(conn, site, %{ + "date_range" => ["2021-01-01", "2021-01-01"], + "dimensions" => ["visit:browser"], + "order_by" => [["visitors", "desc"], ["visit:browser", "asc"]], + "metrics" => ["visitors", "percentage"], + "include" => %{"imports" => true} + }) + + assert response["results"] == [ + %{"dimensions" => ["Firefox"], "metrics" => [2, 50.0]}, + %{"dimensions" => ["Chrome"], "metrics" => [1, 25.0]}, + %{"dimensions" => ["Mobile App"], "metrics" => [1, 25.0]} + ] + end + + test "OS data imported from Google Analytics", %{ + conn: conn, + site: site, + import_id: import_id + } do + populate_stats(site, import_id, [ + build(:pageview, operating_system: "Mac", timestamp: ~N[2021-01-01 00:15:00]), + build(:pageview, operating_system: "Mac", timestamp: ~N[2021-01-01 00:15:00]), + build(:pageview, + operating_system: "GNU/Linux", + timestamp: ~N[2021-01-01 00:15:00] + ), + build(:imported_visitors, date: ~D[2021-01-01], visitors: 2) + ]) + + import_data( + [ + %{ + dimensions: %{ + "date" => "20210101", + "operatingSystem" => "Macintosh", + "operatingSystemVersion" => "10.15.1" + }, + metrics: %{ + "bounces" => "0", + "userEngagementDuration" => "10", + "sessions" => "1", + "totalUsers" => "1", + "screenPageViews" => "1" + } + }, + %{ + dimensions: %{ + "date" => "20210101", + "operatingSystem" => "Linux", + "operatingSystemVersion" => "12.12" + }, + metrics: %{ + "bounces" => "0", + "userEngagementDuration" => "10", + "sessions" => "1", + "totalUsers" => "1", + "screenPageViews" => "1" + } + } + ], + site.id, + import_id, + "imported_operating_systems" + ) + + response = + do_query(conn, site, %{ + "date_range" => ["2021-01-01", "2021-01-01"], + "dimensions" => ["visit:os"], + "metrics" => ["visitors", "percentage"], + "include" => %{"imports" => true} + }) + + assert response["results"] == [ + %{"dimensions" => ["Mac"], "metrics" => [3, 60.0]}, + %{"dimensions" => ["GNU/Linux"], "metrics" => [2, 40.0]} + ] + end + + test "Can import visit duration with scientific notation", %{ + conn: conn, + site: site, + import_id: import_id + } do + populate_stats(site, [ + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-31 00:00:00]) + ]) + + import_data( + [ + %{ + dimensions: %{"date" => "20210101"}, + metrics: %{ + "bounces" => "0", + "screenPageViews" => "1", + "userEngagementDuration" => "1.391607E7", + "sessions" => "1", + "totalUsers" => "1" + } + }, + %{ + dimensions: %{"date" => "20210131"}, + metrics: %{ + "bounces" => "1", + "screenPageViews" => "1", + "userEngagementDuration" => "60", + "sessions" => "1", + "totalUsers" => "1" + } + } + ], + site.id, + import_id, + "imported_visitors" + ) + + params = %{ + "metrics" => ["visit_duration"], + "date_range" => ["2021-01-01", "2021-01-31"], + "include" => %{"imports" => true}, + "filters" => [] + } + + response = do_query(conn, site, params) + + assert response["results"] == [ + %{"dimensions" => [], "metrics" => [3_479_032]} + ] + end + + test "skips empty dates from import", %{conn: conn, site: site, import_id: import_id} do + import_data( + [ + %{ + dimensions: %{"date" => "20210101"}, + metrics: %{ + "totalUsers" => "1", + "screenPageViews" => "1", + "bounces" => "0", + "sessions" => "1", + "userEngagementDuration" => "60" + } + }, + %{ + dimensions: %{"date" => "(other)"}, + metrics: %{ + "totalUsers" => "1", + "screenPageViews" => "1", + "bounces" => "0", + "sessions" => "1", + "userEngagementDuration" => "60" + } + } + ], + site.id, + import_id, + "imported_visitors" + ) + + params = %{ + "metrics" => [ + "visitors", + "visits", + "pageviews", + "views_per_visit", + "bounce_rate", + "visit_duration" + ], + "date_range" => ["2021-01-01", "2021-01-31"], + "include" => %{"imports" => true}, + "filters" => [] + } + + response = do_query(conn, site, params) + + assert response["results"] == [ + %{"dimensions" => [], "metrics" => [1, 1, 1, 1, 0, 60]} + ] + end + end + end +end diff --git a/test/plausible_web/controllers/api/stats_controller/main_graph_test.exs b/test/plausible_web/controllers/api/stats_controller/main_graph_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..2a51ddd1d6ae2028212a82ae96ee6917fcf2462f --- /dev/null +++ b/test/plausible_web/controllers/api/stats_controller/main_graph_test.exs @@ -0,0 +1,2651 @@ +defmodule PlausibleWeb.Api.StatsController.MainGraphTest do + use PlausibleWeb.ConnCase + + @user_id Enum.random(1000..9999) + + defp do_query(conn, site, params, opts \\ []) do + now = Keyword.get(opts, :now) + + conn + |> Plug.Conn.put_private(:now, now) + |> post("/api/stats/#{site.domain}/query", params) + |> json_response(200) + end + + defp do_query_fail(conn, site, params) do + conn + |> post("/api/stats/#{site.domain}/query", params) + end + + describe "plot" do + setup [:create_user, :log_in, :create_site, :create_legacy_site_import] + + test "displays pageviews for the last 30 minutes in realtime graph", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, timestamp: ~N[2024-04-02 03:20:46]) + ]) + + response = + do_query( + conn, + site, + %{ + "date_range" => "realtime_30m", + "metrics" => ["pageviews"], + "dimensions" => ["time:minute"], + "include" => %{"time_labels" => true} + }, + now: ~U[2024-04-02 03:27:30Z] + ) + + %{"results" => results, "meta" => meta} = response + + assert length(meta["time_labels"]) == 30 + assert List.first(meta["time_labels"]) == "2024-04-02 02:57:00" + assert List.last(meta["time_labels"]) == "2024-04-02 03:26:00" + assert [%{"dimensions" => ["2024-04-02 03:20:00"], "metrics" => [1]}] = results + end + + test "displays pageviews for the last 30 minutes for a non-UTC timezone site", %{ + conn: conn, + site: site + } do + Plausible.Site.changeset(site, %{timezone: "Europe/Tallinn"}) + |> Plausible.Repo.update() + + populate_stats(site, [ + build(:pageview, timestamp: ~N[2024-04-02 03:20:46]) + ]) + + response = + do_query( + conn, + site, + %{ + "date_range" => "realtime_30m", + "metrics" => ["pageviews"], + "dimensions" => ["time:minute"], + "include" => %{"time_labels" => true} + }, + now: ~U[2024-04-02 03:27:30Z] + ) + + %{"results" => results, "meta" => meta} = response + + assert length(meta["time_labels"]) == 30 + assert List.first(meta["time_labels"]) == "2024-04-02 05:57:00" + assert List.last(meta["time_labels"]) == "2024-04-02 06:26:00" + assert [%{"dimensions" => ["2024-04-02 06:20:00"], "metrics" => [1]}] = results + end + + test "displays pageviews for a day", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-01 23:00:00]) + ]) + + response = + do_query(conn, site, %{ + "date_range" => "day", + "relative_date" => "2021-01-01", + "metrics" => ["pageviews"], + "dimensions" => ["time:hour"] + }) + + assert response["results"] == [ + %{"dimensions" => ["2021-01-01 00:00:00"], "metrics" => [1]}, + %{"dimensions" => ["2021-01-01 23:00:00"], "metrics" => [1]} + ] + end + + test "returns empty plot with no native data and recently imported from ga in realtime graph", + %{conn: conn, site: site} do + populate_stats(site, [ + build(:imported_visitors, date: Date.utc_today()), + build(:imported_visitors, date: Date.utc_today()) + ]) + + response = + do_query(conn, site, %{ + "date_range" => "realtime_30m", + "metrics" => ["visitors"], + "dimensions" => ["time:minute"], + "include" => %{"imports" => true, "time_labels" => true} + }) + + assert length(response["meta"]["time_labels"]) == 30 + assert response["results"] == [] + end + + test "imported data is not included for hourly interval", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-31 00:00:00]), + build(:imported_visitors, date: ~D[2021-01-01]), + build(:imported_visitors, date: ~D[2021-01-31]) + ]) + + response = + do_query(conn, site, %{ + "date_range" => "day", + "relative_date" => "2021-01-01", + "metrics" => ["visitors"], + "dimensions" => ["time:hour"], + "include" => %{"imports" => true} + }) + + assert response["results"] == [ + %{"dimensions" => ["2021-01-01 00:00:00"], "metrics" => [1]} + ] + end + + test "displays hourly stats in configured timezone", %{conn: conn, user: user} do + # UTC+1 + site = + new_site( + domain: "tz-test.com", + owner: user, + timezone: "CET" + ) + + populate_stats(site, [ + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + response = + do_query(conn, site, %{ + "date_range" => "day", + "relative_date" => "2021-01-01", + "metrics" => ["visitors"], + "dimensions" => ["time:hour"] + }) + + # Expecting pageview to show at 1am CET + assert response["results"] == [ + %{"dimensions" => ["2021-01-01 01:00:00"], "metrics" => [1]} + ] + end + + test "displays visitors for a month", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-31 00:00:00]) + ]) + + response = + do_query(conn, site, %{ + "date_range" => "month", + "relative_date" => "2021-01-01", + "metrics" => ["visitors"], + "include" => %{"time_labels" => true}, + "dimensions" => ["time:day"] + }) + + assert length(response["meta"]["time_labels"]) == 31 + + assert response["results"] == [ + %{"dimensions" => ["2021-01-01"], "metrics" => [1]}, + %{"dimensions" => ["2021-01-31"], "metrics" => [1]} + ] + end + + test "displays visitors for last 28d", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-28 00:00:00]) + ]) + + response = + do_query(conn, site, %{ + "date_range" => "28d", + "relative_date" => "2021-01-29", + "metrics" => ["visitors"], + "dimensions" => ["time:day"], + "include" => %{"time_labels" => true} + }) + + assert length(response["meta"]["time_labels"]) == 28 + + assert response["results"] == [ + %{"dimensions" => ["2021-01-01"], "metrics" => [1]}, + %{"dimensions" => ["2021-01-28"], "metrics" => [1]} + ] + end + + test "displays visitors for last 91d", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, timestamp: ~N[2021-01-16 00:00:00]), + build(:pageview, timestamp: ~N[2021-04-16 00:00:00]) + ]) + + response = + do_query(conn, site, %{ + "date_range" => "91d", + "relative_date" => "2021-04-17", + "metrics" => ["visitors"], + "dimensions" => ["time:day"] + }) + + assert response["results"] == [ + %{"dimensions" => ["2021-01-16"], "metrics" => [1]}, + %{"dimensions" => ["2021-04-16"], "metrics" => [1]} + ] + end + + test "displays visitors for a month with imported data", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-31 00:00:00]), + build(:imported_visitors, date: ~D[2021-01-01]), + build(:imported_visitors, date: ~D[2021-01-31]) + ]) + + response = + do_query(conn, site, %{ + "date_range" => "month", + "relative_date" => "2021-01-01", + "metrics" => ["visitors"], + "dimensions" => ["time:day"], + "include" => %{"imports" => true} + }) + + assert response["results"] == [ + %{"dimensions" => ["2021-01-01"], "metrics" => [2]}, + %{"dimensions" => ["2021-01-31"], "metrics" => [2]} + ] + end + + test "displays visitors for a month with only imported data", %{conn: conn, site: site} do + populate_stats(site, [ + build(:imported_visitors, date: ~D[2021-01-01]), + build(:imported_visitors, date: ~D[2021-01-31]) + ]) + + response = + do_query(conn, site, %{ + "date_range" => "month", + "relative_date" => "2021-01-01", + "metrics" => ["visitors"], + "dimensions" => ["time:day"], + "include" => %{"imports" => true} + }) + + assert response["results"] == [ + %{"dimensions" => ["2021-01-01"], "metrics" => [1]}, + %{"dimensions" => ["2021-01-31"], "metrics" => [1]} + ] + end + + test "displays visitors for a month with imported data and filter", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, timestamp: ~N[2021-01-01 00:00:00], pathname: "/pageA"), + build(:pageview, timestamp: ~N[2021-01-31 00:00:00], pathname: "/pageA"), + build(:imported_visitors, date: ~D[2021-01-01]), + build(:imported_visitors, date: ~D[2021-01-31]) + ]) + + response = + do_query(conn, site, %{ + "date_range" => "month", + "relative_date" => "2021-01-01", + "metrics" => ["visitors"], + "dimensions" => ["time:day"], + "filters" => [["is", "event:page", ["/pageA"]]], + "include" => %{"imports" => true} + }) + + assert response["results"] == [ + %{"dimensions" => ["2021-01-01"], "metrics" => [1]}, + %{"dimensions" => ["2021-01-31"], "metrics" => [1]} + ] + end + + test "displays visitors for 6 months with imported data", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, timestamp: ~N[2020-12-01 00:00:00]), + build(:pageview, timestamp: ~N[2021-05-31 00:00:00]), + build(:imported_visitors, date: ~D[2020-12-01]), + build(:imported_visitors, date: ~D[2021-05-31]) + ]) + + response = + do_query(conn, site, %{ + "date_range" => "6mo", + "relative_date" => "2021-06-30", + "metrics" => ["visitors"], + "dimensions" => ["time:month"], + "include" => %{"imports" => true, "time_labels" => true} + }) + + assert response["meta"]["time_labels"] == [ + "2020-12-01", + "2021-01-01", + "2021-02-01", + "2021-03-01", + "2021-04-01", + "2021-05-01" + ] + + assert response["results"] == [ + %{"dimensions" => ["2020-12-01"], "metrics" => [2]}, + %{"dimensions" => ["2021-05-01"], "metrics" => [2]} + ] + + assert response["meta"]["time_label_result_indices"] == [0, nil, nil, nil, nil, 1] + end + + test "displays visitors for 6 months with only imported data", %{conn: conn, site: site} do + populate_stats(site, [ + build(:imported_visitors, date: ~D[2020-12-01]), + build(:imported_visitors, date: ~D[2021-05-31]) + ]) + + response = + do_query(conn, site, %{ + "date_range" => "6mo", + "relative_date" => "2021-06-30", + "metrics" => ["visitors"], + "dimensions" => ["time:month"], + "include" => %{"imports" => true} + }) + + assert response["results"] == [ + %{"dimensions" => ["2020-12-01"], "metrics" => [1]}, + %{"dimensions" => ["2021-05-01"], "metrics" => [1]} + ] + end + + test "displays visitors for 12 months with imported data", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, timestamp: ~N[2020-12-01 00:00:00]), + build(:pageview, timestamp: ~N[2021-11-30 00:00:00]), + build(:imported_visitors, date: ~D[2020-12-01]), + build(:imported_visitors, date: ~D[2021-11-30]) + ]) + + response = + do_query(conn, site, %{ + "date_range" => "12mo", + "relative_date" => "2021-12-31", + "metrics" => ["visitors"], + "dimensions" => ["time:month"], + "include" => %{"imports" => true, "time_labels" => true} + }) + + assert length(response["meta"]["time_labels"]) == 12 + + assert response["results"] == [ + %{"dimensions" => ["2020-12-01"], "metrics" => [2]}, + %{"dimensions" => ["2021-11-01"], "metrics" => [2]} + ] + end + + test "displays visitors for 12 months with only imported data", %{conn: conn, site: site} do + populate_stats(site, [ + build(:imported_visitors, date: ~D[2020-12-01]), + build(:imported_visitors, date: ~D[2021-11-30]) + ]) + + response = + do_query(conn, site, %{ + "date_range" => "12mo", + "relative_date" => "2021-12-31", + "metrics" => ["visitors"], + "dimensions" => ["time:month"], + "include" => %{"imports" => true, "time_labels" => true} + }) + + assert response["meta"]["time_labels"] == [ + "2020-12-01", + "2021-01-01", + "2021-02-01", + "2021-03-01", + "2021-04-01", + "2021-05-01", + "2021-06-01", + "2021-07-01", + "2021-08-01", + "2021-09-01", + "2021-10-01", + "2021-11-01" + ] + + assert response["results"] == [ + %{"dimensions" => ["2020-12-01"], "metrics" => [1]}, + %{"dimensions" => ["2021-11-01"], "metrics" => [1]} + ] + + assert response["meta"]["time_label_result_indices"] == + [0] ++ List.duplicate(nil, 10) ++ [1] + end + + test "displays visitors for calendar year with imported data", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2021-12-31 00:00:00]), + build(:imported_visitors, date: ~D[2021-01-01]), + build(:imported_visitors, date: ~D[2021-12-31]) + ]) + + response = + do_query(conn, site, %{ + "date_range" => "year", + "relative_date" => "2021-12-31", + "metrics" => ["visitors"], + "dimensions" => ["time:month"], + "include" => %{"imports" => true} + }) + + assert response["results"] == [ + %{"dimensions" => ["2021-01-01"], "metrics" => [2]}, + %{"dimensions" => ["2021-12-01"], "metrics" => [2]} + ] + end + + test "displays visitors for calendar year with only imported data", %{conn: conn, site: site} do + populate_stats(site, [ + build(:imported_visitors, date: ~D[2021-01-01]), + build(:imported_visitors, date: ~D[2021-12-31]) + ]) + + response = + do_query(conn, site, %{ + "date_range" => "year", + "relative_date" => "2021-12-31", + "metrics" => ["visitors"], + "dimensions" => ["time:month"], + "include" => %{"imports" => true} + }) + + assert response["results"] == [ + %{"dimensions" => ["2021-01-01"], "metrics" => [1]}, + %{"dimensions" => ["2021-12-01"], "metrics" => [1]} + ] + end + + test "displays visitors for all time with just native data", %{conn: conn, site: site} do + use Plausible.Repo + + Repo.update_all(from(s in "sites", where: s.id == ^site.id), + set: [stats_start_date: ~D[2020-01-01]] + ) + + populate_stats(site, [ + build(:pageview, timestamp: ~N[2020-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2021-12-31 00:00:00]) + ]) + + response = + do_query( + conn, + site, + %{ + "date_range" => "all", + "metrics" => ["visitors"], + "dimensions" => ["time:month"], + "include" => %{"imports" => true, "time_labels" => true} + }, + now: ~U[2022-03-15 10:00:00Z] + ) + + assert length(response["meta"]["time_labels"]) == 27 + assert List.last(response["meta"]["time_labels"]) == "2022-03-01" + + assert response["results"] == [ + %{"dimensions" => ["2020-01-01"], "metrics" => [1]}, + %{"dimensions" => ["2021-01-01"], "metrics" => [1]}, + %{"dimensions" => ["2021-12-01"], "metrics" => [1]} + ] + end + + test "returns empty metrics under response.meta", %{conn: conn, site: site} do + response = + do_query( + conn, + site, + %{ + "date_range" => "28d", + "metrics" => ["visitors"], + "dimensions" => ["time:day"], + "include" => %{"empty_metrics" => true} + } + ) + + assert response["meta"]["empty_metrics"] == [0] + end + end + + describe "default labels" do + setup [:create_user, :log_in, :create_site] + + test "shows last 30 days", %{conn: conn, site: site} do + response = + do_query(conn, site, %{ + "date_range" => "30d", + "metrics" => ["visitors"], + "dimensions" => ["time:day"], + "include" => %{"time_labels" => true} + }) + + labels = response["meta"]["time_labels"] + + first = Date.utc_today() |> Date.shift(day: -30) |> Date.to_iso8601() + last = Date.utc_today() |> Date.shift(day: -1) |> Date.to_iso8601() + assert List.first(labels) == first + assert List.last(labels) == last + end + + test "shows last 7 days", %{conn: conn, site: site} do + response = + do_query(conn, site, %{ + "date_range" => "7d", + "metrics" => ["visitors"], + "dimensions" => ["time:day"], + "include" => %{"time_labels" => true} + }) + + labels = response["meta"]["time_labels"] + + first = Date.utc_today() |> Date.shift(day: -7) |> Date.to_iso8601() + last = Date.utc_today() |> Date.shift(day: -1) |> Date.to_iso8601() + assert List.first(labels) == first + assert List.last(labels) == last + end + end + + describe "pageviews plot" do + setup [:create_user, :log_in, :create_site, :create_legacy_site_import] + + test "displays pageviews for a month", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-31 00:00:00]) + ]) + + response = + do_query(conn, site, %{ + "date_range" => "month", + "relative_date" => "2021-01-01", + "metrics" => ["pageviews"], + "dimensions" => ["time:day"] + }) + + assert response["results"] == [ + %{"dimensions" => ["2021-01-01"], "metrics" => [2]}, + %{"dimensions" => ["2021-01-31"], "metrics" => [1]} + ] + end + + test "displays pageviews for a month with imported data", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-31 00:00:00]), + build(:imported_visitors, date: ~D[2021-01-01]), + build(:imported_visitors, date: ~D[2021-01-31]) + ]) + + response = + do_query(conn, site, %{ + "date_range" => "month", + "relative_date" => "2021-01-01", + "metrics" => ["pageviews"], + "dimensions" => ["time:day"], + "include" => %{"imports" => true} + }) + + assert response["results"] == [ + %{"dimensions" => ["2021-01-01"], "metrics" => [2]}, + %{"dimensions" => ["2021-01-31"], "metrics" => [2]} + ] + end + + test "displays pageviews for a month with only imported data", %{conn: conn, site: site} do + populate_stats(site, [ + build(:imported_visitors, date: ~D[2021-01-01]), + build(:imported_visitors, date: ~D[2021-01-31]) + ]) + + response = + do_query(conn, site, %{ + "date_range" => "month", + "relative_date" => "2021-01-01", + "metrics" => ["pageviews"], + "dimensions" => ["time:day"], + "include" => %{"imports" => true} + }) + + assert response["results"] == [ + %{"dimensions" => ["2021-01-01"], "metrics" => [1]}, + %{"dimensions" => ["2021-01-31"], "metrics" => [1]} + ] + end + end + + describe "views_per_visit plot" do + setup [:create_user, :log_in, :create_site, :create_legacy_site_import] + + test "views_per_visit for 28 days in weekly buckets (native data only)", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, user_id: 1, timestamp: ~N[2021-01-04 00:00:00]), + build(:pageview, user_id: 1, timestamp: ~N[2021-01-04 00:05:00]), + build(:pageview, user_id: 2, timestamp: ~N[2021-01-18 00:00:00]), + build(:pageview, user_id: 2, timestamp: ~N[2021-01-18 00:05:00]), + build(:pageview, user_id: 2, timestamp: ~N[2021-01-18 00:10:00]) + ]) + + response = + do_query(conn, site, %{ + "date_range" => "28d", + "relative_date" => "2021-01-29", + "metrics" => ["views_per_visit"], + "dimensions" => ["time:week"] + }) + + assert response["results"] == [ + %{"dimensions" => ["2021-01-04"], "metrics" => [2.0]}, + %{"dimensions" => ["2021-01-18"], "metrics" => [3.0]} + ] + end + + test "views_per_visit for a year in monthly buckets (with imported data)", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + # January 2021 - only imported + build(:imported_visitors, date: ~D[2021-01-01], visits: 6, pageviews: 7), + # March 2021 - imported + native combined + build(:imported_visitors, date: ~D[2021-03-01], visits: 1, pageviews: 4), + build(:pageview, user_id: 1, timestamp: ~N[2021-03-15 00:00:00]), + build(:pageview, user_id: 1, timestamp: ~N[2021-03-15 00:05:00]), + # September 2021 - only native + build(:pageview, user_id: 2, timestamp: ~N[2021-09-01 00:00:00]) + ]) + + response = + do_query(conn, site, %{ + "date_range" => "year", + "relative_date" => "2021-01-01", + "metrics" => ["views_per_visit"], + "dimensions" => ["time:month"], + "include" => %{"imports" => true} + }) + + assert response["results"] == [ + %{"dimensions" => ["2021-01-01"], "metrics" => [1.17]}, + %{"dimensions" => ["2021-03-01"], "metrics" => [3.0]}, + %{"dimensions" => ["2021-09-01"], "metrics" => [1.0]} + ] + end + end + + describe "visitors plot" do + setup [:create_user, :log_in, :create_site, :create_legacy_site_import] + + test "displays visitors per hour with short visits", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, timestamp: ~N[2021-01-01 00:00:00], user_id: 1), + build(:pageview, timestamp: ~N[2021-01-01 00:10:00], user_id: 1), + build(:pageview, timestamp: ~N[2021-01-01 00:20:00]) + ]) + + response = + do_query(conn, site, %{ + "date_range" => "day", + "relative_date" => "2021-01-01", + "metrics" => ["visitors"], + "dimensions" => ["time:hour"] + }) + + assert response["results"] == [ + %{"dimensions" => ["2021-01-01 00:00:00"], "metrics" => [2]} + ] + end + + test "displays visitors realtime with visits spanning multiple minutes", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, timestamp: ~N[2023-09-10 15:15:00], user_id: 1), + build(:pageview, timestamp: ~N[2023-09-10 15:30:00], user_id: 1), + build(:pageview, timestamp: ~N[2023-09-10 15:25:00], user_id: 2), + build(:pageview, timestamp: ~N[2023-09-10 15:35:00], user_id: 2), + build(:pageview, timestamp: ~N[2023-09-10 15:45:00], user_id: 3), + build(:pageview, timestamp: ~N[2023-09-10 15:47:00], user_id: 3) + ]) + + response = + do_query( + conn, + site, + %{ + "date_range" => "realtime_30m", + "metrics" => ["visitors"], + "dimensions" => ["time:minute"] + }, + now: ~U[2023-09-10 15:50:01Z] + ) + + assert response["results"] == [ + %{"dimensions" => ["2023-09-10 15:20:00"], "metrics" => [1]}, + %{"dimensions" => ["2023-09-10 15:21:00"], "metrics" => [1]}, + %{"dimensions" => ["2023-09-10 15:22:00"], "metrics" => [1]}, + %{"dimensions" => ["2023-09-10 15:23:00"], "metrics" => [1]}, + %{"dimensions" => ["2023-09-10 15:24:00"], "metrics" => [1]}, + %{"dimensions" => ["2023-09-10 15:25:00"], "metrics" => [2]}, + %{"dimensions" => ["2023-09-10 15:26:00"], "metrics" => [2]}, + %{"dimensions" => ["2023-09-10 15:27:00"], "metrics" => [2]}, + %{"dimensions" => ["2023-09-10 15:28:00"], "metrics" => [2]}, + %{"dimensions" => ["2023-09-10 15:29:00"], "metrics" => [2]}, + %{"dimensions" => ["2023-09-10 15:30:00"], "metrics" => [2]}, + %{"dimensions" => ["2023-09-10 15:31:00"], "metrics" => [1]}, + %{"dimensions" => ["2023-09-10 15:32:00"], "metrics" => [1]}, + %{"dimensions" => ["2023-09-10 15:33:00"], "metrics" => [1]}, + %{"dimensions" => ["2023-09-10 15:34:00"], "metrics" => [1]}, + %{"dimensions" => ["2023-09-10 15:35:00"], "metrics" => [1]}, + %{"dimensions" => ["2023-09-10 15:45:00"], "metrics" => [1]}, + %{"dimensions" => ["2023-09-10 15:46:00"], "metrics" => [1]}, + %{"dimensions" => ["2023-09-10 15:47:00"], "metrics" => [1]} + ] + end + + test "displays visitors per hour with visits spanning multiple hours", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, timestamp: ~N[2020-12-31 23:45:00], user_id: 1), + build(:pageview, timestamp: ~N[2021-01-01 00:10:00], user_id: 1), + build(:pageview, timestamp: ~N[2021-01-01 00:15:00], user_id: 1), + build(:pageview, timestamp: ~N[2021-01-01 00:35:00], user_id: 2), + build(:pageview, timestamp: ~N[2021-01-01 01:00:00], user_id: 2), + build(:pageview, timestamp: ~N[2021-01-01 01:25:00], user_id: 2), + build(:pageview, timestamp: ~N[2021-01-01 01:50:00], user_id: 2), + build(:pageview, timestamp: ~N[2021-01-01 02:05:00], user_id: 2), + build(:pageview, timestamp: ~N[2021-01-01 23:45:00], user_id: 3), + build(:pageview, timestamp: ~N[2021-01-02 00:05:00], user_id: 3) + ]) + + response = + do_query(conn, site, %{ + "date_range" => "day", + "relative_date" => "2021-01-01", + "metrics" => ["visitors"], + "dimensions" => ["time:hour"] + }) + + assert response["results"] == [ + %{"dimensions" => ["2021-01-01 00:00:00"], "metrics" => [2]}, + %{"dimensions" => ["2021-01-01 01:00:00"], "metrics" => [1]}, + %{"dimensions" => ["2021-01-01 02:00:00"], "metrics" => [1]}, + %{"dimensions" => ["2021-01-01 23:00:00"], "metrics" => [1]} + ] + end + + test "displays visitors per day with sessions being counted only in the last time bucket they were active in", + %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, timestamp: ~N[2020-12-31 23:45:00], user_id: 1), + build(:pageview, timestamp: ~N[2021-01-01 00:10:00], user_id: 1), + build(:pageview, timestamp: ~N[2021-01-02 23:45:00], user_id: 2), + build(:pageview, timestamp: ~N[2021-01-03 00:10:00], user_id: 2), + build(:pageview, timestamp: ~N[2021-01-03 23:45:00], user_id: 3), + build(:pageview, timestamp: ~N[2021-01-04 00:10:00], user_id: 3), + build(:pageview, timestamp: ~N[2021-01-07 23:45:00], user_id: 4), + build(:pageview, timestamp: ~N[2021-01-08 00:10:00], user_id: 4) + ]) + + response = + do_query(conn, site, %{ + "date_range" => "7d", + "relative_date" => "2021-01-08", + "metrics" => ["visitors"], + "dimensions" => ["time:day"] + }) + + assert response["results"] == [ + %{"dimensions" => ["2021-01-01"], "metrics" => [1]}, + %{"dimensions" => ["2021-01-03"], "metrics" => [1]}, + %{"dimensions" => ["2021-01-04"], "metrics" => [1]}, + %{"dimensions" => ["2021-01-07"], "metrics" => [1]} + ] + end + + test "displays visitors per week with sessions being counted only in the last time bucket they were active in", + %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, timestamp: ~N[2020-12-31 23:45:00], user_id: 1), + build(:pageview, timestamp: ~N[2021-01-01 00:10:00], user_id: 1), + build(:pageview, timestamp: ~N[2021-01-03 23:45:00], user_id: 2), + build(:pageview, timestamp: ~N[2021-01-04 00:10:00], user_id: 2), + build(:pageview, timestamp: ~N[2021-01-31 23:45:00], user_id: 3), + build(:pageview, timestamp: ~N[2021-02-01 00:05:00], user_id: 3) + ]) + + response = + do_query(conn, site, %{ + "date_range" => "month", + "relative_date" => "2021-01-01", + "metrics" => ["visitors"], + "dimensions" => ["time:week"] + }) + + assert response["results"] == [ + %{"dimensions" => ["2021-01-01"], "metrics" => [1]}, + %{"dimensions" => ["2021-01-04"], "metrics" => [1]}, + %{"dimensions" => ["2021-01-25"], "metrics" => [1]} + ] + end + end + + describe "scroll_depth plot" do + setup [:create_user, :log_in, :create_site] + + test "returns 400 when scroll_depth is queried without a page filter", %{ + conn: conn, + site: site + } do + response = + do_query_fail(conn, site, %{ + "date_range" => "month", + "relative_date" => "2021-01-01", + "metrics" => ["scroll_depth"], + "dimensions" => ["time:day"] + }) + + assert %{"error" => error} = json_response(response, 400) + assert error =~ "can only be queried with event:page filters or dimensions" + end + + test "returns scroll depth per day", %{conn: conn, site: site} do + t0 = ~N[2020-01-01 00:00:00] + [t1, t2, t3] = for i <- 1..3, do: NaiveDateTime.add(t0, i, :minute) + + populate_stats(site, [ + build(:pageview, user_id: 12, timestamp: t0), + build(:engagement, user_id: 12, timestamp: t1, scroll_depth: 20), + build(:pageview, user_id: 34, timestamp: t0), + build(:engagement, user_id: 34, timestamp: t1, scroll_depth: 17), + build(:pageview, user_id: 34, timestamp: t2), + build(:engagement, user_id: 34, timestamp: t3, scroll_depth: 60), + build(:pageview, user_id: 56, timestamp: NaiveDateTime.add(t0, 1, :day)), + build(:engagement, + user_id: 56, + timestamp: NaiveDateTime.add(t1, 1, :day), + scroll_depth: 20 + ) + ]) + + response = + do_query(conn, site, %{ + "date_range" => "7d", + "relative_date" => "2020-01-08", + "metrics" => ["scroll_depth"], + "dimensions" => ["time:day"], + "filters" => [["is", "event:page", ["/"]]] + }) + + assert response["results"] == [ + %{"dimensions" => ["2020-01-01"], "metrics" => [40]}, + %{"dimensions" => ["2020-01-02"], "metrics" => [20]} + ] + end + + test "returns scroll depth per day with imported data", %{conn: conn, site: site} do + site_import = insert(:site_import, site: site) + + populate_stats(site, site_import.id, [ + # 2020-01-01 - only native data + build(:pageview, user_id: 12, timestamp: ~N[2020-01-01 00:00:00]), + build(:engagement, user_id: 12, timestamp: ~N[2020-01-01 00:01:00], scroll_depth: 20), + build(:pageview, user_id: 34, timestamp: ~N[2020-01-01 00:00:00]), + build(:engagement, user_id: 34, timestamp: ~N[2020-01-01 00:01:00], scroll_depth: 17), + build(:pageview, user_id: 34, timestamp: ~N[2020-01-01 00:02:00]), + build(:engagement, user_id: 34, timestamp: ~N[2020-01-01 00:03:00], scroll_depth: 60), + # 2020-01-02 - both imported and native data + build(:pageview, user_id: 56, timestamp: ~N[2020-01-02 00:00:00]), + build(:engagement, user_id: 56, timestamp: ~N[2020-01-02 00:01:00], scroll_depth: 20), + build(:imported_pages, + date: ~D[2020-01-02], + page: "/", + visitors: 1, + total_scroll_depth: 40, + total_scroll_depth_visits: 1 + ), + # 2020-01-03 - only imported data + build(:imported_pages, + date: ~D[2020-01-03], + page: "/", + visitors: 1, + total_scroll_depth: 90, + total_scroll_depth_visits: 1 + ), + build(:imported_pages, date: ~D[2020-01-03], page: "/", visitors: 100) + ]) + + response = + do_query(conn, site, %{ + "date_range" => "7d", + "relative_date" => "2020-01-08", + "metrics" => ["scroll_depth"], + "dimensions" => ["time:day"], + "filters" => [["is", "event:page", ["/"]]], + "include" => %{"imports" => true} + }) + + assert response["results"] == [ + %{"dimensions" => ["2020-01-01"], "metrics" => [40]}, + %{"dimensions" => ["2020-01-02"], "metrics" => [30]}, + %{"dimensions" => ["2020-01-03"], "metrics" => [90]} + ] + end + end + + describe "conversion_rate plot" do + setup [:create_user, :log_in, :create_site] + + test "returns 400 when conversion rate is queried without a goal filter", %{ + conn: conn, + site: site + } do + response = + do_query_fail(conn, site, %{ + "date_range" => "month", + "relative_date" => "2021-01-01", + "metrics" => ["conversion_rate"], + "dimensions" => ["time:day"] + }) + + assert %{"error" => error} = json_response(response, 400) + assert error =~ "can only be queried with event:goal filters or dimensions" + end + + test "displays conversion_rate for a month", %{conn: conn, site: site} do + insert(:goal, site: site, event_name: "Signup") + + populate_stats(site, [ + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]), + build(:event, name: "Signup", timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-31 00:00:00]), + build(:event, name: "Signup", timestamp: ~N[2021-01-31 00:00:00]) + ]) + + response = + do_query(conn, site, %{ + "date_range" => "month", + "relative_date" => "2021-01-01", + "metrics" => ["group_conversion_rate"], + "dimensions" => ["time:day"], + "filters" => [["is", "event:goal", ["Signup"]]] + }) + + assert response["results"] == [ + %{"dimensions" => ["2021-01-01"], "metrics" => [33.33]}, + %{"dimensions" => ["2021-01-31"], "metrics" => [50.0]} + ] + end + end + + describe "events (total conversions) plot" do + setup [:create_user, :log_in, :create_site] + + test "displays total conversions for a goal", %{conn: conn, site: site} do + insert(:goal, site: site, event_name: "Signup") + + populate_stats(site, [ + build(:event, name: "Different", timestamp: ~N[2021-01-01 00:00:00]), + build(:event, name: "Signup", timestamp: ~N[2021-01-01 00:00:00]), + build(:event, name: "Signup", timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-31 00:00:00]), + build(:event, name: "Signup", user_id: 123, timestamp: ~N[2021-01-31 00:00:00]), + build(:event, name: "Signup", user_id: 123, timestamp: ~N[2021-01-31 00:00:00]), + build(:event, name: "Signup", user_id: 123, timestamp: ~N[2021-01-31 00:00:00]) + ]) + + response = + do_query(conn, site, %{ + "date_range" => "month", + "relative_date" => "2021-01-01", + "metrics" => ["events"], + "dimensions" => ["time:day"], + "filters" => [["is", "event:goal", ["Signup"]]] + }) + + assert response["results"] == [ + %{"dimensions" => ["2021-01-01"], "metrics" => [2]}, + %{"dimensions" => ["2021-01-31"], "metrics" => [3]} + ] + end + + test "displays total conversions per hour with previous day comparison plot", %{ + conn: conn, + site: site + } do + insert(:goal, site: site, event_name: "Signup") + + populate_stats(site, [ + build(:event, name: "Different", timestamp: ~N[2021-01-10 05:00:00]), + build(:event, name: "Signup", timestamp: ~N[2021-01-10 05:00:00]), + build(:event, name: "Signup", timestamp: ~N[2021-01-10 05:00:00]), + build(:event, name: "Signup", timestamp: ~N[2021-01-10 19:00:00]), + build(:pageview, timestamp: ~N[2021-01-10 19:00:00]), + build(:event, name: "Signup", timestamp: ~N[2021-01-11 04:00:00]), + build(:event, name: "Signup", timestamp: ~N[2021-01-11 05:00:00]), + build(:event, name: "Signup", timestamp: ~N[2021-01-11 18:00:00]) + ]) + + %{"results" => results, "comparison_results" => comparison_results} = + do_query(conn, site, %{ + "date_range" => "day", + "relative_date" => "2021-01-11", + "metrics" => ["events"], + "dimensions" => ["time:hour"], + "filters" => [["is", "event:goal", ["Signup"]]], + "include" => %{"compare" => "previous_period"} + }) + + assert results == [ + %{"dimensions" => ["2021-01-11 04:00:00"], "metrics" => [1]}, + %{"dimensions" => ["2021-01-11 05:00:00"], "metrics" => [1]}, + %{"dimensions" => ["2021-01-11 18:00:00"], "metrics" => [1]} + ] + + assert comparison_results == [ + %{"dimensions" => ["2021-01-10 05:00:00"], "metrics" => [2], "change" => [-50]}, + %{"dimensions" => ["2021-01-10 19:00:00"], "metrics" => [1], "change" => nil} + ] + end + + test "displays conversions per month with 12mo comparison plot", %{ + conn: conn, + site: site + } do + insert(:goal, site: site, event_name: "Signup") + + populate_stats(site, [ + build(:event, name: "Different", timestamp: ~N[2019-12-10 00:00:00]), + build(:event, name: "Signup", timestamp: ~N[2020-01-10 00:00:00]), + build(:event, name: "Signup", timestamp: ~N[2020-02-10 00:00:00]), + build(:event, name: "Signup", timestamp: ~N[2020-03-10 00:00:00]), + build(:pageview, timestamp: ~N[2021-04-10 00:00:00]), + build(:event, name: "Signup", timestamp: ~N[2021-05-11 04:00:00]), + build(:event, name: "Signup", timestamp: ~N[2021-06-11 00:00:00]), + build(:event, name: "Signup", timestamp: ~N[2021-07-11 00:00:00]) + ]) + + %{"results" => results, "comparison_results" => comparison_results} = + do_query(conn, site, %{ + "date_range" => "12mo", + "relative_date" => "2021-12-11", + "metrics" => ["events"], + "dimensions" => ["time:month"], + "filters" => [["is", "event:goal", ["Signup"]]], + "include" => %{"compare" => "previous_period"} + }) + + assert results == [ + %{"dimensions" => ["2021-05-01"], "metrics" => [1]}, + %{"dimensions" => ["2021-06-01"], "metrics" => [1]}, + %{"dimensions" => ["2021-07-01"], "metrics" => [1]} + ] + + assert comparison_results == [ + %{"dimensions" => ["2020-01-01"], "metrics" => [1], "change" => nil}, + %{"dimensions" => ["2020-02-01"], "metrics" => [1], "change" => nil}, + %{"dimensions" => ["2020-03-01"], "metrics" => [1], "change" => nil} + ] + end + end + + describe "bounce_rate plot" do + setup [:create_user, :log_in, :create_site, :create_legacy_site_import] + + test "displays bounce_rate for a month", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, timestamp: ~N[2021-01-03 00:00:00], user_id: 1), + build(:pageview, timestamp: ~N[2021-01-03 00:10:00], user_id: 1), + build(:pageview, timestamp: ~N[2021-01-31 00:00:00]) + ]) + + response = + do_query(conn, site, %{ + "date_range" => "month", + "relative_date" => "2021-01-01", + "metrics" => ["bounce_rate"], + "dimensions" => ["time:day"] + }) + + assert response["results"] == [ + %{"dimensions" => ["2021-01-03"], "metrics" => [0]}, + %{"dimensions" => ["2021-01-31"], "metrics" => [100]} + ] + end + + test "displays bounce rate for a month with imported data", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-31 00:00:00]), + build(:imported_visitors, visits: 1, bounces: 0, date: ~D[2021-01-01]), + build(:imported_visitors, visits: 1, bounces: 1, date: ~D[2021-01-31]) + ]) + + response = + do_query(conn, site, %{ + "date_range" => "month", + "relative_date" => "2021-01-01", + "metrics" => ["bounce_rate"], + "dimensions" => ["time:day"], + "include" => %{"imports" => true} + }) + + assert response["results"] == [ + %{"dimensions" => ["2021-01-01"], "metrics" => [50]}, + %{"dimensions" => ["2021-01-31"], "metrics" => [100]} + ] + end + + test "displays bounce rate for a month with only imported data", %{conn: conn, site: site} do + populate_stats(site, [ + build(:imported_visitors, visits: 1, bounces: 0, date: ~D[2021-01-01]), + build(:imported_visitors, visits: 1, bounces: 1, date: ~D[2021-01-31]) + ]) + + response = + do_query(conn, site, %{ + "date_range" => "month", + "relative_date" => "2021-01-01", + "metrics" => ["bounce_rate"], + "dimensions" => ["time:day"], + "include" => %{"imports" => true} + }) + + assert response["results"] == [ + %{"dimensions" => ["2021-01-01"], "metrics" => [0]}, + %{"dimensions" => ["2021-01-31"], "metrics" => [100]} + ] + end + end + + describe "visit_duration plot" do + setup [:create_user, :log_in, :create_site, :create_legacy_site_import] + + test "displays visit_duration for a month", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/page3", + user_id: @user_id, + timestamp: ~N[2021-01-31 00:10:00] + ), + build(:pageview, + pathname: "/page2", + user_id: @user_id, + timestamp: ~N[2021-01-31 00:15:00] + ) + ]) + + response = + do_query(conn, site, %{ + "date_range" => "month", + "relative_date" => "2021-01-01", + "metrics" => ["visit_duration"], + "dimensions" => ["time:day"] + }) + + assert response["results"] == [ + %{"dimensions" => ["2021-01-31"], "metrics" => [300]} + ] + end + + test "displays visit_duration for a month with imported data", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, user_id: @user_id, timestamp: ~N[2021-01-01 00:10:00]), + build(:pageview, user_id: @user_id, timestamp: ~N[2021-01-01 00:15:00]), + build(:imported_visitors, visits: 1, visit_duration: 100, date: ~D[2021-01-01]) + ]) + + response = + do_query(conn, site, %{ + "date_range" => "month", + "relative_date" => "2021-01-01", + "metrics" => ["visit_duration"], + "dimensions" => ["time:day"], + "include" => %{"imports" => true} + }) + + assert response["results"] == [ + %{"dimensions" => ["2021-01-01"], "metrics" => [200]} + ] + end + + test "displays visit_duration for a month with only imported data", %{conn: conn, site: site} do + populate_stats(site, [ + build(:imported_visitors, visits: 1, visit_duration: 100, date: ~D[2021-01-01]) + ]) + + response = + do_query(conn, site, %{ + "date_range" => "month", + "relative_date" => "2021-01-01", + "metrics" => ["visit_duration"], + "dimensions" => ["time:day"], + "include" => %{"imports" => true} + }) + + assert response["results"] == [ + %{"dimensions" => ["2021-01-01"], "metrics" => [100]} + ] + end + end + + describe "varying intervals" do + setup [:create_user, :log_in, :create_site] + + test "displays visitors for 6mo on a day scale", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, timestamp: ~N[2020-12-01 00:00:00]), + build(:pageview, timestamp: ~N[2020-12-15 00:00:00]), + build(:pageview, timestamp: ~N[2020-12-15 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-15 00:00:00]), + build(:pageview, timestamp: ~N[2021-05-31 01:00:00]) + ]) + + response = + do_query(conn, site, %{ + "date_range" => "6mo", + "relative_date" => "2021-06-01", + "metrics" => ["visitors"], + "dimensions" => ["time:day"], + "include" => %{"time_labels" => true} + }) + + assert length(response["meta"]["time_labels"]) == 182 + + assert response["results"] == [ + %{"dimensions" => ["2020-12-01"], "metrics" => [1]}, + %{"dimensions" => ["2020-12-15"], "metrics" => [2]}, + %{"dimensions" => ["2021-01-15"], "metrics" => [1]}, + %{"dimensions" => ["2021-05-31"], "metrics" => [1]} + ] + end + + test "displays visitors for a custom period on a monthly scale", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-15 00:00:00]), + build(:pageview, timestamp: ~N[2021-02-15 00:00:00]), + build(:pageview, timestamp: ~N[2021-06-01 00:00:00]) + ]) + + response = + do_query(conn, site, %{ + "date_range" => ["2021-01-01", "2021-06-30"], + "metrics" => ["visitors"], + "dimensions" => ["time:month"] + }) + + assert response["results"] == [ + %{"dimensions" => ["2021-01-01"], "metrics" => [2]}, + %{"dimensions" => ["2021-02-01"], "metrics" => [1]}, + %{"dimensions" => ["2021-06-01"], "metrics" => [1]} + ] + end + + test "returns error when the interval is not valid", %{ + conn: conn, + site: site + } do + response = + do_query_fail(conn, site, %{ + "date_range" => "day", + "relative_date" => "2021-01-01", + "metrics" => ["visitors"], + "dimensions" => ["time:biweekly"] + }) + + assert %{"error" => error} = json_response(response, 400) + assert error =~ "Invalid dimensions" + end + + test "time:minute dimension for a complete day, including DST transition", %{ + conn: conn, + site: site + } do + # On Oct 26, 2025, in Tallinn, Estonia, the clocks shifted back + # from 04:00 -> 03:00, so there were 25 hours in that day. + Plausible.Site.changeset(site, %{timezone: "Europe/Tallinn"}) + |> Plausible.Repo.update!() + + populate_stats(site, [ + build(:pageview, timestamp: ~N[2025-10-26 01:01:01]), + build(:pageview, timestamp: ~N[2025-10-26 01:01:02]), + build(:pageview, timestamp: ~N[2025-10-26 10:02:30]) + ]) + + response = + do_query(conn, site, %{ + "date_range" => "day", + "relative_date" => "2025-10-26", + "metrics" => ["pageviews"], + "dimensions" => ["time:minute"], + "include" => %{"time_labels" => true} + }) + + assert length(response["meta"]["time_labels"]) == 1500 + + assert response["results"] == [ + %{"dimensions" => ["2025-10-26 03:01:00"], "metrics" => [2]}, + %{"dimensions" => ["2025-10-26 12:02:00"], "metrics" => [1]} + ] + end + + test "time:minute dimension for last 24h", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, timestamp: ~N[2021-01-01 00:01:01]), + build(:pageview, timestamp: ~N[2021-01-01 00:01:02]), + build(:pageview, timestamp: ~N[2021-01-01 00:02:30]) + ]) + + response = + do_query( + conn, + site, + %{ + "date_range" => "24h", + "metrics" => ["pageviews"], + "dimensions" => ["time:minute"], + "include" => %{"time_labels" => true} + }, + now: ~U[2021-01-01 12:00:00Z] + ) + + assert length(response["meta"]["time_labels"]) == 1440 + + assert response["results"] == [ + %{"dimensions" => ["2021-01-01 00:01:00"], "metrics" => [2]}, + %{"dimensions" => ["2021-01-01 00:02:00"], "metrics" => [1]} + ] + end + + test "returns error when time:minute dimension is queried for a period longer than 24h", %{ + conn: conn, + site: site + } do + response = + do_query_fail(conn, site, %{ + "date_range" => ["2021-01-01", "2021-01-02"], + "metrics" => ["visitors"], + "dimensions" => ["time:minute"] + }) + + assert %{"error" => error} = json_response(response, 400) + assert error =~ "Invalid dimensions" + assert error =~ "time:minute" + end + + test "displays visitors for a month on a weekly scale", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-01 00:15:01]), + build(:pageview, timestamp: ~N[2021-01-05 00:15:02]) + ]) + + response = + do_query(conn, site, %{ + "date_range" => "month", + "relative_date" => "2021-01-01", + "metrics" => ["visitors"], + "dimensions" => ["time:week"], + "include" => %{"time_labels" => true} + }) + + assert length(response["meta"]["time_labels"]) == 5 + + assert response["results"] == [ + %{"dimensions" => ["2021-01-01"], "metrics" => [2]}, + %{"dimensions" => ["2021-01-04"], "metrics" => [1]} + ] + end + + test "shows imperfect week-split month on week scale with partial week indicators", %{ + conn: conn, + site: site + } do + response = + do_query(conn, site, %{ + "date_range" => "month", + "relative_date" => "2021-09-01", + "metrics" => ["visitors"], + "dimensions" => ["time:week"], + "include" => %{"time_labels" => true, "partial_time_labels" => true} + }) + + assert response["meta"]["time_labels"] == [ + "2021-09-01", + "2021-09-06", + "2021-09-13", + "2021-09-20", + "2021-09-27" + ] + + assert response["meta"]["partial_time_labels"] == ["2021-09-01", "2021-09-27"] + end + + test "returns stats for the first week of the month when site timezone is ahead of UTC", %{ + conn: conn, + site: site + } do + site = + site + |> Plausible.Site.changeset(%{timezone: "Europe/Copenhagen"}) + |> Plausible.Repo.update!() + + populate_stats(site, [ + build(:pageview, timestamp: ~N[2023-03-01 12:00:00]) + ]) + + response = + do_query(conn, site, %{ + "date_range" => "month", + "relative_date" => "2023-03-01", + "metrics" => ["visitors"], + "dimensions" => ["time:week"], + "include" => %{"time_labels" => true} + }) + + assert List.first(response["meta"]["time_labels"]) == "2023-03-01" + + assert response["results"] == [ + %{"metrics" => [1], "dimensions" => ["2023-03-01"]} + ] + end + + test "shows half-perfect week-split month on week scale with partial week indicators", %{ + conn: conn, + site: site + } do + response = + do_query(conn, site, %{ + "date_range" => "month", + "relative_date" => "2021-10-01", + "metrics" => ["visitors"], + "dimensions" => ["time:week"], + "include" => %{"time_labels" => true, "partial_time_labels" => true} + }) + + assert response["meta"]["time_labels"] == [ + "2021-10-01", + "2021-10-04", + "2021-10-11", + "2021-10-18", + "2021-10-25" + ] + + assert response["meta"]["partial_time_labels"] == ["2021-10-01"] + end + + test "shows perfect week-split range on week scale with full week indicators for custom period", + %{ + conn: conn, + site: site + } do + response = + do_query(conn, site, %{ + "date_range" => ["2020-12-21", "2021-02-07"], + "metrics" => ["visitors"], + "dimensions" => ["time:week"], + "include" => %{"time_labels" => true, "partial_time_labels" => true} + }) + + assert response["meta"]["time_labels"] == [ + "2020-12-21", + "2020-12-28", + "2021-01-04", + "2021-01-11", + "2021-01-18", + "2021-01-25", + "2021-02-01" + ] + + assert response["meta"]["partial_time_labels"] == [] + end + + test "shows imperfect week-split for last 28d with full week indicators", %{ + conn: conn, + site: site + } do + response = + do_query(conn, site, %{ + "date_range" => "28d", + "relative_date" => "2021-10-30", + "metrics" => ["visitors"], + "dimensions" => ["time:week"], + "include" => %{"time_labels" => true, "partial_time_labels" => true} + }) + + assert response["meta"]["time_labels"] == [ + "2021-10-02", + "2021-10-04", + "2021-10-11", + "2021-10-18", + "2021-10-25" + ] + + assert response["meta"]["partial_time_labels"] == ["2021-10-02", "2021-10-25"] + end + + test "shows imperfect month-split for custom period with full month indicators", %{ + conn: conn, + site: site + } do + response = + do_query(conn, site, %{ + "date_range" => ["2021-09-06", "2021-12-13"], + "metrics" => ["visitors"], + "dimensions" => ["time:month"], + "include" => %{"time_labels" => true, "partial_time_labels" => true} + }) + + assert response["meta"]["time_labels"] == [ + "2021-09-01", + "2021-10-01", + "2021-11-01", + "2021-12-01" + ] + + assert response["meta"]["partial_time_labels"] == ["2021-09-01", "2021-12-01"] + end + + test "shows perfect month-split for last 91d with full month indicators", %{ + conn: conn, + site: site + } do + response = + do_query(conn, site, %{ + "date_range" => "91d", + "relative_date" => "2021-12-01", + "metrics" => ["visitors"], + "dimensions" => ["time:month"], + "include" => %{"time_labels" => true, "partial_time_labels" => true} + }) + + assert response["meta"]["time_labels"] == ["2021-09-01", "2021-10-01", "2021-11-01"] + + assert response["meta"]["partial_time_labels"] == [] + end + + test "partial_time_labels is an empty list for time:day when all days are complete", %{ + conn: conn, + site: site + } do + response = + do_query(conn, site, %{ + "date_range" => "28d", + "relative_date" => "2021-01-28", + "metrics" => ["visitors"], + "dimensions" => ["time:day"], + "include" => %{"time_labels" => true, "partial_time_labels" => true} + }) + + assert length(response["meta"]["time_labels"]) == 28 + assert response["meta"]["partial_time_labels"] == [] + end + + test "returns comparison_partial_time_labels", %{ + conn: conn, + site: site + } do + response = + do_query(conn, site, %{ + "date_range" => ["2026-02-08", "2026-02-25"], + "metrics" => ["visitors"], + "dimensions" => ["time:week"], + "include" => %{ + "time_labels" => true, + "partial_time_labels" => true, + "compare" => ["2026-01-08", "2026-01-25"] + } + }) + + assert response["meta"]["comparison_time_labels"] == [ + "2026-01-08", + "2026-01-12", + "2026-01-19" + ] + + assert response["meta"]["comparison_partial_time_labels"] == ["2026-01-08"] + end + + test "returns stats for a day with a minute interval", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, timestamp: ~N[2023-03-01 12:00:00]) + ]) + + response = + do_query(conn, site, %{ + "date_range" => "day", + "relative_date" => "2023-03-01", + "metrics" => ["visitors"], + "dimensions" => ["time:minute"], + "include" => %{"time_labels" => true} + }) + + labels = response["meta"]["time_labels"] + + assert length(labels) == 24 * 60 + assert List.first(labels) == "2023-03-01 00:00:00" + assert Enum.at(labels, 1) == "2023-03-01 00:01:00" + assert List.last(labels) == "2023-03-01 23:59:00" + + assert response["results"] == [ + %{"dimensions" => ["2023-03-01 12:00:00"], "metrics" => [1]} + ] + end + + test "trims hourly relative date range", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, timestamp: ~N[2021-01-08 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-08 06:05:00]), + build(:pageview, timestamp: ~N[2021-01-08 08:59:00]), + build(:pageview, timestamp: ~N[2021-01-08 23:59:00]) + ]) + + response = + do_query( + conn, + site, + %{ + "date_range" => "day", + "relative_date" => "2021-01-08", + "metrics" => ["visitors"], + "dimensions" => ["time:hour"], + "include" => %{"time_labels" => true} + }, + now: ~U[2021-01-08 08:05:00Z] + ) + + assert response["meta"]["time_labels"] == [ + "2021-01-08 00:00:00", + "2021-01-08 01:00:00", + "2021-01-08 02:00:00", + "2021-01-08 03:00:00", + "2021-01-08 04:00:00", + "2021-01-08 05:00:00", + "2021-01-08 06:00:00", + "2021-01-08 07:00:00", + "2021-01-08 08:00:00" + ] + + assert response["results"] == [ + %{"dimensions" => ["2021-01-08 00:00:00"], "metrics" => [1]}, + %{"dimensions" => ["2021-01-08 06:00:00"], "metrics" => [1]}, + %{"dimensions" => ["2021-01-08 08:00:00"], "metrics" => [1]} + ] + end + + test "trims monthly relative date range", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-05 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-07 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-31 00:00:00]) + ]) + + response = + do_query( + conn, + site, + %{ + "date_range" => "month", + "relative_date" => "2021-01-07", + "metrics" => ["visitors"], + "dimensions" => ["time:day"], + "include" => %{"time_labels" => true} + }, + now: ~U[2021-01-07 12:00:00Z] + ) + + assert response["meta"]["time_labels"] == [ + "2021-01-01", + "2021-01-02", + "2021-01-03", + "2021-01-04", + "2021-01-05", + "2021-01-06", + "2021-01-07" + ] + + assert response["results"] == [ + %{"dimensions" => ["2021-01-01"], "metrics" => [1]}, + %{"dimensions" => ["2021-01-05"], "metrics" => [1]}, + %{"dimensions" => ["2021-01-07"], "metrics" => [1]} + ] + end + + test "trims yearly relative date range", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-05 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-30 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-31 00:00:00]), + build(:pageview, timestamp: ~N[2021-02-01 00:00:00]), + build(:pageview, timestamp: ~N[2021-02-09 00:00:00]) + ]) + + response = + do_query( + conn, + site, + %{ + "date_range" => "year", + "relative_date" => "2021-02-07", + "metrics" => ["visitors"], + "dimensions" => ["time:month"], + "include" => %{"time_labels" => true} + }, + now: ~U[2021-02-07 12:00:00Z] + ) + + assert response["meta"]["time_labels"] == ["2021-01-01", "2021-02-01"] + + assert response["results"] == [ + %{"dimensions" => ["2021-01-01"], "metrics" => [4]}, + %{"dimensions" => ["2021-02-01"], "metrics" => [1]} + ] + end + end + + describe "comparisons" do + setup [:create_user, :log_in, :create_site, :create_legacy_site_import] + + test "returns past month stats when period=30d and comparison=previous_period", %{ + conn: conn, + site: site + } do + response = + do_query(conn, site, %{ + "date_range" => "30d", + "metrics" => ["visitors"], + "dimensions" => ["time:day"], + "include" => %{"compare" => "previous_period", "time_labels" => true} + }) + + labels = response["meta"]["time_labels"] + comparison_labels = response["meta"]["comparison_time_labels"] + + first = Date.utc_today() |> Date.shift(day: -30) |> Date.to_iso8601() + last = Date.utc_today() |> Date.shift(day: -1) |> Date.to_iso8601() + + assert List.first(labels) == first + assert List.last(labels) == last + + first = Date.utc_today() |> Date.shift(day: -60) |> Date.to_iso8601() + last = Date.utc_today() |> Date.shift(day: -31) |> Date.to_iso8601() + + assert List.first(comparison_labels) == first + assert List.last(comparison_labels) == last + end + + test "returns past year stats when period=month and comparison=year_over_year", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, timestamp: ~N[2020-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2020-01-05 00:00:00]), + build(:pageview, timestamp: ~N[2020-01-30 00:00:00]), + build(:pageview, timestamp: ~N[2020-01-31 00:00:00]), + build(:pageview, timestamp: ~N[2019-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2019-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2019-01-05 00:00:00]), + build(:pageview, timestamp: ~N[2019-01-05 00:00:00]), + build(:pageview, timestamp: ~N[2019-01-31 00:00:00]) + ]) + + response = + do_query(conn, site, %{ + "date_range" => "month", + "relative_date" => "2020-01-01", + "metrics" => ["visitors"], + "dimensions" => ["time:day"], + "include" => %{"compare" => "year_over_year", "time_labels" => true} + }) + + assert response["results"] == [ + %{"dimensions" => ["2020-01-01"], "metrics" => [1]}, + %{"dimensions" => ["2020-01-05"], "metrics" => [1]}, + %{"dimensions" => ["2020-01-30"], "metrics" => [1]}, + %{"dimensions" => ["2020-01-31"], "metrics" => [1]} + ] + + assert response["comparison_results"] == [ + %{"dimensions" => ["2019-01-01"], "metrics" => [2], "change" => [-50]}, + %{"dimensions" => ["2019-01-05"], "metrics" => [2], "change" => [-50]}, + %{"dimensions" => ["2019-01-31"], "metrics" => [1], "change" => [0]} + ] + + assert length(response["meta"]["time_labels"]) == 31 + assert length(response["meta"]["comparison_time_labels"]) == 31 + + assert response["meta"]["time_label_result_indices"] == + [0, nil, nil, nil, 1] ++ List.duplicate(nil, 24) ++ [2, 3] + + assert response["meta"]["comparison_time_label_result_indices"] == + [0, nil, nil, nil, 1] ++ List.duplicate(nil, 25) ++ [2] + end + + test "can return custom comparison period larger than original query", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, timestamp: ~N[2020-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2020-01-05 00:00:00]), + build(:pageview, timestamp: ~N[2020-01-30 00:00:00]) + ]) + + response = + do_query(conn, site, %{ + "date_range" => "month", + "relative_date" => "2020-01-01", + "metrics" => ["visitors"], + "dimensions" => ["time:day"], + "include" => %{ + "compare" => ["2022-01-01", "2022-06-01"], + "time_labels" => true + } + }) + + labels = response["meta"]["time_labels"] + comparison_labels = response["meta"]["comparison_time_labels"] + + assert length(labels) == 31 + assert length(comparison_labels) == 152 + assert length(response["results"]) == 3 + assert response["comparison_results"] == [] + assert List.first(comparison_labels) == "2022-01-01" + assert List.last(comparison_labels) == "2022-06-01" + end + + test "compares imported data and native data together", %{conn: conn, site: site} do + populate_stats(site, [ + build(:imported_visitors, date: ~D[2020-01-02]), + build(:imported_visitors, date: ~D[2020-01-02]), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + response = + do_query(conn, site, %{ + "date_range" => "year", + "relative_date" => "2021-01-01", + "metrics" => ["visitors"], + "dimensions" => ["time:month"], + "include" => %{"imports" => true, "compare" => "year_over_year"} + }) + + plot = Enum.map(response["results"], fn r -> List.first(r["metrics"]) end) + + comparison_plot = + Enum.map(response["comparison_results"], fn r -> List.first(r["metrics"]) end) + + assert 4 == Enum.sum(plot) + assert 2 == Enum.sum(comparison_plot) + end + + test "does not return imported data when with_imported is set to false when comparing", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:imported_visitors, date: ~D[2020-01-02]), + build(:imported_visitors, date: ~D[2020-01-02]), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + response = + do_query(conn, site, %{ + "date_range" => "year", + "relative_date" => "2021-01-01", + "metrics" => ["visitors"], + "dimensions" => ["time:month"], + "include" => %{"imports" => false, "compare" => "year_over_year"} + }) + + plot = Enum.map(response["results"], fn r -> List.first(r["metrics"]) end) + + comparison_plot = + Enum.map(response["comparison_results"], fn r -> List.first(r["metrics"]) end) + + assert 4 == Enum.sum(plot) + assert 0 == Enum.sum(comparison_plot) + end + + test "plots conversion rate previous period comparison", %{site: site, conn: conn} do + insert(:goal, site: site, event_name: "Signup") + + populate_stats(site, [ + build(:event, name: "Signup", timestamp: ~N[2021-01-01 00:01:00]), + build(:pageview, timestamp: ~N[2021-01-01 00:01:00]), + build(:pageview, timestamp: ~N[2021-01-01 00:01:00]), + build(:event, name: "Signup", timestamp: ~N[2021-01-08 00:01:00]), + build(:pageview, timestamp: ~N[2021-01-08 00:01:00]) + ]) + + %{"results" => results, "comparison_results" => comparison_results} = + do_query(conn, site, %{ + "date_range" => "7d", + "relative_date" => "2021-01-15", + "metrics" => ["conversion_rate"], + "dimensions" => ["time:day"], + "filters" => [["is", "event:goal", ["Signup"]]], + "include" => %{"compare" => "previous_period"} + }) + + assert results == [ + %{"dimensions" => ["2021-01-08"], "metrics" => [50.0]} + ] + + assert comparison_results == [ + %{"dimensions" => ["2021-01-01"], "metrics" => [33.33], "change" => [16.7]} + ] + end + + test "does trim hourly relative date range when comparing", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, timestamp: ~N[2021-01-08 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-08 06:05:00]), + build(:pageview, timestamp: ~N[2021-01-08 08:04:00]) + ]) + + response = + do_query( + conn, + site, + %{ + "date_range" => "day", + "relative_date" => "2021-01-08", + "metrics" => ["visitors"], + "dimensions" => ["time:hour"], + "include" => %{"compare" => "previous_period", "time_labels" => true} + }, + now: ~U[2021-01-08 08:05:00Z] + ) + + assert response["meta"]["time_labels"] == [ + "2021-01-08 00:00:00", + "2021-01-08 01:00:00", + "2021-01-08 02:00:00", + "2021-01-08 03:00:00", + "2021-01-08 04:00:00", + "2021-01-08 05:00:00", + "2021-01-08 06:00:00", + "2021-01-08 07:00:00", + "2021-01-08 08:00:00" + ] + + assert response["results"] == [ + %{"dimensions" => ["2021-01-08 00:00:00"], "metrics" => [1]}, + %{"dimensions" => ["2021-01-08 06:00:00"], "metrics" => [1]}, + %{"dimensions" => ["2021-01-08 08:00:00"], "metrics" => [1]} + ] + + assert response["meta"]["comparison_time_labels"] == [ + "2021-01-07 00:00:00", + "2021-01-07 01:00:00", + "2021-01-07 02:00:00", + "2021-01-07 03:00:00", + "2021-01-07 04:00:00", + "2021-01-07 05:00:00", + "2021-01-07 06:00:00", + "2021-01-07 07:00:00", + "2021-01-07 08:00:00" + ] + + assert response["comparison_results"] == [] + + assert response["meta"]["time_label_result_indices"] == + [0, nil, nil, nil, nil, nil, 1, nil, 2] + + assert response["meta"]["comparison_time_label_result_indices"] == List.duplicate(nil, 9) + end + end + + describe "revenue metric dropped" do + @describetag :ee_only + setup [:create_user, :log_in, :create_site] + + for revenue_metric <- ["total_revenue", "average_revenue"] do + test "#{revenue_metric} without goal filter", %{ + conn: conn, + site: site + } do + response = + do_query_fail(conn, site, %{ + "date_range" => "day", + "metrics" => [unquote(revenue_metric)], + "dimensions" => ["time:hour"] + }) + + assert %{"error" => error} = json_response(response, 400) + assert error =~ "Revenue metrics were dropped" + end + + test "#{revenue_metric} with filtered goals mixing currencies", %{ + conn: conn, + site: site + } do + insert(:goal, site: site, event_name: "PurchaseEUR", currency: "EUR") + insert(:goal, site: site, event_name: "PurchaseUSD", currency: "USD") + + response = + do_query_fail(conn, site, %{ + "date_range" => "day", + "metrics" => [unquote(revenue_metric)], + "filters" => [["is", "event:goal", ["PurchaseUSD", "PurchaseEUR"]]], + "dimensions" => ["time:hour"] + }) + + assert %{"error" => error} = json_response(response, 400) + assert error =~ "Revenue metrics were dropped" + end + + test "#{revenue_metric} with insufficient subscription", %{ + conn: conn, + user: user, + site: site + } do + insert(:goal, site: site, event_name: "PurchaseEUR", currency: "EUR") + subscribe_to_growth_plan(user) + + response = + do_query_fail(conn, site, %{ + "date_range" => "day", + "metrics" => [unquote(revenue_metric)], + "filters" => [["is", "event:goal", ["PurchaseEUR"]]], + "dimensions" => ["time:hour"] + }) + + assert %{"error" => error} = json_response(response, 400) + assert error =~ "Revenue metrics were dropped" + end + end + end + + describe "total_revenue plot" do + @describetag :ee_only + setup [:create_user, :log_in, :create_site, :create_legacy_site_import] + + test "plots total_revenue for a month", %{conn: conn, site: site} do + insert(:goal, site: site, event_name: "Payment", currency: "USD") + + populate_stats(site, [ + build(:event, + name: "Payment", + revenue_reporting_amount: Decimal.new("13.29"), + revenue_reporting_currency: "USD", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Payment", + revenue_reporting_amount: Decimal.new("19.90"), + revenue_reporting_currency: "USD", + timestamp: ~N[2021-01-05 00:00:00] + ), + build(:event, + name: "Payment", + revenue_reporting_amount: Decimal.new("10.31"), + revenue_reporting_currency: "USD", + timestamp: ~N[2021-01-31 00:00:00] + ), + build(:event, + name: "Payment", + revenue_reporting_amount: Decimal.new("20.0"), + revenue_reporting_currency: "USD", + timestamp: ~N[2021-01-31 00:00:00] + ) + ]) + + response = + do_query(conn, site, %{ + "date_range" => "month", + "relative_date" => "2021-01-01", + "metrics" => ["total_revenue"], + "dimensions" => ["time:day"], + "filters" => [["is", "event:goal", ["Payment"]]] + }) + + assert response["results"] == [ + %{ + "dimensions" => ["2021-01-01"], + "metrics" => [ + %{ + "currency" => "USD", + "long" => "$13.29", + "short" => "$13.3", + "value" => 13.29 + } + ] + }, + %{ + "dimensions" => ["2021-01-05"], + "metrics" => [ + %{"currency" => "USD", "long" => "$19.90", "short" => "$19.9", "value" => 19.9} + ] + }, + %{ + "dimensions" => ["2021-01-31"], + "metrics" => [ + %{ + "currency" => "USD", + "long" => "$30.31", + "short" => "$30.3", + "value" => 30.31 + } + ] + } + ] + end + + test "plots total_revenue for a week compared to last week", %{conn: conn, site: site} do + insert(:goal, + site: site, + event_name: "Payment", + currency: "USD", + display_name: "PaymentUSD" + ) + + populate_stats(site, [ + build(:event, + name: "Payment", + revenue_reporting_amount: Decimal.new("13.29"), + revenue_reporting_currency: "USD", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Payment", + revenue_reporting_amount: Decimal.new("19.90"), + revenue_reporting_currency: "USD", + timestamp: ~N[2021-01-05 00:00:00] + ), + build(:event, + name: "Payment", + revenue_reporting_amount: Decimal.new("10.31"), + revenue_reporting_currency: "USD", + timestamp: ~N[2021-01-10 00:00:00] + ), + build(:event, + name: "Payment", + revenue_reporting_amount: Decimal.new("20.0"), + revenue_reporting_currency: "USD", + timestamp: ~N[2021-01-12 00:00:00] + ), + build(:event, + name: "Payment", + revenue_reporting_amount: Decimal.new("10.0"), + revenue_reporting_currency: "USD", + timestamp: ~N[2021-01-12 01:00:00] + ) + ]) + + %{"results" => results, "comparison_results" => comparison_results} = + do_query(conn, site, %{ + "date_range" => "7d", + "relative_date" => "2021-01-15", + "metrics" => ["total_revenue"], + "dimensions" => ["time:day"], + "filters" => [["is", "event:goal", ["PaymentUSD"]]], + "include" => %{"compare" => "previous_period"} + }) + + assert results == [ + %{ + "dimensions" => ["2021-01-10"], + "metrics" => [ + %{ + "currency" => "USD", + "long" => "$10.31", + "short" => "$10.3", + "value" => 10.31 + } + ] + }, + %{ + "dimensions" => ["2021-01-12"], + "metrics" => [ + %{"currency" => "USD", "long" => "$30.00", "short" => "$30.0", "value" => 30.0} + ] + } + ] + + assert comparison_results == [ + %{ + "dimensions" => ["2021-01-01"], + "metrics" => [ + %{ + "currency" => "USD", + "long" => "$13.29", + "short" => "$13.3", + "value" => 13.29 + } + ], + "change" => nil + }, + %{ + "dimensions" => ["2021-01-05"], + "metrics" => [ + %{"currency" => "USD", "long" => "$19.90", "short" => "$19.9", "value" => 19.9} + ], + "change" => [51] + } + ] + end + + test "silently ignores imported data", %{conn: conn, site: site} do + insert(:goal, site: site, event_name: "Payment", currency: "USD") + + populate_stats(site, [ + build(:event, + name: "Payment", + revenue_reporting_amount: Decimal.new("13.29"), + revenue_reporting_currency: "USD", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Payment", + revenue_reporting_amount: Decimal.new("13.21"), + revenue_reporting_currency: "USD", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:imported_visitors, date: ~D[2021-01-01]) + ]) + + response = + do_query(conn, site, %{ + "date_range" => "all", + "metrics" => ["total_revenue"], + "dimensions" => ["time:month"], + "filters" => [["is", "event:goal", ["Payment"]]], + "include" => %{"imports" => true} + }) + + assert response["results"] == [ + %{ + "dimensions" => ["2021-01-01"], + "metrics" => [ + %{ + "currency" => "USD", + "long" => "$26.50", + "short" => "$26.5", + "value" => 26.5 + } + ] + } + ] + end + end + + describe "average_revenue plot" do + @describetag :ee_only + setup [:create_user, :log_in, :create_site, :create_legacy_site_import] + + test "plots average_revenue for a month", %{conn: conn, site: site} do + insert(:goal, site: site, event_name: "Payment", currency: "USD") + + populate_stats(site, [ + build(:event, + name: "Payment", + revenue_reporting_amount: Decimal.new("13.29"), + revenue_reporting_currency: "USD", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Payment", + revenue_reporting_amount: Decimal.new("50.50"), + revenue_reporting_currency: "USD", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Payment", + revenue_reporting_amount: Decimal.new("19.90"), + revenue_reporting_currency: "USD", + timestamp: ~N[2021-01-05 00:00:00] + ), + build(:event, + name: "Payment", + revenue_reporting_amount: Decimal.new("10.31"), + revenue_reporting_currency: "USD", + timestamp: ~N[2021-01-31 00:00:00] + ), + build(:event, + name: "Payment", + revenue_reporting_amount: Decimal.new("20.0"), + revenue_reporting_currency: "USD", + timestamp: ~N[2021-01-31 00:00:00] + ) + ]) + + response = + do_query(conn, site, %{ + "date_range" => "month", + "relative_date" => "2021-01-01", + "metrics" => ["average_revenue"], + "dimensions" => ["time:day"], + "filters" => [["is", "event:goal", ["Payment"]]] + }) + + assert response["results"] == [ + %{ + "dimensions" => ["2021-01-01"], + "metrics" => [ + %{ + "currency" => "USD", + "long" => "$31.90", + "short" => "$31.9", + "value" => 31.895 + } + ] + }, + %{ + "dimensions" => ["2021-01-05"], + "metrics" => [ + %{"currency" => "USD", "long" => "$19.90", "short" => "$19.9", "value" => 19.9} + ] + }, + %{ + "dimensions" => ["2021-01-31"], + "metrics" => [ + %{ + "currency" => "USD", + "long" => "$15.16", + "short" => "$15.2", + "value" => 15.155 + } + ] + } + ] + end + + test "plots average_revenue for a week compared to last week", %{conn: conn, site: site} do + insert(:goal, + site: site, + event_name: "Payment", + currency: "USD", + display_name: "PaymentUSD" + ) + + populate_stats(site, [ + build(:event, + name: "Payment", + revenue_reporting_amount: Decimal.new("13.29"), + revenue_reporting_currency: "USD", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Payment", + revenue_reporting_amount: Decimal.new("19.90"), + revenue_reporting_currency: "USD", + timestamp: ~N[2021-01-05 00:00:00] + ), + build(:event, + name: "Payment", + revenue_reporting_amount: Decimal.new("10.31"), + revenue_reporting_currency: "USD", + timestamp: ~N[2021-01-10 00:00:00] + ), + build(:event, + name: "Payment", + revenue_reporting_amount: Decimal.new("20.0"), + revenue_reporting_currency: "USD", + timestamp: ~N[2021-01-12 00:00:00] + ), + build(:event, + name: "Payment", + revenue_reporting_amount: Decimal.new("10.0"), + revenue_reporting_currency: "USD", + timestamp: ~N[2021-01-12 01:00:00] + ) + ]) + + %{"results" => results, "comparison_results" => comparison_results} = + do_query(conn, site, %{ + "date_range" => "7d", + "relative_date" => "2021-01-15", + "metrics" => ["average_revenue"], + "dimensions" => ["time:day"], + "filters" => [["is", "event:goal", ["PaymentUSD"]]], + "include" => %{"compare" => "previous_period", "time_labels" => true} + }) + + assert results == [ + %{ + "dimensions" => ["2021-01-10"], + "metrics" => [ + %{ + "currency" => "USD", + "long" => "$10.31", + "short" => "$10.3", + "value" => 10.31 + } + ] + }, + %{ + "dimensions" => ["2021-01-12"], + "metrics" => [ + %{"currency" => "USD", "long" => "$15.00", "short" => "$15.0", "value" => 15.0} + ] + } + ] + + assert comparison_results == [ + %{ + "dimensions" => ["2021-01-01"], + "metrics" => [ + %{ + "currency" => "USD", + "long" => "$13.29", + "short" => "$13.3", + "value" => 13.29 + } + ], + "change" => nil + }, + %{ + "dimensions" => ["2021-01-05"], + "metrics" => [ + %{"currency" => "USD", "long" => "$19.90", "short" => "$19.9", "value" => 19.9} + ], + "change" => [-25] + } + ] + end + + test "silently ignores imported data", %{conn: conn, site: site} do + insert(:goal, site: site, event_name: "Payment", currency: "USD") + + populate_stats(site, [ + build(:event, + name: "Payment", + revenue_reporting_amount: Decimal.new("3.3"), + revenue_reporting_currency: "USD", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Payment", + revenue_reporting_amount: Decimal.new("1.1"), + revenue_reporting_currency: "USD", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:imported_visitors, date: ~D[2021-01-01]) + ]) + + response = + do_query(conn, site, %{ + "date_range" => "all", + "metrics" => ["average_revenue"], + "dimensions" => ["time:month"], + "filters" => [["is", "event:goal", ["Payment"]]], + "include" => %{"imports" => true} + }) + + assert response["results"] == [ + %{ + "dimensions" => ["2021-01-01"], + "metrics" => [ + %{ + "currency" => "USD", + "long" => "$2.20", + "short" => "$2.2", + "value" => 2.2 + } + ] + } + ] + end + end + + describe "present_index" do + setup [:create_user, :log_in, :create_site] + + test "exists for a date range that includes the current day", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview) + ]) + + response = + do_query(conn, site, %{ + "date_range" => "month", + "metrics" => ["pageviews"], + "dimensions" => ["time:day"], + "include" => %{"time_labels" => true, "present_index" => true} + }) + + present_index = response["meta"]["present_index"] + + assert present_index >= 0 + end + + test "is null for a date range that does not include the current day", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview) + ]) + + response = + do_query(conn, site, %{ + "date_range" => "month", + "relative_date" => "2021-01-01", + "metrics" => ["pageviews"], + "dimensions" => ["time:day"], + "include" => %{"time_labels" => true, "present_index" => true} + }) + + refute response["meta"]["present_index"] + end + + for period <- ["7d", "28d", "30d", "91d"] do + test "#{period} period does not include today", %{conn: conn, site: site} do + today = "2021-01-01" + yesterday = "2020-12-31" + + response = + do_query(conn, site, %{ + "date_range" => unquote(period), + "relative_date" => today, + "metrics" => ["pageviews"], + "dimensions" => ["time:day"], + "include" => %{"time_labels" => true, "present_index" => true} + }) + + labels = response["meta"]["time_labels"] + present_index = response["meta"]["present_index"] + + refute present_index + assert List.last(labels) == yesterday + end + end + + test "returns present_index for time:hour interval", %{conn: conn, site: site} do + response = + do_query( + conn, + site, + %{ + "date_range" => "day", + "relative_date" => "2021-01-08", + "metrics" => ["pageviews"], + "dimensions" => ["time:hour"], + "include" => %{"time_labels" => true, "present_index" => true} + }, + now: ~U[2021-01-08 01:30:00Z] + ) + + assert response["meta"]["time_labels"] == ["2021-01-08 00:00:00", "2021-01-08 01:00:00"] + assert response["meta"]["present_index"] == 1 + end + + test "returns present_index for time:week interval", %{conn: conn, site: site} do + response = + do_query( + conn, + site, + %{ + "date_range" => ["2021-01-04", "2021-01-17"], + "metrics" => ["pageviews"], + "dimensions" => ["time:week"], + "include" => %{"time_labels" => true, "present_index" => true} + }, + now: ~U[2021-01-14 12:00:00Z] + ) + + assert response["meta"]["time_labels"] == ["2021-01-04", "2021-01-11"] + assert response["meta"]["present_index"] == 1 + end + + test "returns present_index for time:month interval", %{conn: conn, site: site} do + response = + do_query( + conn, + site, + %{ + "date_range" => ["2021-01-01", "2021-02-28"], + "metrics" => ["pageviews"], + "dimensions" => ["time:month"], + "include" => %{"time_labels" => true, "present_index" => true} + }, + now: ~U[2021-02-15 12:00:00Z] + ) + + assert response["meta"]["time_labels"] == ["2021-01-01", "2021-02-01"] + assert response["meta"]["present_index"] == 1 + end + end +end diff --git a/test/plausible_web/controllers/api/stats_controller/operating_systems_test.exs b/test/plausible_web/controllers/api/stats_controller/operating_systems_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..987eb41c78e535d8cc7f29d289b7e3f9e9f82ec3 --- /dev/null +++ b/test/plausible_web/controllers/api/stats_controller/operating_systems_test.exs @@ -0,0 +1,443 @@ +defmodule PlausibleWeb.Api.StatsController.OperatingSystemsTest do + use PlausibleWeb.ConnCase + + defp query_os(conn, site, opts) do + params = %{ + "dimensions" => Keyword.get(opts, :dimensions, ["visit:os"]), + "date_range" => Keyword.get(opts, :date_range, "all"), + "filters" => Keyword.get(opts, :filters, []), + "metrics" => Keyword.get(opts, :metrics, ["visitors", "percentage"]), + "include" => Keyword.get(opts, :include, nil), + "pagination" => Keyword.get(opts, :pagination, nil), + "order_by" => Keyword.get(opts, :order_by, nil) + } + + conn + |> post("/api/stats/#{site.domain}/query", params) + |> json_response(200) + end + + describe "GET /api/stats/:domain/operating-systems" do + setup [:create_user, :log_in, :create_site, :create_legacy_site_import] + + test "returns operating systems by unique visitors", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, operating_system: "Mac"), + build(:pageview, operating_system: "Mac"), + build(:pageview, operating_system: "Android") + ]) + + response = query_os(conn, site, date_range: "day") + + assert response["results"] == [ + %{"dimensions" => ["Mac"], "metrics" => [2, 66.67]}, + %{"dimensions" => ["Android"], "metrics" => [1, 33.33]} + ] + end + + test "returns (not set) when appropriate", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, operating_system: ""), + build(:pageview, operating_system: "Linux") + ]) + + response1 = query_os(conn, site, date_range: "day", order_by: [["visit:os", "asc"]]) + + assert response1["results"] == [ + %{"dimensions" => ["(not set)"], "metrics" => [1, 50.0]}, + %{"dimensions" => ["Linux"], "metrics" => [1, 50.0]} + ] + + response2 = + query_os(conn, site, + date_range: "day", + filters: [["is", "visit:os", ["(not set)"]]] + ) + + assert response2["results"] == [ + %{"dimensions" => ["(not set)"], "metrics" => [1, 100.0]} + ] + end + + test "select empty imported_operating_systems as (not set), merging with the native (not set)", + %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, user_id: 123), + build(:imported_operating_systems, visitors: 1), + build(:imported_visitors, visitors: 1) + ]) + + response = query_os(conn, site, date_range: "day", include: %{"imports" => true}) + + assert response["results"] == [ + %{"dimensions" => ["(not set)"], "metrics" => [2, 100.0]} + ] + end + + test "calculates conversion_rate when filtering for goal", %{conn: conn, site: site} do + insert(:goal, site: site, event_name: "Signup") + + populate_stats(site, [ + build(:pageview, user_id: 1, operating_system: "Mac"), + build(:pageview, user_id: 2, operating_system: "Mac"), + build(:event, user_id: 1, name: "Signup") + ]) + + response = + query_os(conn, site, + date_range: "day", + filters: [["is", "event:goal", ["Signup"]]], + metrics: ["visitors", "total_visitors", "group_conversion_rate"] + ) + + assert response["results"] == [ + %{"dimensions" => ["Mac"], "metrics" => [1, 2, 50.0]} + ] + end + + test "returns operating systems with :is filter on custom pageview props", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, + user_id: 123, + operating_system: "Mac" + ), + build(:pageview, + user_id: 123, + operating_system: "Mac", + "meta.key": ["author"], + "meta.value": ["John Doe"] + ), + build(:pageview, + operating_system: "Windows", + "meta.key": ["author"], + "meta.value": ["other"] + ), + build(:pageview, + operating_system: "Android" + ) + ]) + + response = + query_os(conn, site, + date_range: "day", + filters: [["is", "event:props:author", ["John Doe"]]] + ) + + assert response["results"] == [ + %{"dimensions" => ["Mac"], "metrics" => [1, 100.0]} + ] + end + + test "returns screen sizes with :is_not filter on custom pageview props", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, + user_id: 123, + operating_system: "Windows", + "meta.key": ["author"], + "meta.value": ["John Doe"] + ), + build(:pageview, + user_id: 123, + operating_system: "Windows", + "meta.key": ["author"], + "meta.value": ["John Doe"] + ), + build(:pageview, + operating_system: "Mac", + "meta.key": ["author"], + "meta.value": ["other"] + ), + build(:pageview, + operating_system: "Android" + ) + ]) + + response = + query_os(conn, site, + date_range: "day", + filters: [["is_not", "event:props:author", ["John Doe"]]], + order_by: [["visit:os", "asc"]] + ) + + assert response["results"] == [ + %{"dimensions" => ["Android"], "metrics" => [1, 50.0]}, + %{"dimensions" => ["Mac"], "metrics" => [1, 50.0]} + ] + end + + test "returns operating systems by unique visitors with imported data", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, operating_system: "Mac"), + build(:pageview, operating_system: "Mac"), + build(:pageview, operating_system: "Android"), + build(:imported_operating_systems, operating_system: "Mac"), + build(:imported_operating_systems, operating_system: "Android"), + build(:imported_visitors, visitors: 2) + ]) + + response1 = query_os(conn, site, date_range: "day") + + assert response1["results"] == [ + %{"dimensions" => ["Mac"], "metrics" => [2, 66.67]}, + %{"dimensions" => ["Android"], "metrics" => [1, 33.33]} + ] + + response2 = query_os(conn, site, date_range: "day", include: %{"imports" => true}) + + assert response2["results"] == [ + %{"dimensions" => ["Mac"], "metrics" => [3, 60.0]}, + %{"dimensions" => ["Android"], "metrics" => [2, 40.0]} + ] + end + + test "imported data is ignored when filtering for goal", %{conn: conn, site: site} do + insert(:goal, site: site, event_name: "Signup") + + populate_stats(site, [ + build(:pageview, user_id: 1, operating_system: "Mac"), + build(:pageview, user_id: 2, operating_system: "Mac"), + build(:imported_operating_systems, operating_system: "Mac"), + build(:event, user_id: 1, name: "Signup") + ]) + + response = + query_os(conn, site, + date_range: "day", + filters: [["is", "event:goal", ["Signup"]]], + metrics: ["visitors", "total_visitors", "group_conversion_rate"] + ) + + assert response["results"] == [ + %{"dimensions" => ["Mac"], "metrics" => [1, 2, 50.0]} + ] + end + + @tag :ee_only + test "return revenue metrics for operating systems breakdown", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, user_id: 1, operating_system: "Mac"), + build(:event, + name: "Payment", + user_id: 1, + revenue_reporting_amount: Decimal.new("1000"), + revenue_reporting_currency: "USD" + ), + build(:pageview, user_id: 2, operating_system: "Mac"), + build(:event, + name: "Payment", + user_id: 2, + revenue_reporting_amount: Decimal.new("2000"), + revenue_reporting_currency: "USD" + ), + build(:pageview, user_id: 3, operating_system: "Mac"), + build(:pageview, user_id: 4, operating_system: "Android"), + build(:event, + name: "Payment", + user_id: 4, + revenue_reporting_amount: Decimal.new("500"), + revenue_reporting_currency: "USD" + ), + build(:pageview, user_id: 5, operating_system: "Android"), + build(:pageview, user_id: 6), + build(:event, + name: "Payment", + user_id: 6, + revenue_reporting_amount: Decimal.new("600"), + revenue_reporting_currency: "USD" + ), + build(:pageview, user_id: 7), + build(:event, + name: "Payment", + user_id: 7, + revenue_reporting_amount: nil + ) + ]) + + insert(:goal, %{site: site, event_name: "Payment", currency: :USD}) + + response = + query_os(conn, site, + date_range: "day", + filters: [["is", "event:goal", ["Payment"]]], + metrics: [ + "visitors", + "total_visitors", + "group_conversion_rate", + "average_revenue", + "total_revenue" + ], + order_by: [["visitors", "desc"], ["visit:os", "asc"]] + ) + + assert response["results"] == [ + %{ + "dimensions" => ["(not set)"], + "metrics" => [ + 2, + 2, + 100.0, + %{ + "currency" => "USD", + "long" => "$600.00", + "short" => "$600.0", + "value" => 600.0 + }, + %{ + "currency" => "USD", + "long" => "$600.00", + "short" => "$600.0", + "value" => 600.0 + } + ] + }, + %{ + "dimensions" => ["Mac"], + "metrics" => [ + 2, + 3, + 66.67, + %{ + "currency" => "USD", + "long" => "$1,500.00", + "short" => "$1.5K", + "value" => 1500.0 + }, + %{ + "currency" => "USD", + "long" => "$3,000.00", + "short" => "$3.0K", + "value" => 3000.0 + } + ] + }, + %{ + "dimensions" => ["Android"], + "metrics" => [ + 1, + 2, + 50.0, + %{ + "currency" => "USD", + "long" => "$500.00", + "short" => "$500.0", + "value" => 500.0 + }, + %{ + "currency" => "USD", + "long" => "$500.00", + "short" => "$500.0", + "value" => 500.0 + } + ] + } + ] + end + end + + describe "GET /api/stats/:domain/operating-system-versions" do + setup [:create_user, :log_in, :create_site, :create_legacy_site_import] + + test "returns top OS versions by unique visitors", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + operating_system: "Mac", + operating_system_version: "10.15" + ), + build(:pageview, + operating_system: "Mac", + operating_system_version: "10.16" + ), + build(:pageview, + operating_system: "Mac", + operating_system_version: "10.16" + ), + build(:pageview, + operating_system: "Android", + operating_system_version: "4" + ) + ]) + + response = + query_os(conn, site, + date_range: "day", + dimensions: ["visit:os", "visit:os_version"], + filters: [["is", "visit:os", ["Mac"]]] + ) + + assert response["results"] == [ + %{"dimensions" => ["Mac", "10.16"], "metrics" => [2, 66.67]}, + %{"dimensions" => ["Mac", "10.15"], "metrics" => [1, 33.33]} + ] + end + + test "returns OS and version with additional metrics", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, operating_system: "Mac", operating_system_version: "14") + ]) + + response = + query_os(conn, site, + date_range: "day", + dimensions: ["visit:os", "visit:os_version"], + filters: [["is", "visit:os", ["Mac"]]], + metrics: ["visitors", "bounce_rate", "visit_duration", "percentage"] + ) + + assert response["results"] == [ + %{"dimensions" => ["Mac", "14"], "metrics" => [1, 100, 0, 100.0]} + ] + end + + test "with imported data", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + operating_system: "Mac", + operating_system_version: "11", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + operating_system: "Mac", + operating_system_version: "12", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:imported_operating_systems, + date: ~D[2021-01-01], + operating_system: "Mac", + operating_system_version: "11", + visitors: 5 + ), + build(:imported_operating_systems, + date: ~D[2021-01-01], + operating_system: "Windows", + operating_system_version: "11", + visitors: 3 + ), + build(:imported_operating_systems, date: ~D[2021-01-01], visitors: 10), + build(:imported_visitors, date: ~D[2021-01-01], visitors: 18) + ]) + + response = + query_os(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + dimensions: ["visit:os", "visit:os_version"], + include: %{"imports" => true} + ) + + assert response["results"] == [ + %{"dimensions" => ["(not set)", "(not set)"], "metrics" => [10, 50.0]}, + %{"dimensions" => ["Mac", "11"], "metrics" => [6, 30.0]}, + %{"dimensions" => ["Windows", "11"], "metrics" => [3, 15.0]}, + %{"dimensions" => ["Mac", "12"], "metrics" => [1, 5.0]} + ] + end + end +end diff --git a/test/plausible_web/controllers/api/stats_controller/pages_test.exs b/test/plausible_web/controllers/api/stats_controller/pages_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..fa7303cec4f663ff181ae7ea7ad3ca8c066de3b8 --- /dev/null +++ b/test/plausible_web/controllers/api/stats_controller/pages_test.exs @@ -0,0 +1,3308 @@ +defmodule PlausibleWeb.Api.StatsController.PagesTest do + use PlausibleWeb.ConnCase + + @user_id Enum.random(1000..9999) + + @default_metrics ["visitors", "percentage"] + @detailed_metrics [ + "visitors", + "pageviews", + "bounce_rate", + "time_on_page", + "scroll_depth", + "percentage" + ] + @goal_filter_metrics ["visitors", "group_conversion_rate", "total_visitors"] + + defp query_pages(conn, site, opts) do + params = %{ + "dimensions" => Keyword.get(opts, :dimensions, ["event:page"]), + "date_range" => Keyword.get(opts, :date_range, "all"), + "relative_date" => Keyword.get(opts, :relative_date, nil), + "filters" => Keyword.get(opts, :filters, []), + "metrics" => Keyword.get(opts, :metrics, @default_metrics), + "include" => Keyword.get(opts, :include, nil), + "pagination" => Keyword.get(opts, :pagination, nil), + "order_by" => Keyword.get(opts, :order_by, nil) + } + + conn + |> post("/api/stats/#{site.domain}/query", params) + |> json_response(200) + end + + describe "GET /api/stats/:domain/pages" do + setup [ + :create_user, + :log_in, + :create_site, + :create_legacy_site_import + ] + + test "returns top pages by visitors", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/"), + build(:pageview, pathname: "/"), + build(:pageview, pathname: "/"), + build(:pageview, pathname: "/register"), + build(:pageview, pathname: "/register"), + build(:pageview, pathname: "/contact") + ]) + + response = query_pages(conn, site, date_range: "day") + + assert response["results"] == [ + %{"dimensions" => ["/"], "metrics" => [3, 50.0]}, + %{"dimensions" => ["/register"], "metrics" => [2, 33.33]}, + %{"dimensions" => ["/contact"], "metrics" => [1, 16.67]} + ] + end + + test "returns top pages by visitors by hostname", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/", hostname: "a.example.com"), + build(:pageview, pathname: "/", hostname: "b.example.com"), + build(:pageview, pathname: "/", hostname: "d.example.com"), + build(:pageview, pathname: "/landing", hostname: "x.example.com", user_id: 123), + build(:pageview, pathname: "/register", hostname: "d.example.com", user_id: 123), + build(:pageview, pathname: "/register", hostname: "d.example.com", user_id: 123), + build(:pageview, pathname: "/register", hostname: "d.example.com"), + build(:pageview, pathname: "/contact", hostname: "e.example.com") + ]) + + response = + query_pages(conn, site, + date_range: "day", + filters: [["contains", "event:hostname", [".example.com"]]], + order_by: [["visitors", "desc"], ["event:page", "asc"]] + ) + + assert response["results"] == [ + %{"dimensions" => ["/"], "metrics" => [3, 50.0]}, + %{"dimensions" => ["/register"], "metrics" => [2, 33.33]}, + %{"dimensions" => ["/contact"], "metrics" => [1, 16.67]}, + %{"dimensions" => ["/landing"], "metrics" => [1, 16.67]} + ] + + response = + query_pages(conn, site, + date_range: "day", + filters: [["is", "event:hostname", ["d.example.com"]]] + ) + + assert response["results"] == [ + %{"dimensions" => ["/register"], "metrics" => [2, 66.67]}, + %{"dimensions" => ["/"], "metrics" => [1, 33.33]} + ] + end + + test "returns top pages broken down by hostname and page", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/docs", hostname: "main.example.com"), + build(:pageview, pathname: "/docs", hostname: "main.example.com"), + build(:pageview, pathname: "/docs", hostname: "secondary.example.com") + ]) + + response = + query_pages(conn, site, + date_range: "day", + dimensions: ["event:hostname", "event:page"], + metrics: ["visitors", "bounce_rate"] + ) + + assert response["results"] == [ + %{"dimensions" => ["main.example.com", "/docs"], "metrics" => [2, 100]}, + %{"dimensions" => ["secondary.example.com", "/docs"], "metrics" => [1, 100]} + ] + end + + test "correctly computes bounce_rate when broken down by hostname and page", + %{conn: conn, site: site} do + populate_stats(site, [ + # 1: 2 pageviews on primary (no bounce) + build(:pageview, + pathname: "/docs", + hostname: "main.example.com", + user_id: 1, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/docs", + hostname: "main.example.com", + user_id: 1, + timestamp: ~N[2021-01-01 00:01:00] + ), + # 2: single pageview on primary (bounce) + build(:pageview, + pathname: "/docs", + hostname: "main.example.com", + user_id: 2, + timestamp: ~N[2021-01-01 00:00:00] + ), + # 3: single pageview on secondary (bounce) + build(:pageview, + pathname: "/docs", + hostname: "secondary.example.com", + user_id: 3, + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + response = + query_pages(conn, site, + dimensions: ["event:hostname", "event:page"], + metrics: ["visitors", "bounce_rate"] + ) + + assert response["results"] == [ + %{"dimensions" => ["main.example.com", "/docs"], "metrics" => [2, 50]}, + %{"dimensions" => ["secondary.example.com", "/docs"], "metrics" => [1, 100]} + ] + end + + test "returns top pages with :is filter on custom pageview props", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/blog/john-1", + "meta.key": ["author"], + "meta.value": ["John Doe"] + ), + build(:pageview, + pathname: "/blog/other-post", + "meta.key": ["author"], + "meta.value": ["other"] + ), + build(:pageview, user_id: 123, pathname: "/") + ]) + + response = + query_pages(conn, site, + date_range: "day", + filters: [["is", "event:props:author", ["John Doe"]]] + ) + + assert response["results"] == [ + %{"dimensions" => ["/blog/john-1"], "metrics" => [1, 100.0]} + ] + end + + test "returns top pages with :is_not filter on custom pageview props", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, + pathname: "/blog/john-1", + "meta.key": ["author"], + "meta.value": ["John Doe"] + ), + build(:pageview, + pathname: "/blog/other-post", + "meta.key": ["author"], + "meta.value": ["other"] + ), + build(:pageview, pathname: "/") + ]) + + response = + query_pages(conn, site, + date_range: "day", + filters: [["is_not", "event:props:author", ["John Doe"]]] + ) + + assert response["results"] == [ + %{"dimensions" => ["/"], "metrics" => [1, 50.0]}, + %{"dimensions" => ["/blog/other-post"], "metrics" => [1, 50.0]} + ] + end + + test "returns top pages with :matches_wildcard filter on custom pageview props", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, + pathname: "/1", + "meta.key": ["prop"], + "meta.value": ["bar"] + ), + build(:pageview, + pathname: "/2", + "meta.key": ["prop"], + "meta.value": ["foobar"] + ), + build(:pageview, + pathname: "/3", + "meta.key": ["prop"], + "meta.value": ["baar"] + ), + build(:pageview, + pathname: "/4", + "meta.key": ["another"], + "meta.value": ["bar"] + ), + build(:pageview, pathname: "/5") + ]) + + response = + query_pages(conn, site, + date_range: "day", + filters: [["contains", "event:props:prop", ["bar"]]], + order_by: [["visitors", "desc"], ["event:page", "asc"]] + ) + + assert response["results"] == [ + %{"dimensions" => ["/1"], "metrics" => [1, 50.0]}, + %{"dimensions" => ["/2"], "metrics" => [1, 50.0]} + ] + end + + test "returns top pages with :matches_member filter on custom pageview props", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, + pathname: "/1", + "meta.key": ["prop"], + "meta.value": ["bar"] + ), + build(:pageview, + pathname: "/2", + "meta.key": ["prop"], + "meta.value": ["foobar"] + ), + build(:pageview, + pathname: "/3", + "meta.key": ["prop"], + "meta.value": ["baar"] + ), + build(:pageview, + pathname: "/4", + "meta.key": ["another"], + "meta.value": ["bar"] + ), + build(:pageview, pathname: "/5"), + build(:pageview, + pathname: "/6", + "meta.key": ["prop"], + "meta.value": ["near"] + ) + ]) + + response = + query_pages(conn, site, + date_range: "day", + filters: [["contains", "event:props:prop", ["bar", "nea"]]], + order_by: [["visitors", "desc"], ["event:page", "asc"]] + ) + + assert response["results"] == [ + %{"dimensions" => ["/1"], "metrics" => [1, 33.33]}, + %{"dimensions" => ["/2"], "metrics" => [1, 33.33]}, + %{"dimensions" => ["/6"], "metrics" => [1, 33.33]} + ] + end + + test "returns top pages with multiple filters on custom pageview props", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, + pathname: "/1", + "meta.key": ["prop", "number"], + "meta.value": ["bar", "1"] + ), + build(:pageview, + pathname: "/2", + "meta.key": ["prop", "number"], + "meta.value": ["bar", "2"] + ), + build(:pageview, + pathname: "/3", + "meta.key": ["prop"], + "meta.value": ["bar"] + ), + build(:pageview, + pathname: "/4", + "meta.key": ["number"], + "meta.value": ["bar"] + ), + build(:pageview, pathname: "/5") + ]) + + response = + query_pages(conn, site, + date_range: "day", + filters: [ + ["is", "event:props:prop", ["bar"]], + ["is", "event:props:number", ["1"]] + ] + ) + + assert response["results"] == [ + %{"dimensions" => ["/1"], "metrics" => [1, 100.0]} + ] + end + + test "calculates bounce_rate and time_on_page with :is filter on custom pageview props", + %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/blog/john-1", + "meta.key": ["author"], + "meta.value": ["John Doe"], + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:engagement, + pathname: "/blog/john-1", + "meta.key": ["author"], + "meta.value": ["John Doe"], + user_id: @user_id, + timestamp: ~N[2021-01-01 00:01:00], + engagement_time: 60_000 + ), + build(:pageview, + pathname: "/blog", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:01:00] + ), + build(:engagement, + pathname: "/blog", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:02:00], + engagement_time: 60_000 + ), + build(:pageview, + pathname: "/blog/john-2", + "meta.key": ["author"], + "meta.value": ["John Doe"], + user_id: @user_id, + timestamp: ~N[2021-01-01 00:02:00] + ), + build(:engagement, + pathname: "/blog/john-2", + "meta.key": ["author"], + "meta.value": ["John Doe"], + user_id: @user_id, + timestamp: ~N[2021-01-01 00:02:30], + engagement_time: 30_000 + ), + build(:pageview, + pathname: "/blog/john-2", + "meta.key": ["author"], + "meta.value": ["John Doe"], + user_id: 456, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:engagement, + pathname: "/blog/john-2", + "meta.key": ["author"], + "meta.value": ["John Doe"], + user_id: 456, + timestamp: ~N[2021-01-01 00:10:00], + engagement_time: 600_000 + ), + build(:pageview, + pathname: "/blog", + user_id: 456, + timestamp: ~N[2021-01-01 00:10:00] + ), + build(:engagement, + pathname: "/blog", + user_id: 456, + timestamp: ~N[2021-01-01 00:10:30], + engagement_time: 30_000 + ) + ]) + + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + filters: [["is", "event:props:author", ["John Doe"]]], + metrics: @detailed_metrics + ) + + assert response["results"] == [ + %{"dimensions" => ["/blog/john-2"], "metrics" => [2, 2, 0, 315, 0, 100.0]}, + %{"dimensions" => ["/blog/john-1"], "metrics" => [1, 1, 0, 60, 0, 50.0]} + ] + end + + test "calculates bounce_rate and time_on_page with :is_not filter on custom pageview props", + %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/blog", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:engagement, + pathname: "/blog", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:01:00], + engagement_time: 60_000 + ), + build(:pageview, + pathname: "/blog/john-1", + user_id: @user_id, + "meta.key": ["author"], + "meta.value": ["John Doe"], + timestamp: ~N[2021-01-01 00:01:00] + ), + build(:engagement, + pathname: "/blog/john-1", + user_id: @user_id, + "meta.key": ["author"], + "meta.value": ["John Doe"], + timestamp: ~N[2021-01-01 00:02:00], + engagement_time: 60_000 + ), + build(:pageview, + pathname: "/blog/other-post", + user_id: @user_id, + "meta.key": ["author"], + "meta.value": ["other"], + timestamp: ~N[2021-01-01 00:02:00] + ), + build(:engagement, + pathname: "/blog/other-post", + user_id: @user_id, + "meta.key": ["author"], + "meta.value": ["other"], + timestamp: ~N[2021-01-01 00:02:30], + engagement_time: 30_000 + ), + build(:pageview, + pathname: "/blog", + user_id: 456, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:engagement, + pathname: "/blog", + user_id: 456, + timestamp: ~N[2021-01-01 00:03:00], + engagement_time: 180_000 + ), + build(:pageview, + pathname: "/blog/john-1", + "meta.key": ["author"], + "meta.value": ["John Doe"], + user_id: 456, + timestamp: ~N[2021-01-01 00:03:00] + ), + build(:engagement, + pathname: "/blog/john-1", + "meta.key": ["author"], + "meta.value": ["John Doe"], + user_id: 456, + timestamp: ~N[2021-01-01 00:03:30], + engagement_time: 30_000 + ) + ]) + + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + filters: [["is_not", "event:props:author", ["John Doe"]]], + metrics: @detailed_metrics + ) + + assert response["results"] == [ + %{"dimensions" => ["/blog"], "metrics" => [2, 2, 0, 120, 0, 100.0]}, + %{"dimensions" => ["/blog/other-post"], "metrics" => [1, 1, 0, 30, 0, 50.0]} + ] + end + + test "calculates bounce_rate and time_on_page with :is (none) filter on custom pageview props", + %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/blog", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:engagement, + pathname: "/blog", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:01:00], + engagement_time: 60_000 + ), + build(:pageview, + pathname: "/blog/john-1", + user_id: @user_id, + "meta.key": ["author"], + "meta.value": ["John Doe"], + timestamp: ~N[2021-01-01 00:01:00] + ), + build(:engagement, + pathname: "/blog/john-1", + user_id: @user_id, + "meta.key": ["author"], + "meta.value": ["John Doe"], + timestamp: ~N[2021-01-01 00:02:00], + engagement_time: 60_000 + ), + build(:pageview, + pathname: "/blog/other-post", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:02:00] + ), + build(:engagement, + pathname: "/blog/other-post", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:02:30], + engagement_time: 30_000 + ), + build(:pageview, + pathname: "/blog", + user_id: 456, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:engagement, + pathname: "/blog", + user_id: 456, + timestamp: ~N[2021-01-01 00:00:30], + engagement_time: 30_000 + ) + ]) + + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + filters: [["is", "event:props:author", ["(none)"]]], + metrics: @detailed_metrics + ) + + assert response["results"] == [ + %{"dimensions" => ["/blog"], "metrics" => [2, 2, 50, 45, 0, 100.0]}, + %{"dimensions" => ["/blog/other-post"], "metrics" => [1, 1, 0, 30, 0, 50.0]} + ] + end + + test "calculates bounce_rate and time_on_page with :is_not (none) filter on custom pageview props", + %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/blog/john-1", + user_id: @user_id, + "meta.key": ["author"], + "meta.value": ["John Doe"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:engagement, + pathname: "/blog/john-1", + user_id: @user_id, + "meta.key": ["author"], + "meta.value": ["John Doe"], + timestamp: ~N[2021-01-01 00:01:00], + engagement_time: 60_000 + ), + build(:pageview, + pathname: "/blog", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:01:00] + ), + build(:engagement, + pathname: "/blog", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:02:00], + engagement_time: 60_000 + ), + build(:pageview, + pathname: "/blog/other-post", + "meta.key": ["author"], + "meta.value": ["other"], + user_id: @user_id, + timestamp: ~N[2021-01-01 00:02:00] + ), + build(:engagement, + pathname: "/blog/other-post", + "meta.key": ["author"], + "meta.value": ["other"], + user_id: @user_id, + timestamp: ~N[2021-01-01 00:02:30], + engagement_time: 30_000 + ), + build(:pageview, + pathname: "/blog/other-post", + "meta.key": ["author"], + "meta.value": [""], + user_id: 456, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:engagement, + pathname: "/blog/other-post", + "meta.key": ["author"], + "meta.value": [""], + user_id: 456, + timestamp: ~N[2021-01-01 00:00:30], + engagement_time: 30_000 + ) + ]) + + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + filters: [["is_not", "event:props:author", ["(none)"]]], + metrics: @detailed_metrics + ) + + assert response["results"] == [ + %{"dimensions" => ["/blog/other-post"], "metrics" => [2, 2, 100, 30, 0, 100.0]}, + %{"dimensions" => ["/blog/john-1"], "metrics" => [1, 1, 0, 60, 0, 50.0]} + ] + end + + test "returns top pages with :not_member filter on custom pageview props", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, + pathname: "/chrome", + "meta.key": ["browser"], + "meta.value": ["Chrome"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/chrome", + "meta.key": ["browser"], + "meta.value": ["Chrome"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/safari", + "meta.key": ["browser"], + "meta.value": ["Safari"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/firefox", + "meta.key": ["browser"], + "meta.value": ["Firefox"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/firefox", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + filters: [["is_not", "event:props:browser", ["Chrome", "Safari"]]] + ) + + assert response["results"] == [ + %{"dimensions" => ["/firefox"], "metrics" => [2, 100.0]} + ] + + assert %{ + "date_range" => ["2021-01-01T00:00:00Z", "2021-01-01T23:59:59Z"] + } = response["query"] + end + + test "returns top pages with :not_member filter on custom pageview props including (none) value", + %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/chrome", + "meta.key": ["browser"], + "meta.value": ["Chrome"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/chrome", + "meta.key": ["browser"], + "meta.value": ["Chrome"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/safari", + "meta.key": ["browser"], + "meta.value": ["Safari"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/no-browser-prop", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + filters: [["is_not", "event:props:browser", ["Chrome", "(none)"]]] + ) + + assert response["results"] == [ + %{"dimensions" => ["/safari"], "metrics" => [1, 100.0]} + ] + end + + test "calculates bounce_rate and time_on_page for pages filtered by page path", + %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:engagement, + pathname: "/", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:01:00], + engagement_time: 60_000 + ), + build(:pageview, + pathname: "/about", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:01:00] + ), + build(:engagement, + pathname: "/about", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:02:00], + engagement_time: 60_000 + ), + build(:pageview, + pathname: "/", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:02:00] + ), + build(:engagement, + pathname: "/", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:02:30], + engagement_time: 30_000 + ), + build(:pageview, + pathname: "/", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/about", + user_id: 456, + timestamp: ~N[2021-01-01 00:10:00] + ), + build(:engagement, + pathname: "/about", + user_id: 456, + timestamp: ~N[2021-01-01 00:10:30], + engagement_time: 30_000 + ) + ]) + + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + filters: [["is", "event:page", ["/"]]], + metrics: @detailed_metrics + ) + + assert response["results"] == [ + %{"dimensions" => ["/"], "metrics" => [2, 3, 50, 90, 0, 100.0]} + ] + end + + test "calculates scroll_depth", %{conn: conn, site: site} do + t0 = ~N[2020-01-01 00:00:00] + [t1, t2, t3] = for i <- 1..3, do: NaiveDateTime.add(t0, i, :minute) + + populate_stats(site, [ + build(:pageview, user_id: 12, pathname: "/blog", timestamp: t0), + build(:engagement, + user_id: 12, + pathname: "/blog", + timestamp: t1, + scroll_depth: 20, + engagement_time: 60_000 + ), + build(:pageview, user_id: 12, pathname: "/another", timestamp: t1), + build(:engagement, + user_id: 12, + pathname: "/another", + timestamp: t2, + scroll_depth: 24, + engagement_time: 60_000 + ), + build(:pageview, user_id: 34, pathname: "/blog", timestamp: t0), + build(:engagement, + user_id: 34, + pathname: "/blog", + timestamp: t1, + scroll_depth: 17, + engagement_time: 60_000 + ), + build(:pageview, user_id: 34, pathname: "/another", timestamp: t1), + build(:engagement, + user_id: 34, + pathname: "/another", + timestamp: t2, + scroll_depth: 26, + engagement_time: 60_000 + ), + build(:pageview, user_id: 34, pathname: "/blog", timestamp: t2), + build(:engagement, + user_id: 34, + pathname: "/blog", + timestamp: t3, + scroll_depth: 60, + engagement_time: 60_000 + ), + build(:pageview, user_id: 56, pathname: "/blog", timestamp: t0), + build(:engagement, + user_id: 56, + pathname: "/blog", + timestamp: t1, + scroll_depth: 100, + engagement_time: 60_000 + ) + ]) + + response = + query_pages(conn, site, + date_range: ["2020-01-01", "2020-01-01"], + metrics: @detailed_metrics, + order_by: [["scroll_depth", "asc"]] + ) + + assert response["results"] == [ + %{"dimensions" => ["/another"], "metrics" => [2, 2, 0, 60, 25, 66.67]}, + %{"dimensions" => ["/blog"], "metrics" => [3, 4, 33, 80, 60, 100.0]} + ] + end + + test "calculates scroll_depth from native and imported data combined", %{ + conn: conn, + site: site, + site_import: site_import + } do + populate_stats(site, [ + build(:pageview, + user_id: @user_id, + pathname: "/blog", + timestamp: ~N[2020-01-01 00:00:00] + ), + build(:engagement, + user_id: @user_id, + pathname: "/blog", + timestamp: ~N[2020-01-01 00:00:00], + scroll_depth: 80, + engagement_time: 20_000 + ), + build(:imported_pages, + date: ~D[2020-01-01], + visitors: 3, + pageviews: 3, + total_time_on_page: 90, + total_time_on_page_visits: 3, + page: "/blog", + total_scroll_depth: 120, + total_scroll_depth_visits: 3 + ) + ]) + + populate_stats(site, site_import.id, [ + build(:imported_visitors, date: ~D[2020-01-01]), + build(:imported_visitors, date: ~D[2020-01-01]), + build(:imported_visitors, date: ~D[2020-01-01]) + ]) + + response = + query_pages(conn, site, + date_range: ["2020-01-01", "2020-01-01"], + metrics: @detailed_metrics, + include: %{"imports" => true}, + order_by: [["scroll_depth", "desc"]] + ) + + assert response["results"] == [ + %{"dimensions" => ["/blog"], "metrics" => [4, 4, 100, 28, 50, 100.0]} + ] + end + + test "handles missing scroll_depth data from native and imported sources", %{ + conn: conn, + site: site, + site_import: site_import + } do + populate_stats(site, [ + build(:pageview, + user_id: @user_id, + pathname: "/native-and-imported", + timestamp: ~N[2020-01-01 00:00:00] + ), + build(:engagement, + user_id: @user_id, + pathname: "/native-and-imported", + timestamp: ~N[2020-01-01 00:01:00], + scroll_depth: 80, + engagement_time: 60_000 + ), + build(:pageview, + user_id: @user_id, + pathname: "/native-only", + timestamp: ~N[2020-01-01 00:01:00] + ), + build(:engagement, + user_id: @user_id, + pathname: "/native-only", + timestamp: ~N[2020-01-01 00:02:00], + scroll_depth: 40, + engagement_time: 60_000 + ), + build(:imported_pages, + date: ~D[2020-01-01], + visitors: 4, + pageviews: 4, + total_time_on_page: 180, + total_time_on_page_visits: 4, + page: "/native-and-imported", + total_scroll_depth: 120, + total_scroll_depth_visits: 3 + ), + build(:imported_pages, + date: ~D[2020-01-01], + visitors: 20, + pageviews: 30, + total_time_on_page: 300, + total_time_on_page_visits: 10, + page: "/imported-only", + total_scroll_depth: 100, + total_scroll_depth_visits: 10 + ) + ]) + + populate_stats( + site, + site_import.id, + for(_ <- 1..24, do: build(:imported_visitors, date: ~D[2020-01-01])) + ) + + response = + query_pages(conn, site, + date_range: ["2020-01-01", "2020-01-01"], + metrics: @detailed_metrics, + include: %{"imports" => true}, + order_by: [["scroll_depth", "desc"]] + ) + + assert response["results"] == [ + %{"dimensions" => ["/native-and-imported"], "metrics" => [5, 5, 0, 48, 50, 20.0]}, + %{"dimensions" => ["/native-only"], "metrics" => [1, 1, 0, 60, 40, 4.0]}, + %{"dimensions" => ["/imported-only"], "metrics" => [20, 30, 0, 30, 10, 80.0]} + ] + end + + test "can query scroll depth and time-on-page only from imported data, ignoring rows where scroll depth doesn't exist", + %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:imported_pages, + date: ~D[2020-01-01], + visitors: 10, + pageviews: 10, + page: "/blog", + total_scroll_depth: 100, + total_scroll_depth_visits: 10, + total_time_on_page: 300, + total_time_on_page_visits: 5 + ), + build(:imported_pages, + date: ~D[2020-01-01], + visitors: 100, + pageviews: 150, + page: "/blog", + total_scroll_depth: 0, + total_scroll_depth_visits: 0, + total_time_on_page: 0, + total_time_on_page_visits: 0 + ) + ]) + + response = + query_pages(conn, site, + date_range: "7d", + relative_date: "2020-01-02", + metrics: @detailed_metrics, + include: %{"imports" => true} + ) + + assert response["results"] == [ + %{"dimensions" => ["/blog"], "metrics" => [110, 160, 0, 60, 10, nil]} + ] + end + + test "can filter using the | (OR) filter", + %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:engagement, + pathname: "/", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:01:00], + engagement_time: 60_000 + ), + build(:pageview, + pathname: "/irrelevant", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:01:00] + ), + build(:engagement, + pathname: "/irrelevant", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:02:00], + engagement_time: 60_000 + ), + build(:pageview, + pathname: "/", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:02:00] + ), + build(:engagement, + pathname: "/", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:02:30], + engagement_time: 30_000 + ), + build(:pageview, + pathname: "/", + user_id: 123, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:engagement, + pathname: "/", + user_id: 123, + timestamp: ~N[2021-01-01 00:01:00], + engagement_time: 60_000 + ), + build(:pageview, + pathname: "/about", + user_id: 456, + timestamp: ~N[2021-01-01 00:10:00] + ), + build(:engagement, + pathname: "/about", + user_id: 456, + timestamp: ~N[2021-01-01 00:10:30], + engagement_time: 30_000 + ) + ]) + + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + filters: [["is", "event:page", ["/", "/about"]]], + metrics: @detailed_metrics + ) + + assert response["results"] == [ + %{"dimensions" => ["/"], "metrics" => [2, 3, 50, 75, 0, 66.67]}, + %{"dimensions" => ["/about"], "metrics" => [1, 1, 100, 30, 0, 33.33]} + ] + end + + test "can filter using the not_member filter type", + %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:engagement, + pathname: "/", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:01:00], + engagement_time: 60_000 + ), + build(:pageview, + pathname: "/irrelevant", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:01:00] + ), + build(:engagement, + pathname: "/irrelevant", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:02:00], + engagement_time: 60_000 + ), + build(:pageview, + pathname: "/", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:02:00] + ), + build(:engagement, + pathname: "/", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:02:30], + engagement_time: 30_000 + ), + build(:pageview, + pathname: "/", + user_id: 123, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:engagement, + pathname: "/", + user_id: 123, + timestamp: ~N[2021-01-01 00:01:00], + engagement_time: 60_000 + ), + build(:pageview, + pathname: "/about", + user_id: 456, + timestamp: ~N[2021-01-01 00:10:00] + ), + build(:engagement, + pathname: "/about", + user_id: 456, + timestamp: ~N[2021-01-01 00:10:30], + engagement_time: 30_000 + ) + ]) + + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + filters: [["is_not", "event:page", ["/irrelevant", "/about"]]], + metrics: @detailed_metrics + ) + + assert response["results"] == [ + %{"dimensions" => ["/"], "metrics" => [2, 3, 50, 75, 0, 100.0]} + ] + end + + test "can filter using the matches_member filter type", + %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/blog/post-1", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:engagement, + pathname: "/blog/post-1", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:01:00], + engagement_time: 60_000 + ), + build(:pageview, + pathname: "/blog/post-2", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:01:00] + ), + build(:engagement, + pathname: "/blog/post-2", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:01:30], + engagement_time: 30_000 + ), + build(:pageview, + pathname: "/", + user_id: 100, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:engagement, + pathname: "/", + user_id: 100, + timestamp: ~N[2021-01-01 00:00:30], + engagement_time: 30_000 + ), + build(:pageview, + pathname: "/articles/post-1", + user_id: 200, + timestamp: ~N[2021-01-01 00:10:00] + ), + build(:engagement, + pathname: "/articles/post-1", + user_id: 200, + timestamp: ~N[2021-01-01 00:10:30], + engagement_time: 30_000 + ), + build(:pageview, + pathname: "/articles/post-1", + user_id: 300, + timestamp: ~N[2021-01-01 00:10:00] + ), + build(:engagement, + pathname: "/articles/post-1", + user_id: 300, + timestamp: ~N[2021-01-01 00:10:30], + engagement_time: 30_000 + ) + ]) + + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + filters: [["contains", "event:page", ["/blog/", "/articles/"]]], + metrics: @detailed_metrics, + order_by: [["visitors", "desc"], ["event:page", "asc"]] + ) + + assert response["results"] == [ + %{"dimensions" => ["/articles/post-1"], "metrics" => [2, 2, 100, 30, 0, 66.67]}, + %{"dimensions" => ["/blog/post-1"], "metrics" => [1, 1, 0, 60, 0, 33.33]}, + %{"dimensions" => ["/blog/post-2"], "metrics" => [1, 1, 0, 30, 0, 33.33]} + ] + end + + test "page filter escapes brackets", + %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/blog/(/post-1", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:engagement, + pathname: "/blog/(/post-1", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:01:00], + engagement_time: 60_000 + ), + build(:pageview, + pathname: "/blog/(/post-2", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:01:00] + ), + build(:engagement, + pathname: "/blog/(/post-2", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:01:30], + engagement_time: 30_000 + ), + build(:pageview, + pathname: "/", + user_id: 456, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:engagement, + pathname: "/", + user_id: 456, + timestamp: ~N[2021-01-01 00:00:30], + engagement_time: 30_000 + ) + ]) + + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + filters: [["contains", "event:page", ["/blog/(/", "/blog/)/"]]], + order_by: [["visitors", "desc"], ["event:page", "asc"]], + metrics: @detailed_metrics + ) + + assert response["results"] == [ + %{"dimensions" => ["/blog/(/post-1"], "metrics" => [1, 1, 0, 60, 0, 100.0]}, + %{"dimensions" => ["/blog/(/post-2"], "metrics" => [1, 1, 0, 30, 0, 100.0]} + ] + end + + test "can filter using the not_matches_member filter type", + %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/blog/post-1", + user_id: 100, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:engagement, + pathname: "/blog/post-1", + user_id: 100, + timestamp: ~N[2021-01-01 00:00:30], + engagement_time: 30_000 + ), + build(:pageview, + pathname: "/", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:engagement, + pathname: "/", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:10:00], + engagement_time: 600_000 + ), + build(:pageview, + pathname: "/about", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:10:00] + ), + build(:engagement, + pathname: "/about", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:10:30], + engagement_time: 30_000 + ), + build(:pageview, + pathname: "/", + user_id: 200, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:engagement, + pathname: "/", + user_id: 200, + timestamp: ~N[2021-01-01 00:10:00], + engagement_time: 600_000 + ), + build(:pageview, + pathname: "/articles/post-1", + user_id: 300, + timestamp: ~N[2021-01-01 00:10:00] + ), + build(:engagement, + pathname: "/articles/post-1", + user_id: 300, + timestamp: ~N[2021-01-01 00:10:30], + engagement_time: 30_000 + ) + ]) + + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + filters: [["contains_not", "event:page", ["/blog/", "/articles/"]]], + metrics: @detailed_metrics + ) + + assert response["results"] == [ + %{"dimensions" => ["/"], "metrics" => [2, 2, 50, 600, 0, 100.0]}, + %{"dimensions" => ["/about"], "metrics" => [1, 1, 0, 30, 0, 50.0]} + ] + end + + test "returns top pages by visitors with imported data", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/"), + build(:pageview, pathname: "/"), + build(:pageview, pathname: "/"), + build(:imported_pages, page: "/"), + build(:pageview, pathname: "/register"), + build(:pageview, pathname: "/register"), + build(:imported_pages, page: "/register"), + build(:pageview, pathname: "/contact") + ]) + + response = query_pages(conn, site, date_range: "day") + + assert response["results"] == [ + %{"dimensions" => ["/"], "metrics" => [3, 50.0]}, + %{"dimensions" => ["/register"], "metrics" => [2, 33.33]}, + %{"dimensions" => ["/contact"], "metrics" => [1, 16.67]} + ] + + response = query_pages(conn, site, date_range: "day", include: %{"imports" => true}) + + assert response["results"] == [ + %{"dimensions" => ["/"], "metrics" => [4, 66.67]}, + %{"dimensions" => ["/register"], "metrics" => [3, 50.0]}, + %{"dimensions" => ["/contact"], "metrics" => [1, 16.67]} + ] + end + + test "returns scroll depth warning code", %{conn: conn, site: site} do + response = + query_pages(conn, site, + date_range: "day", + metrics: @detailed_metrics, + include: %{"imports" => true} + ) + + assert response["meta"]["metric_warnings"]["scroll_depth"]["code"] == + "no_imported_scroll_depth" + end + + test "returns imported pages with a pageview goal filter", %{conn: conn, site: site} do + insert(:goal, site: site, page_path: "/blog**") + + populate_stats(site, [ + build(:imported_pages, page: "/blog"), + build(:imported_pages, page: "/not-this"), + build(:imported_pages, page: "/blog/post-1", visitors: 2), + build(:imported_visitors, visitors: 4) + ]) + + response = + query_pages(conn, site, + date_range: "day", + filters: [["is", "event:goal", ["Visit /blog**"]]], + metrics: @goal_filter_metrics, + include: %{"imports" => true} + ) + + assert response["results"] == [ + %{"dimensions" => ["/blog/post-1"], "metrics" => [2, 100.0, 2]}, + %{"dimensions" => ["/blog"], "metrics" => [1, 100.0, 1]} + ] + end + + test "calculates bounce rate and time on page for pages", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:engagement, + pathname: "/", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:15:00], + engagement_time: 900_000 + ), + build(:pageview, + pathname: "/some-other-page", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:15:00] + ), + build(:engagement, + pathname: "/some-other-page", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:15:30], + engagement_time: 30_000 + ), + build(:pageview, + pathname: "/", + user_id: 123, + timestamp: ~N[2021-01-01 00:15:00] + ), + build(:engagement, + pathname: "/", + user_id: 123, + timestamp: ~N[2021-01-01 00:15:30], + engagement_time: 30_000 + ) + ]) + + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + metrics: @detailed_metrics + ) + + assert response["results"] == [ + %{"dimensions" => ["/"], "metrics" => [2, 2, 50.0, 465.0, 0, 100.0]}, + %{"dimensions" => ["/some-other-page"], "metrics" => [1, 1, 0, 30, 0, 50.0]} + ] + end + + test "filtering by hostname, excludes a page on different hostname", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + timestamp: ~N[2021-01-01 05:01:00], + pathname: "/about", + hostname: "blog.example.com", + user_id: @user_id + ), + build(:pageview, + timestamp: ~N[2021-01-01 05:01:02], + pathname: "/hello", + hostname: "example.com", + user_id: @user_id + ), + build(:pageview, + timestamp: ~N[2021-01-01 05:01:02], + pathname: "/about", + hostname: "blog.example.com" + ) + ]) + + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + filters: [["is", "event:hostname", ["blog.example.com"]]], + metrics: @detailed_metrics + ) + + assert response["results"] == [ + %{"dimensions" => ["/about"], "metrics" => [2, 2, 50, nil, nil, 100.0]} + ] + end + + test "calculates bounce rate and time on page for pages when filtered by hostname", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + # session 1 + build(:pageview, + pathname: "/about-blog", + hostname: "blog.example.com", + user_id: @user_id + 1, + timestamp: ~N[2021-01-01 00:01:00] + ), + build(:engagement, + pathname: "/about-blog", + hostname: "blog.example.com", + user_id: @user_id + 1, + timestamp: ~N[2021-01-01 00:01:30], + engagement_time: 30_000 + ), + + # session 2 + build(:pageview, + pathname: "/about-blog", + hostname: "blog.example.com", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:01:00] + ), + build(:engagement, + pathname: "/about-blog", + hostname: "blog.example.com", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:10:00], + engagement_time: 540_000 + ), + build(:pageview, + pathname: "/about", + hostname: "example.com", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:10:00] + ), + build(:engagement, + pathname: "/about", + hostname: "example.com", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:15:00], + engagement_time: 300_000 + ), + build(:pageview, + pathname: "/about-blog", + hostname: "blog.example.com", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:15:00] + ), + build(:engagement, + pathname: "/about-blog", + hostname: "blog.example.com", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:20:00], + engagement_time: 300_000 + ), + build(:pageview, + pathname: "/exit-blog", + hostname: "blog.example.com", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:20:00] + ), + build(:engagement, + pathname: "/exit-blog", + hostname: "blog.example.com", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:22:00], + engagement_time: 120_000 + ), + build(:pageview, + pathname: "/about", + hostname: "example.com", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:22:00] + ), + build(:engagement, + pathname: "/about", + hostname: "example.com", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:25:00], + engagement_time: 180_000 + ), + build(:pageview, + pathname: "/exit", + hostname: "example.com", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:engagement, + pathname: "/exit", + hostname: "example.com", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:25:30], + engagement_time: 30_000 + ), + + # session 3 + build(:pageview, + pathname: "/about", + hostname: "example.com", + user_id: @user_id + 2, + timestamp: ~N[2021-01-01 00:01:00] + ), + build(:engagement, + pathname: "/about", + hostname: "example.com", + user_id: @user_id + 2, + timestamp: ~N[2021-01-01 00:01:30], + engagement_time: 30_000 + ) + ]) + + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + filters: [["is", "event:hostname", ["blog.example.com"]]], + metrics: @detailed_metrics + ) + + assert response["results"] == [ + %{"dimensions" => ["/about-blog"], "metrics" => [2, 3, 50, 435, 0, 100.0]}, + %{"dimensions" => ["/exit-blog"], "metrics" => [1, 1, 0, 120, 0, 50.0]} + ] + end + + test "calculates bounce rate and time on page for pages with imported data", %{ + conn: conn, + site: site, + site_import: site_import + } do + populate_stats(site, [ + build(:pageview, + pathname: "/", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:engagement, + pathname: "/", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:15:00], + engagement_time: 900_000 + ), + build(:pageview, + pathname: "/some-other-page", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:15:00] + ), + build(:engagement, + pathname: "/some-other-page", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:15:30], + engagement_time: 30_000 + ), + build(:pageview, + pathname: "/", + user_id: 123, + timestamp: ~N[2021-01-01 00:15:00] + ), + build(:engagement, + pathname: "/", + user_id: 123, + timestamp: ~N[2021-01-01 00:30:00], + engagement_time: 900_000 + ), + build(:imported_pages, + page: "/", + date: ~D[2021-01-01], + total_time_on_page: 700, + total_time_on_page_visits: 3 + ), + build(:imported_entry_pages, + entry_page: "/", + date: ~D[2021-01-01], + entrances: 3, + bounces: 1 + ), + build(:imported_pages, + page: "/some-other-page", + date: ~D[2021-01-01], + total_time_on_page: 60, + total_time_on_page_visits: 1 + ) + ]) + + populate_stats(site, site_import.id, [ + build(:imported_visitors, date: ~D[2021-01-01]), + build(:imported_visitors, date: ~D[2021-01-01]), + build(:imported_visitors, date: ~D[2021-01-01]) + ]) + + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + metrics: @detailed_metrics, + include: %{"imports" => true} + ) + + assert response["results"] == [ + %{"dimensions" => ["/"], "metrics" => [3, 3, 40.0, 500, 0, 60.0]}, + %{"dimensions" => ["/some-other-page"], "metrics" => [2, 2, 0, 45, 0, 40.0]} + ] + end + + test "returns top pages in realtime report", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/page1"), + build(:pageview, pathname: "/page2"), + build(:pageview, pathname: "/page1") + ]) + + response = query_pages(conn, site, date_range: "realtime") + + assert response["results"] == [ + %{"dimensions" => ["/page1"], "metrics" => [2, 66.67]}, + %{"dimensions" => ["/page2"], "metrics" => [1, 33.33]} + ] + end + + test "calculates conversion_rate when filtering for goal", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, user_id: 1, pathname: "/"), + build(:pageview, user_id: 2, pathname: "/"), + build(:pageview, user_id: 3, pathname: "/"), + build(:event, user_id: 3, name: "Signup") + ]) + + insert(:goal, site: site, event_name: "Signup") + + response = + query_pages(conn, site, + date_range: "day", + filters: [["is", "event:goal", ["Signup"]]], + metrics: @goal_filter_metrics + ) + + assert response["results"] == [ + %{"dimensions" => ["/"], "metrics" => [1, 33.33, 3]} + ] + end + + test "filter by :is page with imported data", %{conn: conn, site: site} do + site_import = insert(:site_import, site: site) + + populate_stats(site, site_import.id, [ + build(:pageview, user_id: 1, pathname: "/", timestamp: ~N[2021-01-01 12:00:00]), + build(:engagement, + user_id: 1, + pathname: "/", + timestamp: ~N[2021-01-01 12:01:00], + engagement_time: 60_000 + ), + build(:pageview, user_id: 1, pathname: "/ignored", timestamp: ~N[2021-01-01 12:01:00]), + build(:engagement, + user_id: 1, + pathname: "/ignored", + timestamp: ~N[2021-01-01 12:02:00], + engagement_time: 60_000 + ), + build(:imported_entry_pages, + entry_page: "/", + visitors: 1, + bounces: 1, + date: ~D[2021-01-01] + ), + build(:imported_pages, + page: "/", + visitors: 3, + pageviews: 3, + total_time_on_page: 300, + total_time_on_page_visits: 3, + date: ~D[2021-01-01] + ), + build(:imported_pages, page: "/ignored", visitors: 10, date: ~D[2021-01-01]) + ]) + + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + filters: [["is", "event:page", ["/"]]], + metrics: @detailed_metrics, + include: %{"imports" => true} + ) + + assert response["results"] == [ + %{"dimensions" => ["/"], "metrics" => [4, 4, 50, 90.0, 0, 100.0]} + ] + end + + test "filter by :member page with imported data", %{conn: conn, site: site} do + site_import = insert(:site_import, site: site) + + populate_stats(site, site_import.id, [ + build(:pageview, user_id: 1, pathname: "/", timestamp: ~N[2021-01-01 12:00:00]), + build(:engagement, + user_id: 1, + pathname: "/", + timestamp: ~N[2021-01-01 12:01:00], + engagement_time: 60_000 + ), + build(:pageview, user_id: 1, pathname: "/ignored", timestamp: ~N[2021-01-01 12:01:00]), + build(:engagement, + user_id: 1, + pathname: "/ignored", + timestamp: ~N[2021-01-01 12:02:00], + engagement_time: 60_000 + ), + build(:imported_entry_pages, + entry_page: "/", + visitors: 1, + bounces: 1, + date: ~D[2021-01-01] + ), + build(:imported_entry_pages, + entry_page: "/a", + visitors: 1, + bounces: 1, + date: ~D[2021-01-01] + ), + build(:imported_pages, + page: "/", + visitors: 3, + pageviews: 3, + total_time_on_page: 300, + total_time_on_page_visits: 3, + date: ~D[2021-01-01] + ), + build(:imported_pages, + page: "/a", + visitors: 1, + date: ~D[2021-01-01] + ), + build(:imported_pages, page: "/ignored", visitors: 10, date: ~D[2021-01-01]) + ]) + + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + filters: [["is", "event:page", ["/", "/a"]]], + metrics: @detailed_metrics, + include: %{"imports" => true} + ) + + assert response["results"] == [ + %{"dimensions" => ["/"], "metrics" => [4, 4, 50, 90.0, 0, 80.0]}, + %{"dimensions" => ["/a"], "metrics" => [1, 1, 100, 10.0, nil, 20.0]} + ] + end + + test "filter by :matches_wildcard page with imported data", %{conn: conn, site: site} do + site_import = insert(:site_import, site: site) + + populate_stats(site, site_import.id, [ + build(:pageview, user_id: 1, pathname: "/aaa", timestamp: ~N[2021-01-01 12:00:00]), + build(:engagement, + user_id: 1, + pathname: "/aaa", + timestamp: ~N[2021-01-01 12:01:00], + engagement_time: 60_000 + ), + build(:pageview, user_id: 1, pathname: "/ignored", timestamp: ~N[2021-01-01 12:01:00]), + build(:engagement, + user_id: 1, + pathname: "/ignored", + timestamp: ~N[2021-01-01 12:02:00], + engagement_time: 60_000 + ), + build(:imported_entry_pages, + entry_page: "/aaa", + visitors: 1, + bounces: 1, + date: ~D[2021-01-01] + ), + build(:imported_entry_pages, + entry_page: "/a", + visitors: 1, + bounces: 1, + date: ~D[2021-01-01] + ), + build(:imported_pages, + page: "/aaa", + visitors: 3, + pageviews: 3, + total_time_on_page: 300, + total_time_on_page_visits: 3, + date: ~D[2021-01-01] + ), + build(:imported_pages, + page: "/a", + visitors: 1, + date: ~D[2021-01-01] + ), + build(:imported_pages, page: "/ignored", visitors: 10, date: ~D[2021-01-01]) + ]) + + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + filters: [["contains", "event:page", ["/a"]]], + metrics: @detailed_metrics, + include: %{"imports" => true} + ) + + assert response["results"] == [ + %{"dimensions" => ["/aaa"], "metrics" => [4, 4, 50.0, 90, 0, 80.0]}, + %{"dimensions" => ["/a"], "metrics" => [1, 1, 100.0, 10, nil, 20.0]} + ] + end + + test "can compare with previous period", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/page1", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/page1", + timestamp: ~N[2021-01-02 00:00:00] + ), + build(:pageview, + pathname: "/page2", + timestamp: ~N[2021-01-02 00:00:00] + ), + build(:pageview, + pathname: "/page2", + timestamp: ~N[2021-01-02 00:00:00] + ) + ]) + + response = + query_pages(conn, site, + date_range: ["2021-01-02", "2021-01-02"], + metrics: @detailed_metrics, + include: %{"compare" => "previous_period"} + ) + + assert response["results"] == [ + %{ + "dimensions" => ["/page2"], + "metrics" => [2, 2, 100, nil, nil, 66.67], + "comparison" => %{ + "dimensions" => ["/page2"], + "metrics" => [0, 0, 0.0, nil, nil, 0.0], + "change" => [100, 100, nil, nil, nil, 100] + } + }, + %{ + "dimensions" => ["/page1"], + "metrics" => [1, 1, 100, nil, nil, 33.33], + "comparison" => %{ + "dimensions" => ["/page1"], + "metrics" => [1, 1, 100, nil, nil, 100.0], + "change" => [0, 0, 0, nil, nil, -67] + } + } + ] + + assert %{ + "comparison_date_range" => ["2021-01-01T00:00:00Z", "2021-01-01T23:59:59Z"], + "date_range" => ["2021-01-02T00:00:00Z", "2021-01-02T23:59:59Z"] + } = response["query"] + end + + on_ee do + test "returns pages across all sites on a consolidated view", %{conn: conn, site: site} do + another_site = new_site(team: site.team) + cv = new_consolidated_view(site.team) + + populate_stats(site, [ + build(:pageview, pathname: "/a1", timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, pathname: "/a2", timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, pathname: "/a2", timestamp: ~N[2021-01-01 00:00:00]) + ]) + + populate_stats(another_site, [ + build(:pageview, pathname: "/b1", timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, pathname: "/b1", timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, pathname: "/b1", timestamp: ~N[2021-01-01 00:00:00]) + ]) + + response = + query_pages(conn, cv, + date_range: ["2021-01-01", "2021-01-01"], + metrics: @detailed_metrics + ) + + assert response["results"] == [ + %{"dimensions" => ["/b1"], "metrics" => [3, 3, 100, nil, nil, 50.0]}, + %{"dimensions" => ["/a2"], "metrics" => [2, 2, 100, nil, nil, 33.33]}, + %{"dimensions" => ["/a1"], "metrics" => [1, 1, 100, nil, nil, 16.67]} + ] + end + end + end + + describe "GET /api/stats/:domain/entry-pages" do + setup [:create_user, :log_in, :create_site, :create_legacy_site_import] + + test "returns top entry pages by visitors", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/page1", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/page1", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/page2", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/page2", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:15:00] + ) + ]) + + populate_stats(site, [ + build(:pageview, + pathname: "/page2", + user_id: @user_id, + timestamp: ~N[2021-01-01 23:15:00] + ) + ]) + + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + dimensions: ["visit:entry_page"], + metrics: ["visitors", "visits", "bounce_rate", "visit_duration", "percentage"] + ) + + assert response["results"] == [ + %{"dimensions" => ["/page1"], "metrics" => [2, 2, 100, 0, 66.67]}, + %{"dimensions" => ["/page2"], "metrics" => [1, 2, 50, 450, 33.33]} + ] + end + + test "returns top entry pages filtered by custom pageview props", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/blog", + user_id: 123, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/blog/john-1", + "meta.key": ["author"], + "meta.value": ["John Doe"], + user_id: 123, + timestamp: ~N[2021-01-01 00:01:00] + ), + build(:pageview, + pathname: "/blog/john-2", + "meta.key": ["author"], + "meta.value": ["John Doe"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/blog/other-post", + "meta.key": ["author"], + "meta.value": ["other"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/blog", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + dimensions: ["visit:entry_page"], + filters: [["is", "event:props:author", ["John Doe"]]], + metrics: ["visitors", "visits", "bounce_rate", "visit_duration", "percentage"] + ) + + assert response["results"] == [ + %{"dimensions" => ["/blog"], "metrics" => [1, 1, 0, 60, 50.0]}, + %{"dimensions" => ["/blog/john-2"], "metrics" => [1, 1, 100, 0, 50.0]} + ] + end + + test "returns top entry pages by visitors with imported data", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/page1", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/page1", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/page2", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/page2", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:15:00] + ), + build(:pageview, + pathname: "/page2", + user_id: @user_id, + timestamp: ~N[2021-01-01 23:15:00] + ) + ]) + + populate_stats(site, [ + build(:imported_visitors, + date: ~D[2021-01-01], + visitors: 2 + ), + build(:imported_entry_pages, + entry_page: "/page2", + date: ~D[2021-01-01], + entrances: 3, + visitors: 2, + visit_duration: 300 + ) + ]) + + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + dimensions: ["visit:entry_page"], + metrics: ["visitors", "visits", "bounce_rate", "visit_duration", "percentage"] + ) + + assert response["results"] == [ + %{"dimensions" => ["/page1"], "metrics" => [2, 2, 100, 0, 66.67]}, + %{"dimensions" => ["/page2"], "metrics" => [1, 2, 50, 450, 33.33]} + ] + + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + dimensions: ["visit:entry_page"], + metrics: ["visitors", "visits", "bounce_rate", "visit_duration", "percentage"], + include: %{"imports" => true} + ) + + assert response["results"] == [ + %{"dimensions" => ["/page2"], "metrics" => [3, 5, 20.0, 240.0, 60.0]}, + %{"dimensions" => ["/page1"], "metrics" => [2, 2, 100.0, 0.0, 40.0]} + ] + end + + test "returns top entry pages by visitors filtered by hostname", + %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/page1", + hostname: "en.example.com", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/page1", + hostname: "es.example.com", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/page2", + hostname: "en.example.com", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/page2", + hostname: "es.example.com", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:15:00] + ), + build(:pageview, + pathname: "/exit", + hostname: "es.example.com", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:16:00] + ), + build(:pageview, + pathname: "/page2", + hostname: "es.example.com", + timestamp: ~N[2021-01-01 23:15:00] + ) + ]) + + # We're going to only join sessions where the exit hostname matches the filter + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + dimensions: ["visit:entry_page"], + filters: [["is", "event:hostname", ["es.example.com"]]], + metrics: ["visitors", "visits", "bounce_rate", "visit_duration", "percentage"], + order_by: [["visitors", "desc"], ["visit:entry_page", "asc"]] + ) + + assert response["results"] == [ + %{"dimensions" => ["/page1"], "metrics" => [1, 1, 100, 0, 50.0]}, + %{"dimensions" => ["/page2"], "metrics" => [1, 1, 100, 0, 50.0]} + ] + end + + test "bugfix: pagination on /pages filtered by goal", %{conn: conn, site: site} do + populate_stats( + site, + for i <- 1..30 do + build(:event, + user_id: i, + name: "Signup", + pathname: "/signup/#{String.pad_leading(to_string(i), 2, "0")}", + timestamp: ~N[2021-01-01 00:01:00] + ) + end + ) + + insert(:goal, site: site, event_name: "Signup") + + request = fn conn, opts -> + conn + |> query_pages(site, + metrics: ["visitors"], + pagination: %{ + "offset" => Keyword.fetch!(opts, :offset), + "limit" => Keyword.fetch!(opts, :limit) + }, + filters: [["is", "event:goal", ["Signup"]]], + order_by: [["event:page", "asc"]] + ) + |> Map.get("results") + |> Enum.map(fn %{"dimensions" => ["/signup/" <> seq]} -> + seq + end) + end + + assert List.first(request.(conn, offset: 0, limit: 100)) == "01" + assert List.last(request.(conn, offset: 0, limit: 100)) == "30" + assert List.last(request.(conn, offset: 0, limit: 29)) == "29" + assert ["01", "02"] = request.(conn, offset: 0, limit: 2) + assert ["03", "04"] = request.(conn, offset: 2, limit: 2) + assert ["01", "02", "03", "04", "05"] = request.(conn, offset: 0, limit: 5) + assert ["06", "07", "08", "09", "10"] = request.(conn, offset: 5, limit: 5) + assert ["11", "12", "13", "14", "15"] = request.(conn, offset: 10, limit: 5) + assert ["20"] = request.(conn, offset: 19, limit: 1) + assert [] = request.(conn, offset: 30, limit: 1) + end + + test "calculates conversion_rate when filtering for goal", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/page1", + user_id: 1, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/page1", + user_id: 2, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Signup", + user_id: 1, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/page2", + user_id: 3, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/page2", + user_id: 3, + timestamp: ~N[2021-01-01 00:15:00] + ), + build(:event, + name: "Signup", + user_id: 3, + timestamp: ~N[2021-01-01 00:15:00] + ) + ]) + + insert(:goal, site: site, event_name: "Signup") + + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + dimensions: ["visit:entry_page"], + filters: [["is", "event:goal", ["Signup"]]], + metrics: ["visitors", "group_conversion_rate", "total_visitors"], + order_by: [["visitors", "desc"], ["visit:entry_page", "asc"]] + ) + + assert response["results"] == [ + %{"dimensions" => ["/page1"], "metrics" => [1, 50.0, 2]}, + %{"dimensions" => ["/page2"], "metrics" => [1, 100.0, 1]} + ] + end + + test "can filter out empty entry pages (sessions with only custom events)", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:event, + name: "Signup", + timestamp: ~N[2021-01-01 00:15:00], + pathname: "/" + ) + ]) + + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + dimensions: ["visit:entry_page"], + filters: [["is_not", "visit:entry_page", [""]]], + metrics: ["visitors", "visits", "bounce_rate", "visit_duration", "percentage"] + ) + + assert response["results"] == [] + end + + test "filter by :matches_member entry_page with imported data", %{conn: conn, site: site} do + site_import = insert(:site_import, site: site) + + populate_stats(site, site_import.id, [ + build(:pageview, pathname: "/aaa", timestamp: ~N[2021-01-01 12:00:00]), + build(:pageview, pathname: "/a", timestamp: ~N[2021-01-01 12:00:00]), + build(:pageview, pathname: "/ignored", timestamp: ~N[2021-01-01 12:01:00]), + build(:imported_entry_pages, + entry_page: "/a", + visitors: 5, + entrances: 9, + visit_duration: 1000, + date: ~D[2021-01-01] + ), + build(:imported_entry_pages, + entry_page: "/bbb", + visitors: 2, + entrances: 2, + visit_duration: 100, + date: ~D[2021-01-01] + ) + ]) + + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + dimensions: ["visit:entry_page"], + filters: [["contains", "visit:entry_page", ["/a", "/b"]]], + metrics: ["visitors", "visits", "bounce_rate", "visit_duration", "percentage"], + include: %{"imports" => true} + ) + + assert response["results"] == [ + %{"dimensions" => ["/a"], "metrics" => [6, 10, 10.0, 100.0, 66.67]}, + %{"dimensions" => ["/bbb"], "metrics" => [2, 2, 0.0, 50.0, 22.22]}, + %{"dimensions" => ["/aaa"], "metrics" => [1, 1, 100.0, 0, 11.11]} + ] + end + + test "returns entry pages broken down by entry page hostname and page", + %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/landing", + hostname: "blog.example.com", + user_id: 1, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/about", + hostname: "blog.example.com", + user_id: 1, + timestamp: ~N[2021-01-01 00:05:00] + ), + build(:pageview, + pathname: "/landing", + hostname: "blog.example.com", + user_id: 2, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/landing", + hostname: "www.example.com", + user_id: 3, + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + dimensions: ["visit:entry_page_hostname", "visit:entry_page"], + metrics: ["visitors", "visits", "bounce_rate", "percentage"], + filters: [["is_not", "visit:entry_page", [""]]], + order_by: [["visitors", "desc"], ["visit:entry_page_hostname", "asc"]] + ) + + assert response["results"] == [ + %{ + "dimensions" => ["blog.example.com", "/landing"], + "metrics" => [2, 2, 50, 66.67] + }, + %{ + "dimensions" => ["www.example.com", "/landing"], + "metrics" => [1, 1, 100, 33.33] + } + ] + end + end + + describe "GET /api/stats/:domain/exit-pages" do + setup [:create_user, :log_in, :create_site, :create_legacy_site_import] + + test "returns top exit pages by visitors", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/page1", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/page1", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/page1", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/page2", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:15:00] + ) + ]) + + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + dimensions: ["visit:exit_page"], + metrics: ["visitors", "visits", "exit_rate", "percentage"] + ) + + assert response["results"] == [ + %{"dimensions" => ["/page1"], "metrics" => [2, 2, 66.7, 66.67]}, + %{"dimensions" => ["/page2"], "metrics" => [1, 1, 100, 33.33]} + ] + end + + test "returns top exit pages by ascending visits", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/page1", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/page1", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/page1", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/page2", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:15:00] + ) + ]) + + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + dimensions: ["visit:exit_page"], + metrics: ["visitors", "visits", "exit_rate", "percentage"], + order_by: [["visits", "asc"]] + ) + + assert response["results"] == [ + %{"dimensions" => ["/page2"], "metrics" => [1, 1, 100.0, 33.33]}, + %{"dimensions" => ["/page1"], "metrics" => [2, 2, 66.7, 66.67]} + ] + end + + test "returns top exit pages by visitors filtered by hostname", + %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/page1", + hostname: "en.example.com", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/page1", + hostname: "es.example.com", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/page1", + hostname: "en.example.com", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/page2", + hostname: "es.example.com", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:15:00] + ), + build(:pageview, + pathname: "/exit", + hostname: "en.example.com", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:16:00] + ) + ]) + + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + dimensions: ["visit:exit_page"], + filters: [["is", "event:hostname", ["es.example.com"]]], + metrics: ["visitors", "visits", "percentage"] + ) + + # We're going to only join sessions where the entry hostname matches the filter + assert response["results"] == [ + %{"dimensions" => ["/page1"], "metrics" => [1, 1, 100.0]} + ] + end + + test "returns top exit pages filtered by custom pageview props", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/blog/john-1", + "meta.key": ["author"], + "meta.value": ["John Doe"], + user_id: 123, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/", + user_id: 123, + timestamp: ~N[2021-01-01 00:01:00] + ), + build(:pageview, + pathname: "/blog/other-post", + "meta.key": ["author"], + "meta.value": ["other"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + dimensions: ["visit:exit_page"], + filters: [["is", "event:props:author", ["John Doe"]]], + metrics: ["visitors", "visits", "percentage"] + ) + + assert response["results"] == [ + %{"dimensions" => ["/"], "metrics" => [1, 1, 100.0]} + ] + end + + test "returns top exit pages by visitors with imported data", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/page1", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/page1", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/page1", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/page2", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:15:00] + ) + ]) + + populate_stats(site, [ + build(:imported_pages, + page: "/page2", + date: ~D[2021-01-01], + pageviews: 4, + visitors: 2 + ), + build(:imported_exit_pages, + exit_page: "/page2", + date: ~D[2021-01-01], + exits: 3, + visitors: 2 + ) + ]) + + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + dimensions: ["visit:exit_page"], + filters: [["is_not", "visit:exit_page", [""]]], + metrics: ["visitors", "visits", "exit_rate", "percentage"] + ) + + assert response["results"] == [ + %{"dimensions" => ["/page1"], "metrics" => [2, 2, 66.7, 66.67]}, + %{"dimensions" => ["/page2"], "metrics" => [1, 1, 100.0, 33.33]} + ] + + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + dimensions: ["visit:exit_page"], + filters: [["is_not", "visit:exit_page", [""]]], + metrics: ["visitors", "visits", "exit_rate", "percentage"], + include: %{"imports" => true} + ) + + assert response["results"] == [ + %{"dimensions" => ["/page2"], "metrics" => [3, 4, 80.0, 60.0]}, + %{"dimensions" => ["/page1"], "metrics" => [2, 2, 66.7, 40.0]} + ] + end + + test "returns top exit pages when filtering for goal", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:event, + name: "Signup", + user_id: 1, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: 1, + pathname: "/exit1", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Signup", + user_id: 2, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: 2, + pathname: "/exit1", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: 2, + pathname: "/exit2", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + insert(:goal, site: site, event_name: "Signup") + + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + dimensions: ["visit:exit_page"], + filters: [["is", "event:goal", ["Signup"]]], + metrics: ["visitors", "group_conversion_rate", "total_visitors"], + order_by: [["visitors", "desc"], ["visit:exit_page", "asc"]] + ) + + assert response["results"] == [ + %{"dimensions" => ["/exit1"], "metrics" => [1, 100.0, 1]}, + %{"dimensions" => ["/exit2"], "metrics" => [1, 100.0, 1]} + ] + end + + test "returns top exit pages when filtering for page", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + user_id: 1, + pathname: "/exit1", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: 2, + pathname: "/exit1", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: 2, + pathname: "/exit2", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: 3, + pathname: "/exit2", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: 3, + pathname: "/should-not-appear", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + dimensions: ["visit:exit_page"], + filters: [["is", "event:page", ["/exit1"]]], + metrics: ["visitors", "visits", "percentage"] + ) + + assert response["results"] == [ + %{"dimensions" => ["/exit1"], "metrics" => [1, 1, 50.0]}, + %{"dimensions" => ["/exit2"], "metrics" => [1, 1, 50.0]} + ] + end + + test "can filter out empty exit pages (sessions with only custom events)", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:event, + name: "Signup", + timestamp: ~N[2021-01-01 00:15:00], + pathname: "/" + ) + ]) + + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + dimensions: ["visit:exit_page"], + filters: [["is_not", "visit:exit_page", [""]]], + metrics: ["visitors", "visits", "exit_rate", "percentage"] + ) + + assert response["results"] == [] + end + + test "returns exit pages broken down by exit page hostname and page with exit_rate", + %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/about", + hostname: "blog.example.com", + user_id: 1, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/about", + hostname: "blog.example.com", + user_id: 2, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/about", + hostname: "www.example.com", + user_id: 3, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/docs", + hostname: "www.example.com", + user_id: 3, + timestamp: ~N[2021-01-01 00:05:00] + ) + ]) + + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + dimensions: ["visit:exit_page_hostname", "visit:exit_page"], + metrics: ["visitors", "visits", "exit_rate", "percentage"], + filters: [["is_not", "visit:exit_page", [""]]], + order_by: [["visitors", "desc"], ["visit:exit_page_hostname", "asc"]] + ) + + assert response["results"] == [ + %{ + "dimensions" => ["blog.example.com", "/about"], + "metrics" => [2, 2, 66.7, 66.67] + }, + %{ + "dimensions" => ["www.example.com", "/docs"], + "metrics" => [1, 1, 100.0, 33.33] + } + ] + end + + test "exit_rate works with exit page hostname and page dimensions in either order", + %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/about", + hostname: "blog.example.com", + user_id: 1, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/about", + hostname: "blog.example.com", + user_id: 2, + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + for dimensions <- [ + ["visit:exit_page_hostname", "visit:exit_page"], + ["visit:exit_page", "visit:exit_page_hostname"] + ] do + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + dimensions: dimensions, + metrics: ["visitors", "exit_rate"], + filters: [["is_not", "visit:exit_page", [""]]] + ) + + assert response["results"] != [], + "expected results for dimensions #{inspect(dimensions)}" + + assert Enum.all?(response["results"], fn r -> not is_nil(r["metrics"]) end), + "expected no errors for dimensions #{inspect(dimensions)}" + end + end + + test "filter by :is_not exit_page with imported data", %{conn: conn, site: site} do + site_import = insert(:site_import, site: site) + + populate_stats(site, site_import.id, [ + build(:pageview, pathname: "/aaa", timestamp: ~N[2021-01-01 12:00:00]), + build(:pageview, pathname: "/a", timestamp: ~N[2021-01-01 12:00:00]), + build(:pageview, pathname: "/ignored", timestamp: ~N[2021-01-01 12:01:00]), + build(:imported_exit_pages, + exit_page: "/a", + visitors: 5, + exits: 9, + visit_duration: 1000, + date: ~D[2021-01-01] + ), + build(:imported_exit_pages, + exit_page: "/bbb", + visitors: 2, + exits: 2, + visit_duration: 100, + date: ~D[2021-01-01] + ), + build(:imported_pages, page: "/a", pageviews: 19, date: ~D[2021-01-01]), + build(:imported_pages, page: "/bbb", pageviews: 2, date: ~D[2021-01-01]) + ]) + + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + dimensions: ["visit:exit_page"], + filters: [["is_not", "visit:exit_page", ["/ignored"]]], + metrics: ["visitors", "visits", "exit_rate", "percentage"], + include: %{"imports" => true} + ) + + assert response["results"] == [ + %{"dimensions" => ["/a"], "metrics" => [6, 10, 50.0, 66.67]}, + %{"dimensions" => ["/bbb"], "metrics" => [2, 2, 100.0, 22.22]}, + %{"dimensions" => ["/aaa"], "metrics" => [1, 1, 100.0, 11.11]} + ] + end + + @tag :ee_only + test "return revenue metrics for entry pages breakdown", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, user_id: 1, pathname: "/first"), + build(:event, + name: "Payment", + user_id: 1, + revenue_reporting_amount: Decimal.new("2000"), + revenue_reporting_currency: "USD" + ), + build(:pageview, user_id: 2, pathname: "/second"), + build(:event, + user_id: 2, + name: "Payment", + revenue_reporting_amount: Decimal.new("3000"), + revenue_reporting_currency: "USD" + ), + build(:event, + name: "Payment", + user_id: 2, + revenue_reporting_amount: Decimal.new("4000"), + revenue_reporting_currency: "USD" + ), + build(:pageview, user_id: 3, pathname: "/first"), + build(:event, + name: "Payment", + user_id: 3, + revenue_reporting_amount: Decimal.new("1000"), + revenue_reporting_currency: "USD" + ), + build(:pageview, user_id: 4, pathname: "/third"), + build(:event, + name: "Payment", + user_id: 4, + revenue_reporting_amount: Decimal.new("2500"), + revenue_reporting_currency: "USD" + ), + build(:event, name: "Payment", revenue_reporting_amount: nil), + build(:event, name: "Payment", revenue_reporting_amount: nil) + ]) + + insert(:goal, %{site: site, event_name: "Payment", currency: :USD}) + + response = + query_pages(conn, site, + date_range: "day", + dimensions: ["visit:entry_page"], + filters: [["is", "event:goal", ["Payment"]], ["is_not", "visit:entry_page", [""]]], + metrics: [ + "visitors", + "group_conversion_rate", + "total_visitors", + "average_revenue", + "total_revenue" + ], + order_by: [["visitors", "desc"], ["visit:entry_page", "asc"]] + ) + + assert response["results"] == [ + %{ + "dimensions" => ["/first"], + "metrics" => [ + 2, + 100.0, + 2, + %{ + "currency" => "USD", + "long" => "$1,500.00", + "short" => "$1.5K", + "value" => 1500.0 + }, + %{ + "currency" => "USD", + "long" => "$3,000.00", + "short" => "$3.0K", + "value" => 3000.0 + } + ] + }, + %{ + "dimensions" => ["/second"], + "metrics" => [ + 1, + 100.0, + 1, + %{ + "currency" => "USD", + "long" => "$3,500.00", + "short" => "$3.5K", + "value" => 3500.0 + }, + %{ + "currency" => "USD", + "long" => "$7,000.00", + "short" => "$7.0K", + "value" => 7000.0 + } + ] + }, + %{ + "dimensions" => ["/third"], + "metrics" => [ + 1, + 100.0, + 1, + %{ + "currency" => "USD", + "long" => "$2,500.00", + "short" => "$2.5K", + "value" => 2500.0 + }, + %{ + "currency" => "USD", + "long" => "$2,500.00", + "short" => "$2.5K", + "value" => 2500.0 + } + ] + } + ] + end + + @tag :ee_only + test "return revenue metrics for exit pages breakdown", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, user_id: 1, pathname: "/first"), + build(:event, + name: "Payment", + user_id: 1, + revenue_reporting_amount: Decimal.new("2000"), + revenue_reporting_currency: "USD" + ), + build(:pageview, user_id: 1, pathname: "/exit_first"), + build(:pageview, user_id: 2, pathname: "/second"), + build(:event, + user_id: 2, + name: "Payment", + revenue_reporting_amount: Decimal.new("3000"), + revenue_reporting_currency: "USD" + ), + build(:event, + name: "Payment", + user_id: 2, + revenue_reporting_amount: Decimal.new("4000"), + revenue_reporting_currency: "USD" + ), + build(:pageview, user_id: 2, pathname: "/exit_second"), + build(:pageview, user_id: 3, pathname: "/first"), + build(:event, + name: "Payment", + user_id: 3, + revenue_reporting_amount: Decimal.new("1000"), + revenue_reporting_currency: "USD" + ), + build(:pageview, user_id: 3, pathname: "/exit_first"), + build(:pageview, user_id: 4, pathname: "/third"), + build(:event, + name: "Payment", + user_id: 4, + revenue_reporting_amount: Decimal.new("2500"), + revenue_reporting_currency: "USD" + ), + build(:event, name: "Payment", revenue_reporting_amount: nil), + build(:event, name: "Payment", revenue_reporting_amount: nil) + ]) + + insert(:goal, %{site: site, event_name: "Payment", currency: :USD}) + + response = + query_pages(conn, site, + date_range: "day", + dimensions: ["visit:exit_page"], + filters: [["is", "event:goal", ["Payment"]], ["is_not", "visit:exit_page", [""]]], + metrics: [ + "visitors", + "group_conversion_rate", + "total_visitors", + "average_revenue", + "total_revenue" + ], + order_by: [["visitors", "desc"], ["visit:exit_page", "asc"]] + ) + + assert response["results"] == [ + %{ + "dimensions" => ["/exit_first"], + "metrics" => [ + 2, + 100.0, + 2, + %{ + "currency" => "USD", + "long" => "$1,500.00", + "short" => "$1.5K", + "value" => 1500.0 + }, + %{ + "currency" => "USD", + "long" => "$3,000.00", + "short" => "$3.0K", + "value" => 3000.0 + } + ] + }, + %{ + "dimensions" => ["/exit_second"], + "metrics" => [ + 1, + 100.0, + 1, + %{ + "currency" => "USD", + "long" => "$3,500.00", + "short" => "$3.5K", + "value" => 3500.0 + }, + %{ + "currency" => "USD", + "long" => "$7,000.00", + "short" => "$7.0K", + "value" => 7000.0 + } + ] + }, + %{ + "dimensions" => ["/third"], + "metrics" => [ + 1, + 100.0, + 1, + %{ + "currency" => "USD", + "long" => "$2,500.00", + "short" => "$2.5K", + "value" => 2500.0 + }, + %{ + "currency" => "USD", + "long" => "$2,500.00", + "short" => "$2.5K", + "value" => 2500.0 + } + ] + } + ] + end + + @tag :ee_only + test "return revenue metrics for pages breakdown", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, user_id: 1, pathname: "/first"), + build(:event, + name: "Payment", + pathname: "/purchase/first", + user_id: 1, + revenue_reporting_amount: Decimal.new("2000"), + revenue_reporting_currency: "USD" + ), + build(:pageview, user_id: 1, pathname: "/exit_first"), + build(:pageview, user_id: 2, pathname: "/second"), + build(:event, + user_id: 2, + name: "Payment", + pathname: "/purchase/second", + revenue_reporting_amount: Decimal.new("3000"), + revenue_reporting_currency: "USD" + ), + build(:event, + name: "Payment", + pathname: "/purchase/second", + user_id: 2, + revenue_reporting_amount: Decimal.new("4000"), + revenue_reporting_currency: "USD" + ), + build(:pageview, user_id: 2, pathname: "/exit_second"), + build(:pageview, user_id: 3, pathname: "/first"), + build(:event, + name: "Payment", + pathname: "/purchase/first", + user_id: 3, + revenue_reporting_amount: Decimal.new("1000"), + revenue_reporting_currency: "USD" + ), + build(:pageview, user_id: 3, pathname: "/exit_first"), + build(:pageview, user_id: 4, pathname: "/third"), + build(:event, + name: "Payment", + pathname: "/purchase/third", + user_id: 4, + revenue_reporting_amount: Decimal.new("2500"), + revenue_reporting_currency: "USD" + ), + build(:event, name: "Payment", pathname: "/nopay", revenue_reporting_amount: nil), + build(:event, name: "Payment", pathname: "/nopay", revenue_reporting_amount: nil), + build(:event, name: "Payment", pathname: "/nopay", revenue_reporting_amount: nil) + ]) + + insert(:goal, %{site: site, event_name: "Payment", currency: :USD}) + + response = + query_pages(conn, site, + date_range: "day", + filters: [["is", "event:goal", ["Payment"]]], + metrics: [ + "visitors", + "group_conversion_rate", + "total_visitors", + "average_revenue", + "total_revenue" + ] + ) + + assert response["results"] == [ + %{ + "dimensions" => ["/nopay"], + "metrics" => [ + 3, + 100.0, + 3, + %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, + %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0} + ] + }, + %{ + "dimensions" => ["/purchase/first"], + "metrics" => [ + 2, + 100.0, + 2, + %{ + "currency" => "USD", + "long" => "$1,500.00", + "short" => "$1.5K", + "value" => 1500.0 + }, + %{ + "currency" => "USD", + "long" => "$3,000.00", + "short" => "$3.0K", + "value" => 3000.0 + } + ] + }, + %{ + "dimensions" => ["/purchase/second"], + "metrics" => [ + 1, + 100.0, + 1, + %{ + "currency" => "USD", + "long" => "$3,500.00", + "short" => "$3.5K", + "value" => 3500.0 + }, + %{ + "currency" => "USD", + "long" => "$7,000.00", + "short" => "$7.0K", + "value" => 7000.0 + } + ] + }, + %{ + "dimensions" => ["/purchase/third"], + "metrics" => [ + 1, + 100.0, + 1, + %{ + "currency" => "USD", + "long" => "$2,500.00", + "short" => "$2.5K", + "value" => 2500.0 + }, + %{ + "currency" => "USD", + "long" => "$2,500.00", + "short" => "$2.5K", + "value" => 2500.0 + } + ] + } + ] + end + end +end diff --git a/test/plausible_web/controllers/api/stats_controller/query_controller_test.exs b/test/plausible_web/controllers/api/stats_controller/query_controller_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..f8473641a51b20b7ae25008482cccd9de889d2e8 --- /dev/null +++ b/test/plausible_web/controllers/api/stats_controller/query_controller_test.exs @@ -0,0 +1,216 @@ +defmodule PlausibleWeb.Api.InternalController.QueryTest do + use PlausibleWeb.ConnCase, async: false + use Plausible.Repo + + describe "POST /api/:domain/query shared/public" do + test "returns aggregated metrics for public site", %{conn: conn} do + site = new_site(public: true) + + populate_stats(site, [ + build(:pageview, user_id: 5, pathname: "/foo", timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + post(conn, "/api/stats/#{URI.encode(site.domain)}/query", %{ + "metrics" => ["pageviews"], + "date_range" => "all", + "filters" => [["is", "event:page", ["/foo"]]] + }) + + assert json_response(conn, 200)["results"] == [ + %{"metrics" => [1], "dimensions" => []} + ] + end + + test "returns aggregated metrics with shared link auth", %{conn: conn} do + site = new_site() + + populate_stats(site, [ + build(:pageview, user_id: 5, pathname: "/foo", timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, user_id: 5, timestamp: ~N[2021-01-01 00:25:00]), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + link = insert(:shared_link, site: site) + + conn = + post(conn, "/api/stats/#{URI.encode(site.domain)}/query?auth=#{link.slug}", %{ + "metrics" => ["pageviews"], + "date_range" => "all", + "filters" => [["is", "event:page", ["/foo"]]] + }) + + assert json_response(conn, 200)["results"] == [%{"metrics" => [1], "dimensions" => []}] + end + + test "returns metrics with shared link auth that is limited to segment", %{conn: conn} do + site = new_site() + + populate_stats(site, [ + build(:pageview, user_id: 5, pathname: "/foo", timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, user_id: 5, timestamp: ~N[2021-01-01 00:25:00]), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + segment = + insert(:segment, + site: site, + name: "Scandinavia", + type: :site, + segment_data: %{"filters" => [["is", "event:page", ["/foo"]]]} + ) + + link = insert(:shared_link, segment_id: segment.id, site: site) + + conn = + post(conn, "/api/stats/#{URI.encode(site.domain)}/query?auth=#{link.slug}", %{ + "metrics" => ["pageviews"], + "date_range" => "all", + "filters" => [["is", "segment", [segment.id]]] + }) + + assert json_response(conn, 200)["results"] == [%{"metrics" => [1], "dimensions" => []}] + end + + test "errors when expected segment filter not present for shared link auth that is limited to segment", + %{conn: conn} do + site = new_site() + + segment = + insert(:segment, + site: site, + name: "Scandinavia", + type: :site, + segment_data: %{"filters" => [["is", "event:page", ["/foo"]]]} + ) + + link = insert(:shared_link, segment_id: segment.id, site: site) + + conn = + post(conn, "/api/stats/#{URI.encode(site.domain)}/query?auth=#{link.slug}", %{ + "metrics" => ["pageviews"], + "date_range" => "all", + "filters" => [] + }) + + assert json_response(conn, 400) == %{ + "error" => "The first filter must be for the segment with id #{segment.id}" + } + end + end + + describe "POST /api/:domain/query" do + setup [:create_user, :create_site, :log_in] + + test "rejects when accessing any other site", %{conn: conn, site: site} do + conn = + post(conn, "/api/stats/any.other.site/query", %{ + # ignored + "site_id" => site.domain, + "metrics" => ["pageviews"], + "date_range" => "all", + "filters" => [] + }) + + assert json_response(conn, 404) == %{ + "error" => "Site does not exist or user does not have sufficient access." + } + end + + test "adds x-api-version response header", %{conn: conn, site: site} do + conn = + post(conn, "/api/stats/#{URI.encode(site.domain)}/query", %{ + "metrics" => ["pageviews"], + "date_range" => "all", + "filters" => [] + }) + + assert get_resp_header(conn, "x-api-version") == [ + to_string(Plausible.InternalStatsApiVersion.effective_version()) + ] + end + + test "returns aggregated metrics", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, user_id: 5, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, user_id: 5, timestamp: ~N[2021-01-01 00:25:00]), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + post(conn, "/api/stats/#{URI.encode(site.domain)}/query?site_id=ignored", %{ + "metrics" => ["pageviews"], + "date_range" => "all", + "filters" => [] + }) + + assert json_response(conn, 200)["results"] == [%{"metrics" => [3], "dimensions" => []}] + end + + test "silently ignores missing event goal in filter", %{conn: conn, site: site} do + populate_stats(site, [ + build(:event, name: "Signup", timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + post(conn, "/api/stats/#{URI.encode(site.domain)}/query", %{ + "metrics" => ["visitors"], + "date_range" => "all", + "filters" => [["is", "event:goal", ["Missing goal"]]] + }) + + assert json_response(conn, 200)["results"] == [%{"metrics" => [0], "dimensions" => []}] + end + + test "silently ignores missing pageview goal in filter", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/register", timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + post(conn, "/api/stats/#{URI.encode(site.domain)}/query", %{ + "metrics" => ["visitors"], + "date_range" => "all", + "filters" => [["is", "event:goal", ["Visit /"]]] + }) + + assert json_response(conn, 200)["results"] == [%{"metrics" => [0], "dimensions" => []}] + end + + test "silently ignores missing goal and still returns results for existing goal in filter", + %{conn: conn, site: site} do + insert(:goal, %{site: site, event_name: "Signup"}) + + populate_stats(site, [ + build(:event, name: "Signup", user_id: 1, timestamp: ~N[2021-01-01 00:00:00]), + build(:event, name: "Purchase", user_id: 2, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + post(conn, "/api/stats/#{URI.encode(site.domain)}/query", %{ + "metrics" => ["visitors"], + "date_range" => "all", + "filters" => [["is", "event:goal", ["Signup", "Missing goal"]]] + }) + + assert json_response(conn, 200)["results"] == [%{"metrics" => [1], "dimensions" => []}] + end + + test "silently ignores missing goal in has_done behavioral filter", + %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + post(conn, "/api/stats/#{URI.encode(site.domain)}/query", %{ + "metrics" => ["visitors"], + "date_range" => "all", + "filters" => [["has_done", ["is", "event:goal", ["Missing goal"]]]] + }) + + assert json_response(conn, 200)["results"] == [%{"metrics" => [0], "dimensions" => []}] + end + end +end diff --git a/test/plausible_web/controllers/api/stats_controller/regions_test.exs b/test/plausible_web/controllers/api/stats_controller/regions_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..8df5d5b79a12e7e2f7d2308a786417d650960707 --- /dev/null +++ b/test/plausible_web/controllers/api/stats_controller/regions_test.exs @@ -0,0 +1,205 @@ +defmodule PlausibleWeb.Api.StatsController.RegionsTest do + use PlausibleWeb.ConnCase + + defp query_regions(conn, site, opts) do + always_on_filters = ["is_not", "visit:region", [""]] + + params = %{ + "dimensions" => Keyword.get(opts, :dimensions, ["visit:region", "visit:region_name"]), + "date_range" => Keyword.get(opts, :date_range, "all"), + "filters" => [ + always_on_filters + | Keyword.get(opts, :filters, []) + ], + "metrics" => Keyword.get(opts, :metrics, ["visitors", "percentage"]), + "include" => Keyword.get(opts, :include, nil), + "pagination" => Keyword.get(opts, :pagination, nil), + "order_by" => Keyword.get(opts, :order_by, nil) + } + + conn + |> post("/api/stats/#{site.domain}/query", params) + |> json_response(200) + end + + describe "querying for regions with POST /query" do + defp seed(%{site: site}) do + populate_stats(site, [ + build(:pageview, + country_code: "EE", + subdivision1_code: "EE-37", + city_geoname_id: 588_409 + ), + build(:pageview, + country_code: "EE", + subdivision1_code: "EE-37", + city_geoname_id: 588_409 + ), + build(:pageview, + country_code: "EE", + subdivision1_code: "EE-37", + city_geoname_id: 588_409 + ), + build(:pageview, + country_code: "EE", + subdivision1_code: "EE-39", + city_geoname_id: 591_632 + ), + build(:pageview, + country_code: "EE", + subdivision1_code: "EE-39", + city_geoname_id: 591_632 + ) + ]) + end + + setup [:create_user, :log_in, :create_site, :create_legacy_site_import, :seed] + + test "returns top regions by visitors", %{conn: conn, site: site} do + response = query_regions(conn, site, date_range: "day") + + assert response["results"] == [ + %{"dimensions" => ["EE-37", "Harjumaa"], "metrics" => [3, 60.0]}, + %{"dimensions" => ["EE-39", "Hiiumaa"], "metrics" => [2, 40.0]} + ] + end + + test "when list is filtered returns one region only", %{conn: conn, site: site} do + response = + query_regions(conn, site, + date_range: "day", + filters: [["is", "visit:region", ["EE-39"]]] + ) + + assert response["results"] == [ + %{"dimensions" => ["EE-39", "Hiiumaa"], "metrics" => [2, 100.0]} + ] + end + + @tag :ee_only + test "return revenue metrics for regions breakdown", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + user_id: 1, + country_code: "EE", + subdivision1_code: "EE-37", + city_geoname_id: 588_409 + ), + build(:event, + name: "Payment", + user_id: 1, + revenue_reporting_amount: Decimal.new("1000"), + revenue_reporting_currency: "USD" + ), + build(:pageview, + user_id: 2, + country_code: "EE", + subdivision1_code: "EE-37", + city_geoname_id: 588_409 + ), + build(:event, + name: "Payment", + user_id: 2, + revenue_reporting_amount: Decimal.new("2000"), + revenue_reporting_currency: "USD" + ), + build(:pageview, + user_id: 3, + country_code: "EE", + subdivision1_code: "EE-37", + city_geoname_id: 588_409 + ), + build(:pageview, + user_id: 4, + country_code: "EE", + subdivision1_code: "EE-39", + city_geoname_id: 591_632 + ), + build(:event, + name: "Payment", + user_id: 4, + revenue_reporting_amount: Decimal.new("500"), + revenue_reporting_currency: "USD" + ), + build(:pageview, + user_id: 5, + country_code: "EE", + subdivision1_code: "EE-39", + city_geoname_id: 591_632 + ), + build(:pageview, user_id: 6), + build(:event, + name: "Payment", + user_id: 6, + revenue_reporting_amount: Decimal.new("600"), + revenue_reporting_currency: "USD" + ), + build(:pageview, user_id: 7), + build(:event, + name: "Payment", + user_id: 7, + revenue_reporting_amount: nil + ) + ]) + + insert(:goal, %{site: site, event_name: "Payment", currency: :USD}) + + response = + query_regions(conn, site, + date_range: "day", + filters: [["is", "event:goal", ["Payment"]]], + metrics: [ + "visitors", + "total_visitors", + "group_conversion_rate", + "average_revenue", + "total_revenue" + ], + order_by: [["visitors", "desc"], ["visit:region", "asc"]] + ) + + assert response["results"] == [ + %{ + "dimensions" => ["EE-37", "Harjumaa"], + "metrics" => [ + 2, + 6, + 33.33, + %{ + "currency" => "USD", + "long" => "$1,500.00", + "short" => "$1.5K", + "value" => 1500.0 + }, + %{ + "currency" => "USD", + "long" => "$3,000.00", + "short" => "$3.0K", + "value" => 3000.0 + } + ] + }, + %{ + "dimensions" => ["EE-39", "Hiiumaa"], + "metrics" => [ + 1, + 4, + 25.0, + %{ + "currency" => "USD", + "long" => "$500.00", + "short" => "$500.0", + "value" => 500.0 + }, + %{ + "currency" => "USD", + "long" => "$500.00", + "short" => "$500.0", + "value" => 500.0 + } + ] + } + ] + end + end +end diff --git a/test/plausible_web/controllers/api/stats_controller/screen_sizes_test.exs b/test/plausible_web/controllers/api/stats_controller/screen_sizes_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..dade756c05b8e3e00bab45d9104ccc762055ac99 --- /dev/null +++ b/test/plausible_web/controllers/api/stats_controller/screen_sizes_test.exs @@ -0,0 +1,468 @@ +defmodule PlausibleWeb.Api.StatsController.ScreenSizesTest do + use PlausibleWeb.ConnCase + + defp query_screen_sizes(conn, site, opts) do + params = %{ + "dimensions" => Keyword.get(opts, :dimensions, ["visit:device"]), + "date_range" => Keyword.get(opts, :date_range, "all"), + "filters" => Keyword.get(opts, :filters, []), + "metrics" => Keyword.get(opts, :metrics, ["visitors", "percentage"]), + "include" => Keyword.get(opts, :include, nil), + "pagination" => Keyword.get(opts, :pagination, nil), + "order_by" => Keyword.get(opts, :order_by, nil) + } + + conn + |> post("/api/stats/#{site.domain}/query", params) + |> json_response(200) + end + + describe "GET /api/stats/:domain/screen-sizes" do + setup [:create_user, :log_in, :create_site, :create_legacy_site_import] + + test "returns screen sizes by new visitors", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, screen_size: "Desktop"), + build(:pageview, screen_size: "Desktop"), + build(:pageview, screen_size: "Laptop") + ]) + + response = query_screen_sizes(conn, site, date_range: "day") + + assert response["results"] == [ + %{"dimensions" => ["Desktop"], "metrics" => [2, 66.67]}, + %{"dimensions" => ["Laptop"], "metrics" => [1, 33.33]} + ] + end + + test "returns bounce_rate and visit_duration when detailed=true", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + user_id: 123, + timestamp: ~N[2021-01-01 12:00:00], + screen_size: "Desktop" + ), + build(:pageview, + user_id: 123, + timestamp: ~N[2021-01-01 12:10:00], + screen_size: "Desktop" + ), + build(:pageview, timestamp: ~N[2021-01-01 12:00:00], screen_size: "Desktop"), + build(:pageview, timestamp: ~N[2021-01-01 12:00:00], screen_size: "Laptop") + ]) + + response = + query_screen_sizes(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + metrics: ["visitors", "bounce_rate", "visit_duration", "percentage"] + ) + + assert response["results"] == [ + %{"dimensions" => ["Desktop"], "metrics" => [2, 50, 300, 66.67]}, + %{"dimensions" => ["Laptop"], "metrics" => [1, 100, 0, 33.33]} + ] + end + + test "returns screen sizes for user making multiple sessions", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + user_id: 1, + screen_size: "Desktop", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: 1, + screen_size: "Laptop", + timestamp: ~N[2021-01-01 05:00:00] + ) + ]) + + response = + query_screen_sizes(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + order_by: [["visit:device", "asc"]] + ) + + assert response["results"] == [ + %{"dimensions" => ["Desktop"], "metrics" => [1, 100.0]}, + %{"dimensions" => ["Laptop"], "metrics" => [1, 100.0]} + ] + end + + test "returns (not set) when appropriate", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + screen_size: "" + ), + build(:pageview, + screen_size: "Desktop" + ) + ]) + + response1 = + query_screen_sizes(conn, site, + date_range: "day", + order_by: [["visit:device", "asc"]] + ) + + assert response1["results"] == [ + %{"dimensions" => ["(not set)"], "metrics" => [1, 50.0]}, + %{"dimensions" => ["Desktop"], "metrics" => [1, 50.0]} + ] + + response2 = + query_screen_sizes(conn, site, + date_range: "day", + filters: [["is", "visit:device", ["(not set)"]]] + ) + + assert response2["results"] == [ + %{"dimensions" => ["(not set)"], "metrics" => [1, 100.0]} + ] + end + + test "select empty imported_devices as (not set), merging with the native (not set)", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, user_id: 123), + build(:imported_devices, visitors: 1), + build(:imported_visitors, visitors: 1) + ]) + + response = + query_screen_sizes(conn, site, date_range: "day", include: %{"imports" => true}) + + assert response["results"] == [ + %{"dimensions" => ["(not set)"], "metrics" => [2, 100.0]} + ] + end + + test "returns screen sizes with :is filter on custom pageview props", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, + user_id: 123, + screen_size: "Desktop" + ), + build(:pageview, + user_id: 123, + screen_size: "Desktop", + "meta.key": ["author"], + "meta.value": ["John Doe"] + ), + build(:pageview, + screen_size: "Mobile", + "meta.key": ["author"], + "meta.value": ["other"] + ), + build(:pageview, + screen_size: "Tablet" + ) + ]) + + response = + query_screen_sizes(conn, site, + date_range: "day", + filters: [["is", "event:props:author", ["John Doe"]]] + ) + + assert response["results"] == [ + %{"dimensions" => ["Desktop"], "metrics" => [1, 100.0]} + ] + end + + test "returns screen sizes with :is_not filter on custom pageview props", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, + user_id: 123, + screen_size: "Desktop", + "meta.key": ["author"], + "meta.value": ["John Doe"] + ), + build(:pageview, + user_id: 123, + screen_size: "Desktop", + "meta.key": ["author"], + "meta.value": ["John Doe"] + ), + build(:pageview, + screen_size: "Mobile", + "meta.key": ["author"], + "meta.value": ["other"] + ), + build(:pageview, + screen_size: "Tablet" + ) + ]) + + response = + query_screen_sizes(conn, site, + date_range: "day", + filters: [["is_not", "event:props:author", ["John Doe"]]], + order_by: [["visit:device", "asc"]] + ) + + assert response["results"] == [ + %{"dimensions" => ["Mobile"], "metrics" => [1, 50.0]}, + %{"dimensions" => ["Tablet"], "metrics" => [1, 50.0]} + ] + end + + test "returns screen sizes by new visitors with imported data", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, screen_size: "Desktop"), + build(:pageview, screen_size: "Desktop"), + build(:pageview, screen_size: "Laptop") + ]) + + populate_stats(site, [ + build(:imported_devices, device: "Mobile"), + build(:imported_devices, device: "Laptop"), + build(:imported_visitors, visitors: 2) + ]) + + response1 = query_screen_sizes(conn, site, date_range: "day") + + assert response1["results"] == [ + %{"dimensions" => ["Desktop"], "metrics" => [2, 66.67]}, + %{"dimensions" => ["Laptop"], "metrics" => [1, 33.33]} + ] + + response2 = + query_screen_sizes(conn, site, date_range: "day", include: %{"imports" => true}) + + assert response2["results"] == [ + %{"dimensions" => ["Desktop"], "metrics" => [2, 40.0]}, + %{"dimensions" => ["Laptop"], "metrics" => [2, 40.0]}, + %{"dimensions" => ["Mobile"], "metrics" => [1, 20.0]} + ] + end + + test "returns screen sizes when filtering by imported screen size", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, screen_size: "Desktop"), + build(:imported_devices, device: "Desktop"), + build(:imported_devices, device: "Laptop"), + build(:imported_visitors, visitors: 2) + ]) + + response = + query_screen_sizes(conn, site, + date_range: "day", + filters: [["is", "visit:device", ["Desktop"]]], + include: %{"imports" => true} + ) + + assert response["results"] == [ + %{"dimensions" => ["Desktop"], "metrics" => [2, 100.0]} + ] + end + + test "returns screen sizes for user making multiple sessions by no of visitors with imported data", + %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + user_id: 1, + screen_size: "Desktop", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: 1, + screen_size: "Laptop", + timestamp: ~N[2021-01-01 05:00:00] + ) + ]) + + populate_stats(site, [ + build(:imported_devices, device: "Desktop", date: ~D[2021-01-01]), + build(:imported_devices, device: "Laptop", date: ~D[2021-01-01]), + build(:imported_visitors, visitors: 1, date: ~D[2021-01-01]) + ]) + + response = + query_screen_sizes(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + include: %{"imports" => true}, + order_by: [["visit:device", "asc"]] + ) + + assert response["results"] == [ + %{"dimensions" => ["Desktop"], "metrics" => [2, 100.0]}, + %{"dimensions" => ["Laptop"], "metrics" => [2, 100.0]} + ] + end + + test "calculates conversion_rate when filtering for goal", %{conn: conn, site: site} do + insert(:goal, site: site, event_name: "Signup") + + populate_stats(site, [ + build(:pageview, user_id: 1, screen_size: "Desktop"), + build(:pageview, user_id: 2, screen_size: "Desktop"), + build(:event, user_id: 1, name: "Signup") + ]) + + response = + query_screen_sizes(conn, site, + date_range: "day", + filters: [["is", "event:goal", ["Signup"]]], + metrics: ["visitors", "total_visitors", "group_conversion_rate"] + ) + + assert response["results"] == [ + %{"dimensions" => ["Desktop"], "metrics" => [1, 2, 50.0]} + ] + end + + test "returns screen sizes with not_member filter type", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, referrer_source: "Google", screen_size: "Desktop"), + build(:pageview, referrer_source: "Bad source", screen_size: "Desktop"), + build(:pageview, referrer_source: "Google", screen_size: "Desktop"), + build(:pageview, referrer_source: "Twitter", screen_size: "Mobile"), + build(:pageview, + referrer_source: "Second bad source", + screen_size: "Mobile" + ) + ]) + + response = + query_screen_sizes(conn, site, + date_range: "day", + filters: [["is_not", "visit:source", ["Bad source", "Second bad source"]]] + ) + + assert response["results"] == [ + %{"dimensions" => ["Desktop"], "metrics" => [2, 66.67]}, + %{"dimensions" => ["Mobile"], "metrics" => [1, 33.33]} + ] + end + + @tag :ee_only + test "return revenue metrics for screen sizes breakdown", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, user_id: 1, screen_size: "Mobile"), + build(:event, + name: "Payment", + user_id: 1, + revenue_reporting_amount: Decimal.new("1000"), + revenue_reporting_currency: "USD" + ), + build(:pageview, user_id: 2, screen_size: "Mobile"), + build(:event, + name: "Payment", + user_id: 2, + revenue_reporting_amount: Decimal.new("2000"), + revenue_reporting_currency: "USD" + ), + build(:pageview, user_id: 3, screen_size: "Mobile"), + build(:pageview, user_id: 4, screen_size: "Desktop"), + build(:event, + name: "Payment", + user_id: 4, + revenue_reporting_amount: Decimal.new("500"), + revenue_reporting_currency: "USD" + ), + build(:pageview, user_id: 5, screen_size: "Desktop"), + build(:pageview, user_id: 6), + build(:event, + name: "Payment", + user_id: 6, + revenue_reporting_amount: Decimal.new("600"), + revenue_reporting_currency: "USD" + ), + build(:pageview, user_id: 7), + build(:event, + name: "Payment", + user_id: 7, + revenue_reporting_amount: nil + ) + ]) + + insert(:goal, %{site: site, event_name: "Payment", currency: :USD}) + + response = + query_screen_sizes(conn, site, + date_range: "day", + filters: [["is", "event:goal", ["Payment"]]], + metrics: [ + "visitors", + "total_visitors", + "group_conversion_rate", + "average_revenue", + "total_revenue" + ], + order_by: [["visitors", "desc"], ["visit:device", "asc"]] + ) + + assert response["results"] == [ + %{ + "dimensions" => ["(not set)"], + "metrics" => [ + 2, + 2, + 100.0, + %{ + "currency" => "USD", + "long" => "$600.00", + "short" => "$600.0", + "value" => 600.0 + }, + %{ + "currency" => "USD", + "long" => "$600.00", + "short" => "$600.0", + "value" => 600.0 + } + ] + }, + %{ + "dimensions" => ["Mobile"], + "metrics" => [ + 2, + 3, + 66.67, + %{ + "currency" => "USD", + "long" => "$1,500.00", + "short" => "$1.5K", + "value" => 1500.0 + }, + %{ + "currency" => "USD", + "long" => "$3,000.00", + "short" => "$3.0K", + "value" => 3000.0 + } + ] + }, + %{ + "dimensions" => ["Desktop"], + "metrics" => [ + 1, + 2, + 50.0, + %{ + "currency" => "USD", + "long" => "$500.00", + "short" => "$500.0", + "value" => 500.0 + }, + %{ + "currency" => "USD", + "long" => "$500.00", + "short" => "$500.0", + "value" => 500.0 + } + ] + } + ] + end + end +end diff --git a/test/plausible_web/controllers/api/stats_controller/sources_test.exs b/test/plausible_web/controllers/api/stats_controller/sources_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..b831fdda95d811c788c0c292ba3665e525314d2c --- /dev/null +++ b/test/plausible_web/controllers/api/stats_controller/sources_test.exs @@ -0,0 +1,2797 @@ +defmodule PlausibleWeb.Api.StatsController.SourcesTest do + use PlausibleWeb.ConnCase + + @user_id Enum.random(1000..9999) + + defp query_sources(conn, site, opts) do + params = %{ + "dimensions" => Keyword.get(opts, :dimensions, ["visit:source"]), + "date_range" => Keyword.get(opts, :date_range, "all"), + "relative_date" => Keyword.get(opts, :relative_date, nil), + "filters" => Keyword.get(opts, :filters, []), + "metrics" => Keyword.get(opts, :metrics, ["visitors", "percentage"]), + "include" => Keyword.get(opts, :include, nil), + "pagination" => Keyword.get(opts, :pagination, nil), + "order_by" => Keyword.get(opts, :order_by, nil) + } + + conn + |> post("/api/stats/#{site.domain}/query", params) + |> json_response(200) + end + + describe "GET /api/stats/:domain/sources" do + setup [:create_user, :log_in, :create_site, :create_legacy_site_import] + + test "returns top sources by unique user ids", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + referrer_source: "Google", + referrer: "google.com" + ), + build(:pageview, + referrer_source: "Google", + referrer: "google.com" + ), + build(:pageview, + referrer_source: "Google", + referrer: "google.com" + ), + build(:pageview, + referrer_source: "DuckDuckGo", + referrer: "duckduckgo.com" + ), + build(:pageview, + referrer_source: "DuckDuckGo", + referrer: "duckduckgo.com" + ), + build(:pageview) + ]) + + response = query_sources(conn, site, date_range: "day") + + assert response["results"] == [ + %{"dimensions" => ["Google"], "metrics" => [3, 50.0]}, + %{"dimensions" => ["DuckDuckGo"], "metrics" => [2, 33.33]}, + %{"dimensions" => ["Direct / None"], "metrics" => [1, 16.67]} + ] + end + + test "returns top sources with :is filter on custom pageview props", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + referrer_source: "DuckDuckGo", + referrer: "duckduckgo.com", + user_id: 123, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + "meta.key": ["author"], + "meta.value": ["John Doe"], + user_id: 123, + timestamp: ~N[2021-01-01 00:01:00] + ), + build(:pageview, + referrer_source: "Google", + referrer: "google.com", + "meta.key": ["author"], + "meta.value": ["John Doe"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + referrer_source: "Google", + referrer: "google.com", + "meta.key": ["author"], + "meta.value": ["John Doe"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + referrer_source: "Facebook", + referrer: "facebook.com", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + response = + query_sources(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + filters: [["is", "event:props:author", ["John Doe"]]] + ) + + assert response["results"] == [ + %{"dimensions" => ["Google"], "metrics" => [2, 66.67]}, + %{"dimensions" => ["DuckDuckGo"], "metrics" => [1, 33.33]} + ] + + assert response["query"]["date_range"] == [ + "2021-01-01T00:00:00Z", + "2021-01-01T23:59:59Z" + ] + end + + test "returns top sources with :is_not filter on custom pageview props", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, + referrer_source: "DuckDuckGo", + referrer: "duckduckgo.com", + "meta.key": ["author"], + "meta.value": ["John Doe"], + user_id: 123, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + "meta.key": ["author"], + "meta.value": ["other"], + user_id: 123, + timestamp: ~N[2021-01-01 00:01:00] + ), + build(:pageview, + referrer_source: "Google", + referrer: "google.com", + "meta.key": ["author"], + "meta.value": ["other"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + referrer_source: "Google", + referrer: "google.com", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + referrer_source: "Facebook", + referrer: "facebook.com", + "meta.key": ["author"], + "meta.value": ["John Doe"], + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + response = + query_sources(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + filters: [["is_not", "event:props:author", ["John Doe"]]] + ) + + assert response["results"] == [ + %{"dimensions" => ["Google"], "metrics" => [2, 66.67]}, + %{"dimensions" => ["DuckDuckGo"], "metrics" => [1, 33.33]} + ] + end + + test "returns top sources with :is (none) filter on custom pageview props", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, + referrer_source: "DuckDuckGo", + referrer: "duckduckgo.com", + "meta.key": ["author"], + "meta.value": ["John Doe"], + user_id: 123, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: 123, + timestamp: ~N[2021-01-01 00:01:00] + ), + build(:pageview, + referrer_source: "Google", + referrer: "google.com", + "meta.key": ["author"], + "meta.value": ["other"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + referrer_source: "Facebook", + referrer: "facebook.com", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + referrer_source: "Facebook", + referrer: "facebook.com", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + response = + query_sources(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + filters: [["is", "event:props:author", ["(none)"]]] + ) + + assert response["results"] == [ + %{"dimensions" => ["Facebook"], "metrics" => [2, 66.67]}, + %{"dimensions" => ["DuckDuckGo"], "metrics" => [1, 33.33]} + ] + end + + test "returns top sources with :is_not (none) filter on custom pageview props", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, + referrer_source: "DuckDuckGo", + referrer: "duckduckgo.com", + "meta.key": ["logged_in"], + "meta.value": ["true"], + user_id: 123, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + "meta.key": ["author"], + "meta.value": ["other"], + user_id: 123, + timestamp: ~N[2021-01-01 00:01:00] + ), + build(:pageview, + referrer_source: "Google", + referrer: "google.com", + "meta.key": ["author"], + "meta.value": ["other"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + referrer_source: "Google", + referrer: "google.com", + "meta.key": ["author"], + "meta.value": ["another"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + referrer_source: "Facebook", + referrer: "facebook.com", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + response = + query_sources(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + filters: [["is_not", "event:props:author", ["(none)"]]] + ) + + assert response["results"] == [ + %{"dimensions" => ["Google"], "metrics" => [2, 66.67]}, + %{"dimensions" => ["DuckDuckGo"], "metrics" => [1, 33.33]} + ] + end + + test "returns top sources with imported data", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, referrer_source: "Google", referrer: "google.com"), + build(:pageview, referrer_source: "Google", referrer: "google.com"), + build(:pageview, + referrer_source: "DuckDuckGo", + referrer: "duckduckgo.com" + ) + ]) + + populate_stats(site, [ + build(:imported_visitors), + build(:imported_visitors), + build(:imported_visitors), + build(:imported_sources, + source: "Google", + visitors: 2 + ), + build(:imported_sources, + source: "DuckDuckGo", + visitors: 1 + ) + ]) + + response1 = query_sources(conn, site, date_range: "day") + + assert response1["results"] == [ + %{"dimensions" => ["Google"], "metrics" => [2, 66.67]}, + %{"dimensions" => ["DuckDuckGo"], "metrics" => [1, 33.33]} + ] + + response2 = query_sources(conn, site, date_range: "day", include: %{"imports" => true}) + + assert response2["results"] == [ + %{"dimensions" => ["Google"], "metrics" => [4, 66.67]}, + %{"dimensions" => ["DuckDuckGo"], "metrics" => [2, 33.33]} + ] + end + + test "calculates bounce rate and visit duration for sources", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + referrer_source: "Google", + referrer: "google.com", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + referrer_source: "Google", + referrer: "google.com", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:15:00] + ), + build(:pageview, + referrer_source: "DuckDuckGo", + referrer: "duckduckgo.com", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + response = + query_sources(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + metrics: ["visitors", "bounce_rate", "visit_duration", "percentage"], + order_by: [["visit_duration", "asc"]] + ) + + assert response["results"] == [ + %{"dimensions" => ["DuckDuckGo"], "metrics" => [1, 100, 0, 50.0]}, + %{"dimensions" => ["Google"], "metrics" => [1, 0, 900, 50.0]} + ] + end + + test "calculates bounce rate and visit duration for sources with imported data", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, + referrer_source: "Google", + referrer: "google.com", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + referrer_source: "Google", + referrer: "google.com", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:15:00] + ), + build(:pageview, + referrer_source: "DuckDuckGo", + referrer: "duckduckgo.com", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + populate_stats(site, [ + build(:imported_visitors, date: ~D[2021-01-01]), + build(:imported_visitors, date: ~D[2021-01-01]), + build(:imported_visitors, date: ~D[2021-01-01]), + build(:imported_sources, + source: "Google", + date: ~D[2021-01-01], + visitors: 2, + visits: 3, + bounces: 1, + visit_duration: 900 + ), + build(:imported_sources, + source: "DuckDuckGo", + date: ~D[2021-01-01], + visitors: 1, + visits: 1, + visit_duration: 100, + bounces: 0 + ) + ]) + + response1 = + query_sources(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + metrics: ["visitors", "bounce_rate", "visit_duration", "percentage"], + order_by: [["visit_duration", "asc"]] + ) + + assert response1["results"] == [ + %{"dimensions" => ["DuckDuckGo"], "metrics" => [1, 100, 0, 50.0]}, + %{"dimensions" => ["Google"], "metrics" => [1, 0, 900, 50.0]} + ] + + response2 = + query_sources(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + metrics: ["visitors", "bounce_rate", "visit_duration", "percentage"], + include: %{"imports" => true} + ) + + assert response2["results"] == [ + %{"dimensions" => ["Google"], "metrics" => [3, 25.0, 450.0, 60.0]}, + %{"dimensions" => ["DuckDuckGo"], "metrics" => [2, 50.0, 50.0, 40.0]} + ] + end + + test "returns top sources in realtime report", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + referrer_source: "Google", + referrer: "google.com", + timestamp: relative_time(minute: -3) + ), + build(:pageview, + referrer_source: "Google", + referrer: "google.com", + timestamp: relative_time(minute: -2) + ), + build(:pageview, + referrer_source: "DuckDuckGo", + referrer: "duckduckgo.com", + timestamp: relative_time(minute: -1) + ) + ]) + + response = query_sources(conn, site, date_range: "realtime") + + assert response["results"] == [ + %{"dimensions" => ["Google"], "metrics" => [2, 66.67]}, + %{"dimensions" => ["DuckDuckGo"], "metrics" => [1, 33.33]} + ] + end + + test "can paginate the results", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + referrer_source: "Google", + referrer: "google.com" + ), + build(:pageview, + referrer_source: "Google", + referrer: "google.com" + ), + build(:pageview, + referrer_source: "DuckDuckGo", + referrer: "duckduckgo.com" + ), + build(:imported_sources, + source: "DuckDuckGo" + ), + build(:imported_sources, + source: "DuckDuckGo" + ) + ]) + + response1 = + query_sources(conn, site, date_range: "day", pagination: %{"limit" => 1, "offset" => 1}) + + assert response1["results"] == [ + %{"dimensions" => ["DuckDuckGo"], "metrics" => [1, 33.33]} + ] + + response2 = + query_sources(conn, site, + date_range: "day", + pagination: %{"limit" => 1, "offset" => 1}, + include: %{"imports" => true} + ) + + assert response2["results"] == [ + %{"dimensions" => ["Google"], "metrics" => [2, 66.67]} + ] + end + + test "shows sources for a page", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/page1", referrer_source: "Google"), + build(:pageview, pathname: "/page1", referrer_source: "Google"), + build(:pageview, + user_id: 1, + pathname: "/page2", + referrer_source: "DuckDuckGo" + ), + build(:pageview, + user_id: 1, + pathname: "/page1", + referrer_source: "DuckDuckGo" + ) + ]) + + response = + query_sources(conn, site, + date_range: "day", + filters: [["is", "event:page", ["/page1"]]] + ) + + assert response["results"] == [ + %{"dimensions" => ["Google"], "metrics" => [2, 66.67]}, + %{"dimensions" => ["DuckDuckGo"], "metrics" => [1, 33.33]} + ] + end + + test "order_by [[visit:source, desc]] is respected", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, referrer_source: "C"), + build(:pageview, referrer_source: "A"), + build(:pageview, referrer_source: "B") + ]) + + response = + query_sources(conn, site, + date_range: "day", + order_by: [["visit:source", "desc"]] + ) + + assert response["results"] == [ + %{"dimensions" => ["C"], "metrics" => [1, 33.33]}, + %{"dimensions" => ["B"], "metrics" => [1, 33.33]}, + %{"dimensions" => ["A"], "metrics" => [1, 33.33]} + ] + end + + test "order_by [[visit_duration, asc], [visit:source, desc]]] is respected and flipping the sort orders works", + %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, + pathname: "/in", + user_id: @user_id, + referrer_source: "B", + timestamp: ~N[2024-08-10 09:00:00] + ), + build(:pageview, + pathname: "/out", + user_id: @user_id, + referrer_source: "B", + timestamp: ~N[2024-08-10 09:00:45] + ), + build(:pageview, + pathname: "/in", + user_id: @user_id, + referrer_source: "C", + timestamp: ~N[2024-08-10 10:00:00] + ), + build(:pageview, + pathname: "/out", + user_id: @user_id, + referrer_source: "C", + timestamp: ~N[2024-08-10 10:00:30] + ), + build(:pageview, referrer_source: "A", timestamp: ~N[2024-08-10 10:00:30]), + build(:pageview, referrer_source: "A", timestamp: ~N[2024-08-10 10:00:30]), + build(:pageview, referrer_source: "Z", timestamp: ~N[2024-08-10 10:00:30]) + ]) + + response1 = + query_sources(conn, site, + date_range: ["2024-08-10", "2024-08-10"], + metrics: ["visitors", "bounce_rate", "visit_duration", "percentage"], + order_by: [["visit_duration", "asc"], ["visit:source", "desc"]] + ) + + assert response1["results"] == [ + %{"dimensions" => ["Z"], "metrics" => [1, 100, 0, 25.0]}, + %{"dimensions" => ["A"], "metrics" => [2, 100, 0, 50.0]}, + %{"dimensions" => ["C"], "metrics" => [1, 0, 30, 25.0]}, + %{"dimensions" => ["B"], "metrics" => [1, 0, 45, 25.0]} + ] + + response2 = + query_sources(conn, site, + date_range: ["2024-08-10", "2024-08-10"], + metrics: ["visitors", "bounce_rate", "visit_duration", "percentage"], + order_by: [["visit_duration", "desc"], ["visit:source", "asc"]] + ) + + assert response2["results"] == [ + %{"dimensions" => ["B"], "metrics" => [1, 0, 45, 25.0]}, + %{"dimensions" => ["C"], "metrics" => [1, 0, 30, 25.0]}, + %{"dimensions" => ["A"], "metrics" => [2, 100, 0, 50.0]}, + %{"dimensions" => ["Z"], "metrics" => [1, 100, 0, 25.0]} + ] + end + + test "can compare with previous period", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + referrer_source: "DuckDuckGo", + referrer: "duckduckgo.com", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + referrer_source: "DuckDuckGo", + referrer: "duckduckgo.com", + timestamp: ~N[2021-01-02 00:00:00] + ), + build(:pageview, + referrer_source: "Google", + referrer: "google.com", + timestamp: ~N[2021-01-02 00:00:00] + ), + build(:pageview, + referrer_source: "Google", + referrer: "google.com", + timestamp: ~N[2021-01-02 00:00:00] + ) + ]) + + response = + query_sources(conn, site, + date_range: ["2021-01-02", "2021-01-02"], + metrics: ["visitors", "bounce_rate", "visit_duration", "percentage"], + include: %{"compare" => "previous_period"} + ) + + assert response["results"] == [ + %{ + "dimensions" => ["Google"], + "metrics" => [2, 100, 0, 66.67], + "comparison" => %{ + "dimensions" => ["Google"], + "metrics" => [0, 0, nil, 0.0], + "change" => [100, nil, nil, 100] + } + }, + %{ + "dimensions" => ["DuckDuckGo"], + "metrics" => [1, 100, 0, 33.33], + "comparison" => %{ + "dimensions" => ["DuckDuckGo"], + "metrics" => [1, 100, 0, 100.0], + "change" => [0, 0, 0, -67] + } + } + ] + + assert response["query"]["date_range"] == [ + "2021-01-02T00:00:00Z", + "2021-01-02T23:59:59Z" + ] + + assert response["query"]["comparison_date_range"] == [ + "2021-01-01T00:00:00Z", + "2021-01-01T23:59:59Z" + ] + end + + @tag :ee_only + test "return revenue metrics for sources breakdown", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + user_id: 1, + referrer_source: "Google", + referrer: "google.com" + ), + build(:event, + name: "Payment", + user_id: 1, + revenue_reporting_amount: Decimal.new("1000"), + revenue_reporting_currency: "USD" + ), + build(:pageview, + user_id: 2, + referrer_source: "Google", + referrer: "google.com" + ), + build(:event, + name: "Payment", + user_id: 2, + revenue_reporting_amount: Decimal.new("2000"), + revenue_reporting_currency: "USD" + ), + build(:pageview, + user_id: 3, + referrer_source: "Google", + referrer: "google.com" + ), + build(:pageview, + user_id: 4, + referrer_source: "DuckDuckGo", + referrer: "duckduckgo.com" + ), + build(:event, + name: "Payment", + user_id: 4, + revenue_reporting_amount: Decimal.new("500"), + revenue_reporting_currency: "USD" + ), + build(:pageview, + user_id: 5, + referrer_source: "DuckDuckGo", + referrer: "duckduckgo.com" + ), + build(:pageview, user_id: 6), + build(:event, + name: "Payment", + user_id: 6, + revenue_reporting_amount: Decimal.new("600"), + revenue_reporting_currency: "USD" + ), + build(:pageview, user_id: 7), + build(:event, + name: "Payment", + user_id: 7, + revenue_reporting_amount: nil + ), + build(:pageview, + user_id: 8, + referrer_source: "Bing", + referrer: "bing.com" + ) + ]) + + insert(:goal, %{site: site, event_name: "Payment", currency: :USD}) + + response = + query_sources(conn, site, + date_range: "day", + metrics: [ + "visitors", + "group_conversion_rate", + "total_visitors", + "average_revenue", + "total_revenue" + ], + filters: [["is", "event:goal", ["Payment"]]], + order_by: [["visitors", "desc"], ["visit:source", "asc"]] + ) + + assert response["results"] == [ + %{ + "dimensions" => ["Direct / None"], + "metrics" => [ + 2, + 100.0, + 2, + %{ + "currency" => "USD", + "long" => "$600.00", + "short" => "$600.0", + "value" => 600.0 + }, + %{ + "currency" => "USD", + "long" => "$600.00", + "short" => "$600.0", + "value" => 600.0 + } + ] + }, + %{ + "dimensions" => ["Google"], + "metrics" => [ + 2, + 66.67, + 3, + %{ + "currency" => "USD", + "long" => "$1,500.00", + "short" => "$1.5K", + "value" => 1500.0 + }, + %{ + "currency" => "USD", + "long" => "$3,000.00", + "short" => "$3.0K", + "value" => 3000.0 + } + ] + }, + %{ + "dimensions" => ["DuckDuckGo"], + "metrics" => [ + 1, + 50.0, + 2, + %{ + "currency" => "USD", + "long" => "$500.00", + "short" => "$500.0", + "value" => 500.0 + }, + %{ + "currency" => "USD", + "long" => "$500.00", + "short" => "$500.0", + "value" => 500.0 + } + ] + } + ] + end + end + + describe "UTM parameters with hostname filter" do + setup [:create_user, :log_in, :create_site] + + for {resource, attr, dimension} <- [ + {:utm_campaigns, :utm_campaign, "visit:utm_campaign"}, + {:utm_sources, :utm_source, "visit:utm_source"}, + {:utm_terms, :utm_term, "visit:utm_term"}, + {:utm_contents, :utm_content, "visit:utm_content"} + ] do + test "returns #{resource} when filtered by hostname", %{conn: conn, site: site} do + populate_stats(site, [ + # session starts at two.example.com with utm_param=ad + build( + :pageview, + [ + {unquote(attr), "ad"}, + {:user_id, @user_id}, + {:hostname, "two.example.com"}, + {:timestamp, ~N[2021-01-01 00:00:00]} + ] + ), + # session continues on one.example.com without any utm_params + build( + :pageview, + [ + {:user_id, @user_id}, + {:hostname, "one.example.com"}, + {:timestamp, ~N[2021-01-01 00:15:00]} + ] + ) + ]) + + response = + query_sources(conn, site, + dimensions: [unquote(dimension)], + date_range: ["2021-01-01", "2021-01-01"], + filters: [["is", "event:hostname", ["one.example.com"]]] + ) + + # nobody landed on one.example.com from utm_param=ad + assert response["results"] == [] + end + end + end + + describe "GET /api/stats/:domain/channels" do + setup [:create_user, :log_in, :create_site] + + test "returns top channels by unique user ids", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + referrer_source: "Bing", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + referrer_source: "Bing", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:15:00] + ), + build(:pageview, + referrer_source: "Facebook", + utm_source: "fb-ads", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + referrer_source: "Facebook", + utm_source: "fb-ads", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + response = + query_sources(conn, site, + dimensions: ["visit:channel"], + metrics: ["visitors", "bounce_rate", "visit_duration", "percentage"], + date_range: ["2021-01-01", "2021-01-01"] + ) + + assert response["results"] == [ + %{"dimensions" => ["Paid Social"], "metrics" => [2, 100, 0, 66.67]}, + %{"dimensions" => ["Organic Search"], "metrics" => [1, 0, 900, 33.33]} + ] + end + + @tag :ee_only + test "return revenue metrics for channels breakdown", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + user_id: 1, + referrer_source: "Google", + referrer: "google.com" + ), + build(:event, + name: "Payment", + user_id: 1, + revenue_reporting_amount: Decimal.new("1000"), + revenue_reporting_currency: "USD" + ), + build(:pageview, + user_id: 2, + referrer_source: "Google", + referrer: "google.com" + ), + build(:event, + name: "Payment", + user_id: 2, + revenue_reporting_amount: Decimal.new("2000"), + revenue_reporting_currency: "USD" + ), + build(:pageview, + user_id: 3, + referrer_source: "Google", + referrer: "google.com" + ), + build(:pageview, + user_id: 4, + referrer_source: "Facebook", + utm_source: "fb-ads" + ), + build(:event, + name: "Payment", + user_id: 4, + revenue_reporting_amount: Decimal.new("500"), + revenue_reporting_currency: "USD" + ), + build(:pageview, + user_id: 5, + referrer_source: "Facebook", + utm_source: "fb-ads" + ), + build(:pageview, user_id: 6), + build(:event, + name: "Payment", + user_id: 6, + revenue_reporting_amount: Decimal.new("600"), + revenue_reporting_currency: "USD" + ), + build(:pageview, user_id: 7), + build(:event, + name: "Payment", + user_id: 7, + revenue_reporting_amount: nil + ) + ]) + + insert(:goal, %{site: site, event_name: "Payment", currency: :USD}) + + response = + query_sources(conn, site, + dimensions: ["visit:channel"], + date_range: "day", + metrics: [ + "visitors", + "group_conversion_rate", + "total_visitors", + "average_revenue", + "total_revenue" + ], + filters: [["is", "event:goal", ["Payment"]]], + order_by: [["visitors", "desc"], ["visit:channel", "asc"]] + ) + + assert response["results"] == [ + %{ + "dimensions" => ["Direct"], + "metrics" => [ + 2, + 100.0, + 2, + %{ + "currency" => "USD", + "long" => "$600.00", + "short" => "$600.0", + "value" => 600.0 + }, + %{ + "currency" => "USD", + "long" => "$600.00", + "short" => "$600.0", + "value" => 600.0 + } + ] + }, + %{ + "dimensions" => ["Organic Search"], + "metrics" => [ + 2, + 66.67, + 3, + %{ + "currency" => "USD", + "long" => "$1,500.00", + "short" => "$1.5K", + "value" => 1500.0 + }, + %{ + "currency" => "USD", + "long" => "$3,000.00", + "short" => "$3.0K", + "value" => 3000.0 + } + ] + }, + %{ + "dimensions" => ["Paid Social"], + "metrics" => [ + 1, + 50.0, + 2, + %{ + "currency" => "USD", + "long" => "$500.00", + "short" => "$500.0", + "value" => 500.0 + }, + %{ + "currency" => "USD", + "long" => "$500.00", + "short" => "$500.0", + "value" => 500.0 + } + ] + } + ] + end + end + + describe "GET /api/stats/:domain/utm_mediums" do + setup [:create_user, :log_in, :create_site, :create_legacy_site_import] + + test "returns top utm_mediums by unique user ids", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + utm_medium: "social", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + utm_medium: "social", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:15:00] + ), + build(:pageview, + utm_medium: "email", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + populate_stats(site, [ + build(:imported_visitors, + date: ~D[2021-01-01], + visit_duration: 800, + bounces: 1, + visits: 2, + visitors: 2 + ), + build(:imported_sources, + utm_medium: "social", + date: ~D[2021-01-01], + visit_duration: 700, + bounces: 1, + visits: 1, + visitors: 1 + ), + build(:imported_sources, + utm_medium: "email", + date: ~D[2021-01-01], + bounces: 0, + visits: 1, + visitors: 1, + visit_duration: 100 + ) + ]) + + response1 = + query_sources(conn, site, + dimensions: ["visit:utm_medium"], + metrics: ["visitors", "bounce_rate", "visit_duration", "percentage"], + date_range: ["2021-01-01", "2021-01-01"], + order_by: [["visit:utm_medium", "asc"]] + ) + + assert response1["results"] == [ + %{"dimensions" => ["email"], "metrics" => [1, 100, 0, 50.0]}, + %{"dimensions" => ["social"], "metrics" => [1, 0, 900, 50.0]} + ] + + response2 = + query_sources(conn, site, + dimensions: ["visit:utm_medium"], + metrics: ["visitors", "bounce_rate", "visit_duration", "percentage"], + date_range: ["2021-01-01", "2021-01-01"], + order_by: [["visit:utm_medium", "asc"]], + include: %{"imports" => true} + ) + + assert response2["results"] == [ + %{"dimensions" => ["email"], "metrics" => [2, 50, 50, 50.0]}, + %{"dimensions" => ["social"], "metrics" => [2, 50, 800.0, 50.0]} + ] + end + + test "filters out entries without utm_medium present", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + utm_medium: "social", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + utm_medium: "social", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:15:00] + ), + build(:pageview, + utm_medium: "", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + populate_stats(site, [ + build(:imported_visitors, + date: ~D[2021-01-01], + visit_duration: 800, + bounces: 1, + visits: 2, + visitors: 2 + ), + build(:imported_sources, + utm_medium: "social", + date: ~D[2021-01-01], + visit_duration: 700, + bounces: 1, + visits: 1, + visitors: 1 + ), + build(:imported_sources, + utm_medium: "", + date: ~D[2021-01-01], + bounces: 0, + visits: 1, + visitors: 1, + visit_duration: 100 + ) + ]) + + response1 = + query_sources(conn, site, + dimensions: ["visit:utm_medium"], + metrics: ["visitors", "bounce_rate", "visit_duration", "percentage"], + filters: [["is_not", "visit:utm_medium", [""]]], + date_range: ["2021-01-01", "2021-01-01"] + ) + + assert response1["results"] == [ + %{"dimensions" => ["social"], "metrics" => [1, 0, 900, 100.0]} + ] + + response2 = + query_sources(conn, site, + dimensions: ["visit:utm_medium"], + metrics: ["visitors", "bounce_rate", "visit_duration", "percentage"], + filters: [["is_not", "visit:utm_medium", [""]]], + date_range: ["2021-01-01", "2021-01-01"], + include: %{"imports" => true} + ) + + assert response2["results"] == [ + %{"dimensions" => ["social"], "metrics" => [2, 50.0, 800.0, 100.0]} + ] + end + + @tag :ee_only + test "return revenue metrics for UTM mediums breakdown", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + user_id: 1, + utm_medium: "social" + ), + build(:event, + name: "Payment", + user_id: 1, + revenue_reporting_amount: Decimal.new("1000"), + revenue_reporting_currency: "USD" + ), + build(:pageview, + user_id: 2, + utm_medium: "social" + ), + build(:event, + name: "Payment", + user_id: 2, + revenue_reporting_amount: Decimal.new("2000"), + revenue_reporting_currency: "USD" + ), + build(:pageview, + user_id: 3, + utm_medium: "social" + ), + build(:pageview, + user_id: 4, + utm_medium: "email" + ), + build(:event, + name: "Payment", + user_id: 4, + revenue_reporting_amount: Decimal.new("500"), + revenue_reporting_currency: "USD" + ), + build(:pageview, + user_id: 5, + utm_medium: "email" + ), + build(:pageview, user_id: 6), + build(:event, + name: "Payment", + user_id: 6, + revenue_reporting_amount: Decimal.new("600"), + revenue_reporting_currency: "USD" + ), + build(:pageview, user_id: 7), + build(:event, + name: "Payment", + user_id: 7, + revenue_reporting_amount: nil + ) + ]) + + insert(:goal, %{site: site, event_name: "Payment", currency: :USD}) + + response = + query_sources(conn, site, + dimensions: ["visit:utm_medium"], + date_range: "day", + metrics: [ + "visitors", + "group_conversion_rate", + "total_visitors", + "average_revenue", + "total_revenue" + ], + filters: [["is", "event:goal", ["Payment"]], ["is_not", "visit:utm_medium", [""]]], + order_by: [["visitors", "desc"]] + ) + + assert response["results"] == [ + %{ + "dimensions" => ["social"], + "metrics" => [ + 2, + 66.67, + 3, + %{ + "currency" => "USD", + "long" => "$1,500.00", + "short" => "$1.5K", + "value" => 1500.0 + }, + %{ + "currency" => "USD", + "long" => "$3,000.00", + "short" => "$3.0K", + "value" => 3000.0 + } + ] + }, + %{ + "dimensions" => ["email"], + "metrics" => [ + 1, + 50.0, + 2, + %{ + "currency" => "USD", + "long" => "$500.00", + "short" => "$500.0", + "value" => 500.0 + }, + %{ + "currency" => "USD", + "long" => "$500.00", + "short" => "$500.0", + "value" => 500.0 + } + ] + } + ] + end + end + + describe "GET /api/stats/:domain/utm_campaigns" do + setup [:create_user, :log_in, :create_site, :create_legacy_site_import] + + test "returns top utm_campaigns by unique user ids", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + utm_campaign: "profile", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + utm_campaign: "profile", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:15:00] + ), + build(:pageview, + utm_campaign: "august", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + utm_campaign: "august", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + populate_stats(site, [ + build(:imported_visitors, + date: ~D[2021-01-01], + visit_duration: 1600, + bounces: 1, + visits: 2, + visitors: 2 + ), + build(:imported_sources, + utm_campaign: "profile", + date: ~D[2021-01-01], + visit_duration: 700, + bounces: 1, + visits: 1, + visitors: 1 + ), + build(:imported_sources, + utm_campaign: "august", + date: ~D[2021-01-01], + bounces: 0, + visits: 1, + visitors: 1, + visit_duration: 900 + ) + ]) + + response1 = + query_sources(conn, site, + dimensions: ["visit:utm_campaign"], + metrics: ["visitors", "bounce_rate", "visit_duration", "percentage"], + date_range: ["2021-01-01", "2021-01-01"] + ) + + assert response1["results"] == [ + %{"dimensions" => ["august"], "metrics" => [2, 100, 0, 66.67]}, + %{"dimensions" => ["profile"], "metrics" => [1, 0, 900, 33.33]} + ] + + response2 = + query_sources(conn, site, + dimensions: ["visit:utm_campaign"], + metrics: ["visitors", "bounce_rate", "visit_duration", "percentage"], + date_range: ["2021-01-01", "2021-01-01"], + include: %{"imports" => true} + ) + + assert response2["results"] == [ + %{"dimensions" => ["august"], "metrics" => [3, 67, 300, 60.0]}, + %{"dimensions" => ["profile"], "metrics" => [2, 50, 800.0, 40.0]} + ] + end + + test "filters out entries without utm_campaign present", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + utm_campaign: "profile", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + utm_campaign: "profile", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:15:00] + ), + build(:pageview, + utm_campaign: "", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + utm_campaign: "", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + populate_stats(site, [ + build(:imported_visitors, + date: ~D[2021-01-01], + visit_duration: 1600, + bounces: 1, + visits: 2, + visitors: 2 + ), + build(:imported_sources, + utm_campaign: "profile", + date: ~D[2021-01-01], + visit_duration: 700, + bounces: 1, + visits: 1, + visitors: 1 + ), + build(:imported_sources, + utm_campaign: "", + date: ~D[2021-01-01], + bounces: 0, + visits: 1, + visitors: 1, + visit_duration: 900 + ) + ]) + + response1 = + query_sources(conn, site, + dimensions: ["visit:utm_campaign"], + metrics: ["visitors", "bounce_rate", "visit_duration", "percentage"], + filters: [["is_not", "visit:utm_campaign", [""]]], + date_range: ["2021-01-01", "2021-01-01"] + ) + + assert response1["results"] == [ + %{"dimensions" => ["profile"], "metrics" => [1, 0, 900, 100.0]} + ] + + response2 = + query_sources(conn, site, + dimensions: ["visit:utm_campaign"], + metrics: ["visitors", "bounce_rate", "visit_duration", "percentage"], + filters: [["is_not", "visit:utm_campaign", [""]]], + date_range: ["2021-01-01", "2021-01-01"], + include: %{"imports" => true} + ) + + assert response2["results"] == [ + %{"dimensions" => ["profile"], "metrics" => [2, 50.0, 800.0, 100.0]} + ] + end + + @tag :ee_only + test "return revenue metrics for UTM campaigns breakdown", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + user_id: 1, + utm_campaign: "profile" + ), + build(:event, + name: "Payment", + user_id: 1, + revenue_reporting_amount: Decimal.new("1000"), + revenue_reporting_currency: "USD" + ), + build(:pageview, + user_id: 2, + utm_campaign: "profile" + ), + build(:event, + name: "Payment", + user_id: 2, + revenue_reporting_amount: Decimal.new("2000"), + revenue_reporting_currency: "USD" + ), + build(:pageview, + user_id: 3, + utm_campaign: "profile" + ), + build(:pageview, + user_id: 4, + utm_campaign: "august" + ), + build(:event, + name: "Payment", + user_id: 4, + revenue_reporting_amount: Decimal.new("500"), + revenue_reporting_currency: "USD" + ), + build(:pageview, + user_id: 5, + utm_campaign: "august" + ), + build(:pageview, user_id: 6), + build(:event, + name: "Payment", + user_id: 6, + revenue_reporting_amount: Decimal.new("600"), + revenue_reporting_currency: "USD" + ), + build(:pageview, user_id: 7), + build(:event, + name: "Payment", + user_id: 7, + revenue_reporting_amount: nil + ) + ]) + + insert(:goal, %{site: site, event_name: "Payment", currency: :USD}) + + response = + query_sources(conn, site, + dimensions: ["visit:utm_campaign"], + date_range: "day", + metrics: [ + "visitors", + "group_conversion_rate", + "total_visitors", + "average_revenue", + "total_revenue" + ], + filters: [["is", "event:goal", ["Payment"]], ["is_not", "visit:utm_campaign", [""]]], + order_by: [["visitors", "desc"]] + ) + + assert response["results"] == [ + %{ + "dimensions" => ["profile"], + "metrics" => [ + 2, + 66.67, + 3, + %{ + "currency" => "USD", + "long" => "$1,500.00", + "short" => "$1.5K", + "value" => 1500.0 + }, + %{ + "currency" => "USD", + "long" => "$3,000.00", + "short" => "$3.0K", + "value" => 3000.0 + } + ] + }, + %{ + "dimensions" => ["august"], + "metrics" => [ + 1, + 50.0, + 2, + %{ + "currency" => "USD", + "long" => "$500.00", + "short" => "$500.0", + "value" => 500.0 + }, + %{ + "currency" => "USD", + "long" => "$500.00", + "short" => "$500.0", + "value" => 500.0 + } + ] + } + ] + end + end + + describe "GET /api/stats/:domain/utm_sources" do + setup [:create_user, :log_in, :create_site, :create_legacy_site_import] + + test "returns top utm_sources by unique user ids", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + utm_source: "Twitter", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + utm_source: "Twitter", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:15:00] + ), + build(:pageview, + utm_source: "newsletter", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + utm_source: "newsletter", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + response = + query_sources(conn, site, + dimensions: ["visit:utm_source"], + metrics: ["visitors", "bounce_rate", "visit_duration", "percentage"], + date_range: ["2021-01-01", "2021-01-01"] + ) + + assert response["results"] == [ + %{"dimensions" => ["newsletter"], "metrics" => [2, 100, 0, 66.67]}, + %{"dimensions" => ["Twitter"], "metrics" => [1, 0, 900, 33.33]} + ] + end + + @tag :ee_only + test "return revenue metrics for UTM sources breakdown", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + user_id: 1, + utm_source: "Twitter" + ), + build(:event, + name: "Payment", + user_id: 1, + revenue_reporting_amount: Decimal.new("1000"), + revenue_reporting_currency: "USD" + ), + build(:pageview, + user_id: 2, + utm_source: "Twitter" + ), + build(:event, + name: "Payment", + user_id: 2, + revenue_reporting_amount: Decimal.new("2000"), + revenue_reporting_currency: "USD" + ), + build(:pageview, + user_id: 3, + utm_source: "Twitter" + ), + build(:pageview, + user_id: 4, + utm_source: "newsletter" + ), + build(:event, + name: "Payment", + user_id: 4, + revenue_reporting_amount: Decimal.new("500"), + revenue_reporting_currency: "USD" + ), + build(:pageview, + user_id: 5, + utm_source: "newsletter" + ), + build(:pageview, user_id: 6), + build(:event, + name: "Payment", + user_id: 6, + revenue_reporting_amount: Decimal.new("600"), + revenue_reporting_currency: "USD" + ), + build(:pageview, user_id: 7), + build(:event, + name: "Payment", + user_id: 7, + revenue_reporting_amount: nil + ) + ]) + + insert(:goal, %{site: site, event_name: "Payment", currency: :USD}) + + response = + query_sources(conn, site, + dimensions: ["visit:utm_source"], + date_range: "day", + metrics: [ + "visitors", + "group_conversion_rate", + "total_visitors", + "average_revenue", + "total_revenue" + ], + filters: [["is", "event:goal", ["Payment"]], ["is_not", "visit:utm_source", [""]]], + order_by: [["visitors", "desc"]] + ) + + assert response["results"] == [ + %{ + "dimensions" => ["Twitter"], + "metrics" => [ + 2, + 66.67, + 3, + %{ + "currency" => "USD", + "long" => "$1,500.00", + "short" => "$1.5K", + "value" => 1500.0 + }, + %{ + "currency" => "USD", + "long" => "$3,000.00", + "short" => "$3.0K", + "value" => 3000.0 + } + ] + }, + %{ + "dimensions" => ["newsletter"], + "metrics" => [ + 1, + 50.0, + 2, + %{ + "currency" => "USD", + "long" => "$500.00", + "short" => "$500.0", + "value" => 500.0 + }, + %{ + "currency" => "USD", + "long" => "$500.00", + "short" => "$500.0", + "value" => 500.0 + } + ] + } + ] + end + end + + describe "GET /api/stats/:domain/utm_terms" do + setup [:create_user, :log_in, :create_site, :create_legacy_site_import] + + test "returns top utm_terms by unique user ids", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + utm_term: "oat milk", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + utm_term: "oat milk", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:15:00] + ), + build(:pageview, + utm_term: "Sweden", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + utm_term: "Sweden", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + populate_stats(site, [ + build(:imported_visitors, + date: ~D[2021-01-01], + visit_duration: 1600, + bounces: 1, + visits: 2, + visitors: 2 + ), + build(:imported_sources, + utm_term: "oat milk", + date: ~D[2021-01-01], + visit_duration: 700, + bounces: 1, + visits: 1, + visitors: 1 + ), + build(:imported_sources, + utm_term: "Sweden", + date: ~D[2021-01-01], + bounces: 0, + visits: 1, + visitors: 1, + visit_duration: 900 + ) + ]) + + response1 = + query_sources(conn, site, + dimensions: ["visit:utm_term"], + metrics: ["visitors", "bounce_rate", "visit_duration", "percentage"], + date_range: ["2021-01-01", "2021-01-01"] + ) + + assert response1["results"] == [ + %{"dimensions" => ["Sweden"], "metrics" => [2, 100, 0, 66.67]}, + %{"dimensions" => ["oat milk"], "metrics" => [1, 0, 900, 33.33]} + ] + + response2 = + query_sources(conn, site, + dimensions: ["visit:utm_term"], + metrics: ["visitors", "bounce_rate", "visit_duration", "percentage"], + date_range: ["2021-01-01", "2021-01-01"], + include: %{"imports" => true} + ) + + assert response2["results"] == [ + %{"dimensions" => ["Sweden"], "metrics" => [3, 67.0, 300.0, 60.0]}, + %{"dimensions" => ["oat milk"], "metrics" => [2, 50.0, 800.0, 40.0]} + ] + end + + test "filters out entries without utm_term present", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + utm_term: "oat milk", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + utm_term: "oat milk", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:15:00] + ), + build(:pageview, + utm_term: "", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + utm_term: "", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + populate_stats(site, [ + build(:imported_visitors, + date: ~D[2021-01-01], + visit_duration: 1600, + bounces: 1, + visits: 2, + visitors: 2 + ), + build(:imported_sources, + utm_term: "oat milk", + date: ~D[2021-01-01], + visit_duration: 700, + bounces: 1, + visits: 1, + visitors: 1 + ), + build(:imported_sources, + utm_term: "", + date: ~D[2021-01-01], + bounces: 0, + visits: 1, + visitors: 1, + visit_duration: 900 + ) + ]) + + response1 = + query_sources(conn, site, + dimensions: ["visit:utm_term"], + metrics: ["visitors", "bounce_rate", "visit_duration", "percentage"], + filters: [["is_not", "visit:utm_term", [""]]], + date_range: ["2021-01-01", "2021-01-01"] + ) + + assert response1["results"] == [ + %{"dimensions" => ["oat milk"], "metrics" => [1, 0, 900, 100.0]} + ] + + response2 = + query_sources(conn, site, + dimensions: ["visit:utm_term"], + metrics: ["visitors", "bounce_rate", "visit_duration", "percentage"], + filters: [["is_not", "visit:utm_term", [""]]], + date_range: ["2021-01-01", "2021-01-01"], + include: %{"imports" => true} + ) + + assert response2["results"] == [ + %{"dimensions" => ["oat milk"], "metrics" => [2, 50.0, 800.0, 100.0]} + ] + end + + @tag :ee_only + test "return revenue metrics for UTM terms breakdown", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + user_id: 1, + utm_term: "oat milk" + ), + build(:event, + name: "Payment", + user_id: 1, + revenue_reporting_amount: Decimal.new("1000"), + revenue_reporting_currency: "USD" + ), + build(:pageview, + user_id: 2, + utm_term: "oat milk" + ), + build(:event, + name: "Payment", + user_id: 2, + revenue_reporting_amount: Decimal.new("2000"), + revenue_reporting_currency: "USD" + ), + build(:pageview, + user_id: 3, + utm_term: "oat milk" + ), + build(:pageview, + user_id: 4, + utm_term: "Sweden" + ), + build(:event, + name: "Payment", + user_id: 4, + revenue_reporting_amount: Decimal.new("500"), + revenue_reporting_currency: "USD" + ), + build(:pageview, + user_id: 5, + utm_term: "Sweden" + ), + build(:pageview, user_id: 6), + build(:event, + name: "Payment", + user_id: 6, + revenue_reporting_amount: Decimal.new("600"), + revenue_reporting_currency: "USD" + ), + build(:pageview, user_id: 7), + build(:event, + name: "Payment", + user_id: 7, + revenue_reporting_amount: nil + ) + ]) + + insert(:goal, %{site: site, event_name: "Payment", currency: :USD}) + + response = + query_sources(conn, site, + dimensions: ["visit:utm_term"], + date_range: "day", + metrics: [ + "visitors", + "group_conversion_rate", + "total_visitors", + "average_revenue", + "total_revenue" + ], + filters: [["is", "event:goal", ["Payment"]], ["is_not", "visit:utm_term", [""]]], + order_by: [["visitors", "desc"]] + ) + + assert response["results"] == [ + %{ + "dimensions" => ["oat milk"], + "metrics" => [ + 2, + 66.67, + 3, + %{ + "currency" => "USD", + "long" => "$1,500.00", + "short" => "$1.5K", + "value" => 1500.0 + }, + %{ + "currency" => "USD", + "long" => "$3,000.00", + "short" => "$3.0K", + "value" => 3000.0 + } + ] + }, + %{ + "dimensions" => ["Sweden"], + "metrics" => [ + 1, + 50.0, + 2, + %{ + "currency" => "USD", + "long" => "$500.00", + "short" => "$500.0", + "value" => 500.0 + }, + %{ + "currency" => "USD", + "long" => "$500.00", + "short" => "$500.0", + "value" => 500.0 + } + ] + } + ] + end + end + + describe "GET /api/stats/:domain/utm_contents" do + setup [:create_user, :log_in, :create_site, :create_legacy_site_import] + + test "returns top utm_contents by unique user ids", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + utm_content: "ad", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + utm_content: "ad", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:15:00] + ), + build(:pageview, + utm_content: "blog", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + utm_content: "blog", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + populate_stats(site, [ + build(:imported_visitors, + date: ~D[2021-01-01], + visit_duration: 1600, + bounces: 1, + visits: 2, + visitors: 2 + ), + build(:imported_sources, + utm_content: "ad", + date: ~D[2021-01-01], + visit_duration: 700, + bounces: 1, + visits: 1, + visitors: 1 + ), + build(:imported_sources, + utm_content: "blog", + date: ~D[2021-01-01], + bounces: 0, + visits: 1, + visitors: 1, + visit_duration: 900 + ) + ]) + + response1 = + query_sources(conn, site, + dimensions: ["visit:utm_content"], + metrics: ["visitors", "bounce_rate", "visit_duration", "percentage"], + date_range: ["2021-01-01", "2021-01-01"] + ) + + assert response1["results"] == [ + %{"dimensions" => ["blog"], "metrics" => [2, 100, 0, 66.67]}, + %{"dimensions" => ["ad"], "metrics" => [1, 0, 900, 33.33]} + ] + + response2 = + query_sources(conn, site, + dimensions: ["visit:utm_content"], + metrics: ["visitors", "bounce_rate", "visit_duration", "percentage"], + date_range: ["2021-01-01", "2021-01-01"], + include: %{"imports" => true} + ) + + assert response2["results"] == [ + %{"dimensions" => ["blog"], "metrics" => [3, 67.0, 300.0, 60.0]}, + %{"dimensions" => ["ad"], "metrics" => [2, 50.0, 800.0, 40.0]} + ] + end + + test "filters out entries without utm_content present", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + utm_content: "ad", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + utm_content: "ad", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:15:00] + ), + build(:pageview, + utm_content: "", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + utm_content: "", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + populate_stats(site, [ + build(:imported_visitors, + date: ~D[2021-01-01], + visit_duration: 1600, + bounces: 1, + visits: 2, + visitors: 2 + ), + build(:imported_sources, + utm_content: "ad", + date: ~D[2021-01-01], + visit_duration: 700, + bounces: 1, + visits: 1, + visitors: 1 + ), + build(:imported_sources, + utm_content: "", + date: ~D[2021-01-01], + bounces: 0, + visits: 1, + visitors: 1, + visit_duration: 900 + ) + ]) + + response1 = + query_sources(conn, site, + dimensions: ["visit:utm_content"], + metrics: ["visitors", "bounce_rate", "visit_duration", "percentage"], + filters: [["is_not", "visit:utm_content", [""]]], + date_range: ["2021-01-01", "2021-01-01"] + ) + + assert response1["results"] == [ + %{"dimensions" => ["ad"], "metrics" => [1, 0, 900, 100.0]} + ] + + response2 = + query_sources(conn, site, + dimensions: ["visit:utm_content"], + metrics: ["visitors", "bounce_rate", "visit_duration", "percentage"], + filters: [["is_not", "visit:utm_content", [""]]], + date_range: ["2021-01-01", "2021-01-01"], + include: %{"imports" => true} + ) + + assert response2["results"] == [ + %{"dimensions" => ["ad"], "metrics" => [2, 50.0, 800.0, 100.0]} + ] + end + + @tag :ee_only + test "return revenue metrics for UTM contents breakdown", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + user_id: 1, + utm_content: "ad" + ), + build(:event, + name: "Payment", + user_id: 1, + revenue_reporting_amount: Decimal.new("1000"), + revenue_reporting_currency: "USD" + ), + build(:pageview, + user_id: 2, + utm_content: "ad" + ), + build(:event, + name: "Payment", + user_id: 2, + revenue_reporting_amount: Decimal.new("2000"), + revenue_reporting_currency: "USD" + ), + build(:pageview, + user_id: 3, + utm_content: "ad" + ), + build(:pageview, + user_id: 4, + utm_content: "blog" + ), + build(:event, + name: "Payment", + user_id: 4, + revenue_reporting_amount: Decimal.new("500"), + revenue_reporting_currency: "USD" + ), + build(:pageview, + user_id: 5, + utm_content: "blog" + ), + build(:pageview, user_id: 6), + build(:event, + name: "Payment", + user_id: 6, + revenue_reporting_amount: Decimal.new("600"), + revenue_reporting_currency: "USD" + ), + build(:pageview, user_id: 7), + build(:event, + name: "Payment", + user_id: 7, + revenue_reporting_amount: nil + ) + ]) + + insert(:goal, %{site: site, event_name: "Payment", currency: :USD}) + + response = + query_sources(conn, site, + dimensions: ["visit:utm_content"], + date_range: "day", + metrics: [ + "visitors", + "group_conversion_rate", + "total_visitors", + "average_revenue", + "total_revenue" + ], + filters: [["is", "event:goal", ["Payment"]], ["is_not", "visit:utm_content", [""]]], + order_by: [["visitors", "desc"]] + ) + + assert response["results"] == [ + %{ + "dimensions" => ["ad"], + "metrics" => [ + 2, + 66.67, + 3, + %{ + "currency" => "USD", + "long" => "$1,500.00", + "short" => "$1.5K", + "value" => 1500.0 + }, + %{ + "currency" => "USD", + "long" => "$3,000.00", + "short" => "$3.0K", + "value" => 3000.0 + } + ] + }, + %{ + "dimensions" => ["blog"], + "metrics" => [ + 1, + 50.0, + 2, + %{ + "currency" => "USD", + "long" => "$500.00", + "short" => "$500.0", + "value" => 500.0 + }, + %{ + "currency" => "USD", + "long" => "$500.00", + "short" => "$500.0", + "value" => 500.0 + } + ] + } + ] + end + end + + describe "GET /api/stats/:domain/sources - with goal filter" do + setup [:create_user, :log_in, :create_site] + + test "returns top referrers for a custom goal including conversion_rate", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, + referrer_source: "Twitter", + user_id: @user_id + ), + build(:event, + name: "Signup", + user_id: @user_id + ), + build(:pageview, + referrer_source: "Twitter" + ) + ]) + + # Imported data is ignored when filtering + populate_stats(site, [ + build(:imported_sources, source: "Twitter") + ]) + + insert(:goal, site: site, event_name: "Signup") + + response = + query_sources(conn, site, + date_range: "day", + metrics: ["visitors", "group_conversion_rate", "total_visitors"], + filters: [["is", "event:goal", ["Signup"]]] + ) + + assert response["results"] == [ + %{"dimensions" => ["Twitter"], "metrics" => [1, 50.0, 2]} + ] + end + + test "returns no top referrers for a custom goal and filtered by hostname", + %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, + hostname: "blog.example.com", + referrer_source: "Facebook", + user_id: @user_id + ), + build(:pageview, + hostname: "app.example.com", + pathname: "/register", + user_id: @user_id + ), + build(:event, + name: "Signup", + hostname: "app.example.com", + pathname: "/register", + user_id: @user_id + ) + ]) + + response = + query_sources(conn, site, + date_range: "day", + metrics: ["visitors", "group_conversion_rate", "total_visitors"], + filters: [ + ["is", "event:goal", ["Signup"]], + ["is", "event:hostname", ["app.example.com"]] + ] + ) + + assert response["results"] == [] + end + + test "returns top referrers for a custom goal and filtered by hostname (2)", + %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, + hostname: "app.example.com", + referrer_source: "Facebook", + pathname: "/register", + user_id: @user_id + ), + build(:event, + name: "Signup", + hostname: "app.example.com", + pathname: "/register", + user_id: @user_id + ) + ]) + + insert(:goal, site: site, event_name: "Signup") + + response = + query_sources(conn, site, + date_range: "day", + metrics: ["visitors", "group_conversion_rate", "total_visitors"], + filters: [ + ["is", "event:goal", ["Signup"]], + ["is", "event:hostname", ["app.example.com"]] + ] + ) + + assert response["results"] == [ + %{"dimensions" => ["Facebook"], "metrics" => [1, 100.0, 1]} + ] + end + + test "returns top referrers with goal filter + :is prop filter", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + referrer_source: "DuckDuckGo", + referrer: "duckduckgo.com", + user_id: 123, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Download", + "meta.key": ["method", "logged_in"], + "meta.value": ["HTTP", "true"], + user_id: 123, + timestamp: ~N[2021-01-01 00:01:00] + ), + build(:pageview, + referrer_source: "DuckDuckGo", + referrer: "duckduckgo.com", + "meta.key": ["logged_in"], + "meta.value": ["true"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + referrer_source: "Facebook", + referrer: "facebook.com", + name: "Download", + "meta.key": ["method", "logged_in"], + "meta.value": ["HTTP", "false"], + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + insert(:goal, site: site, event_name: "Download") + + response = + query_sources(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + metrics: ["visitors", "group_conversion_rate", "total_visitors"], + filters: [ + ["is", "event:goal", ["Download"]], + ["is", "event:props:logged_in", ["true"]] + ] + ) + + assert response["results"] == [ + %{"dimensions" => ["DuckDuckGo"], "metrics" => [1, 50.0, 2]} + ] + end + + test "returns top referrers with goal filter + prop :is_not filter", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + referrer_source: "DuckDuckGo", + referrer: "duckduckgo.com", + user_id: 123, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Download", + "meta.key": ["method"], + "meta.value": ["HTTP"], + user_id: 123, + timestamp: ~N[2021-01-01 00:01:00] + ), + build(:event, + name: "Download", + referrer_source: "Google", + referrer: "google.com", + "meta.key": ["method", "logged_in"], + "meta.value": ["HTTP", "true"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Download", + referrer_source: "Google", + referrer: "google.com", + "meta.key": ["method", "logged_in"], + "meta.value": ["HTTP", "false"], + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + insert(:goal, site: site, event_name: "Download") + + response = + query_sources(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + metrics: ["visitors", "group_conversion_rate", "total_visitors"], + filters: [ + ["is", "event:goal", ["Download"]], + ["is_not", "event:props:logged_in", ["true"]] + ], + order_by: [["visit:source", "asc"]] + ) + + assert response["results"] == [ + %{"dimensions" => ["DuckDuckGo"], "metrics" => [1, 100.0, 1]}, + %{"dimensions" => ["Google"], "metrics" => [1, 50.0, 2]} + ] + end + + test "returns top referrers for a pageview goal including conversion_rate", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, + referrer_source: "Twitter", + user_id: @user_id + ), + build(:pageview, + pathname: "/register", + user_id: @user_id + ), + build(:pageview, + referrer_source: "Twitter" + ) + ]) + + insert(:goal, site: site, page_path: "/register") + + response = + query_sources(conn, site, + date_range: "day", + metrics: ["visitors", "group_conversion_rate", "total_visitors"], + filters: [["is", "event:goal", ["Visit /register"]]] + ) + + assert response["results"] == [ + %{"dimensions" => ["Twitter"], "metrics" => [1, 50.0, 2]} + ] + end + end + + describe "GET /api/stats/:domain/referrer-drilldown (Google Search Terms)" do + setup [:create_user, :log_in, :create_site] + + test "gets keywords from Google", %{conn: conn, site: site} do + conn = get(conn, "/api/stats/#{site.domain}/google-search-terms?period=day") + + assert %{ + "results" => [ + %{ + "name" => "simple web analytics", + "visitors" => 25, + "impressions" => 50, + "position" => 2.0, + "ctr" => 37.0 + }, + %{ + "name" => "open-source analytics", + "visitors" => 15, + "impressions" => 25, + "position" => 4.0, + "ctr" => 50.0 + } + ] + } = json_response(conn, 200) + end + + test "returns 200 with empty keywords list when no data returned from last 30d", %{ + conn: conn, + site: site + } do + filters = Jason.encode!([[:is, "event:page", ["/empty"]]]) + + conn = + get(conn, "/api/stats/#{site.domain}/google-search-terms?period=30d&filters=#{filters}") + + assert json_response(conn, 200) == %{"results" => []} + end + + test "returns 422 with error when no data returned and queried range is too recent", %{ + conn: conn, + site: site + } do + filters = Jason.encode!([[:is, "event:page", ["/empty"]]]) + + conn = + get(conn, "/api/stats/#{site.domain}/google-search-terms?period=day&filters=#{filters}") + + assert json_response(conn, 422) == %{"error_code" => "period_too_recent"} + end + + test "returns 422 with error when Google account not connected (admin)", %{ + conn: conn, + site: site + } do + filters = Jason.encode!([[:is, "event:page", ["/not-configured"]]]) + + conn = + get(conn, "/api/stats/#{site.domain}/google-search-terms?period=day&filters=#{filters}") + + assert %{"error_code" => "not_configured", "is_admin" => true} = json_response(conn, 422) + end + + test "returns 422 with error when Google account not connected (non-admin)", %{conn: conn} do + site = new_site(public: true) + + filters = Jason.encode!([[:is, "event:page", ["/not-configured"]]]) + + conn = + get(conn, "/api/stats/#{site.domain}/google-search-terms?period=day&filters=#{filters}") + + %{ + "error_code" => "not_configured", + "is_admin" => false + } = json_response(conn, 422) + end + + test "returns 422 with error when unsupported filters used", %{conn: conn, site: site} do + filters = Jason.encode!([[:is, "event:page", ["/unsupported-filters"]]]) + + conn = + get(conn, "/api/stats/#{site.domain}/google-search-terms?period=day&filters=#{filters}") + + assert %{"error_code" => "unsupported_filters"} = json_response(conn, 422) + end + + @tag :capture_log + test "returns 502 when Google API responds with an unexpected error", %{ + conn: conn, + site: site + } do + filters = Jason.encode!([[:is, "event:page", ["/unexpected-error"]]]) + + conn = + get(conn, "/api/stats/#{site.domain}/google-search-terms?period=day&filters=#{filters}") + + assert %{"error_code" => "not_configured"} = json_response(conn, 502) + end + end + + describe "GET /api/stats/:domain/referrer-drilldown" do + setup [:create_user, :log_in, :create_site] + + test "returns top referrers for a particular source", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + referrer_source: "10words", + referrer: "10words.com" + ), + build(:pageview, + referrer_source: "10words", + referrer: "10words.com" + ), + build(:pageview, + referrer_source: "10words", + referrer: "10words.com/page1" + ), + build(:pageview, + referrer_source: "ignored", + referrer: "ignored" + ) + ]) + + response = + query_sources(conn, site, + date_range: "day", + dimensions: ["visit:referrer"], + filters: [["is", "visit:source", ["10words"]]] + ) + + assert response["results"] == [ + %{"dimensions" => ["10words.com"], "metrics" => [2, 66.67]}, + %{"dimensions" => ["10words.com/page1"], "metrics" => [1, 33.33]} + ] + end + + test "returns top referrers for a particular source filtered by hostname", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, + referrer_source: "example", + referrer: "example.com", + hostname: "two.example.com" + ), + build(:pageview, + referrer_source: "example", + referrer: "example.com", + hostname: "two.example.com", + user_id: @user_id + ), + build(:pageview, + hostname: "one.example.com", + user_id: @user_id + ), + build(:pageview, + referrer_source: "example", + referrer: "example.com/page1", + hostname: "one.example.com" + ), + build(:pageview, + referrer_source: "ignored", + referrer: "ignored", + hostname: "two.example.com" + ) + ]) + + response = + query_sources(conn, site, + date_range: "day", + dimensions: ["visit:referrer"], + filters: [ + ["is", "visit:source", ["example"]], + ["is", "event:hostname", ["one.example.com"]] + ] + ) + + assert response["results"] == [ + %{"dimensions" => ["example.com/page1"], "metrics" => [1, 100.0]} + ] + end + + test "calculates bounce rate and visit duration for referrer urls", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + referrer_source: "10words", + referrer: "10words.com", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + referrer_source: "10words", + referrer: "10words.com", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:15:00] + ), + build(:pageview, + referrer_source: "10words", + referrer: "10words.com", + timestamp: ~N[2021-01-01 00:15:00] + ), + build(:pageview, + referrer_source: "ignored", + referrer: "ignored", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + response = + query_sources(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + dimensions: ["visit:referrer"], + filters: [["is", "visit:source", ["10words"]]], + metrics: ["visitors", "bounce_rate", "visit_duration", "percentage"] + ) + + assert response["results"] == [ + %{"dimensions" => ["10words.com"], "metrics" => [2, 50.0, 450, 100.0]} + ] + end + + test "returns top referring urls for a custom goal", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + referrer_source: "10words", + referrer: "10words.com" + ), + build(:pageview, + referrer_source: "10words", + referrer: "10words.com", + user_id: @user_id + ), + build(:event, + name: "Signup", + user_id: @user_id + ), + build(:event, + name: "Signup" + ) + ]) + + insert(:goal, site: site, event_name: "Signup") + + response = + query_sources(conn, site, + date_range: "day", + dimensions: ["visit:referrer"], + filters: [ + ["is", "visit:source", ["10words"]], + ["is", "event:goal", ["Signup"]] + ], + metrics: ["visitors", "total_visitors", "group_conversion_rate"] + ) + + assert response["results"] == [ + %{"dimensions" => ["10words.com"], "metrics" => [1, 2, 50.0]} + ] + end + + test "returns top referring urls for a pageview goal", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + referrer_source: "10words", + referrer: "10words.com" + ), + build(:pageview, + referrer_source: "10words", + referrer: "10words.com", + user_id: @user_id + ), + build(:pageview, + pathname: "/register", + user_id: @user_id + ), + build(:pageview, + pathname: "/register" + ) + ]) + + insert(:goal, site: site, page_path: "/register") + + response = + query_sources(conn, site, + date_range: "day", + dimensions: ["visit:referrer"], + filters: [ + ["is", "visit:source", ["10words"]], + ["is", "event:goal", ["Visit /register"]] + ], + metrics: ["visitors", "total_visitors", "group_conversion_rate"] + ) + + assert response["results"] == [ + %{"dimensions" => ["10words.com"], "metrics" => [1, 2, 50.0]} + ] + end + end +end diff --git a/test/plausible_web/controllers/api/stats_controller/suggestions_test.exs b/test/plausible_web/controllers/api/stats_controller/suggestions_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..bc368d252a20ca534fe9a4b68c608f761f4ce19f --- /dev/null +++ b/test/plausible_web/controllers/api/stats_controller/suggestions_test.exs @@ -0,0 +1,1490 @@ +defmodule PlausibleWeb.Api.StatsController.SuggestionsTest do + use PlausibleWeb.ConnCase + + describe "GET /api/stats/:domain/suggestions/:filter_name" do + setup [:create_user, :log_in, :create_site] + + test "returns suggestions for pages with empty query", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, timestamp: ~N[2019-01-01 23:00:00], pathname: "/"), + build(:pageview, timestamp: ~N[2019-01-01 23:00:00], pathname: "/register"), + build(:pageview, timestamp: ~N[2019-01-01 23:00:01], pathname: "/contact"), + build(:pageview, timestamp: ~N[2019-01-01 23:00:01], pathname: "/irrelevant") + ]) + + conn = + get(conn, "/api/stats/#{site.domain}/suggestions/page?period=month&date=2019-01-01&q=") + + assert json_response(conn, 200) == [ + %{"label" => "/", "value" => "/"}, + %{"label" => "/contact", "value" => "/contact"}, + %{"label" => "/irrelevant", "value" => "/irrelevant"}, + %{"label" => "/register", "value" => "/register"} + ] + end + + test "returns suggestions for pages with a query", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, timestamp: ~N[2019-01-01 23:00:00], pathname: "/"), + build(:pageview, timestamp: ~N[2019-01-01 23:00:00], pathname: "/register"), + build(:pageview, timestamp: ~N[2019-01-01 23:00:01], pathname: "/contact"), + build(:pageview, timestamp: ~N[2019-01-01 23:00:01], pathname: "/irrelevant") + ]) + + conn = + get(conn, "/api/stats/#{site.domain}/suggestions/page?period=month&date=2019-01-01&q=re") + + assert json_response(conn, 200) == [ + %{"label" => "/irrelevant", "value" => "/irrelevant"}, + %{"label" => "/register", "value" => "/register"} + ] + end + + test "returns suggestions for pages without any suggestions", %{conn: conn, site: site} do + conn = + get( + conn, + "/api/stats/#{site.domain}/suggestions/page?period=month&date=2019-01-01&q=/123" + ) + + assert json_response(conn, 200) == [] + end + + test "returns suggestions for goals", %{conn: conn, site: site} do + conn = + get(conn, "/api/stats/#{site.domain}/suggestions/goal?period=month&date=2019-01-01&q=") + + assert json_response(conn, 200) == [] + end + + test "returns suggestions for configured site goals but not all event names", %{ + conn: conn, + site: site + } do + insert(:goal, site: site, event_name: "Signup") + + populate_stats(site, [ + build(:event, name: "Signup", timestamp: ~N[2019-01-01 00:00:00]), + build(:event, name: "another", timestamp: ~N[2019-01-01 00:00:00]) + ]) + + conn = get(conn, "/api/stats/#{site.domain}/suggestions/goal?period=day&date=2019-01-01&q=") + + assert json_response(conn, 200) == [%{"label" => "Signup", "value" => "Signup"}] + end + + @tag :ee_only + test "excludes goals with custom props when Props feature is unavailable", %{ + conn: conn, + site: site, + user: user + } do + user + |> team_of() + |> Plausible.Teams.Team.end_trial() + |> Plausible.Repo.update!() + + {:ok, _goal_with_props} = + Plausible.Goals.create(site, %{ + "event_name" => "Purchase", + "custom_props" => %{"product" => "Shirt"} + }) + + {:ok, _goal_without_props} = + Plausible.Goals.create(site, %{"event_name" => "Signup"}) + + conn = get(conn, "/api/stats/#{site.domain}/suggestions/goal?period=day&q=") + + assert json_response(conn, 200) == [%{"label" => "Signup", "value" => "Signup"}] + end + + @tag :ee_only + test "includes goals with custom props when Props feature is available", %{ + conn: conn, + site: site + } do + {:ok, _goal_with_props} = + Plausible.Goals.create(site, %{ + "event_name" => "Purchase", + "custom_props" => %{"product" => "Shirt"} + }) + + {:ok, _goal_without_props} = + Plausible.Goals.create(site, %{"event_name" => "Signup"}) + + conn = get(conn, "/api/stats/#{site.domain}/suggestions/goal?period=day&q=") + + suggestions = json_response(conn, 200) + assert length(suggestions) == 2 + assert %{"label" => "Purchase", "value" => "Purchase"} in suggestions + assert %{"label" => "Signup", "value" => "Signup"} in suggestions + end + + test "returns suggestions for sources", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, timestamp: ~N[2019-01-01 23:00:00], referrer_source: "Bing"), + build(:pageview, timestamp: ~N[2019-01-01 23:00:00], referrer_source: "Bing"), + build(:pageview, + timestamp: ~N[2019-01-01 23:00:00], + referrer_source: "10words" + ) + ]) + + conn = + get(conn, "/api/stats/#{site.domain}/suggestions/source?period=month&date=2019-01-01&q=") + + assert json_response(conn, 200) == [ + %{"label" => "Direct / None", "value" => "Direct / None"}, + %{"label" => "Bing", "value" => "Bing"}, + %{"label" => "10words", "value" => "10words"} + ] + end + + test "returns suggestions for channels", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, timestamp: ~N[2019-01-01 23:00:00], referrer_source: "Bing"), + build(:pageview, timestamp: ~N[2019-01-01 23:00:00], referrer_source: "Bing"), + build(:pageview, + timestamp: ~N[2019-01-01 23:00:00], + referrer_source: "Youtube" + ) + ]) + + conn = + get(conn, "/api/stats/#{site.domain}/suggestions/channel?period=month&date=2019-01-01&q=") + + assert json_response(conn, 200) == [ + %{"label" => "Organic Search", "value" => "Organic Search"}, + %{"label" => "Organic Video", "value" => "Organic Video"} + ] + end + + test "returns suggestions for countries", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + timestamp: ~N[2019-01-01 23:00:01], + pathname: "/", + country_code: "US" + ) + ]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/suggestions/country?period=month&date=2019-01-01&q=Unit" + ) + + assert json_response(conn, 200) == [%{"value" => "US", "label" => "United States"}] + end + + test "returns suggestions for regions", %{conn: conn, user: user} do + {:ok, [site: site]} = create_site(%{user: user}) + + populate_stats(site, [ + build(:pageview, country_code: "EE", subdivision1_code: "EE-37"), + build(:pageview, country_code: "EE", subdivision1_code: "EE-39") + ]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/suggestions/region?period=day&q=Har" + ) + + assert json_response(conn, 200) == [%{"value" => "EE-37", "label" => "Harjumaa"}] + end + + test "returns suggestions for cities", %{conn: conn, user: user} do + {:ok, [site: site]} = create_site(%{user: user}) + + populate_stats(site, [ + build(:pageview, + country_code: "EE", + subdivision1_code: "EE-37", + city_geoname_id: 588_409 + ), + build(:pageview, + country_code: "EE", + subdivision1_code: "EE-39", + city_geoname_id: 591_632 + ) + ]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/suggestions/city?period=day&q=Kär" + ) + + assert json_response(conn, 200) == [%{"value" => 591_632, "label" => "Kärdla"}] + end + + test "returns suggestions for countries without country in search", %{conn: conn, site: site} do + conn = + get( + conn, + "/api/stats/#{site.domain}/suggestions/country?period=month&date=2019-01-01&q=GBR,UKR,URY" + ) + + assert json_response(conn, 200) == [] + end + + test "returns 400 for nil query parameter", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + timestamp: ~N[2019-01-01 23:00:01], + pathname: "/", + country_code: "US" + ) + ]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/suggestions/country?period=month&date=2019-01-01" + ) + + assert json_response(conn, 400) == %{ + "error" => "Search parameter 'q' is required" + } + end + + test "returns suggestions for screen sizes", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + timestamp: ~N[2019-01-01 23:00:00], + pathname: "/", + screen_size: "Desktop" + ) + ]) + + conn = + get(conn, "/api/stats/#{site.domain}/suggestions/screen?period=month&date=2019-01-01&q=") + + assert json_response(conn, 200) == [%{"value" => "Desktop", "label" => "Desktop"}] + end + + test "returns suggestions for browsers", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + timestamp: ~N[2019-01-01 23:00:00], + pathname: "/", + browser: "Chrome" + ) + ]) + + conn = + get(conn, "/api/stats/#{site.domain}/suggestions/browser?period=month&date=2019-01-01&q=") + + assert json_response(conn, 200) == [%{"label" => "Chrome", "value" => "Chrome"}] + end + + test "returns suggestions for browser versions", %{conn: conn, site: site} do + filters = Jason.encode!([[:is, "visit:browser", ["Chrome"]]]) + + populate_stats(site, [ + build(:pageview, + timestamp: ~N[2019-01-01 00:00:00], + browser: "Chrome", + browser_version: "78.0" + ) + ]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/suggestions/browser_version?period=month&date=2019-01-01&filters=#{filters}&q=" + ) + + assert json_response(conn, 200) == [%{"value" => "78.0", "label" => "78.0"}] + end + + test "returns suggestions for OS", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, timestamp: ~N[2019-01-01 00:00:00], operating_system: "Mac") + ]) + + conn = get(conn, "/api/stats/#{site.domain}/suggestions/os?period=month&date=2019-01-01&q=") + + assert json_response(conn, 200) == [%{"value" => "Mac", "label" => "Mac"}] + end + + test "returns suggestions for OS versions", %{conn: conn, site: site} do + filters = Jason.encode!([[:is, "visit:os", ["Mac"]]]) + + populate_stats(site, [ + build(:pageview, + timestamp: ~N[2019-01-01 00:00:00], + operating_system: "Mac", + operating_system_version: "10.15" + ) + ]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/suggestions/os_version?period=month&date=2019-01-01&filters=#{filters}&q=" + ) + + assert json_response(conn, 200) == [%{"label" => "10.15", "value" => "10.15"}] + end + + test "returns suggestions for OS versions with search", %{conn: conn, site: site} do + filters = Jason.encode!([[:is, "visit:os", ["Mac"]]]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/suggestions/os_version?period=month&date=2019-01-01&filters=#{filters}&q=11" + ) + + assert json_response(conn, 200) == [] + end + + test "returns suggestions for hostnames", %{conn: conn1, user: user} do + {:ok, [site: site]} = create_site(%{user: user}) + + populate_stats(site, [ + build(:pageview, + pathname: "/", + hostname: "host-alice.example.com" + ), + build(:pageview, + pathname: "/some-other-page", + hostname: "host-bob.example.com", + user_id: 123 + ), + build(:pageview, pathname: "/exit", hostname: "host-carol.example.com", user_id: 123) + ]) + + conn = + get( + conn1, + "/api/stats/#{site.domain}/suggestions/hostname?period=day&q=alice" + ) + + assert json_response(conn, 200) == [ + %{"value" => "host-alice.example.com", "label" => "host-alice.example.com"} + ] + + conn = + get( + conn1, + "/api/stats/#{site.domain}/suggestions/hostname?period=day&q=host" + ) + + suggestions = json_response(conn, 200) + + assert length(suggestions) == 3 + + assert %{"label" => "host-alice.example.com", "value" => "host-alice.example.com"} in suggestions + + assert %{"label" => "host-carol.example.com", "value" => "host-carol.example.com"} in suggestions + + assert %{"label" => "host-bob.example.com", "value" => "host-bob.example.com"} in suggestions + end + + test "returns suggestions for hostnames limited by shields", %{conn: conn1, user: user} do + {:ok, [site: site]} = create_site(%{user: user}) + Plausible.Shields.add_hostname_rule(site, %{"hostname" => "*.example.com"}) + Plausible.Shields.add_hostname_rule(site, %{"hostname" => "erin.rogue.com"}) + + populate_stats(site, [ + build(:pageview, + pathname: "/", + hostname: "host-alice.example.com" + ), + build(:pageview, + pathname: "/some-other-page", + hostname: "host-bob.example.com", + user_id: 123 + ), + build(:pageview, pathname: "/exit", hostname: "host-carol.example.com", user_id: 123), + build(:pageview, + pathname: "/", + hostname: "host-dave.rogue.com" + ), + build(:pageview, + pathname: "/", + hostname: "erin.rogue.com" + ) + ]) + + conn = + get( + conn1, + "/api/stats/#{site.domain}/suggestions/hostname?period=day&q=host" + ) + + results = json_response(conn, 200) + + assert length(results) == 3 + + assert %{"label" => "host-alice.example.com", "value" => "host-alice.example.com"} in results + + assert %{"label" => "host-carol.example.com", "value" => "host-carol.example.com"} in results + + assert %{"label" => "host-bob.example.com", "value" => "host-bob.example.com"} in results + + conn = + get( + conn1, + "/api/stats/#{site.domain}/suggestions/hostname?period=day&q=dave" + ) + + assert json_response(conn, 200) == [] + + conn = + get( + conn1, + "/api/stats/#{site.domain}/suggestions/hostname?period=day&q=rogue" + ) + + assert json_response(conn, 200) == [ + %{"label" => "erin.rogue.com", "value" => "erin.rogue.com"} + ] + end + + test "returns suggestions for referrers", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + timestamp: ~N[2019-01-01 23:00:00], + pathname: "/", + referrer: "10words.com/page1" + ) + ]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/suggestions/referrer?period=month&date=2019-01-01&q=" + ) + + assert json_response(conn, 200) == [ + %{"value" => "10words.com/page1", "label" => "10words.com/page1"} + ] + end + end + + describe "suggestions for props" do + setup [:create_user, :log_in, :create_site] + + test "returns suggestions for prop key ordered by count", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + "meta.key": ["author"], + "meta.value": ["Uku Taht"], + timestamp: ~N[2022-01-01 00:00:00] + ), + build(:pageview, + "meta.key": ["author"], + "meta.value": ["Uku Taht"], + timestamp: ~N[2022-01-01 00:00:00] + ), + build(:pageview, + "meta.key": ["author"], + "meta.value": ["Uku Taht"], + timestamp: ~N[2022-01-01 00:00:00] + ), + build(:pageview, + "meta.key": ["logged_in"], + "meta.value": ["false"], + timestamp: ~N[2022-01-01 00:00:00] + ), + build(:pageview, + "meta.key": ["logged_in"], + "meta.value": ["false"], + timestamp: ~N[2022-01-01 00:00:00] + ), + build(:pageview, + "meta.key": ["dark_mode"], + "meta.value": ["true"], + timestamp: ~N[2022-01-01 00:00:00] + ) + ]) + + conn = + get(conn, "/api/stats/#{site.domain}/suggestions/prop_key?period=day&date=2022-01-01&q=") + + assert json_response(conn, 200) == [ + %{"label" => "author", "value" => "author"}, + %{"label" => "logged_in", "value" => "logged_in"}, + %{"label" => "dark_mode", "value" => "dark_mode"} + ] + end + + test "returns prop key suggestions found in time frame", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, + "meta.key": ["author", "logged_in"], + "meta.value": ["Uku Taht", "false"], + timestamp: ~N[2022-01-01 00:00:00] + ), + build(:pageview, + "meta.key": ["dark_mode"], + "meta.value": ["true"], + timestamp: ~N[2022-01-02 00:00:00] + ) + ]) + + conn = + get(conn, "/api/stats/#{site.domain}/suggestions/prop_key?period=day&date=2022-01-01&q=") + + assert json_response(conn, 200) == [ + %{"label" => "author", "value" => "author"}, + %{"label" => "logged_in", "value" => "logged_in"} + ] + end + + test "returns prop key suggestions by a search string", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, + "meta.key": ["author", "logged_in"], + "meta.value": ["Uku Taht", "false"], + timestamp: ~N[2022-01-01 00:00:00] + ), + build(:pageview, + "meta.key": ["dark_mode"], + "meta.value": ["true"], + timestamp: ~N[2022-01-02 00:00:00] + ) + ]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/suggestions/prop_key?period=day&date=2022-01-01&q=aut" + ) + + assert json_response(conn, 200) == [ + %{"label" => "author", "value" => "author"} + ] + end + + test "returns only prop keys which exist under filter", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, + "meta.key": ["author", "bank"], + "meta.value": ["Uku", "a"], + timestamp: ~N[2022-01-01 00:00:00] + ), + build(:pageview, + "meta.key": ["author", "serial_number"], + "meta.value": ["Marko", "b"], + timestamp: ~N[2022-01-01 00:00:00] + ) + ]) + + filters = Jason.encode!([[:is, "event:props:author", ["Uku"]]]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/suggestions/prop_key?period=day&date=2022-01-01&filters=#{filters}&q=" + ) + + assert json_response(conn, 200) == [ + %{"label" => "author", "value" => "author"}, + %{"label" => "bank", "value" => "bank"} + ] + end + + test "returns suggestions for prop key based on site.allowed_event_props list", %{ + conn: conn, + site: site + } do + {:ok, site} = Plausible.Props.allow(site, ["author"]) + + populate_stats(site, [ + build(:pageview, + "meta.key": ["author"], + "meta.value": ["Uku Taht"], + timestamp: ~N[2022-01-01 00:00:00] + ), + build(:pageview, + "meta.key": ["garbage1"], + "meta.value": ["somegarbage1"], + timestamp: ~N[2022-01-01 00:00:00] + ), + build(:pageview, + "meta.key": ["garbage2"], + "meta.value": ["somegarbage2"], + timestamp: ~N[2022-01-01 00:00:00] + ) + ]) + + conn = + get(conn, "/api/stats/#{site.domain}/suggestions/prop_key?period=day&date=2022-01-01&q=") + + assert json_response(conn, 200) == [ + %{"label" => "author", "value" => "author"} + ] + end + + test "does not filter out prop key suggestions by default (when site.allowed_event_props is nil)", + %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, + "meta.key": ["author"], + "meta.value": ["Uku Taht"], + timestamp: ~N[2022-01-01 00:00:00] + ), + build(:pageview, + "meta.key": ["garbage1"], + "meta.value": ["somegarbage1"], + timestamp: ~N[2022-01-01 00:00:00] + ), + build(:pageview, + "meta.key": ["garbage2"], + "meta.value": ["somegarbage2"], + timestamp: ~N[2022-01-01 00:00:00] + ) + ]) + + conn = + get(conn, "/api/stats/#{site.domain}/suggestions/prop_key?period=day&date=2022-01-01&q=") + + suggestions = json_response(conn, 200) + assert %{"label" => "author", "value" => "author"} in suggestions + assert %{"label" => "garbage1", "value" => "garbage1"} in suggestions + assert %{"label" => "garbage2", "value" => "garbage2"} in suggestions + end + + test "returns suggestions for prop value ordered by count, but (none) value is always first", + %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + "meta.key": ["author"], + "meta.value": ["Uku Taht"], + timestamp: ~N[2022-01-01 00:00:00] + ), + build(:pageview, + "meta.key": ["author"], + "meta.value": ["Uku Taht"], + timestamp: ~N[2022-01-01 00:00:00] + ), + build(:pageview, + "meta.key": ["author"], + "meta.value": ["Marko Saric"], + timestamp: ~N[2022-01-01 00:00:00] + ), + build(:pageview, + timestamp: ~N[2022-01-01 00:00:00] + ) + ]) + + filters = Jason.encode!([[:is_not, "event:props:author", ["(none)"]]]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/suggestions/custom-prop-values/author?period=day&date=2022-01-01&filters=#{filters}&q=" + ) + + assert json_response(conn, 200) == [ + %{"label" => "(none)", "value" => "(none)"}, + %{"label" => "Uku Taht", "value" => "Uku Taht"}, + %{"label" => "Marko Saric", "value" => "Marko Saric"} + ] + end + + test "returns (none) value as the first suggestion even when a search string is provided", + %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + "meta.key": ["author"], + "meta.value": ["Uku Taht"], + timestamp: ~N[2022-01-01 00:00:00] + ), + build(:pageview, + "meta.key": ["author"], + "meta.value": ["Marko Saric"], + timestamp: ~N[2022-01-01 00:00:00] + ), + build(:pageview, + "meta.key": ["author"], + "meta.value": ["Marko Saric"], + timestamp: ~N[2022-01-01 00:00:00] + ), + build(:pageview, + timestamp: ~N[2022-01-01 00:00:00] + ) + ]) + + filters = Jason.encode!([[:is_not, "event:props:author", ["(none)"]]]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/suggestions/custom-prop-values/author?period=day&date=2022-01-01&filters=#{filters}&q=Mar" + ) + + assert json_response(conn, 200) == [ + %{"label" => "(none)", "value" => "(none)"}, + %{"label" => "Marko Saric", "value" => "Marko Saric"} + ] + end + + test "does not show (none) value suggestion when all events have that prop_key", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, + "meta.key": ["author"], + "meta.value": ["Uku Taht"], + timestamp: ~N[2022-01-01 00:00:00] + ), + build(:pageview, + "meta.key": ["author"], + "meta.value": ["Uku Taht"], + timestamp: ~N[2022-01-01 00:00:00] + ), + build(:pageview, + "meta.key": ["author"], + "meta.value": ["Marko Saric"], + timestamp: ~N[2022-01-01 00:00:00] + ) + ]) + + filters = Jason.encode!([[:is_not, "event:props:author", ["(none)"]]]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/suggestions/custom-prop-values/author?period=day&date=2022-01-01&filters=#{filters}" + ) + + assert json_response(conn, 200) == [ + %{"label" => "Uku Taht", "value" => "Uku Taht"}, + %{"label" => "Marko Saric", "value" => "Marko Saric"} + ] + end + + test "returns prop value suggestions with multiple custom property filters in query", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, + "meta.key": ["author", "browser_language"], + "meta.value": ["Uku Taht", "en-US"], + timestamp: ~N[2022-01-01 00:00:00] + ), + build(:pageview, + "meta.key": ["author", "browser_language"], + "meta.value": ["Uku Taht", "en-US"], + timestamp: ~N[2022-01-01 00:00:00] + ), + build(:pageview, + "meta.key": ["author", "browser_language"], + "meta.value": ["Marko Saric", "de-DE"], + timestamp: ~N[2022-01-01 00:00:00] + ), + build(:pageview, timestamp: ~N[2022-01-01 00:00:00]) + ]) + + filters = + Jason.encode!([ + [:is_not, "event:props:browser_language", ["(none)"]], + [:is, "event:props:author", ["Uku Taht"]] + ]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/suggestions/custom-prop-values/browser_language?period=day&date=2022-01-01&filters=#{filters}" + ) + + assert json_response(conn, 200) == [ + %{"label" => "en-US", "value" => "en-US"} + ] + end + + test "when date is borked, bad request is returned", %{ + conn: conn, + site: site + } do + today = (Date.utc_today() |> Date.to_iso8601()) <> " 00:00:00" + naive_today = NaiveDateTime.from_iso8601!(today) + + populate_stats(site, [ + build(:pageview, + "meta.key": ["author"], + "meta.value": ["Alice Bob"], + timestamp: naive_today + ), + build(:pageview, + "meta.key": ["author"], + "meta.value": ["Cecil"], + timestamp: ~N[2022-01-01 00:00:00] + ) + ]) + + filters = Jason.encode!([[:is_not, "event:props:author", ["(none)"]]]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/suggestions/custom-prop-values/author?period=all&date=CLEVER_SECURITY_RESEARCH&filters=#{filters}" + ) + + assert json_response(conn, 400) == %{ + "error" => + "Failed to parse 'date' argument. Only ISO 8601 dates are allowed, e.g. `2019-09-07`, `2020-01-01`" + } + end + end + + describe "imported data" do + setup [:create_user, :log_in, :create_site, :create_site_import] + + test "merges country suggestions from native and imported data", %{ + conn: conn, + site: site, + site_import: site_import + } do + populate_stats(site, site_import.id, [ + build(:pageview, timestamp: ~N[2019-01-01 23:00:01], country_code: "US"), + build(:pageview, timestamp: ~N[2019-01-01 23:30:01], country_code: "US"), + build(:pageview, timestamp: ~N[2019-01-01 23:40:01], country_code: "US"), + build(:pageview, timestamp: ~N[2019-01-01 23:00:01], country_code: "GB"), + build(:imported_locations, date: ~D[2019-01-01], country: "GB", pageviews: 3) + ]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/suggestions/country?period=month&date=2019-01-01&q=Unit&with_imported=true" + ) + + assert json_response(conn, 200) == [ + %{"value" => "GB", "label" => "United Kingdom"}, + %{"value" => "US", "label" => "United States"} + ] + end + + test "ignores imported data in country suggestions when a different property is filtered by", + %{ + conn: conn, + site: site, + site_import: site_import + } do + populate_stats(site, site_import.id, [ + build(:pageview, country_code: "EE", referrer_source: "Bing"), + build(:imported_locations, country: "GB") + ]) + + filters = Jason.encode!([[:is, "visit:source", ["Bing"]]]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/suggestions/country?period=day&filters=#{filters}&q=&with_imported=true" + ) + + assert json_response(conn, 200) == [%{"value" => "EE", "label" => "Estonia"}] + end + + test "queries imported countries when filtering by country", %{ + conn: conn, + site: site, + site_import: site_import + } do + populate_stats(site, site_import.id, [ + build(:imported_locations, date: ~D[2019-01-01], country: "EE") + ]) + + filters = Jason.encode!([[:is, "visit:country", ["EE"]]]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/suggestions/country?period=month&date=2019-01-01&filters=#{filters}&q=&with_imported=true" + ) + + assert json_response(conn, 200) == [%{"value" => "EE", "label" => "Estonia"}] + end + + test "ignores imported country data when not requested", %{ + conn: conn, + site: site, + site_import: site_import + } do + populate_stats(site, site_import.id, [ + build(:imported_locations, date: ~D[2019-01-01], country: "GB", pageviews: 3) + ]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/suggestions/country?period=month&date=2019-01-01&q=" + ) + + assert json_response(conn, 200) == [] + end + + for {q, label} <- [{"", "without filter"}, {"H", "with filter"}] do + test "merges region suggestions from native and imported data #{label}", %{ + conn: conn, + site: site, + site_import: site_import + } do + populate_stats(site, site_import.id, [ + build(:pageview, country_code: "EE", subdivision1_code: "EE-37"), + build(:pageview, country_code: "EE", subdivision1_code: "EE-39"), + build(:pageview, country_code: "EE", subdivision1_code: "EE-39"), + build(:imported_locations, country: "EE", region: "EE-37", pageviews: 2) + ]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/suggestions/region?period=day&q=#{unquote(q)}&with_imported=true" + ) + + assert json_response(conn, 200) == [ + %{"value" => "EE-37", "label" => "Harjumaa"}, + %{"value" => "EE-39", "label" => "Hiiumaa"} + ] + end + end + + test "handles invalid region codes in imported data gracefully (GA4)", %{ + conn: conn, + site: site, + site_import: site_import + } do + # NOTE: Currently, the regions imported from GA4 do not conform to region code standard + # we are using. Instead, literal region names are persisted. Those names often do not + # match the names from our region databases either. Regardless of that, we still consider + # them when filtering suggestions. + + populate_stats(site, site_import.id, [ + build(:imported_locations, country: "EE", region: "EE-37", pageviews: 2), + build(:imported_locations, country: "EE", region: "Hiiumaa", pageviews: 1) + ]) + + conn1 = + get( + conn, + "/api/stats/#{site.domain}/suggestions/region?period=day&q=&with_imported=true" + ) + + assert json_response(conn1, 200) == [ + %{"value" => "EE-37", "label" => "Harjumaa"}, + %{"value" => "Hiiumaa", "label" => "Hiiumaa"} + ] + + conn2 = + get( + conn, + "/api/stats/#{site.domain}/suggestions/region?period=day&q=H&with_imported=true" + ) + + assert json_response(conn2, 200) == [ + %{"value" => "EE-37", "label" => "Harjumaa"}, + %{"value" => "Hiiumaa", "label" => "Hiiumaa"} + ] + end + + test "ignores imported data in region suggestions when a different property is filtered by", + %{ + conn: conn, + site: site, + site_import: site_import + } do + populate_stats(site, site_import.id, [ + build(:pageview, + country_code: "EE", + subdivision1_code: "EE-39", + referrer_source: "Bing" + ), + build(:imported_locations, country: "EE", region: "EE-37") + ]) + + filters = Jason.encode!([[:is, "visit:source", ["Bing"]]]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/suggestions/region?period=day&filters=#{filters}&q=&with_imported=true" + ) + + assert json_response(conn, 200) == [%{"value" => "EE-39", "label" => "Hiiumaa"}] + end + + test "queries imported regions when filtering by region", %{ + conn: conn, + site: site, + site_import: site_import + } do + populate_stats(site, site_import.id, [ + build(:imported_locations, date: ~D[2019-01-01], region: "EE-39") + ]) + + filters = Jason.encode!([[:is, "visit:region", ["EE-39"]]]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/suggestions/region?period=month&date=2019-01-01&filters=#{filters}&q=&with_imported=true" + ) + + assert json_response(conn, 200) == [%{"value" => "EE-39", "label" => "Hiiumaa"}] + end + + test "ignores imported region data when not requested", %{ + conn: conn, + site: site, + site_import: site_import + } do + populate_stats(site, site_import.id, [ + build(:imported_locations, country: "EE", region: "EE-37", pageviews: 2) + ]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/suggestions/region?q=" + ) + + assert json_response(conn, 200) == [] + end + + for {q, label} <- [{"", "without filter"}, {"l", "with filter"}] do + test "merges city suggestions from native and imported data #{label}", %{ + conn: conn, + site: site, + site_import: site_import + } do + populate_stats(site, site_import.id, [ + build(:pageview, + country_code: "EE", + subdivision1_code: "EE-37", + city_geoname_id: 588_409 + ), + build(:pageview, + country_code: "EE", + subdivision1_code: "EE-39", + city_geoname_id: 591_632 + ), + build(:pageview, + country_code: "EE", + subdivision1_code: "EE-39", + city_geoname_id: 591_632 + ), + build(:imported_locations, country: "EE", region: "EE-37", city: 588_409, pageviews: 2) + ]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/suggestions/city?period=day&q=#{unquote(q)}&with_imported=true" + ) + + assert json_response(conn, 200) == [ + %{"value" => 588_409, "label" => "Tallinn"}, + %{"value" => 591_632, "label" => "Kärdla"} + ] + end + end + + test "ignores imported data in city suggestions when a different property is filtered by", %{ + conn: conn, + site: site, + site_import: site_import + } do + populate_stats(site, site_import.id, [ + build(:pageview, + country_code: "EE", + subdivision1_code: "EE-39", + city_geoname_id: 591_632, + referrer_source: "Bing" + ), + build(:imported_locations, country: "EE", region: "EE-37", city: 588_409) + ]) + + filters = Jason.encode!([[:is, "visit:source", ["Bing"]]]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/suggestions/city?period=day&filters=#{filters}&q=&with_imported=true" + ) + + assert json_response(conn, 200) == [%{"value" => 591_632, "label" => "Kärdla"}] + end + + test "queries imported cities when filtering by city", %{ + conn: conn, + site: site, + site_import: site_import + } do + populate_stats(site, site_import.id, [ + build(:imported_locations, date: ~D[2019-01-01], city: 591_632) + ]) + + filters = Jason.encode!([[:is, "visit:city", ["591632"]]]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/suggestions/city?period=month&date=2019-01-01&filters=#{filters}&q=&with_imported=true" + ) + + assert json_response(conn, 200) == [%{"value" => 591_632, "label" => "Kärdla"}] + end + + test "ignores imported city data when not requested", %{ + conn: conn, + site: site, + site_import: site_import + } do + populate_stats(site, site_import.id, [ + build(:imported_locations, country: "EE", region: "EE-37", city: 588_409, pageviews: 2) + ]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/suggestions/city?q=" + ) + + assert json_response(conn, 200) == [] + end + + test "ignores imported data when asking for prop key and value suggestions", %{ + conn: conn, + site: site, + site_import: site_import + } do + populate_stats(site, site_import.id, [ + build(:pageview, + "meta.key": ["url"], + "meta.value": ["http://example1.com"], + timestamp: ~N[2022-01-01 00:00:00] + ), + build(:imported_custom_events, + date: ~D[2022-01-01], + name: "Outbound Link: Click", + link_url: "http://example2.com" + ), + build(:imported_custom_events, + date: ~D[2022-01-01], + name: "404", + path: "/dev/null" + ) + ]) + + key_conn = + get( + conn, + "/api/stats/#{site.domain}/suggestions/prop_key?period=day&date=2022-01-01&with_imported=true&q=" + ) + + assert json_response(key_conn, 200) == [%{"label" => "url", "value" => "url"}] + + filters = Jason.encode!([[:is_not, "event:props:url", ["(none)"]]]) + + value_conn = + get( + conn, + "/api/stats/#{site.domain}/suggestions/custom-prop-values/url?period=day&date=2022-01-01&with_imported=true&filters=#{filters}" + ) + + assert json_response(value_conn, 200) == [ + %{"label" => "http://example1.com", "value" => "http://example1.com"} + ] + end + + for {q, label} <- [{"", "without filter"}, {"g", "with filter"}] do + test "merges source suggestions from native and imported data #{label}", %{ + conn: conn, + site: site, + site_import: site_import + } do + populate_stats(site, site_import.id, [ + build(:pageview, timestamp: ~N[2019-01-01 23:00:01], referrer_source: "Bing"), + build(:pageview, timestamp: ~N[2019-01-01 23:30:01], referrer_source: "Bing"), + build(:pageview, timestamp: ~N[2019-01-01 23:40:01], referrer_source: "Bing"), + build(:pageview, timestamp: ~N[2019-01-01 23:00:01], referrer_source: "Google"), + build(:imported_sources, date: ~D[2019-01-01], source: "Google", pageviews: 3) + ]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/suggestions/source?period=month&date=2019-01-01&q=#{unquote(q)}&with_imported=true" + ) + + if unquote(label) == "with filter" do + assert json_response(conn, 200) == [ + %{"value" => "Google", "label" => "Google"}, + %{"value" => "Bing", "label" => "Bing"} + ] + else + assert json_response(conn, 200) == [ + %{"value" => "Direct / None", "label" => "Direct / None"}, + %{"value" => "Google", "label" => "Google"}, + %{"value" => "Bing", "label" => "Bing"} + ] + end + end + + test "merges channel suggestions from native and imported data #{label}", %{ + conn: conn, + site: site, + site_import: site_import + } do + populate_stats(site, site_import.id, [ + build(:pageview, timestamp: ~N[2019-01-01 23:00:01], referrer_source: "Bing"), + build(:pageview, timestamp: ~N[2019-01-01 23:30:01], referrer_source: "Bing"), + build(:pageview, timestamp: ~N[2019-01-01 23:40:01], referrer_source: "Bing"), + build(:pageview, timestamp: ~N[2019-01-01 23:00:01], referrer_source: "Google"), + build(:imported_sources, date: ~D[2019-01-01], channel: "Organic Social", pageviews: 3) + ]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/suggestions/channel?period=month&date=2019-01-01&q=#{unquote(q)}&with_imported=true" + ) + + assert json_response(conn, 200) == [ + %{"value" => "Organic Search", "label" => "Organic Search"}, + %{"value" => "Organic Social", "label" => "Organic Social"} + ] + end + end + + for {q, label} <- [{"", "without filter"}, {"o", "with filter"}] do + test "merges screen suggestions from native and imported data #{label}", %{ + conn: conn, + site: site, + site_import: site_import + } do + populate_stats(site, site_import.id, [ + build(:pageview, timestamp: ~N[2019-01-01 23:00:01], screen_size: "Mobile"), + build(:pageview, timestamp: ~N[2019-01-01 23:30:01], screen_size: "Mobile"), + build(:pageview, timestamp: ~N[2019-01-01 23:40:01], screen_size: "Mobile"), + build(:pageview, timestamp: ~N[2019-01-01 23:00:01], screen_size: "Desktop"), + build(:imported_devices, date: ~D[2019-01-01], device: "Desktop", pageviews: 3) + ]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/suggestions/screen?period=month&date=2019-01-01&q=#{unquote(q)}&with_imported=true" + ) + + assert json_response(conn, 200) == [ + %{"value" => "Desktop", "label" => "Desktop"}, + %{"value" => "Mobile", "label" => "Mobile"} + ] + end + end + + for {q, label} <- [{"", "without filter"}, {"o", "with filter"}] do + test "merges page suggestions from native and imported data #{label}", %{ + conn: conn, + site: site, + site_import: site_import + } do + populate_stats(site, site_import.id, [ + build(:pageview, timestamp: ~N[2019-01-01 23:00:01], pathname: "/welcome"), + build(:pageview, timestamp: ~N[2019-01-01 23:30:01], pathname: "/welcome"), + build(:pageview, timestamp: ~N[2019-01-01 23:40:01], pathname: "/welcome"), + build(:pageview, timestamp: ~N[2019-01-01 23:00:01], pathname: "/blog"), + build(:imported_pages, date: ~D[2019-01-01], page: "/blog", pageviews: 3) + ]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/suggestions/page?period=month&date=2019-01-01&q=#{unquote(q)}&with_imported=true" + ) + + assert json_response(conn, 200) == [ + %{"value" => "/blog", "label" => "/blog"}, + %{"value" => "/welcome", "label" => "/welcome"} + ] + end + end + + for {q, label} <- [{"", "without filter"}, {"o", "with filter"}] do + test "merges entry page suggestions from native and imported data #{label}", %{ + conn: conn, + site: site, + site_import: site_import + } do + populate_stats(site, site_import.id, [ + build(:pageview, timestamp: ~N[2019-01-01 23:00:01], pathname: "/welcome"), + build(:pageview, timestamp: ~N[2019-01-01 23:30:01], pathname: "/welcome"), + build(:pageview, timestamp: ~N[2019-01-01 23:40:01], pathname: "/welcome"), + build(:pageview, timestamp: ~N[2019-01-01 23:00:01], pathname: "/blog"), + build(:imported_entry_pages, date: ~D[2019-01-01], entry_page: "/blog", pageviews: 3) + ]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/suggestions/entry_page?period=month&date=2019-01-01&q=#{unquote(q)}&with_imported=true" + ) + + assert json_response(conn, 200) == [ + %{"value" => "/blog", "label" => "/blog"}, + %{"value" => "/welcome", "label" => "/welcome"} + ] + end + end + + for {q, label} <- [{"", "without filter"}, {"o", "with filter"}] do + test "merges exit page suggestions from native and imported data #{label}", %{ + conn: conn, + site: site, + site_import: site_import + } do + populate_stats(site, site_import.id, [ + build(:pageview, timestamp: ~N[2019-01-01 23:00:01], pathname: "/welcome"), + build(:pageview, timestamp: ~N[2019-01-01 23:30:01], pathname: "/welcome"), + build(:pageview, timestamp: ~N[2019-01-01 23:40:01], pathname: "/welcome"), + build(:pageview, timestamp: ~N[2019-01-01 23:00:01], pathname: "/blog"), + build(:imported_exit_pages, date: ~D[2019-01-01], exit_page: "/blog", pageviews: 3) + ]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/suggestions/exit_page?period=month&date=2019-01-01&q=#{unquote(q)}&with_imported=true" + ) + + assert json_response(conn, 200) == [ + %{"value" => "/blog", "label" => "/blog"}, + %{"value" => "/welcome", "label" => "/welcome"} + ] + end + end + + for {q, label} <- [{"", "without filter"}, {"o", "with filter"}] do + test "merges browser suggestions from native and imported data #{label}", %{ + conn: conn, + site: site, + site_import: site_import + } do + populate_stats(site, site_import.id, [ + build(:pageview, timestamp: ~N[2019-01-01 23:00:01], browser: "Chrome"), + build(:pageview, timestamp: ~N[2019-01-01 23:30:01], browser: "Chrome"), + build(:pageview, timestamp: ~N[2019-01-01 23:40:01], browser: "Chrome"), + build(:pageview, timestamp: ~N[2019-01-01 23:00:01], browser: "Firefox"), + build(:imported_browsers, date: ~D[2019-01-01], browser: "Firefox", pageviews: 3) + ]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/suggestions/browser?period=month&date=2019-01-01&q=#{unquote(q)}&with_imported=true" + ) + + assert json_response(conn, 200) == [ + %{"value" => "Firefox", "label" => "Firefox"}, + %{"value" => "Chrome", "label" => "Chrome"} + ] + end + end + + for {q, label} <- [{"", "without filter"}, {"i", "with filter"}] do + test "merges operating system suggestions from native and imported data #{label}", %{ + conn: conn, + site: site, + site_import: site_import + } do + populate_stats(site, site_import.id, [ + build(:pageview, timestamp: ~N[2019-01-01 23:00:01], operating_system: "Linux"), + build(:pageview, timestamp: ~N[2019-01-01 23:30:01], operating_system: "Linux"), + build(:pageview, timestamp: ~N[2019-01-01 23:40:01], operating_system: "Linux"), + build(:pageview, timestamp: ~N[2019-01-01 23:00:01], operating_system: "Windows"), + build(:imported_operating_systems, + date: ~D[2019-01-01], + operating_system: "Windows", + pageviews: 3 + ) + ]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/suggestions/operating_system?period=month&date=2019-01-01&q=#{unquote(q)}&with_imported=true" + ) + + assert json_response(conn, 200) == [ + %{"value" => "Windows", "label" => "Windows"}, + %{"value" => "Linux", "label" => "Linux"} + ] + end + end + + test "does not query imported data when a different property is filtered by", %{ + conn: conn, + site: site, + site_import: site_import + } do + populate_stats(site, site_import.id, [ + build(:pageview, + timestamp: ~N[2019-01-01 23:00:01], + pathname: "/blog", + operating_system: "Linux" + ), + build(:imported_operating_systems, date: ~D[2019-01-01], operating_system: "Windows") + ]) + + filters = Jason.encode!([[:is, "event:page", ["/blog"]]]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/suggestions/operating_system?period=month&date=2019-01-01&filters=#{filters}&q=&with_imported=true" + ) + + assert json_response(conn, 200) == [%{"value" => "Linux", "label" => "Linux"}] + end + + test "queries imported data when filtering by the same property", %{ + conn: conn, + site: site, + site_import: site_import + } do + populate_stats(site, site_import.id, [ + build(:pageview, + timestamp: ~N[2019-01-01 23:00:01], + pathname: "/blog", + operating_system: "Linux" + ), + build(:imported_operating_systems, date: ~D[2019-01-01], operating_system: "Windows"), + build(:imported_operating_systems, date: ~D[2019-01-01], operating_system: "Linux") + ]) + + filters = Jason.encode!([[:is_not, "visit:os", ["Linux"]]]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/suggestions/operating_system?period=month&date=2019-01-01&filters=#{filters}&q=&with_imported=true" + ) + + assert json_response(conn, 200) == [%{"value" => "Windows", "label" => "Windows"}] + end + end +end diff --git a/test/plausible_web/controllers/api/stats_controller/top_stats_test.exs b/test/plausible_web/controllers/api/stats_controller/top_stats_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..bdf444257f0d0f7f0d0249bfa371c9bc589b7cf4 --- /dev/null +++ b/test/plausible_web/controllers/api/stats_controller/top_stats_test.exs @@ -0,0 +1,1783 @@ +defmodule PlausibleWeb.Api.StatsController.TopStatsTest do + use PlausibleWeb.ConnCase + + @user_id Enum.random(1000..9999) + + defp do_query_success(conn, site, params) do + conn + |> post("/api/stats/#{site.domain}/query", params) + |> json_response(200) + end + + describe "default" do + setup [:create_user, :log_in, :create_site] + + test "returns all top stats metrics", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, user_id: 3421, timestamp: ~N[2020-12-31 23:30:00]), + build(:pageview, user_id: @user_id, timestamp: ~N[2020-12-31 23:59:00]), + build(:pageview, user_id: @user_id, timestamp: ~N[2021-01-01 00:01:00]), + build(:pageview, user_id: 2, timestamp: ~N[2020-12-31 23:59:00]), + build(:pageview, user_id: 2, timestamp: ~N[2021-01-01 00:01:00]), + build(:pageview, user_id: 617_235, timestamp: ~N[2021-01-03 00:00:00]) + ]) + + params = %{ + "date_range" => "day", + "relative_date" => "2021-01-01", + "filters" => [], + "metrics" => [ + "visitors", + "visits", + "pageviews", + "views_per_visit", + "bounce_rate", + "visit_duration" + ] + } + + response = do_query_success(conn, site, params) + + assert response["results"] == [%{"dimensions" => [], "metrics" => [2, 2, 2, 2.0, 0, 120]}] + end + + test "returns all page-filtered top stats metrics", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/pageA", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:engagement, + pathname: "/pageA", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:15:00], + engagement_time: 199_000 + ), + build(:pageview, + pathname: "/pageB", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:15:00] + ), + build(:pageview, + pathname: "/pageA", + timestamp: ~N[2021-01-01 00:15:00] + ), + build(:engagement, + pathname: "/pageA", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:15:00], + engagement_time: 1_000 + ) + ]) + + params = %{ + "date_range" => "day", + "relative_date" => "2021-01-01", + "filters" => [["is", "event:page", ["/pageA"]]], + "metrics" => [ + "visitors", + "visits", + "pageviews", + "bounce_rate", + "scroll_depth", + "time_on_page" + ] + } + + response = do_query_success(conn, site, params) + + assert response["results"] == [ + %{"dimensions" => [], "metrics" => [2, 2, 2, 50, 0, 200]} + ] + end + + test "calculates time on page when filtered for multiple pages", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, + pathname: "/pageA", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:engagement, + pathname: "/pageA", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:15:00], + engagement_time: 199_000 + ), + build(:pageview, + pathname: "/pageB", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:15:00] + ), + build(:engagement, + pathname: "/pageB", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:15:00], + engagement_time: 20_000 + ), + build(:pageview, + pathname: "/pageC", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:16:00] + ), + build(:engagement, + pathname: "/pageC", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:16:00], + engagement_time: 30_000 + ), + build(:pageview, + pathname: "/pageA", + timestamp: ~N[2021-01-01 00:17:00] + ), + build(:engagement, + pathname: "/pageA", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:17:00], + engagement_time: 1_000 + ) + ]) + + params = %{ + "date_range" => "all", + "filters" => [["is", "event:page", ["/pageA", "/pageB"]]], + "metrics" => ["time_on_page"] + } + + response = do_query_success(conn, site, params) + + assert response["results"] == [%{"dimensions" => [], "metrics" => [220]}] + end + + test "calculates time on page when filtered for multiple negated pages", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, + pathname: "/pageA", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:engagement, + pathname: "/pageA", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:15:00], + engagement_time: 199_000 + ), + build(:pageview, + pathname: "/pageB", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:15:00] + ), + build(:engagement, + pathname: "/pageB", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:15:00], + engagement_time: 20_000 + ), + build(:pageview, + pathname: "/pageC", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:16:00] + ), + build(:engagement, + pathname: "/pageC", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:16:00], + engagement_time: 30_000 + ), + build(:pageview, + pathname: "/pageA", + timestamp: ~N[2021-01-01 00:17:00] + ), + build(:engagement, + pathname: "/pageA", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:17:00], + engagement_time: 1_000 + ) + ]) + + params = %{ + "date_range" => "all", + "filters" => [["is_not", "event:page", ["/pageA", "/pageC"]]], + "metrics" => ["time_on_page"] + } + + response = do_query_success(conn, site, params) + + assert response["results"] == [%{"dimensions" => [], "metrics" => [20]}] + end + + test "calculates time_on_page when filtered for multiple contains pages", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, + pathname: "/blog/post-1", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:engagement, + pathname: "/blog/post-1", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:15:00], + engagement_time: 100_000 + ), + build(:pageview, + pathname: "/about", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:15:00] + ), + build(:engagement, + pathname: "/about", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:16:00], + engagement_time: 20_000 + ), + build(:pageview, + pathname: "/articles/post-1", + user_id: 321, + timestamp: ~N[2021-01-01 00:16:00] + ), + build(:engagement, + pathname: "/articles/post-1", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:17:00], + engagement_time: 30_000 + ), + build(:pageview, + pathname: "/", + user_id: 321, + timestamp: ~N[2021-01-01 00:17:00] + ), + build(:engagement, + pathname: "/", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:17:30], + engagement_time: 3_000 + ) + ]) + + params = %{ + "date_range" => "all", + "filters" => [["contains", "event:page", ["/blog/", "/articles/"]]], + "metrics" => ["time_on_page"] + } + + response = do_query_success(conn, site, params) + + assert response["results"] == [%{"dimensions" => [], "metrics" => [130]}] + end + + test "calculates time on page when filtered for multiple negated contains pages", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, + pathname: "/blog/post-1", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:engagement, + pathname: "/blog/post-1", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:15:00], + engagement_time: 100_000 + ), + build(:pageview, + pathname: "/about", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:15:00] + ), + build(:engagement, + pathname: "/about", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:16:00], + engagement_time: 20_000 + ), + build(:pageview, + pathname: "/articles/post-1", + user_id: 321, + timestamp: ~N[2021-01-01 00:16:00] + ), + build(:engagement, + pathname: "/articles/post-1", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:17:00], + engagement_time: 30_000 + ), + build(:pageview, + pathname: "/", + user_id: 321, + timestamp: ~N[2021-01-01 00:17:00] + ), + build(:engagement, + pathname: "/", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:17:30], + engagement_time: 3_000 + ) + ]) + + params = %{ + "date_range" => "all", + "filters" => [["contains_not", "event:page", ["/blog/", "/articles/"]]], + "metrics" => ["time_on_page"] + } + + response = do_query_success(conn, site, params) + + assert response["results"] == [%{"dimensions" => [], "metrics" => [23]}] + end + + test "bounce_rate is 0 when the page in filter was never a landing page", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, pathname: "/A", user_id: @user_id, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, pathname: "/", user_id: @user_id, timestamp: ~N[2021-01-01 00:10:00]) + ]) + + params = %{ + "date_range" => "all", + "filters" => [["is", "event:page", ["/"]]], + "metrics" => ["bounce_rate"] + } + + response = do_query_success(conn, site, params) + + assert response["results"] == [%{"dimensions" => [], "metrics" => [0]}] + end + + test "time_on_page is nil when it can't be calculated", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/") + ]) + + params = %{ + "date_range" => "all", + "filters" => [["is", "event:page", ["/"]]], + "metrics" => ["time_on_page"] + } + + response = do_query_success(conn, site, params) + + assert response["results"] == [%{"dimensions" => [], "metrics" => [nil]}] + end + + test "returns empty metrics when no data", %{conn: conn, site: site} do + requested_metrics = [ + "visitors", + "visits", + "pageviews", + "views_per_visit", + "bounce_rate", + "visit_duration" + ] + + params = %{ + "date_range" => "all", + "filters" => [], + "metrics" => requested_metrics + } + + response = do_query_success(conn, site, params) + + assert response["query"]["metrics"] == requested_metrics + assert response["results"] == [%{"dimensions" => [], "metrics" => [0, 0, 0, 0.0, 0, 0]}] + end + + test "returns information about imports with the imports_meta option", %{ + conn: conn, + site: site + } do + params = %{ + "date_range" => "all", + "filters" => [], + "metrics" => ["visitors"], + "include" => %{"imports" => false, "imports_meta" => true} + } + + response = do_query_success(conn, site, params) + + assert response["meta"] == %{ + "imports_included" => false, + "imports_skip_reason" => "no_imported_data" + } + end + + test "drops time on page if unavailable", %{conn: conn, site: site} do + site_import = + insert(:site_import, site: site, start_date: ~D[2021-01-01], end_date: ~D[2022-01-01]) + + site = Plausible.Sites.update_legacy_time_on_page_cutoff!(site, ~D[2023-01-01]) + + populate_stats(site, site_import.id, [ + build(:pageview, pathname: "/"), + build(:imported_pages, page: "/", date: ~D[2021-01-01]) + ]) + + params = %{ + "date_range" => "all", + "filters" => [["is", "event:page", ["/"]]], + "metrics" => ["visitors", "time_on_page"], + "include" => %{"imports" => true} + } + + response = do_query_success(conn, site, params) + + assert response["meta"]["imports_included"] + assert response["query"]["metrics"] == ["visitors"] + assert response["results"] == [%{"dimensions" => [], "metrics" => [2]}] + end + end + + describe "imported data" do + setup [ + :create_user, + :log_in, + :create_site, + :create_legacy_site_import + ] + + test "returns scroll depth warning", %{conn: conn, site: site} do + params = %{ + "date_range" => "all", + "filters" => [["is", "event:page", ["/"]]], + "metrics" => ["visitors", "scroll_depth"], + "include" => %{"imports" => true} + } + + assert %{"results" => results, "meta" => meta} = + do_query_success(conn, site, params) + + assert results == [%{"dimensions" => [], "metrics" => [0, nil]}] + assert meta["metric_warnings"]["scroll_depth"]["code"] == "no_imported_scroll_depth" + end + + test "returns divisible metrics as 0 when no stats exist", %{ + site: site, + conn: conn + } do + params = %{ + "date_range" => "all", + "filters" => [], + "metrics" => ["bounce_rate", "views_per_visit", "visit_duration"], + "include" => %{"imports" => true} + } + + response = do_query_success(conn, site, params) + + assert response["results"] == [%{"dimensions" => [], "metrics" => [0, 0, 0]}] + end + + test "merges imported data into all top stat metrics", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: @user_id, + timestamp: ~N[2021-01-01 00:15:00] + ), + build(:pageview, + timestamp: ~N[2021-01-01 00:15:00] + ), + build(:imported_visitors, date: ~D[2021-01-01]) + ]) + + params = %{ + "date_range" => "all", + "filters" => [], + "metrics" => [ + "visitors", + "visits", + "pageviews", + "views_per_visit", + "bounce_rate", + "visit_duration" + ], + "include" => %{"imports" => true} + } + + response = do_query_success(conn, site, params) + + assert response["results"] == [ + %{"dimensions" => [], "metrics" => [3, 3, 4, 1.33, 33, 303]} + ] + end + + test ":member filter on country", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, + country_code: "EE", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + country_code: "EE", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:01:00] + ), + build(:imported_locations, + country: "EE", + date: ~D[2021-01-01], + visitors: 1, + visits: 3, + pageviews: 34, + bounces: 0, + visit_duration: 420 + ), + build(:imported_locations, + country: "FR", + date: ~D[2021-01-01], + visitors: 3, + visits: 7, + pageviews: 65, + bounces: 1, + visit_duration: 300 + ), + build(:imported_locations, country: "US", date: ~D[2021-01-01], visitors: 999) + ]) + + params = %{ + "date_range" => ["2021-01-01", "2021-01-01"], + "filters" => [["is", "visit:country", ["EE", "FR"]]], + "metrics" => [ + "visitors", + "visits", + "pageviews", + "views_per_visit", + "bounce_rate", + "visit_duration" + ], + "include" => %{"imports" => true} + } + + response = do_query_success(conn, site, params) + + assert response["results"] == [ + %{"dimensions" => [], "metrics" => [5, 11, 101, 9.18, 9, 71]} + ] + end + + test ":is filter on page returns visitors, visits, pageviews bounce_rate, time_on_page and scroll_depth", + %{ + conn: conn, + site: site + } do + site_import = + insert(:site_import, site: site, start_date: ~D[2021-01-01], has_scroll_depth: true) + + populate_stats(site, site_import.id, [ + build(:pageview, + pathname: "/", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:01:00] + ), + build(:imported_pages, + page: "/", + date: ~D[2021-01-01], + total_time_on_page: 120, + total_time_on_page_visits: 3, + visitors: 1, + visits: 3, + pageviews: 34 + ), + build(:imported_pages, page: "/ignored", date: ~D[2021-01-01], visitors: 999) + ]) + + params = %{ + "date_range" => ["2021-01-01", "2021-01-01"], + "filters" => [["is", "event:page", ["/"]]], + "metrics" => [ + "visitors", + "visits", + "pageviews", + "bounce_rate", + "time_on_page", + "scroll_depth" + ], + "include" => %{"imports" => true} + } + + response = do_query_success(conn, site, params) + + assert response["results"] == [ + %{"dimensions" => [], "metrics" => [2, 4, 36, 0, 40, nil]} + ] + end + end + + describe "imports_meta" do + setup [:create_user, :log_in, :create_site] + + setup context do + insert(:site_import, + site: context.site, + start_date: ~D[2021-03-01], + end_date: ~D[2021-03-31] + ) + + context + end + + test "in realtime", %{conn: conn, site: site} do + params = %{ + "date_range" => ["2021-01-01", "2021-01-01"], + "filters" => [], + "metrics" => ["visitors"], + "include" => %{"imports_meta" => true} + } + + assert %{"meta" => meta} = do_query_success(conn, site, params) + + assert meta["imports_skip_reason"] == "out_of_range" + assert meta["imports_included"] == false + end + + test "when the site has no imported data", %{conn: conn, site: site} do + Plausible.Imported.delete_imports_for_site(site) + + params = %{ + "date_range" => ["2021-01-01", "2021-01-01"], + "filters" => [], + "metrics" => ["visitors"], + "include" => %{"imports_meta" => true} + } + + assert %{"meta" => meta} = do_query_success(conn, site, params) + + assert meta["imports_skip_reason"] == "no_imported_data" + assert meta["imports_included"] == false + end + + test "when imported data does not exist in the queried period", %{conn: conn, site: site} do + params = %{ + "date_range" => ["2022-05-05", "2022-05-05"], + "filters" => [], + "metrics" => ["visitors"], + "include" => %{"imports_meta" => true} + } + + assert %{"meta" => meta} = do_query_success(conn, site, params) + + assert meta["imports_skip_reason"] == "out_of_range" + assert meta["imports_included"] == false + end + + test "when imported data is requested, in range, and can be included", %{ + conn: conn, + site: site + } do + params = %{ + "date_range" => ["2021-03-15", "2021-03-15"], + "filters" => [], + "metrics" => ["visitors"], + "include" => %{"imports" => true, "imports_meta" => true} + } + + assert %{"meta" => meta} = do_query_success(conn, site, params) + + assert meta["imports_skip_reason"] == nil + assert meta["imports_included"] == true + end + + test "when imported data is not requested, but in range and can be included", %{ + conn: conn, + site: site + } do + params = %{ + "date_range" => ["2021-03-15", "2021-03-15"], + "filters" => [], + "metrics" => ["visitors"], + "include" => %{"imports" => false, "imports_meta" => true} + } + + assert %{"meta" => meta} = do_query_success(conn, site, params) + + assert meta["imports_skip_reason"] == nil + assert meta["imports_included"] == false + end + + test "when imported data is requested and in range, but cannot be included", %{ + conn: conn, + site: site + } do + insert(:goal, site: site, event_name: "Signup") + + params = %{ + "date_range" => ["2021-03-15", "2021-03-15"], + "filters" => [ + ["is", "event:goal", ["Signup"]], + ["is", "event:page", ["/register"]] + ], + "metrics" => ["visitors"], + "include" => %{"imports" => true, "imports_meta" => true} + } + + assert %{"meta" => meta} = do_query_success(conn, site, params) + + assert meta["imports_skip_reason"] == "unsupported_query" + assert meta["imports_included"] == false + end + + test "when imported data is requested and in comparison range", %{conn: conn, site: site} do + params = %{ + "date_range" => ["2022-03-15", "2022-03-15"], + "filters" => [], + "metrics" => ["visitors"], + "include" => %{ + "imports" => true, + "imports_meta" => true, + "compare" => "year_over_year" + } + } + + assert %{"meta" => meta} = do_query_success(conn, site, params) + + assert meta["imports_skip_reason"] == nil + assert meta["imports_included"] == true + end + + test "when imported data is not requested and in comparison range", %{conn: conn, site: site} do + params = %{ + "date_range" => ["2022-03-15", "2022-03-15"], + "filters" => [], + "metrics" => ["visitors"], + "include" => %{ + "imports" => false, + "imports_meta" => true, + "compare" => "year_over_year" + } + } + + assert %{"meta" => meta} = do_query_success(conn, site, params) + + assert meta["imports_skip_reason"] == nil + assert meta["imports_included"] == false + end + end + + describe "realtime" do + setup [:create_user, :log_in, :create_site] + + test "current visitors (last 5 minutes)", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, timestamp: relative_time(minute: -10)), + build(:pageview, timestamp: relative_time(minute: -4)), + build(:pageview, timestamp: relative_time(minute: -1)) + ]) + + params = %{ + "date_range" => "realtime", + "filters" => [], + "metrics" => ["visitors"] + } + + response = do_query_success(conn, site, params) + + assert response["results"] == [%{"dimensions" => [], "metrics" => [2]}] + end + + test "visitors and pageviews (last 30 minutes)", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, user_id: @user_id, timestamp: relative_time(minute: -45)), + build(:pageview, user_id: @user_id, timestamp: relative_time(minute: -25)), + build(:pageview, user_id: @user_id, timestamp: relative_time(minute: -20)), + build(:pageview, timestamp: relative_time(minute: -1)) + ]) + + params = %{ + "date_range" => "realtime_30m", + "filters" => [], + "metrics" => ["visitors", "pageviews"] + } + + response = do_query_success(conn, site, params) + + assert response["results"] == [%{"dimensions" => [], "metrics" => [2, 3]}] + end + + test "visitors, events, and CR with goal filter (last 30 min)", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:event, name: "Signup", timestamp: relative_time(minute: -45)), + build(:event, name: "Signup", timestamp: relative_time(minute: -25)), + build(:event, name: "Signup", user_id: @user_id, timestamp: relative_time(minute: -22)), + build(:event, name: "Signup", user_id: @user_id, timestamp: relative_time(minute: -21)), + build(:event, name: "Signup", user_id: @user_id, timestamp: relative_time(minute: -20)), + build(:pageview, timestamp: relative_time(minute: -20)) + ]) + + insert(:goal, site: site, event_name: "Signup") + + params = %{ + "date_range" => "realtime_30m", + "filters" => [["is", "event:goal", ["Signup"]]], + "metrics" => ["visitors", "events", "conversion_rate"] + } + + response = do_query_success(conn, site, params) + + assert response["results"] == [%{"dimensions" => [], "metrics" => [2, 4, 66.67]}] + end + end + + describe "filters" do + setup [:create_user, :log_in, :create_site] + + test "returns only visitors from a country based on alpha2 code", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, country_code: "US"), + build(:pageview, country_code: "US"), + build(:pageview, country_code: "EE") + ]) + + params = %{ + "date_range" => "month", + "filters" => [["is", "visit:country", ["US"]]], + "metrics" => ["visitors"] + } + + response = do_query_success(conn, site, params) + + assert response["results"] == [%{"dimensions" => [], "metrics" => [2]}] + end + + test "returns scroll_depth with a page filter", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, user_id: 123, timestamp: ~N[2021-01-01 00:00:00]), + build(:engagement, user_id: 123, timestamp: ~N[2021-01-01 00:00:10], scroll_depth: 40), + build(:pageview, user_id: 123, timestamp: ~N[2021-01-01 00:00:10]), + build(:engagement, user_id: 123, timestamp: ~N[2021-01-01 00:00:20], scroll_depth: 60), + build(:pageview, user_id: 456, timestamp: ~N[2021-01-01 00:00:00]), + build(:engagement, user_id: 456, timestamp: ~N[2021-01-01 00:00:10], scroll_depth: 80) + ]) + + params = %{ + "date_range" => "day", + "relative_date" => "2021-01-01", + "filters" => [["is", "event:page", ["/"]]], + "metrics" => ["scroll_depth"] + } + + response = do_query_success(conn, site, params) + + assert response["results"] == [%{"dimensions" => [], "metrics" => [70]}] + end + + test "returns scroll_depth with a page filter with imported data", %{conn: conn, site: site} do + site_import = + insert(:site_import, site: site, start_date: ~D[2021-01-01], has_scroll_depth: true) + + populate_stats(site, site_import.id, [ + build(:pageview, user_id: 123, timestamp: ~N[2021-01-01 00:00:00]), + build(:engagement, user_id: 123, timestamp: ~N[2021-01-01 00:00:10], scroll_depth: 40), + build(:pageview, user_id: 123, timestamp: ~N[2021-01-01 00:00:10]), + build(:engagement, user_id: 123, timestamp: ~N[2021-01-01 00:00:20], scroll_depth: 60), + build(:pageview, user_id: 456, timestamp: ~N[2021-01-01 00:00:00]), + build(:engagement, user_id: 456, timestamp: ~N[2021-01-01 00:00:10], scroll_depth: 80), + build(:imported_pages, + page: "/", + date: ~D[2021-01-01], + visitors: 8, + total_scroll_depth: 410, + total_scroll_depth_visits: 8 + ), + build(:imported_pages, page: "/", date: ~D[2021-01-02], visitors: 100) + ]) + + params = %{ + "date_range" => "7d", + "relative_date" => "2021-01-07", + "filters" => [["is", "event:page", ["/"]]], + "metrics" => ["visitors", "scroll_depth"], + "include" => %{"imports" => true} + } + + response = do_query_success(conn, site, params) + + assert response["results"] == [%{"dimensions" => [], "metrics" => [110, 55]}] + end + + test "contains filter", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/some-blog-post"), + build(:pageview, pathname: "/blog/post1"), + build(:pageview, pathname: "/another/post") + ]) + + params = %{ + "date_range" => "month", + "filters" => [["contains", "event:page", ["blog"]]], + "metrics" => ["visitors"] + } + + response = do_query_success(conn, site, params) + + assert response["results"] == [%{"dimensions" => [], "metrics" => [2]}] + end + + test "returns only visitors with specific screen size", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, screen_size: "Desktop"), + build(:pageview, screen_size: "Desktop"), + build(:pageview, screen_size: "Mobile") + ]) + + params = %{ + "date_range" => "month", + "filters" => [["is", "visit:screen", ["Desktop"]]], + "metrics" => ["visitors"] + } + + response = do_query_success(conn, site, params) + + assert response["results"] == [%{"dimensions" => [], "metrics" => [2]}] + end + + test "returns only visitors with specific screen size for a given hostname", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, screen_size: "Desktop", hostname: "blog.example.com"), + build(:pageview, screen_size: "Desktop", hostname: "example.com", user_id: @user_id), + build(:pageview, screen_size: "Desktop", hostname: "blog.example.com", user_id: @user_id), + build(:pageview, + screen_size: "Desktop", + hostname: "blog.example.com", + user_id: @user_id + 1 + ), + build(:pageview, screen_size: "Desktop", hostname: "example.com", user_id: @user_id + 1), + build(:pageview, screen_size: "Mobile", hostname: "blog.example.com") + ]) + + params = %{ + "date_range" => "month", + "filters" => [ + ["is", "visit:screen", ["Desktop"]], + ["is", "event:hostname", ["blog.example.com"]] + ], + "metrics" => ["visitors", "visits"] + } + + response = do_query_success(conn, site, params) + + assert response["results"] == [%{"dimensions" => [], "metrics" => [3, 3]}] + end + + test "returns only visitors with specific browser", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, browser: "Chrome"), + build(:pageview, browser: "Chrome"), + build(:pageview, browser: "Safari") + ]) + + params = %{ + "date_range" => "month", + "filters" => [["is", "visit:browser", ["Chrome"]]], + "metrics" => ["visitors"] + } + + response = do_query_success(conn, site, params) + + assert response["results"] == [%{"dimensions" => [], "metrics" => [2]}] + end + + test "returns only visitors with specific operating system", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, operating_system: "Mac"), + build(:pageview, operating_system: "Mac"), + build(:pageview, operating_system: "Windows") + ]) + + params = %{ + "date_range" => "month", + "filters" => [["is", "visit:os", ["Mac"]]], + "metrics" => ["visitors"] + } + + response = do_query_success(conn, site, params) + + assert response["results"] == [%{"dimensions" => [], "metrics" => [2]}] + end + + test "returns number of visits from one specific referral source", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + user_id: @user_id, + referrer_source: "Google", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: @user_id, + referrer_source: "Google", + timestamp: ~N[2021-01-01 00:05:00] + ), + build(:pageview, + user_id: @user_id, + referrer_source: "Google", + timestamp: ~N[2021-01-01 05:00:00] + ), + build(:pageview, timestamp: ~N[2021-01-01 00:10:00]) + ]) + + params = %{ + "date_range" => "day", + "relative_date" => "2021-01-01", + "filters" => [["is", "visit:source", ["Google"]]], + "metrics" => ["visits"] + } + + response = do_query_success(conn, site, params) + + assert response["results"] == [%{"dimensions" => [], "metrics" => [2]}] + end + + test "hostname exact filter", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/index", hostname: "example.com"), + build(:pageview, pathname: "/index", hostname: "example.com", user_id: @user_id), + build(:pageview, + pathname: "/blog/post1", + hostname: "blog.example.com", + user_id: @user_id + ), + build(:pageview, pathname: "/blog/post2", hostname: "blog.example.com") + ]) + + params = %{ + "date_range" => "month", + "filters" => [["is", "event:hostname", ["example.com"]]], + "metrics" => ["visitors", "pageviews"] + } + + response = do_query_success(conn, site, params) + + assert response["results"] == [%{"dimensions" => [], "metrics" => [2, 2]}] + end + + test "hostname contains filter", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/index", hostname: "example.com"), + build(:pageview, pathname: "/index", hostname: "example.com", user_id: @user_id), + build(:pageview, + pathname: "/blog/post1", + hostname: "blog.example.com", + user_id: @user_id + ), + build(:pageview, + pathname: "/blog/post2", + hostname: "blog.example.com", + user_id: @user_id + ), + build(:pageview, pathname: "/blog/post2", hostname: "blog.example.com"), + build(:pageview, pathname: "/blog/post2", hostname: "about.example.com") + ]) + + params = %{ + "date_range" => "month", + "filters" => [["contains", "event:hostname", ["example.com"]]], + "metrics" => ["visitors", "pageviews"] + } + + response = do_query_success(conn, site, params) + + assert response["results"] == [%{"dimensions" => [], "metrics" => [4, 6]}] + end + + test "hostname contains subdomain filter", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/index", hostname: "example.com"), + build(:pageview, pathname: "/index", hostname: "example.com", user_id: @user_id), + build(:pageview, + pathname: "/blog/post1", + hostname: "blog.example.com", + user_id: @user_id + ), + build(:pageview, + pathname: "/blog/post2", + hostname: "blog.example.com", + user_id: @user_id + ), + build(:pageview, pathname: "/blog/post3", hostname: "blog.example.com"), + build(:pageview, + pathname: "/blog/post2", + hostname: "blog.example.com", + user_id: 100_002_378_237 + ) + ]) + + params = %{ + "date_range" => "month", + "filters" => [["contains", "event:hostname", [".example.com"]]], + "metrics" => ["visitors", "pageviews"] + } + + response = do_query_success(conn, site, params) + + assert response["results"] == [%{"dimensions" => [], "metrics" => [3, 4]}] + end + end + + describe "goal filter" do + setup [:create_user, :log_in, :create_site] + + test "returns unique and total conversions and conversion rate", %{conn: conn, site: site} do + insert(:goal, site: site, event_name: "Signup") + + populate_stats(site, [ + build(:pageview, user_id: @user_id), + build(:pageview, user_id: @user_id), + build(:pageview), + build(:event, user_id: 1, name: "Signup"), + build(:event, user_id: 1, name: "Signup"), + build(:event, user_id: 2, name: "Signup") + ]) + + params = %{ + "date_range" => "day", + "filters" => [["is", "event:goal", ["Signup"]]], + "metrics" => ["visitors", "events", "conversion_rate"] + } + + response = do_query_success(conn, site, params) + + assert response["results"] == [%{"dimensions" => [], "metrics" => [2, 3, 50.0]}] + end + + @tag :ee_only + test "drops revenue metrics when they are not allowed", %{ + conn: conn, + user: user, + site: site + } do + {:ok, team} = Plausible.Teams.get_or_create(user) + insert(:goal, site: site, event_name: "Purchase", currency: "EUR") + subscribe_to_growth_plan(team) + + params = %{ + "date_range" => "day", + "filters" => [["is", "event:goal", ["Purchase"]]], + "metrics" => [ + "visitors", + "events", + "conversion_rate", + "total_revenue", + "average_revenue" + ] + } + + %{"results" => results, "query" => query} = do_query_success(conn, site, params) + + assert query["metrics"] == ["visitors", "events", "conversion_rate"] + assert results == [%{"dimensions" => [], "metrics" => [0, 0, 0.0]}] + end + + @tag :ee_only + test "returns average and total when filtering by a revenue goal", %{conn: conn, site: site} do + insert(:goal, + site: site, + event_name: "Payment", + currency: "USD", + display_name: "PaymentUSD" + ) + + insert(:goal, + site: site, + event_name: "AddToCart", + currency: "EUR", + display_name: "AddToCartEUR" + ) + + populate_stats(site, [ + build(:event, + name: "Payment", + revenue_reporting_amount: Decimal.new(13_29), + revenue_reporting_currency: "USD" + ), + build(:event, + name: "Payment", + revenue_reporting_amount: Decimal.new(19_90), + revenue_reporting_currency: "USD" + ), + build(:event, + name: "AddToCart", + revenue_reporting_amount: Decimal.new(10_31), + revenue_reporting_currency: "EUR" + ), + build(:event, + name: "AddToCart", + revenue_reporting_amount: Decimal.new(20_00), + revenue_reporting_currency: "EUR" + ) + ]) + + params = %{ + "date_range" => "all", + "filters" => [["is", "event:goal", ["PaymentUSD"]]], + "metrics" => ["average_revenue", "total_revenue"] + } + + response = do_query_success(conn, site, params) + + assert response["results"] == [ + %{ + "dimensions" => [], + "metrics" => [ + %{ + "long" => "$1,659.50", + "short" => "$1.7K", + "value" => 1659.5, + "currency" => "USD" + }, + %{ + "long" => "$3,319.00", + "short" => "$3.3K", + "value" => 3319.0, + "currency" => "USD" + } + ] + } + ] + end + + @tag :ee_only + test "returns average and total when filtering by many revenue goals with same currency", %{ + conn: conn, + site: site + } do + insert(:goal, site: site, event_name: "Payment", currency: "USD") + insert(:goal, site: site, event_name: "Payment2", currency: "USD") + insert(:goal, site: site, event_name: "AddToCart", currency: "EUR") + + populate_stats(site, [ + build(:event, + name: "Payment", + revenue_reporting_amount: Decimal.new(13_29), + revenue_reporting_currency: "USD" + ), + build(:event, + name: "Payment", + revenue_reporting_amount: Decimal.new(19_90), + revenue_reporting_currency: "USD" + ), + build(:event, + name: "Payment2", + revenue_reporting_amount: Decimal.new(13_29), + revenue_reporting_currency: "USD" + ), + build(:event, + name: "Payment2", + revenue_reporting_amount: Decimal.new(19_90), + revenue_reporting_currency: "USD" + ), + build(:event, + name: "AddToCart", + revenue_reporting_amount: Decimal.new(10_31), + revenue_reporting_currency: "EUR" + ), + build(:event, + name: "AddToCart", + revenue_reporting_amount: Decimal.new(20_00), + revenue_reporting_currency: "EUR" + ) + ]) + + params = %{ + "date_range" => "all", + "filters" => [["is", "event:goal", ["Payment", "Payment2"]]], + "metrics" => ["average_revenue", "total_revenue"] + } + + response = do_query_success(conn, site, params) + + assert response["results"] == [ + %{ + "dimensions" => [], + "metrics" => [ + %{ + "long" => "$1,659.50", + "short" => "$1.7K", + "value" => 1659.5, + "currency" => "USD" + }, + %{ + "long" => "$6,638.00", + "short" => "$6.6K", + "value" => 6638.0, + "currency" => "USD" + } + ] + } + ] + end + + @tag :ee_only + test "does not return average and total when filtering by many revenue goals with different currencies", + %{conn: conn, site: site} do + insert(:goal, site: site, event_name: "Payment", currency: "USD") + insert(:goal, site: site, event_name: "AddToCart", currency: "EUR") + + populate_stats(site, [ + build(:event, + name: "Payment", + revenue_reporting_amount: Decimal.new(13_29), + revenue_reporting_currency: "USD" + ), + build(:event, + name: "Payment", + revenue_reporting_amount: Decimal.new(19_90), + revenue_reporting_currency: "USD" + ), + build(:event, + name: "AddToCart", + revenue_reporting_amount: Decimal.new(10_31), + revenue_reporting_currency: "EUR" + ), + build(:event, + name: "AddToCart", + revenue_reporting_amount: Decimal.new(20_00), + revenue_reporting_currency: "EUR" + ) + ]) + + params = %{ + "date_range" => "all", + "filters" => [["is", "event:goal", ["Payment", "AddToCart"]]], + "metrics" => ["visitors", "events", "conversion_rate", "total_revenue", "average_revenue"] + } + + %{"results" => results, "query" => query} = do_query_success(conn, site, params) + + assert query["metrics"] == ["visitors", "events", "conversion_rate"] + assert results == [%{"dimensions" => [], "metrics" => [4, 4, 100.0]}] + end + + @tag :ee_only + test "returns average and total revenue when filtering by many goals some which don't have currencies", + %{conn: conn, site: site} do + insert(:goal, site: site, event_name: "Payment", currency: "USD") + insert(:goal, site: site, event_name: "Signup") + + populate_stats(site, [ + build(:event, + name: "Payment", + revenue_reporting_amount: Decimal.new(1_000), + revenue_reporting_currency: "USD" + ), + build(:event, + name: "Payment", + revenue_reporting_amount: Decimal.new(1_000), + revenue_reporting_currency: "USD" + ), + build(:event, name: "Signup"), + build(:event, name: "Signup") + ]) + + params = %{ + "date_range" => "all", + "filters" => [["is", "event:goal", ["Payment", "Signup"]]], + "metrics" => ["average_revenue", "total_revenue"] + } + + response = do_query_success(conn, site, params) + + assert response["results"] == [ + %{ + "dimensions" => [], + "metrics" => [ + %{ + "long" => "$1,000.00", + "short" => "$1.0K", + "value" => 1000.0, + "currency" => "USD" + }, + %{ + "long" => "$2,000.00", + "short" => "$2.0K", + "value" => 2000.0, + "currency" => "USD" + } + ] + } + ] + end + + @tag :ee_only + test "returns average and total revenue when no conversions", + %{conn: conn, site: site} do + insert(:goal, site: site, event_name: "Payment", currency: "USD") + + params = %{ + "date_range" => "all", + "filters" => [["is", "event:goal", ["Payment", "Signup"]]], + "metrics" => ["average_revenue", "total_revenue"] + } + + response = do_query_success(conn, site, params) + + assert response["results"] == [ + %{ + "dimensions" => [], + "metrics" => [ + %{"long" => "$0.00", "short" => "$0.0", "value" => 0.0, "currency" => "USD"}, + %{"long" => "$0.00", "short" => "$0.0", "value" => 0.0, "currency" => "USD"} + ] + } + ] + end + + @tag :ee_only + test "does not return average and total revenue when filtering non-currency goal", + %{conn: conn, site: site} do + insert(:goal, site: site, event_name: "Payment", display_name: "PaymentWithoutCurrency") + + populate_stats(site, [ + build(:event, + name: "Payment", + revenue_reporting_amount: Decimal.new(1_000), + revenue_reporting_currency: "USD" + ), + build(:event, + name: "Payment", + revenue_reporting_amount: Decimal.new(1_000), + revenue_reporting_currency: "USD" + ) + ]) + + params = %{ + "date_range" => "all", + "filters" => [["is", "event:goal", ["PaymentWithoutCurrency"]]], + "metrics" => ["visitors", "events", "conversion_rate", "total_revenue", "average_revenue"] + } + + %{"results" => results, "query" => query} = do_query_success(conn, site, params) + + assert query["metrics"] == ["visitors", "events", "conversion_rate"] + assert results == [%{"dimensions" => [], "metrics" => [2, 2, 100.0]}] + end + + @tag :ee_only + test "does not return average and total when site owner is on a growth plan", + %{conn: conn, site: site, user: user} do + subscribe_to_growth_plan(user) + insert(:goal, site: site, event_name: "Payment", currency: "USD") + + populate_stats(site, [ + build(:event, + name: "Payment", + revenue_reporting_amount: Decimal.new(13_29), + revenue_reporting_currency: "USD" + ) + ]) + + params = %{ + "date_range" => "all", + "filters" => [["is", "event:goal", ["Payment"]]], + "metrics" => ["visitors", "events", "conversion_rate", "total_revenue", "average_revenue"] + } + + %{"results" => results, "query" => query} = do_query_success(conn, site, params) + + assert query["metrics"] == ["visitors", "events", "conversion_rate"] + assert results == [%{"dimensions" => [], "metrics" => [1, 1, 100.0]}] + end + + test "page scroll goal filter", %{conn: conn, site: site} do + insert(:goal, site: site, page_path: "/blog", scroll_threshold: 50) + + populate_stats(site, [ + build(:pageview, user_id: 123, pathname: "/blog", timestamp: ~N[2021-01-01 00:00:00]), + build(:engagement, + user_id: 123, + pathname: "/blog", + timestamp: ~N[2021-01-01 00:00:10], + scroll_depth: 60 + ), + build(:pageview, user_id: 456, pathname: "/blog", timestamp: ~N[2021-01-01 00:00:00]), + build(:engagement, + user_id: 456, + pathname: "/blog", + timestamp: ~N[2021-01-01 00:00:10], + scroll_depth: 40 + ) + ]) + + params = %{ + "date_range" => "all", + "filters" => [["is", "event:goal", ["Visit /blog"]]], + "metrics" => ["visitors", "events", "conversion_rate"] + } + + response = do_query_success(conn, site, params) + + assert response["results"] == [%{"dimensions" => [], "metrics" => [1, nil, 50.0]}] + end + + test "goal is page scroll OR custom event", %{conn: conn, site: site} do + insert(:goal, site: site, page_path: "/blog", scroll_threshold: 50) + insert(:goal, site: site, event_name: "Signup") + + populate_stats(site, [ + build(:pageview, user_id: 123, pathname: "/blog", timestamp: ~N[2021-01-01 00:00:00]), + build(:event, + user_id: 123, + pathname: "/blog", + name: "Signup", + timestamp: ~N[2021-01-01 00:00:05] + ), + build(:engagement, + user_id: 123, + pathname: "/blog", + timestamp: ~N[2021-01-01 00:00:10], + scroll_depth: 60 + ), + build(:pageview, user_id: 456, pathname: "/blog", timestamp: ~N[2021-01-01 00:00:00]), + build(:engagement, + user_id: 456, + pathname: "/blog", + timestamp: ~N[2021-01-01 00:00:10], + scroll_depth: 40 + ), + build(:pageview, user_id: 789, pathname: "/blog", timestamp: ~N[2021-01-01 00:00:00]), + build(:engagement, + user_id: 789, + pathname: "/blog", + timestamp: ~N[2021-01-01 00:00:10], + scroll_depth: 100 + ), + build(:event, name: "Signup", timestamp: ~N[2021-01-01 00:01:00]), + build(:pageview, timestamp: ~N[2021-01-01 00:01:00]) + ]) + + params = %{ + "date_range" => "all", + "filters" => [["is", "event:goal", ["Visit /blog", "Signup"]]], + "metrics" => ["visitors", "events", "conversion_rate"] + } + + response = do_query_success(conn, site, params) + + assert response["results"] == [%{"dimensions" => [], "metrics" => [3, nil, 60.0]}] + end + + test "goal is page scroll OR pageview goal", %{conn: conn, site: site} do + insert(:goal, + site: site, + page_path: "/blog**", + scroll_threshold: 50, + display_name: "Scroll 50 /blog**" + ) + + insert(:goal, site: site, page_path: "/blog") + + populate_stats(site, [ + build(:pageview, user_id: 123, pathname: "/blog", timestamp: ~N[2021-01-01 00:00:00]), + build(:engagement, + user_id: 123, + pathname: "/blog", + timestamp: ~N[2021-01-01 00:00:10], + scroll_depth: 60 + ), + build(:pageview, user_id: 456, pathname: "/blog", timestamp: ~N[2021-01-01 00:00:00]), + build(:engagement, + user_id: 456, + pathname: "/blog", + timestamp: ~N[2021-01-01 00:00:10], + scroll_depth: 40 + ), + build(:pageview, timestamp: ~N[2021-01-01 00:01:00]) + ]) + + params = %{ + "date_range" => "all", + "filters" => [["is", "event:goal", ["Visit /blog", "Scroll 50 /blog**"]]], + "metrics" => ["visitors", "events", "conversion_rate"] + } + + response = do_query_success(conn, site, params) + + assert response["results"] == [%{"dimensions" => [], "metrics" => [2, nil, 66.67]}] + end + end + + describe "comparisons" do + setup [:create_user, :log_in, :create_site, :create_legacy_site_import] + + test "returns comparison data when mode is custom", %{site: site, conn: conn} do + populate_stats(site, [ + build(:pageview, timestamp: ~N[2020-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2020-01-05 00:00:00]), + build(:pageview, timestamp: ~N[2020-01-20 00:00:00]), + build(:pageview, timestamp: ~N[2020-12-31 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-01 00:01:00]), + build(:pageview, timestamp: ~N[2021-01-01 10:00:00]) + ]) + + params = %{ + "date_range" => "day", + "relative_date" => "2021-01-01", + "filters" => [], + "metrics" => ["visits"], + "include" => %{"compare" => ["2020-01-01", "2020-01-20"]} + } + + response = do_query_success(conn, site, params) + + assert response["results"] == [ + %{ + "dimensions" => [], + "metrics" => [3], + "comparison" => %{"dimensions" => [], "metrics" => [3], "change" => [0]} + } + ] + end + + test "returns source query and comparison query date range", %{site: site, conn: conn} do + populate_stats(site, [ + build(:pageview, timestamp: ~N[2020-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2020-01-05 00:00:00]), + build(:pageview, timestamp: ~N[2020-01-20 00:00:00]), + build(:pageview, timestamp: ~N[2020-12-31 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-01 00:01:00]), + build(:pageview, timestamp: ~N[2021-01-01 10:00:00]) + ]) + + params = %{ + "date_range" => "month", + "relative_date" => "2021-01-01", + "filters" => [], + "metrics" => ["visitors"], + "include" => %{"compare" => "previous_period"} + } + + assert %{"query" => query} = do_query_success(conn, site, params) + + assert query["date_range"] == ["2021-01-01T00:00:00Z", "2021-01-31T23:59:59Z"] + assert query["comparison_date_range"] == ["2020-12-01T00:00:00Z", "2020-12-31T23:59:59Z"] + end + + test "compares native and imported data", %{site: site, conn: conn} do + populate_stats(site, [ + build(:imported_visitors, date: ~D[2020-01-02]), + build(:imported_visitors, date: ~D[2020-01-02]), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + params = %{ + "date_range" => "month", + "relative_date" => "2021-01-01", + "filters" => [], + "metrics" => ["visits"], + "include" => %{"imports" => true, "imports_meta" => true, "compare" => "year_over_year"} + } + + %{"results" => results, "meta" => meta} = do_query_success(conn, site, params) + + assert meta["imports_included"] == true + + assert results == [ + %{ + "dimensions" => [], + "metrics" => [4], + "comparison" => %{"dimensions" => [], "metrics" => [2], "change" => [100]} + } + ] + end + + test "does not compare imported data when include.imports is false", %{ + site: site, + conn: conn + } do + populate_stats(site, [ + build(:imported_visitors, date: ~D[2020-01-02]), + build(:imported_visitors, date: ~D[2020-01-02]), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, timestamp: ~N[2021-01-01 00:00:00]) + ]) + + params = %{ + "date_range" => "month", + "relative_date" => "2021-01-01", + "filters" => [], + "metrics" => ["visits"], + "include" => %{"imports" => false, "compare" => "year_over_year"} + } + + response = do_query_success(conn, site, params) + + assert response["results"] == [ + %{ + "dimensions" => [], + "metrics" => [4], + "comparison" => %{"dimensions" => [], "metrics" => [0], "change" => [100]} + } + ] + end + + test "compares conversion rates", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, user_id: @user_id, timestamp: ~N[2023-01-03T00:00:00]), + build(:pageview, user_id: @user_id, timestamp: ~N[2023-01-03T00:00:00]), + build(:pageview, timestamp: ~N[2023-01-03T00:00:00]), + build(:event, name: "Signup", timestamp: ~N[2023-01-03T00:00:00]), + build(:pageview, user_id: @user_id, timestamp: ~N[2023-01-02T00:00:00]), + build(:event, name: "Signup", timestamp: ~N[2023-01-02T00:00:00]), + build(:event, name: "Signup", timestamp: ~N[2023-01-02T00:00:00]) + ]) + + insert(:goal, site: site, event_name: "Signup") + + params = %{ + "date_range" => "day", + "relative_date" => "2023-01-03", + "filters" => [["is", "event:goal", ["Signup"]]], + "metrics" => ["conversion_rate"], + "include" => %{"compare" => "previous_period"} + } + + response = do_query_success(conn, site, params) + + assert response["results"] == [ + %{ + "dimensions" => [], + "metrics" => [33.33], + "comparison" => %{"dimensions" => [], "metrics" => [66.67], "change" => [-33.3]} + } + ] + end + end +end diff --git a/test/plausible_web/controllers/auth_controller/logs_test.exs b/test/plausible_web/controllers/auth_controller/logs_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..cba37d22784e5272433af430f23c9d6bf416c629 --- /dev/null +++ b/test/plausible_web/controllers/auth_controller/logs_test.exs @@ -0,0 +1,61 @@ +defmodule PlausibleWeb.AuthController.LogsTest do + use PlausibleWeb.ConnCase + import ExUnit.CaptureLog + + setup {PlausibleWeb.FirstLaunchPlug.Test, :skip} + + describe "POST /login" do + setup do + patch_env(:log_failed_login_attempts, true) + end + + test "logs on missing user", %{conn: conn} do + logs = + capture_log(fn -> + post(conn, "/login", email: "user@example.com", password: "password") + end) + + assert logs =~ "[warning] [login] user not found for user@example.com" + end + + test "logs on wrong password", %{conn: conn} do + user = insert(:user, password: "password") + + logs = + capture_log(fn -> + post(conn, "/login", email: user.email, password: "wrong") + end) + + assert logs =~ "[warning] [login] wrong password for #{user.email}" + end + + test "logs on too many login attempts" do + user = insert(:user, password: "password") + + conn = + build_conn() + |> put_req_header("x-forwarded-for", "1.1.1.1") + + logs = + eventually( + fn -> + capture_log(fn -> + Enum.each(1..5, fn _ -> + post(conn, "/login", email: user.email, password: "wrong") + end) + end) + + {conn, logs} = + with_log(fn -> + post(conn, "/login", email: user.email, password: "wrong") + end) + + {conn.status == 429, logs} + end, + 500 + ) + + assert logs =~ "[warning] [login] too many login attempts for #{user.email}" + end + end +end diff --git a/test/plausible_web/controllers/auth_controller_test.exs b/test/plausible_web/controllers/auth_controller_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..78ae1fc00979dbc616b81f5bd1b18f0cc482f8f3 --- /dev/null +++ b/test/plausible_web/controllers/auth_controller_test.exs @@ -0,0 +1,2165 @@ +defmodule PlausibleWeb.AuthControllerTest do + use PlausibleWeb.ConnCase, async: true + use Bamboo.Test + use Plausible.Repo + + import Mox + + require Logger + require Plausible.Billing.Subscription.Status + + alias Plausible.Auth + alias Plausible.Auth.User + alias Plausible.Billing.Subscription + + setup {PlausibleWeb.FirstLaunchPlug.Test, :skip} + setup [:verify_on_exit!] + + describe "GET /register" do + test "shows the register form", %{conn: conn} do + conn = get(conn, "/register") + + html = html_response(conn, 200) + + if ee?() do + assert html =~ "Start your 30-day free trial" + else + assert html =~ "Create your #{Plausible.product_name()} account" + end + end + end + + describe "POST /login (register_action = register_form)" do + test "registering sends an activation link", %{conn: conn} do + Repo.insert!( + User.new(%{ + name: "Jane Doe", + email: "user@example.com", + password: "very-secret-and-very-long-123", + password_confirmation: "very-secret-and-very-long-123" + }) + ) + + post(conn, "/login", + user: %{ + email: "user@example.com", + password: "very-secret-and-very-long-123", + register_action: "register_form" + } + ) + + assert_delivered_email_matches(%{to: [{_, user_email}], subject: subject}) + assert user_email == "user@example.com" + assert subject =~ "is your Plausible email verification code" + end + + test "user is redirected to activate page after registration", %{conn: conn} do + Repo.insert!( + User.new(%{ + name: "Jane Doe", + email: "user@example.com", + password: "very-secret-and-very-long-123", + password_confirmation: "very-secret-and-very-long-123" + }) + ) + + conn = + post(conn, "/login", + user: %{ + email: "user@example.com", + password: "very-secret-and-very-long-123", + register_action: "register_form" + } + ) + + assert redirected_to(conn, 302) == "/activate?flow=register&team_identifier=" + end + + test "logs the user in", %{conn: conn} do + user = + Repo.insert!( + User.new(%{ + name: "Jane Doe", + email: "user@example.com", + password: "very-secret-and-very-long-123", + password_confirmation: "very-secret-and-very-long-123" + }) + ) + + conn = + post(conn, "/login", + user: %{ + email: "user@example.com", + password: "very-secret-and-very-long-123", + register_action: "register_form" + } + ) + + assert %{sessions: [%{token: token}]} = user |> Repo.reload!() |> Repo.preload(:sessions) + assert get_session(conn, :user_token) == token + end + end + + describe "GET /register/invitations/:invitation_id" do + test "shows the register form", %{conn: conn} do + inviter = new_user() + site = new_site(owner: inviter) + + invitation = invite_guest(site, "user@email.co", role: :editor, inviter: inviter) + + conn = get(conn, "/register/invitation/#{invitation.invitation_id}") + + assert html_response(conn, 200) =~ "Accept your invitation to join your team." + end + end + + describe "POST /login (register_action = register_from_invitation_form)" do + setup do + inviter = new_user() + site = new_site(owner: inviter) + + invitation = invite_guest(site, "user@email.co", role: :editor, inviter: inviter) + + user = + Repo.insert!( + User.new(%{ + name: "Jane Doe", + email: "user@example.com", + password: "very-secret-and-very-long-123", + password_confirmation: "very-secret-and-very-long-123" + }) + ) + + {:ok, %{site: site, invitation: invitation, user: user}} + end + + test "registering sends an activation link", %{conn: conn} do + post(conn, "/login", + user: %{ + name: "Jane Doe", + email: "user@example.com", + password: "very-secret-and-very-long-123", + password_confirmation: "very-secret-and-very-long-123", + register_action: "register_from_invitation_form", + team_identifier: "" + } + ) + + assert_delivered_email_matches(%{to: [{_, user_email}], subject: subject}) + assert user_email == "user@example.com" + assert subject =~ "is your Plausible email verification code" + end + + test "user is redirected to activate page after registration", %{conn: conn} do + conn = + post(conn, "/login", + user: %{ + name: "Jane Doe", + email: "user@example.com", + password: "very-secret-and-very-long-123", + password_confirmation: "very-secret-and-very-long-123", + register_action: "register_from_invitation_form", + team_identifier: "" + } + ) + + assert redirected_to(conn, 302) == "/activate?flow=invitation&team_identifier=" + end + + test "user with team invite is redirected to activate page after registration", %{conn: conn} do + team_identifier = Ecto.UUID.generate() + + conn = + post(conn, "/login", + user: %{ + name: "Jane Doe", + email: "user@example.com", + password: "very-secret-and-very-long-123", + password_confirmation: "very-secret-and-very-long-123", + register_action: "register_from_invitation_form", + team_identifier: team_identifier + } + ) + + assert redirected_to(conn, 302) == + "/activate?flow=invitation&team_identifier=#{team_identifier}" + end + + test "logs the user in", %{conn: conn, user: user} do + conn = + post(conn, "/login", + user: %{ + name: "Jane Doe", + email: "user@example.com", + password: "very-secret-and-very-long-123", + password_confirmation: "very-secret-and-very-long-123", + register_action: "register_from_invitation_form", + team_identifier: "" + } + ) + + assert %{sessions: [%{token: token}]} = user |> Repo.reload!() |> Repo.preload(:sessions) + assert get_session(conn, :user_token) == token + end + + test "logs the user in and redirects to sites index when email is already verified", %{ + conn: conn, + user: user + } do + user |> Ecto.Changeset.change(email_verified: true) |> Repo.update!() + + conn = + post(conn, "/login", + user: %{ + name: "Jane Doe", + email: "user@example.com", + password: "very-secret-and-very-long-123", + password_confirmation: "very-secret-and-very-long-123", + register_action: "register_from_invitation_form", + team_identifier: "" + } + ) + + assert %{sessions: [%{token: token}]} = user |> Repo.reload!() |> Repo.preload(:sessions) + assert get_session(conn, :user_token) == token + assert redirected_to(conn, 302) == Routes.site_path(conn, :index) + end + + test "logs the user in, accepts team invite and redirects to team sites index", %{ + conn: conn, + user: user + } do + owner = new_user() + _site = new_site(owner: owner) + team = team_of(owner) + + invite_member(team, user, role: :viewer, inviter: owner) + + user |> Ecto.Changeset.change(email_verified: true) |> Repo.update!() + + conn = + post(conn, "/login", + user: %{ + name: "Jane Doe", + email: "user@example.com", + password: "very-secret-and-very-long-123", + password_confirmation: "very-secret-and-very-long-123", + register_action: "register_from_invitation_form", + team_identifier: team.identifier + } + ) + + assert %{sessions: [%{token: token}]} = user |> Repo.reload!() |> Repo.preload(:sessions) + assert get_session(conn, :user_token) == token + assert redirected_to(conn, 302) == Routes.site_path(conn, :index, __team: team.identifier) + + assert_team_membership(user, team, :viewer) + end + + test "logs the user in and redirects to team sites index even when there's no matching team invite", + %{ + conn: conn, + user: user + } do + owner = new_user() + _site = new_site(owner: owner) + team = team_of(owner) + + add_member(team, user: user, role: :viewer) + + user |> Ecto.Changeset.change(email_verified: true) |> Repo.update!() + + conn = + post(conn, "/login", + user: %{ + name: "Jane Doe", + email: "user@example.com", + password: "very-secret-and-very-long-123", + password_confirmation: "very-secret-and-very-long-123", + register_action: "register_from_invitation_form", + team_identifier: team.identifier + } + ) + + assert %{sessions: [%{token: token}]} = user |> Repo.reload!() |> Repo.preload(:sessions) + assert get_session(conn, :user_token) == token + assert redirected_to(conn, 302) == Routes.site_path(conn, :index, __team: team.identifier) + end + end + + describe "GET /activate" do + setup [:create_user, :log_in] + + test "if user does not have a code: prompts user to request activation code", %{conn: conn} do + conn = get(conn, "/activate") + + assert html_response(conn, 200) =~ "Send activation code" + end + + test "if user does have a code: prompts user to enter the activation code from their email", + %{conn: conn} do + conn = + post(conn, "/activate/request-code") + |> get("/activate") + + assert html_response(conn, 200) =~ "We've sent an email with your code to:" + end + + test "passes team identifier in form data", %{conn: conn, user: user} do + Auth.EmailVerification.issue_code(user) + + team_identifier = Ecto.UUID.generate() + + conn = get(conn, "/activate?team_identifier=#{team_identifier}") + + assert html = html_response(conn, 200) + + assert text_of_attr(html, "input[name=team_identifier]", "value") == team_identifier + end + end + + describe "POST /activate/request-code" do + setup [:create_user, :log_in] + + test "generates an activation pin for user account", %{conn: conn, user: user} do + post(conn, "/activate/request-code") + + assert code = Repo.get_by(Auth.EmailActivationCode, user_id: user.id) + + assert code.user_id == user.id + refute Plausible.Auth.EmailVerification.expired?(code) + end + + test "regenerates an activation pin even if there's one already", %{conn: conn, user: user} do + five_minutes_ago = + NaiveDateTime.utc_now() + |> NaiveDateTime.shift(minute: -5) + |> NaiveDateTime.truncate(:second) + + {:ok, verification} = Auth.EmailVerification.issue_code(user, five_minutes_ago) + + post(conn, "/activate/request-code") + + assert new_verification = Repo.get_by(Auth.EmailActivationCode, user_id: user.id) + + assert verification.id == new_verification.id + assert verification.user_id == new_verification.user_id + # this actually has a chance to fail 1 in 8999 runs + # but at the same time it's good to have a confirmation + # that it indeed generates a new code + if verification.code == new_verification.code do + Logger.warning( + "Congratulations! You you have hit 1 in 8999 chance of the same " <> + "email verification code repeating twice in a row!" + ) + end + + assert NaiveDateTime.compare(verification.issued_at, new_verification.issued_at) == :lt + end + + test "sends activation email to user", %{conn: conn, user: user} do + post(conn, "/activate/request-code") + + assert_delivered_email_matches(%{to: [{_, user_email}], subject: subject}) + assert user_email == user.email + assert subject =~ "is your Plausible email verification code" + end + + test "redirects user to /activate", %{conn: conn} do + conn = post(conn, "/activate/request-code") + + assert redirected_to(conn, 302) == "/activate" + end + end + + describe "POST /activate" do + setup [:create_user, :log_in] + + test "with wrong pin - reloads the form with error", %{conn: conn} do + conn = post(conn, "/activate", %{code: "1234"}) + + assert html_response(conn, 200) =~ + htmlize_quotes("That code didn't work. Please try again.") + end + + test "with expired pin - reloads the form with error", %{conn: conn, user: user} do + one_day_ago = + NaiveDateTime.utc_now() + |> NaiveDateTime.shift(day: -1) + |> NaiveDateTime.truncate(:second) + + {:ok, verification} = Auth.EmailVerification.issue_code(user, one_day_ago) + + conn = post(conn, "/activate", %{code: verification.code}) + + assert html_response(conn, 200) =~ "The code has expired. Please request another one." + end + + test "marks the user account as active", %{conn: conn, user: user} do + Repo.update!(Plausible.Auth.User.changeset(user, %{email_verified: false})) + post(conn, "/activate/request-code") + + verification = Repo.get_by!(Auth.EmailActivationCode, user_id: user.id) + + conn = post(conn, "/activate", %{code: verification.code}) + user = Repo.get_by(Plausible.Auth.User, id: user.id) + + assert user.email_verified + assert redirected_to(conn) == "/sites/new?flow=" + end + + test "redirects to /sites if user has invitation", %{conn: conn, user: user} do + owner = new_user() + site = new_site(owner: owner) + invite_guest(site, user, role: :viewer, inviter: owner) + + Repo.update!(Plausible.Auth.User.changeset(user, %{email_verified: false})) + + post(conn, "/activate/request-code") + + verification = Repo.get_by!(Auth.EmailActivationCode, user_id: user.id) + + conn = post(conn, "/activate", %{code: verification.code, team_identifier: ""}) + + assert redirected_to(conn) == "/sites?flow=" + end + + test "accepts team invite and redirects to team sites, if team provided", %{ + conn: conn, + user: user + } do + owner = new_user() + _site = new_site(owner: owner) + team = team_of(owner) + invite_member(team, user, role: :viewer, inviter: owner) + + Repo.update!(Auth.User.changeset(user, %{email_verified: false})) + + {:ok, %{code: code}} = Auth.EmailVerification.issue_code(user) + + conn = post(conn, "/activate", %{code: code, team_identifier: team.identifier}) + + assert redirected_to(conn) == + Routes.site_path(conn, :index, __team: team.identifier, flow: "") + + assert_team_membership(user, team, :viewer) + end + + test "redirects to team sites if team provided, even if there's no invite", %{ + conn: conn, + user: user + } do + owner = new_user() + _site = new_site(owner: owner) + team = team_of(owner) + add_member(team, user: user, role: :viewer) + + Repo.update!(Auth.User.changeset(user, %{email_verified: false})) + + {:ok, %{code: code}} = Auth.EmailVerification.issue_code(user) + + conn = post(conn, "/activate", %{code: code, team_identifier: team.identifier}) + + assert redirected_to(conn) == + Routes.site_path(conn, :index, __team: team.identifier, flow: "") + end + + test "removes used up verification code", %{conn: conn, user: user} do + Repo.update!(Plausible.Auth.User.changeset(user, %{email_verified: false})) + post(conn, "/activate/request-code") + + verification = Repo.get_by!(Auth.EmailActivationCode, user_id: user.id) + + post(conn, "/activate", %{code: verification.code}) + + refute Repo.get_by(Auth.EmailActivationCode, user_id: user.id) + end + + test "limits activation attempts to 10 per 5 minutes", %{conn: conn} do + conn = put_req_header(conn, "x-forwarded-for", "10.9.8.7") + + response = + eventually( + fn -> + Enum.each(1..10, fn _ -> + post(conn, "/activate", %{code: "1111"}) + end) + + conn = post(conn, "/activate", %{code: "1111"}) + body = html_response(conn, 200) + + {body =~ "Too many attempts", conn} + end, + 500 + ) + + assert html_response(response, 200) =~ + "Too many attempts. Please wait a few minutes before trying again." + end + end + + describe "GET /login_form" do + test "shows the login form", %{conn: conn} do + conn = get(conn, "/login") + assert html_response(conn, 200) =~ "Sign in to your account" + end + + test "renders `return_to` query param as hidden input", %{conn: conn} do + conn = get(conn, "/login?return_to=/dummy.site") + + input_value = + conn + |> html_response(200) + |> text_of_attr("input[name=return_to]", "value") + + assert input_value == "/dummy.site" + end + + @tag :ee_only + test "redirects to sso login if preferred", %{conn: conn} do + conn = PlausibleWeb.LoginPreference.set_sso(conn) + conn = get(conn, "/login?return_to=foo") + assert redirected_to(conn, 302) == "/sso/login?return_to=foo" + end + + @tag :ee_only + test "keeps standard login form if preference manually overridden", %{conn: conn} do + conn = PlausibleWeb.LoginPreference.set_sso(conn) + conn = get(conn, "/login?prefer=manual") + assert html_response(conn, 200) =~ "Sign in to your account" + end + end + + describe "POST /login" do + test "valid email and password - logs the user in", %{conn: conn} do + user = insert(:user, password: "password") + + conn = post(conn, "/login", email: user.email, password: "password") + + assert %{sessions: [%{token: token}]} = user |> Repo.reload!() |> Repo.preload(:sessions) + assert get_session(conn, :user_token) == token + assert redirected_to(conn) == "/sites" + end + + test "valid email and password, user on multiple teams - logs the user in", %{conn: conn} do + user = insert(:user, password: "password") + + # first team + new_site(owner: user) + + # another team + another_team = new_site().team |> Plausible.Teams.complete_setup() + add_member(another_team, user: user, role: :owner) + + conn = post(conn, "/login", email: user.email, password: "password") + + assert %{sessions: [%{token: token}]} = user |> Repo.reload!() |> Repo.preload(:sessions) + assert get_session(conn, :user_token) == token + assert redirected_to(conn) == "/sites" + end + + test "valid email and password with return_to set - redirects properly", %{conn: conn} do + user = insert(:user, password: "password") + + conn = + post(conn, "/login", + email: user.email, + password: "password", + return_to: Routes.settings_path(conn, :index) + ) + + assert redirected_to(conn, 302) == Routes.settings_path(conn, :index) + end + + test "valid email and password with 2FA enabled - sets 2FA session and redirects", %{ + conn: conn + } do + user = insert(:user, password: "password") + + # enable 2FA + {:ok, user, _} = Auth.TOTP.initiate(user) + {:ok, _, _} = Auth.TOTP.enable(user, :skip_verify) + + conn = post(conn, "/login", email: user.email, password: "password") + + assert redirected_to(conn, 302) == Routes.auth_path(conn, :verify_2fa_form) + + assert fetch_cookies(conn).cookies["session_2fa"].current_2fa_user_id == user.id + refute get_session(conn)["user_token"] + end + + test "valid email and password with 2FA enabled and remember 2FA cookie set - logs the user in", + %{conn: conn} do + user = insert(:user, password: "password") + + # enable 2FA + {:ok, user, _} = Auth.TOTP.initiate(user) + {:ok, user, _} = Auth.TOTP.enable(user, :skip_verify) + + conn = set_remember_2fa_cookie(conn, user) + + conn = post(conn, "/login", email: user.email, password: "password") + + assert redirected_to(conn, 302) == Routes.site_path(conn, :index) + + assert conn.resp_cookies["session_2fa"].max_age == 0 + assert %{sessions: [%{token: token}]} = user |> Repo.reload!() |> Repo.preload(:sessions) + assert get_session(conn, :user_token) == token + end + + test "valid email and password with 2FA enabled and rogue remember 2FA cookie set - logs the user in", + %{conn: conn} do + user = insert(:user, password: "password") + + # enable 2FA + {:ok, user, _} = Auth.TOTP.initiate(user) + {:ok, user, _} = Auth.TOTP.enable(user, :skip_verify) + + another_user = insert(:user) + conn = set_remember_2fa_cookie(conn, another_user) + + conn = post(conn, "/login", email: user.email, password: "password") + + assert redirected_to(conn, 302) == Routes.auth_path(conn, :verify_2fa_form) + + assert fetch_cookies(conn).cookies["session_2fa"].current_2fa_user_id == user.id + refute get_session(conn, :user_token) + end + + on_ee do + test "SSO owner user - logs in", %{conn: conn} do + owner = new_user(name: "Jane Shelley", email: "jane@example.com", password: "password") + team = new_site(owner: owner).team + team = Plausible.Teams.complete_setup(team) + + # Setup SSO + integration = Auth.SSO.initiate_saml_integration(team) + + {:ok, sso_domain} = Auth.SSO.Domains.add(integration, "example.com") + _sso_domain = Auth.SSO.Domains.verify(sso_domain, skip_checks?: true) + + identity = new_identity(owner.name, owner.email, integration) + {:ok, _, _, _sso_user} = Auth.SSO.provision_user(identity) + + conn = post(conn, "/login", email: owner.email, password: "password") + + assert redirected_to(conn, 302) == Routes.site_path(conn, :index) + + assert conn.resp_cookies["session_2fa"].max_age == 0 + assert %{sessions: [%{token: token}]} = owner |> Repo.reload!() |> Repo.preload(:sessions) + assert get_session(conn, :user_token) == token + end + + test "SSO user other than owner - renders login form again", %{conn: conn} do + owner = new_user() + team = new_site(owner: owner).team + member = new_user(name: "Jane Shelley", email: "jane@example.com", password: "password") + add_member(team, user: member, role: :viewer) + + # Setup SSO + integration = Auth.SSO.initiate_saml_integration(team) + + {:ok, sso_domain} = Auth.SSO.Domains.add(integration, "example.com") + _sso_domain = Auth.SSO.Domains.verify(sso_domain, skip_checks?: true) + + identity = new_identity(member.name, member.email, integration) + {:ok, _, _, _sso_user} = Auth.SSO.provision_user(identity) + + conn = post(conn, "/login", email: member.email, password: "password") + + assert get_session(conn, :user_token) == nil + assert html_response(conn, 200) =~ "Sign in to your account" + end + + test "SSO user other than owner with personal team - renders login form again", %{ + conn: conn + } do + owner = new_user() + team = new_site(owner: owner).team + member = new_user(name: "Jane Shelley", email: "jane@example.com", password: "password") + {:ok, _} = Plausible.Teams.get_or_create(member) + add_member(team, user: member, role: :viewer) + + # Setup SSO + integration = Auth.SSO.initiate_saml_integration(team) + + {:ok, sso_domain} = Auth.SSO.Domains.add(integration, "example.com") + _sso_domain = Auth.SSO.Domains.verify(sso_domain, skip_checks?: true) + + identity = new_identity(member.name, member.email, integration) + {:ok, _, _, _sso_user} = Auth.SSO.provision_user(identity) + + conn = post(conn, "/login", email: member.email, password: "password") + + assert get_session(conn, :user_token) == nil + assert html_response(conn, 200) =~ "Sign in to your account" + end + end + + test "email does not exist - renders login form again", %{conn: conn} do + conn = post(conn, "/login", email: "user@example.com", password: "password") + + assert get_session(conn, :user_token) == nil + assert html_response(conn, 200) =~ "Sign in to your account" + end + + test "bad password - renders login form again", %{conn: conn} do + user = insert(:user, password: "password") + conn = post(conn, "/login", email: user.email, password: "wrong") + + assert get_session(conn, :user_token) == nil + assert html_response(conn, 200) =~ "Sign in to your account" + end + + test "limits login attempts to 5 per minute" do + user = insert(:user, password: "password") + + conn = put_req_header(build_conn(), "x-forwarded-for", "1.2.3.5") + + response = + eventually( + fn -> + Enum.each(1..5, fn _ -> + post(conn, "/login", email: user.email, password: "wrong") + end) + + conn = post(conn, "/login", email: user.email, password: "wrong") + + {conn.status == 429, conn} + end, + 500 + ) + + assert html_response(response, 429) =~ "Too many login attempts" + end + + test "malicious automated logins", %{conn: conn1} do + conn = + post(conn1, "/login", %{ + "g-recaptcha-response" => "NwBMkEWbh", + "user[email]" => "some@example.com", + "user[name]" => "CJCtGVXVgfORdNN", + "user[password]" => "[Filtered]", + "user[password_confirmation]" => "[Filtered]", + "user[register_action]" => "register_form" + }) + + assert response(conn, 403) + + conn = + conn1 + |> put_req_header("content-type", "application/json") + |> post("/login", """ + { + "g-recaptcha-response": "NwBMkEWbh", + "user[email]": "some@example.com", + "user[name]": "CJCtGVXVgfORdNN", + "user[password]": "[Filtered]", + "user[password_confirmation]": "[Filtered]", + "user[register_action]": "register_form" + } + """) + + assert response(conn, 403) + end + end + + describe "GET /password/request-reset" do + test "renders the form", %{conn: conn} do + conn = get(conn, "/password/request-reset") + assert html_response(conn, 200) =~ "Enter your email to receive a password reset link." + end + end + + describe "POST /password/request-reset" do + test "email is empty - renders form with error", %{conn: conn} do + conn = post(conn, "/password/request-reset", %{email: ""}) + + assert html_response(conn, 200) =~ "Enter your email to receive a password reset link." + end + + test "email is present and exists - sends password reset email", %{conn: conn} do + mock_captcha_success() + user = insert(:user) + conn = post(conn, "/password/request-reset", %{email: user.email}) + + assert html_response(conn, 200) =~ "Check your email" + assert_email_delivered_with(subject: "Plausible password reset") + end + + test "renders captcha errors in case of captcha input verification failure", %{conn: conn} do + mock_captcha_failure() + user = insert(:user) + conn = post(conn, "/password/request-reset", %{email: user.email}) + + assert html_response(conn, 200) =~ "Please complete the captcha" + end + end + + on_ee do + describe "POST /password/request-reset - SSO user" do + setup [:create_user, :create_site, :create_team, :setup_sso, :provision_sso_user] + + test "initiates reset for owner SSO user email", %{conn: conn, user: user} do + mock_captcha_success() + conn = post(conn, "/password/request-reset", %{email: user.email}) + + assert html_response(conn, 200) + + assert_email_delivered_with( + subject: "Plausible password reset", + to: [nil: user.email] + ) + end + + test "does not initiate reset for non-owner SSO user", %{conn: conn, user: user, team: team} do + add_member(team, role: :owner) + + assert {:ok, _} = + Plausible.Teams.Memberships.UpdateRole.update(team, user.id, "editor", user) + + assert Plausible.Teams.Memberships.team_role(team, user) == {:ok, :editor} + + mock_captcha_success() + conn = post(conn, "/password/request-reset", %{email: user.email}) + + assert html_response(conn, 200) + + refute_email_delivered_with( + subject: "Plausible password reset", + to: [nil: user.email] + ) + end + end + end + + describe "GET /password/reset" do + test "with valid token - shows form", %{conn: conn} do + user = insert(:user) + token = Plausible.Auth.Token.sign_password_reset(user.email) + conn = get(conn, "/password/reset", %{token: token}) + + assert html_response(conn, 200) =~ "Reset your password" + end + + test "with invalid token - shows error page", %{conn: conn} do + conn = get(conn, "/password/reset", %{token: "blabla"}) + + assert html_response(conn, 401) =~ "Password reset link invalid" + end + + test "without token - shows error page", %{conn: conn} do + conn = get(conn, "/password/reset", %{}) + + assert html_response(conn, 401) =~ "Password reset link invalid" + end + end + + describe "POST /password/reset" do + test "redirects the user to login and shows success message", %{conn: conn} do + conn = post(conn, "/password/reset", %{}) + + assert location = "/login" = redirected_to(conn, 302) + + # cookie state is as expected for logged out user + assert conn.private[:plug_session_info] == :renew + assert conn.resp_cookies["logged_in"].max_age == 0 + assert get_session(conn, :user_token) == nil + + {:ok, %{conn: conn}} = PlausibleWeb.FirstLaunchPlug.Test.skip(%{conn: recycle(conn)}) + conn = get(conn, location) + assert html_response(conn, 200) =~ "Password updated successfully" + end + end + + describe "GET /logout" do + setup [:create_user, :log_in] + + test "redirects the user to root", %{conn: conn} do + conn = get(conn, "/logout") + + assert location = "/" = redirected_to(conn, 302) + + # cookie state is as expected for logged out user + assert conn.private[:plug_session_info] == :renew + assert conn.resp_cookies["logged_in"].max_age == 0 + assert get_session(conn, :user_token) == nil + + {:ok, %{conn: conn}} = PlausibleWeb.FirstLaunchPlug.Test.skip(%{conn: recycle(conn)}) + conn = get(conn, location) + assert html_response(conn, 200) =~ "Welcome to Plausible!" + end + + test "redirects user to `redirect` param when provided", %{conn: conn} do + conn = get(conn, "/logout", %{redirect: "/docs"}) + + assert redirected_to(conn, 302) == "/docs" + end + end + + on_ee do + describe "DELETE /me - SSO user" do + setup [:create_user, :create_site, :create_team, :setup_sso, :provision_sso_user, :log_in] + + test "refuses to delete SSO user", %{conn: conn, user: user} do + conn = delete(conn, "/me") + + assert redirected_to(conn, 302) == Routes.site_path(conn, :index) + + assert Repo.reload(user) + end + end + end + + describe "DELETE /me" do + setup [:create_user, :log_in, :create_site] + use Plausible.Repo + + test "deletes the user", %{conn: conn, user: user, site: site} do + Repo.insert_all("intro_emails", [ + %{ + user_id: user.id, + timestamp: NaiveDateTime.utc_now() + } + ]) + + Repo.insert_all("feedback_emails", [ + %{ + user_id: user.id, + timestamp: NaiveDateTime.utc_now() + } + ]) + + Repo.insert_all("create_site_emails", [ + %{ + user_id: user.id, + timestamp: NaiveDateTime.utc_now() + } + ]) + + Repo.insert_all("check_stats_emails", [ + %{ + user_id: user.id, + timestamp: NaiveDateTime.utc_now() + } + ]) + + Repo.insert_all("sent_renewal_notifications", [ + %{ + user_id: user.id, + timestamp: NaiveDateTime.utc_now() + } + ]) + + insert(:google_auth, site: site, user: user) + subscribe_to_growth_plan(user, status: Subscription.Status.deleted()) + subscribe_to_enterprise_plan(user, site_limit: 1, subscription?: false) + + {:ok, team} = Plausible.Teams.get_or_create(user) + + conn = delete(conn, "/me") + assert redirected_to(conn) == "/" + assert Repo.reload(site) == nil + assert Repo.reload(user) == nil + assert Repo.all(Plausible.Billing.Subscription) == [] + assert Repo.all(Plausible.Billing.EnterprisePlan) == [] + refute Repo.get(Plausible.Teams.Team, team.id) + end + + test "refuses to delete when a personal team has an active subscription", %{ + conn: conn, + user: user + } do + subscribe_to_growth_plan(user, status: Subscription.Status.active()) + + conn = delete(conn, "/me") + + assert redirected_to(conn, 302) == Routes.settings_path(conn, :danger_zone) + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "You have an active subscription which must be canceled first" + + assert Repo.reload(user) + end + + test "deletes sites that the user owns", %{conn: conn, user: user, site: owner_site} do + viewer_site = new_site() + add_guest(viewer_site, user: user, role: :viewer) + + delete(conn, "/me") + + assert Repo.get(Plausible.Site, viewer_site.id) + refute Repo.get(Plausible.Site, owner_site.id) + end + + test "refuses to delete user when an only owner of a setup team", %{ + conn: conn, + user: user, + site: site + } do + Plausible.Teams.complete_setup(site.team) + + conn = delete(conn, "/me") + + assert redirected_to(conn, 302) == Routes.settings_path(conn, :danger_zone) + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "You can't delete your account when you are the only owner on a team" + + assert Repo.reload(user) + end + + test "refuses to delete user when an only owner of multiple setup teams", %{ + conn: conn, + user: user, + site: site + } do + Plausible.Teams.complete_setup(site.team) + + another_owner = new_user() + another_site = new_site(owner: another_owner) + add_member(another_site.team, user: user, role: :owner) + Repo.delete!(another_owner) + + conn = delete(conn, "/me") + + assert redirected_to(conn, 302) == Routes.settings_path(conn, :danger_zone) + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "You can't delete your account when you are the only owner on a team" + + assert Repo.reload(user) + end + + test "context > team is autodeleted - personal segment is also deleted", %{ + conn: conn, + user: user, + site: owner_site + } do + segment = + insert(:segment, + type: :personal, + owner: user, + site: owner_site, + name: "personal segment" + ) + + delete(conn, "/me") + + refute Repo.reload(segment) + end + + test "context > team is autodeleted - site segment is also deleted", %{ + conn: conn, + user: user, + site: owner_site + } do + segment = + insert(:segment, + type: :site, + owner: user, + site: owner_site, + name: "site segment" + ) + + delete(conn, "/me") + + refute Repo.reload(segment) + end + + test "context > team is not autodeleted - personal segment is deleted", %{ + conn: conn, + user: user + } do + another_owner = new_user() + another_site = new_site(owner: another_owner) + another_team = another_owner |> team_of() |> Plausible.Teams.complete_setup() + add_member(another_team, user: user, role: :admin) + + segment = + insert(:segment, + type: :personal, + owner: user, + site: another_site, + name: "personal segment" + ) + + delete(conn, "/me") + + refute Repo.reload(segment) + end + + test "context > team is not autodeleted - site segment is kept with owner=null", %{ + conn: conn, + user: user + } do + another_owner = new_user() + another_site = new_site(owner: another_owner) + another_team = another_owner |> team_of() |> Plausible.Teams.complete_setup() + add_member(another_team, user: user, role: :admin) + + segment = + insert(:segment, + type: :site, + owner: user, + site: another_site, + name: "site segment" + ) + + delete(conn, "/me") + + assert Repo.reload(segment).owner_id == nil + end + + test "allows to delete user when not the only owner of a setup team", %{ + conn: conn, + user: user + } do + another_owner = new_user() + another_site = new_site(owner: another_owner) + team = another_owner |> team_of() |> Plausible.Teams.complete_setup() + add_member(another_site.team, user: user, role: :owner) + + delete(conn, "/me") + + refute Repo.reload(user) + assert Repo.reload(team) + end + + test "deletes personal team in multiple teams case as well", %{ + conn: conn, + user: user + } do + new_site(owner: user) + personal_team = team_of(user) + another_owner = new_user() + _another_site = new_site(owner: another_owner) + another_team = another_owner |> team_of() |> Plausible.Teams.complete_setup() + add_member(another_team, user: user, role: :owner) + + delete(conn, "/me") + + refute Repo.reload(user) + assert Repo.reload(another_team) + refute Repo.reload(personal_team) + end + end + + describe "GET /auth/google/callback" do + setup [:create_user, :log_in] + + test "shows access denied error and redirects back to settings when authentication fails for import", + %{ + conn: conn + } do + site = new_site() + + state = + Phoenix.Token.sign(PlausibleWeb.Endpoint, "google-oauth-state", [site.id, "import"]) + + callback_params = %{"error" => "access_denied", "state" => state} + conn = get(conn, Routes.auth_path(conn, :google_auth_callback), callback_params) + + assert redirected_to(conn, 302) == + Routes.site_path(conn, :settings_imports_exports, site.domain) + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "unable to authenticate your Google Analytics" + end + + test "shows access denied error and redirects back to settings when authentication fails for search console", + %{conn: conn} do + site = new_site() + + state = + Phoenix.Token.sign(PlausibleWeb.Endpoint, "google-oauth-state", [ + site.id, + "search-console" + ]) + + callback_params = %{"error" => "access_denied", "state" => state} + conn = get(conn, Routes.auth_path(conn, :google_auth_callback), callback_params) + + assert redirected_to(conn, 302) == + Routes.site_path(conn, :settings_integrations, site.domain) + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "unable to authenticate your Google Analytics" + end + + test "shows server error and redirects back to settings when request fails on Google's end for import", + %{ + conn: conn + } do + site = new_site() + + state = + Phoenix.Token.sign(PlausibleWeb.Endpoint, "google-oauth-state", [site.id, "import"]) + + callback_params = %{"error" => "server_error", "state" => state} + conn = get(conn, Routes.auth_path(conn, :google_auth_callback), callback_params) + + assert redirected_to(conn, 302) == + Routes.site_path(conn, :settings_imports_exports, site.domain) + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "Google's authentication service is temporarily unavailable" + end + + test "shows server error and redirects back to settings when request fails on Google's end for search console", + %{ + conn: conn + } do + site = new_site() + + state = + Phoenix.Token.sign(PlausibleWeb.Endpoint, "google-oauth-state", [ + site.id, + "search-console" + ]) + + callback_params = %{"error" => "temporarily_unavailable", "state" => state} + conn = get(conn, Routes.auth_path(conn, :google_auth_callback), callback_params) + + assert redirected_to(conn, 302) == + Routes.site_path(conn, :settings_integrations, site.domain) + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "Google's authentication service is temporarily unavailable" + end + + test "shows generic error and redirects back to settings on unknown Google failure for import", + %{ + conn: conn + } do + site = new_site() + + state = + Phoenix.Token.sign(PlausibleWeb.Endpoint, "google-oauth-state", [site.id, "import"]) + + callback_params = %{"error" => "unknown", "state" => state} + conn = get(conn, Routes.auth_path(conn, :google_auth_callback), callback_params) + + assert redirected_to(conn, 302) == + Routes.site_path(conn, :settings_imports_exports, site.domain) + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "unable to authenticate your Google Analytics" + end + + test "shows generic error and redirects back to settings when request fails on Google's end for search console", + %{ + conn: conn + } do + site = new_site() + + state = + Phoenix.Token.sign(PlausibleWeb.Endpoint, "google-oauth-state", [ + site.id, + "search-console" + ]) + + callback_params = %{"error" => "unknown", "state" => state} + conn = get(conn, Routes.auth_path(conn, :google_auth_callback), callback_params) + + assert redirected_to(conn, 302) == + Routes.site_path(conn, :settings_integrations, site.domain) + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "unable to authenticate your Google Analytics" + end + + test "rejects error callback with tampered state", %{conn: conn} do + site = new_site() + + state = + Phoenix.Token.sign(PlausibleWeb.Endpoint, "google-oauth-state", [site.id, "import"]) + + callback_params = %{"error" => "access_denied", "state" => state <> "gibberish"} + conn = get(conn, Routes.auth_path(conn, :google_auth_callback), callback_params) + + assert redirected_to(conn, 302) == Routes.auth_path(conn, :login_form) + end + + test "rejects error callback with signed state containing non-existent site", %{conn: conn} do + site = new_site() + Repo.delete!(site) + + state = + Phoenix.Token.sign(PlausibleWeb.Endpoint, "google-oauth-state", [site.id, "import"]) + + callback_params = %{"error" => "access_denied", "state" => state} + conn = get(conn, Routes.auth_path(conn, :google_auth_callback), callback_params) + + assert redirected_to(conn, 302) == Routes.auth_path(conn, :login_form) + end + + test "rejects success callback with tampered state", %{conn: conn} do + site = new_site() + + state = + Phoenix.Token.sign(PlausibleWeb.Endpoint, "google-oauth-state", [site.id, "import"]) + + callback_params = %{"code" => "CodeForToken", "state" => state <> "gibberish"} + conn = get(conn, Routes.auth_path(conn, :google_auth_callback), callback_params) + + assert redirected_to(conn, 302) == Routes.auth_path(conn, :login_form) + end + + test "rejects success callback with signed state containing non-existent site", %{conn: conn} do + site = new_site() + Repo.delete!(site) + + state = + Phoenix.Token.sign(PlausibleWeb.Endpoint, "google-oauth-state", [site.id, "import"]) + + callback_params = %{"code" => "CodeForToken", "state" => state} + conn = get(conn, Routes.auth_path(conn, :google_auth_callback), callback_params) + + assert redirected_to(conn, 302) == Routes.auth_path(conn, :login_form) + end + + test "success callback rejects state pointing to a site the current user does not own", + %{conn: conn} do + victim_site = new_site() + + attacker = new_user() + {:ok, conn: attacker_conn} = log_in(%{user: attacker, conn: conn}) + + # craft a validly signed state pointing at the victim's site_id + state = + Phoenix.Token.sign(PlausibleWeb.Endpoint, "google-oauth-state", [ + victim_site.id, + "search-console" + ]) + + callback_params = %{"code" => "CodeForToken", "state" => state} + + attacker_conn = + get(attacker_conn, Routes.auth_path(conn, :google_auth_callback), callback_params) + + assert redirected_to(attacker_conn, 302) == Routes.auth_path(conn, :login_form) + + # confirm no GoogleAuth record was created for the victim's site + refute Repo.exists?( + from ga in Plausible.Site.GoogleAuth, where: ga.site_id == ^victim_site.id + ) + end + + test "success callback for import redirects to property selection", %{conn: conn, user: user} do + mock_google_access_token(user.email) + + site = new_site(owner: user) + + state = + Phoenix.Token.sign(PlausibleWeb.Endpoint, "google-oauth-state", [site.id, "import"]) + + callback_params = %{"code" => "CodeForToken", "state" => state} + conn = get(conn, Routes.auth_path(conn, :google_auth_callback), callback_params) + + assert redirect_url = redirected_to(conn, 302) + + assert redirect_url =~ "/import/google-analytics/property" + assert redirect_url =~ "access_token=SomeAccessToken" + assert redirect_url =~ "refresh_token=SomeRefreshToken" + assert redirect_url =~ "expires_at=" + end + + test "success callback for search console creates a google auth entry and redirects to integrations form", + %{conn: conn, user: user} do + site = new_site(owner: user) + + mock_google_access_token(user.email) + + state = + Phoenix.Token.sign(PlausibleWeb.Endpoint, "google-oauth-state", [ + site.id, + "search-console" + ]) + + callback_params = %{"code" => "CodeForToken", "state" => state} + conn = get(conn, Routes.auth_path(conn, :google_auth_callback), callback_params) + + assert redirected_to(conn, 302) == + Routes.site_path(conn, :settings_integrations, site.domain) + + assert_matches %{ + access_token: "SomeAccessToken", + refresh_token: "SomeRefreshToken", + user_id: ^user.id, + email: ^user.email, + expires: %NaiveDateTime{} + } = + Repo.one( + from ga in Plausible.Site.GoogleAuth, where: ga.site_id == ^site.id + ) + end + + test "success callback for search console creates a google auth entry and redirects to integrations form for editor", + %{conn: conn, user: user} do + site = new_site() + add_guest(site, user: user, role: :editor) + + mock_google_access_token(user.email) + + state = + Phoenix.Token.sign(PlausibleWeb.Endpoint, "google-oauth-state", [ + site.id, + "search-console" + ]) + + callback_params = %{"code" => "CodeForToken", "state" => state} + conn = get(conn, Routes.auth_path(conn, :google_auth_callback), callback_params) + + assert redirected_to(conn, 302) == + Routes.site_path(conn, :settings_integrations, site.domain) + + assert_matches %{ + access_token: "SomeAccessToken", + refresh_token: "SomeRefreshToken", + user_id: ^user.id, + email: ^user.email, + expires: %NaiveDateTime{} + } = + Repo.one( + from ga in Plausible.Site.GoogleAuth, where: ga.site_id == ^site.id + ) + end + + test "success callback for search console handles repeat callback for the same site gracefully", + %{conn: conn, user: user} do + site = new_site(owner: user) + another_email = new_user().email + + mock_google_access_token(user.email) + mock_google_access_token(another_email) + + state = + Phoenix.Token.sign(PlausibleWeb.Endpoint, "google-oauth-state", [ + site.id, + "search-console" + ]) + + callback_params = %{"code" => "CodeForToken", "state" => state} + conn_first = get(conn, Routes.auth_path(conn, :google_auth_callback), callback_params) + + assert redirected_to(conn_first, 302) == + Routes.site_path(conn, :settings_integrations, site.domain) + + assert_matches %{ + access_token: "SomeAccessToken", + refresh_token: "SomeRefreshToken", + user_id: ^user.id, + email: ^user.email, + expires: %NaiveDateTime{} + } = + Repo.one( + from ga in Plausible.Site.GoogleAuth, where: ga.site_id == ^site.id + ) + + conn_second = get(conn, Routes.auth_path(conn, :google_auth_callback), callback_params) + + assert redirected_to(conn_second, 302) == + Routes.site_path(conn, :settings_integrations, site.domain) + + assert_matches %{ + access_token: "SomeAccessToken", + refresh_token: "SomeRefreshToken", + user_id: ^user.id, + email: ^another_email, + expires: %NaiveDateTime{} + } = + Repo.one( + from ga in Plausible.Site.GoogleAuth, where: ga.site_id == ^site.id + ) + end + end + + describe "POST /2fa/setup/initiate" do + setup [:create_user, :log_in] + + test "initiates setup rendering QR and human friendly versions of secret", %{ + conn: conn, + user: user + } do + conn = post(conn, Routes.auth_path(conn, :initiate_2fa_setup)) + + secret = Base.encode32(Repo.reload!(user).totp_secret) + + assert html = html_response(conn, 200) + + assert element_exists?(html, "svg") + assert html =~ secret + refute html =~ "You've been redirected here because your team enforces 2FA." + end + + test "shows additional notice when `force` parameter set", %{conn: conn} do + conn = post(conn, Routes.auth_path(conn, :initiate_2fa_setup, force: "true")) + + assert html = html_response(conn, 200) + assert html =~ "You've been redirected here because your team enforces 2FA." + end + + test "redirects back to settings if 2FA is already setup", %{conn: conn, user: user} do + {:ok, user, _} = Auth.TOTP.initiate(user) + {:ok, _, _} = Auth.TOTP.enable(user, :skip_verify) + + conn = post(conn, Routes.auth_path(conn, :initiate_2fa_setup)) + + assert redirected_to(conn, 302) == Routes.settings_path(conn, :security) <> "#update-2fa" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "Two-Factor Authentication is already setup" + end + end + + describe "GET /2fa/setup/verify" do + setup [:create_user, :log_in] + + test "renders form when 2FA setup is initiated", %{conn: conn, user: user} do + {:ok, _, _} = Auth.TOTP.initiate(user) + + conn = get(conn, Routes.auth_path(conn, :verify_2fa_setup)) + + assert html = html_response(conn, 200) + + assert text_of_attr(html, "form#verify-2fa-form", "action") == + Routes.auth_path(conn, :verify_2fa_setup) + + assert element_exists?(html, "input[name=code]") + + assert element_exists?( + html, + ~s|a[data-method="post"][data-to="#{Routes.auth_path(conn, :initiate_2fa_setup)}"]| + ) + end + + test "redirects back to settings if 2FA not initiated", %{conn: conn} do + conn = get(conn, Routes.auth_path(conn, :verify_2fa_setup)) + + assert redirected_to(conn, 302) == Routes.settings_path(conn, :security) <> "#update-2fa" + end + end + + describe "POST /2fa/setup/verify" do + setup [:create_user, :log_in] + + test "enables 2FA and renders recovery codes when valid code provided", %{ + conn: conn, + user: user + } do + {:ok, user, _} = Auth.TOTP.initiate(user) + code = NimbleTOTP.verification_code(user.totp_secret) + + conn = post(conn, Routes.auth_path(conn, :verify_2fa_setup), %{code: code}) + + assert html = html_response(conn, 200) + + assert list = [_ | _] = find(html, "#recovery-codes-list > *") |> LazyHTML.to_tree() + assert length(list) == 10 + + assert user |> Repo.reload!() |> Auth.TOTP.enabled?() + end + + test "renders error on invalid code provided", %{conn: conn, user: user} do + {:ok, _, _} = Auth.TOTP.initiate(user) + + conn = post(conn, Routes.auth_path(conn, :verify_2fa_setup), %{code: "invalid"}) + + assert html_response(conn, 200) + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "The provided code is invalid." + end + + test "redirects to settings when 2FA is not initiated", %{conn: conn} do + conn = post(conn, Routes.auth_path(conn, :verify_2fa_setup), %{code: "123123"}) + + assert redirected_to(conn, 302) == Routes.settings_path(conn, :security) <> "#update-2fa" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "Please enable Two-Factor Authentication" + end + end + + describe "POST /2fa/disable" do + setup [:create_user, :log_in] + + test "disables 2FA when valid password provided", %{conn: conn, user: user} do + {:ok, user, _} = Auth.TOTP.initiate(user) + {:ok, _, _} = Auth.TOTP.enable(user, :skip_verify) + + conn = post(conn, Routes.auth_path(conn, :disable_2fa), %{password: "password"}) + + assert redirected_to(conn, 302) == Routes.settings_path(conn, :security) <> "#update-2fa" + + assert Phoenix.Flash.get(conn.assigns.flash, :success) =~ + "Two-Factor Authentication is disabled" + + refute user |> Repo.reload!() |> Auth.TOTP.enabled?() + end + + test "renders error when invalid password provided", %{conn: conn, user: user} do + {:ok, user, _} = Auth.TOTP.initiate(user) + {:ok, _, _} = Auth.TOTP.enable(user, :skip_verify) + + conn = post(conn, Routes.auth_path(conn, :disable_2fa), %{password: "invalid"}) + + assert redirected_to(conn, 302) == Routes.settings_path(conn, :security) <> "#update-2fa" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Incorrect password provided" + end + end + + on_ee do + describe "POST /2fa/disable - SSO user" do + setup [:create_user, :create_site, :create_team, :setup_sso, :provision_sso_user, :log_in] + + test "refuses to disable for SSO user", %{conn: conn, user: user} do + {:ok, user, _} = Auth.TOTP.initiate(user) + {:ok, _, _} = Auth.TOTP.enable(user, :skip_verify) + + conn = post(conn, Routes.auth_path(conn, :disable_2fa), %{password: "password"}) + + assert redirected_to(conn, 302) == Routes.site_path(conn, :index) + + assert user |> Repo.reload!() |> Auth.TOTP.enabled?() + end + end + end + + describe "POST /2fa/recovery_codes" do + setup [:create_user, :log_in] + + test "generates new recovery codes when valid password provided", %{conn: conn, user: user} do + {:ok, user, _} = Auth.TOTP.initiate(user) + {:ok, _, _} = Auth.TOTP.enable(user, :skip_verify) + + conn = + post(conn, Routes.auth_path(conn, :generate_2fa_recovery_codes), %{password: "password"}) + + assert html = html_response(conn, 200) + + assert list = [_ | _] = find(html, "#recovery-codes-list > *") |> LazyHTML.to_tree() + assert length(list) == 10 + end + + test "renders error when invalid password provided", %{conn: conn, user: user} do + {:ok, user, _} = Auth.TOTP.initiate(user) + {:ok, _, _} = Auth.TOTP.enable(user, :skip_verify) + + conn = + post(conn, Routes.auth_path(conn, :generate_2fa_recovery_codes), %{password: "invalid"}) + + assert redirected_to(conn, 302) == Routes.settings_path(conn, :security) <> "#update-2fa" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Incorrect password provided" + end + + test "renders error when 2FA is not enabled", %{conn: conn} do + conn = + post(conn, Routes.auth_path(conn, :generate_2fa_recovery_codes), %{password: "password"}) + + assert redirected_to(conn, 302) == Routes.settings_path(conn, :security) <> "#update-2fa" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "Please enable Two-Factor Authentication" + end + end + + describe "GET /2fa/verify" do + test "renders verification form when 2FA session present", %{conn: conn} do + user = insert(:user) + + # enable 2FA + {:ok, user, _} = Auth.TOTP.initiate(user) + {:ok, _, _} = Auth.TOTP.enable(user, :skip_verify) + + conn = login_with_cookie(conn, user.email, "password") + + conn = + get( + conn, + Routes.auth_path(conn, :verify_2fa_form, return_to: Routes.settings_path(conn, :index)) + ) + + assert html = html_response(conn, 200) + + assert text_of_attr(html, "form", "action") == + Routes.auth_path(conn, :verify_2fa, return_to: Routes.settings_path(conn, :index)) + + assert element_exists?(html, "input[name=code]") + + assert element_exists?(html, "input[name=remember_2fa]") + + assert text_of_attr(html, "input[name=return_to]", "value") == + Routes.settings_path(conn, :index) + + assert element_exists?( + html, + "a[href='#{Routes.auth_path(conn, :verify_2fa_recovery_code_form)}']" + ) + end + + test "redirects to login when cookie not found", %{conn: conn} do + user = insert(:user) + + # enable 2FA + {:ok, user, _} = Auth.TOTP.initiate(user) + {:ok, _, _} = Auth.TOTP.enable(user, :skip_verify) + + conn = get(conn, Routes.auth_path(conn, :verify_2fa_form)) + + assert redirected_to(conn, 302) == Routes.auth_path(conn, :login_form) + end + + test "redirects to login when 2FA not enabled", %{conn: conn} do + user = insert(:user) + + # enable 2FA + {:ok, user, _} = Auth.TOTP.initiate(user) + {:ok, _, _} = Auth.TOTP.enable(user, :skip_verify) + + conn = login_with_cookie(conn, user.email, "password") + + {:ok, _} = Auth.TOTP.disable(user, "password") + + conn = get(conn, Routes.auth_path(conn, :verify_2fa_form)) + + assert redirected_to(conn, 302) == Routes.auth_path(conn, :login_form) + end + end + + describe "POST /2fa/verify" do + test "redirects to sites when code verification succeeds", %{conn: conn} do + user = insert(:user) + + # enable 2FA + {:ok, user, _} = Auth.TOTP.initiate(user) + {:ok, user, _} = Auth.TOTP.enable(user, :skip_verify) + + conn = login_with_cookie(conn, user.email, "password") + + code = NimbleTOTP.verification_code(user.totp_secret) + + conn = post(conn, Routes.auth_path(conn, :verify_2fa), %{code: code}) + + assert redirected_to(conn, 302) == Routes.site_path(conn, :index) + + assert %{sessions: [%{token: token}]} = user |> Repo.reload!() |> Repo.preload(:sessions) + assert get_session(conn)["user_token"] == token + # 2FA session terminated + assert conn.resp_cookies["session_2fa"].max_age == 0 + # Remember cookie unset + assert conn.resp_cookies["remember_2fa"].max_age == 0 + end + + test "redirects to return_to when set", %{conn: conn} do + user = insert(:user) + + # enable 2FA + {:ok, user, _} = Auth.TOTP.initiate(user) + {:ok, user, _} = Auth.TOTP.enable(user, :skip_verify) + + conn = login_with_cookie(conn, user.email, "password") + + code = NimbleTOTP.verification_code(user.totp_secret) + + conn = + post(conn, Routes.auth_path(conn, :verify_2fa), %{code: code, return_to: "/dummy.site"}) + + assert redirected_to(conn, 302) == "/dummy.site" + end + + test "sets remember cookie when device trusted", %{conn: conn} do + user = insert(:user) + + # enable 2FA + {:ok, user, _} = Auth.TOTP.initiate(user) + {:ok, user, _} = Auth.TOTP.enable(user, :skip_verify) + + conn = login_with_cookie(conn, user.email, "password") + + code = NimbleTOTP.verification_code(user.totp_secret) + + conn = post(conn, Routes.auth_path(conn, :verify_2fa), %{code: code, remember_2fa: "true"}) + + assert redirected_to(conn, 302) == Routes.site_path(conn, :index) + + assert %{sessions: [%{token: token}]} = user |> Repo.reload!() |> Repo.preload(:sessions) + assert get_session(conn)["user_token"] == token + # 2FA session terminated + assert conn.resp_cookies["session_2fa"].max_age == 0 + # Remember cookie set + assert conn.resp_cookies["remember_2fa"].max_age > 0 + end + + test "overwrites rogue remember cookie when device trusted", %{conn: conn} do + user = insert(:user) + + # enable 2FA + {:ok, user, _} = Auth.TOTP.initiate(user) + {:ok, user, _} = Auth.TOTP.enable(user, :skip_verify) + + conn = login_with_cookie(conn, user.email, "password") + + another_user = insert(:user, totp_token: "different_token") + conn = set_remember_2fa_cookie(conn, another_user) + + code = NimbleTOTP.verification_code(user.totp_secret) + + conn = post(conn, Routes.auth_path(conn, :verify_2fa), %{code: code, remember_2fa: "true"}) + + assert redirected_to(conn, 302) == Routes.site_path(conn, :index) + + assert %{sessions: [%{token: token}]} = user |> Repo.reload!() |> Repo.preload(:sessions) + assert get_session(conn)["user_token"] == token + # 2FA session terminated + assert conn.resp_cookies["session_2fa"].max_age == 0 + # Remember cookie set + assert conn.resp_cookies["remember_2fa"].max_age > 0 + assert fetch_cookies(conn).cookies["remember_2fa"] == user.totp_token + end + + test "clears rogue remember cookie when device _not_ trusted", %{conn: conn} do + user = insert(:user) + + # enable 2FA + {:ok, user, _} = Auth.TOTP.initiate(user) + {:ok, user, _} = Auth.TOTP.enable(user, :skip_verify) + + conn = login_with_cookie(conn, user.email, "password") + + another_user = insert(:user, totp_token: "different_token") + conn = set_remember_2fa_cookie(conn, another_user) + + code = NimbleTOTP.verification_code(user.totp_secret) + + conn = post(conn, Routes.auth_path(conn, :verify_2fa), %{code: code}) + + assert redirected_to(conn, 302) == Routes.site_path(conn, :index) + + assert %{sessions: [%{token: token}]} = user |> Repo.reload!() |> Repo.preload(:sessions) + assert get_session(conn, :user_token) == token + # 2FA session terminated + assert conn.resp_cookies["session_2fa"].max_age == 0 + # Remember cookie cleared + assert conn.resp_cookies["remember_2fa"].max_age == 0 + end + + test "returns error on invalid code", %{conn: conn} do + user = insert(:user) + + # enable 2FA + {:ok, user, _} = Auth.TOTP.initiate(user) + {:ok, user, _} = Auth.TOTP.enable(user, :skip_verify) + + conn = login_with_cookie(conn, user.email, "password") + + conn = post(conn, Routes.auth_path(conn, :verify_2fa), %{code: "invalid"}) + + assert html_response(conn, 200) =~ "The provided code is invalid" + end + + test "redirects to login when cookie not found", %{conn: conn} do + user = insert(:user) + + # enable 2FA + {:ok, user, _} = Auth.TOTP.initiate(user) + {:ok, _, _} = Auth.TOTP.enable(user, :skip_verify) + + code = NimbleTOTP.verification_code(user.totp_secret) + + conn = post(conn, Routes.auth_path(conn, :verify_2fa, %{code: code})) + + assert redirected_to(conn, 302) == Routes.auth_path(conn, :login_form) + end + + test "passes through when 2FA is disabled", %{conn: conn} do + user = insert(:user) + + # enable 2FA + {:ok, user, _} = Auth.TOTP.initiate(user) + {:ok, user, _} = Auth.TOTP.enable(user, :skip_verify) + + conn = login_with_cookie(conn, user.email, "password") + + code = NimbleTOTP.verification_code(user.totp_secret) + + {:ok, _} = Auth.TOTP.disable(user, "password") + + conn = post(conn, Routes.auth_path(conn, :verify_2fa), %{code: code}) + + assert redirected_to(conn, 302) == Routes.site_path(conn, :index) + + assert %{sessions: [%{token: token}]} = user |> Repo.reload!() |> Repo.preload(:sessions) + assert get_session(conn)["user_token"] == token + # 2FA session terminated + assert conn.resp_cookies["session_2fa"].max_age == 0 + end + + test "limits verification attempts to 5 per minute", %{conn: conn} do + user = insert(:user, email: "ratio#{Ecto.UUID.generate()}@example.com") + + # enable 2FA + {:ok, user, _} = Auth.TOTP.initiate(user) + {:ok, user, _} = Auth.TOTP.enable(user, :skip_verify) + + conn = + conn + |> login_with_cookie(user.email, "password") + |> put_req_header("x-forwarded-for", "1.1.1.1") + + response = + eventually( + fn -> + Enum.each(1..5, fn _ -> + post(conn, Routes.auth_path(conn, :verify_2fa), %{code: "invalid"}) + end) + + conn = post(conn, Routes.auth_path(conn, :verify_2fa), %{code: "invalid"}) + + {conn.status == 429, conn} + end, + 500 + ) + + assert get_session(response, :user_token) == nil + # 2FA session terminated + assert response.resp_cookies["session_2fa"].max_age == 0 + assert html_response(response, 429) =~ "Too many login attempts" + end + end + + describe "GET /2fa/use_recovery_code" do + test "renders recovery verification form when 2FA session present", %{conn: conn} do + user = insert(:user) + + # enable 2FA + {:ok, user, _} = Auth.TOTP.initiate(user) + {:ok, _, _} = Auth.TOTP.enable(user, :skip_verify) + + conn = login_with_cookie(conn, user.email, "password") + + conn = get(conn, Routes.auth_path(conn, :verify_2fa_recovery_code_form)) + + assert html = html_response(conn, 200) + + assert text_of_attr(html, "form", "action") == + Routes.auth_path(conn, :verify_2fa_recovery_code) + + assert element_exists?(html, "input[name=recovery_code]") + + assert element_exists?(html, "a[href='#{Routes.auth_path(conn, :verify_2fa_form)}']") + end + + test "redirects to login when cookie not found", %{conn: conn} do + user = insert(:user) + + # enable 2FA + {:ok, user, _} = Auth.TOTP.initiate(user) + {:ok, _, _} = Auth.TOTP.enable(user, :skip_verify) + + conn = get(conn, Routes.auth_path(conn, :verify_2fa_recovery_code_form)) + + assert redirected_to(conn, 302) == Routes.auth_path(conn, :login_form) + end + + test "redirects to login when 2FA not enabled", %{conn: conn} do + user = insert(:user) + + # enable 2FA + {:ok, user, _} = Auth.TOTP.initiate(user) + {:ok, _, _} = Auth.TOTP.enable(user, :skip_verify) + + conn = login_with_cookie(conn, user.email, "password") + + {:ok, _} = Auth.TOTP.disable(user, "password") + + conn = get(conn, Routes.auth_path(conn, :verify_2fa_recovery_code_form)) + + assert redirected_to(conn, 302) == Routes.auth_path(conn, :login_form) + end + end + + describe "POST /2fa/use_recovery_code" do + test "redirects to sites when recovery code verification succeeds", %{conn: conn} do + user = insert(:user) + + # enable 2FA + {:ok, user, _} = Auth.TOTP.initiate(user) + {:ok, user, %{recovery_codes: [recovery_code | _]}} = Auth.TOTP.enable(user, :skip_verify) + + conn = login_with_cookie(conn, user.email, "password") + + conn = + post(conn, Routes.auth_path(conn, :verify_2fa_recovery_code), %{ + recovery_code: recovery_code + }) + + assert redirected_to(conn, 302) == Routes.site_path(conn, :index) + + assert %{sessions: [%{token: token}]} = user |> Repo.reload!() |> Repo.preload(:sessions) + assert get_session(conn)["user_token"] == token + # 2FA session terminated + assert conn.resp_cookies["session_2fa"].max_age == 0 + end + + test "returns error on invalid recovery code", %{conn: conn} do + user = insert(:user) + + # enable 2FA + {:ok, user, _} = Auth.TOTP.initiate(user) + {:ok, user, _} = Auth.TOTP.enable(user, :skip_verify) + + conn = login_with_cookie(conn, user.email, "password") + + conn = + post(conn, Routes.auth_path(conn, :verify_2fa_recovery_code), %{recovery_code: "invalid"}) + + assert html_response(conn, 200) =~ "The provided recovery code is invalid" + end + + test "redirects to login when cookie not found", %{conn: conn} do + user = insert(:user) + + # enable 2FA + {:ok, user, _} = Auth.TOTP.initiate(user) + {:ok, _, %{recovery_codes: [recovery_code | _]}} = Auth.TOTP.enable(user, :skip_verify) + + conn = + post( + conn, + Routes.auth_path(conn, :verify_2fa_recovery_code, %{recovery_code: recovery_code}) + ) + + assert redirected_to(conn, 302) == Routes.auth_path(conn, :login_form) + end + + test "passes through when 2FA is disabled", %{conn: conn} do + user = insert(:user) + + # enable 2FA + {:ok, user, _} = Auth.TOTP.initiate(user) + {:ok, user, %{recovery_codes: [recovery_code | _]}} = Auth.TOTP.enable(user, :skip_verify) + + conn = login_with_cookie(conn, user.email, "password") + + {:ok, _} = Auth.TOTP.disable(user, "password") + + conn = + post(conn, Routes.auth_path(conn, :verify_2fa_recovery_code), %{ + recovery_code: recovery_code + }) + + assert redirected_to(conn, 302) == Routes.site_path(conn, :index) + + assert %{sessions: [%{token: token}]} = user |> Repo.reload!() |> Repo.preload(:sessions) + assert get_session(conn)["user_token"] == token + # 2FA session terminated + assert conn.resp_cookies["session_2fa"].max_age == 0 + end + + test "limits verification attempts to 5 per minute", %{conn: conn} do + user = insert(:user, email: "ratio#{Ecto.UUID.generate()}@example.com") + + # enable 2FA + {:ok, user, _} = Auth.TOTP.initiate(user) + {:ok, user, _} = Auth.TOTP.enable(user, :skip_verify) + + conn = + conn + |> login_with_cookie(user.email, "password") + |> put_req_header("x-forwarded-for", "1.2.3.4") + + response = + eventually( + fn -> + Enum.each(1..5, fn _ -> + post(conn, Routes.auth_path(conn, :verify_2fa_recovery_code), %{ + recovery_code: "invalid" + }) + end) + + conn = + post(conn, Routes.auth_path(conn, :verify_2fa_recovery_code), %{ + recovery_code: "invalid" + }) + + {conn.status == 429, conn} + end, + 500 + ) + + assert get_session(response, :user_token) == nil + # 2FA session terminated + assert response.resp_cookies["session_2fa"].max_age == 0 + assert html_response(response, 429) =~ "Too many login attempts" + end + end + + defp login_with_cookie(conn, email, password) do + conn + |> post(Routes.auth_path(conn, :login), %{ + email: email, + password: password + }) + |> recycle() + |> prepare_conn() + end + + defp set_remember_2fa_cookie(conn, user) do + conn + |> PlausibleWeb.TwoFactor.Session.maybe_set_remember_2fa(user, "true") + |> recycle() + |> prepare_conn() + end + + defp mock_captcha_success() do + mock_captcha(true) + end + + defp mock_captcha_failure() do + mock_captcha(false) + end + + defp mock_captcha(success) do + expect( + Plausible.HTTPClient.Mock, + :post, + fn _, _, _ -> + {:ok, + %Finch.Response{ + status: 200, + headers: [{"content-type", "application/json"}], + body: %{"success" => success} + }} + end + ) + end + + defp mock_google_access_token(email) do + body = + %{"email" => email} + |> Jason.encode!() + |> Base.encode64(padding: false) + + expect( + Plausible.HTTPClient.Mock, + :post, + fn _, _, _ -> + {:ok, + %Finch.Response{ + status: 200, + headers: [{"content-type", "application/json"}], + body: %{ + "expires_in" => 3600, + "access_token" => "SomeAccessToken", + "refresh_token" => "SomeRefreshToken", + "id_token" => ".#{body}." + } + }} + end + ) + end +end diff --git a/test/plausible_web/controllers/avatar_controller_test.exs b/test/plausible_web/controllers/avatar_controller_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..7639f7ca1c1fadce68431d7bfe92f27115afae8b --- /dev/null +++ b/test/plausible_web/controllers/avatar_controller_test.exs @@ -0,0 +1,36 @@ +defmodule PlausibleWeb.AvatarControllerTest do + use PlausibleWeb.ConnCase, async: true + + import Mox + setup :verify_on_exit! + + setup {PlausibleWeb.FirstLaunchPlug.Test, :skip} + + describe "GET /avatar/:hash" do + test "proxies the request to gravatar", %{conn: conn} do + expect( + Plausible.HTTPClient.Mock, + :get, + fn "https://www.gravatar.com/avatar/myhash?s=150&d=identicon" -> + {:ok, + %Finch.Response{ + status: 200, + body: "avatar response body", + headers: [ + {"content-type", "image/png"}, + {"cache-control", "max-age=300"}, + {"expires", "soon"} + ] + }} + end + ) + + conn = get(conn, "/avatar/myhash") + + assert response(conn, 200) =~ "avatar response body" + assert {"content-type", "image/png"} in conn.resp_headers + assert {"cache-control", "max-age=300"} in conn.resp_headers + assert {"expires", "soon"} in conn.resp_headers + end + end +end diff --git a/test/plausible_web/controllers/billing_controller_test.exs b/test/plausible_web/controllers/billing_controller_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..87575e2dd4526bf15a3237275eba714b64510380 --- /dev/null +++ b/test/plausible_web/controllers/billing_controller_test.exs @@ -0,0 +1,380 @@ +defmodule PlausibleWeb.BillingControllerTest do + use PlausibleWeb.ConnCase, async: true + + require Plausible.Billing.Subscription.Status + alias Plausible.Billing.Subscription + + @v4_growth_plan "857097" + @v4_business_plan "857105" + + describe "GET /choose-plan" do + setup [:create_user, :log_in] + + test "redirects to enterprise upgrade page if user has an enterprise plan configured", + %{conn: conn, user: user} do + subscribe_to_enterprise_plan(user, paddle_plan_id: "123") + + conn = get(conn, Routes.billing_path(conn, :choose_plan)) + assert redirected_to(conn) == Routes.billing_path(conn, :upgrade_to_enterprise_plan) + end + end + + describe "POST /change-plan" do + setup [:create_user, :log_in] + + test "errors if usage exceeds team member limit on the new plan", %{conn: conn, user: user} do + subscribe_to_plan(user, "123123") + + site = new_site(owner: user) + + for _ <- 1..4 do + add_guest(site, role: :viewer) + end + + conn = post(conn, Routes.billing_path(conn, :change_plan, @v4_growth_plan)) + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "Unable to subscribe to this plan because the following limits are exceeded: team member limit" + end + + test "errors if usage exceeds site limit even when user.next_upgrade_override is true", %{ + conn: conn, + user: user + } do + subscribe_to_plan(user, "123123") + + team = + user + |> team_of() + |> Ecto.Changeset.change(allow_next_upgrade_override: true) + |> Plausible.Repo.update!() + + for _ <- 1..11, do: new_site(owner: user) + + conn = post(conn, Routes.billing_path(conn, :change_plan, @v4_growth_plan)) + + subscription = Plausible.Repo.get_by(Plausible.Billing.Subscription, team_id: team.id) + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "are exceeded: site limit" + assert subscription.paddle_plan_id == "123123" + end + + test "can override allowing to upgrade when pageview limit is exceeded", %{ + conn: conn, + user: user + } do + subscribe_to_plan(user, "123123") + team = team_of(user) + site = new_site(owner: user) + now = NaiveDateTime.utc_now() + + generate_usage_for(site, 11_000, NaiveDateTime.shift(now, day: -5)) + generate_usage_for(site, 11_000, NaiveDateTime.shift(now, day: -35)) + + conn1 = post(conn, Routes.billing_path(conn, :change_plan, @v4_growth_plan)) + + subscription = Plausible.Repo.get_by(Plausible.Billing.Subscription, team_id: team.id) + + assert Phoenix.Flash.get(conn1.assigns.flash, :error) =~ + "are exceeded: monthly pageview limit" + + assert subscription.paddle_plan_id == "123123" + + user + |> team_of() + |> Ecto.Changeset.change(allow_next_upgrade_override: true) + |> Plausible.Repo.update!() + + conn2 = post(conn, Routes.billing_path(conn, :change_plan, @v4_growth_plan)) + + subscription = Plausible.Repo.reload!(subscription) + + assert Phoenix.Flash.get(conn2.assigns.flash, :success) =~ "Plan changed successfully" + assert subscription.paddle_plan_id == @v4_growth_plan + end + + test "calls Paddle API to update subscription", %{conn: conn, user: user} do + subscribe_to_plan(user, "321321") + team = team_of(user) + + post(conn, Routes.billing_path(conn, :change_plan, "123123")) + + subscription = Plausible.Repo.get_by(Plausible.Billing.Subscription, team_id: team.id) + assert subscription.paddle_plan_id == "123123" + assert subscription.next_bill_date == ~D[2019-07-10] + assert subscription.next_bill_amount == "6.00" + end + end + + describe "GET /billing/upgrade-success" do + setup [:create_user, :create_team, :log_in] + + test "shows success page after user subscribes", %{conn: conn} do + conn = get(conn, Routes.billing_path(conn, :upgrade_success)) + + assert html_response(conn, 200) =~ "Your account is being upgraded" + end + end + + @configured_enterprise_plan_paddle_plan_id "123" + + describe "GET /upgrade-to-enterprise-plan (no existing subscription)" do + setup [:create_user, :log_in, :configure_enterprise_plan] + + test "displays basic page content", %{conn: conn} do + doc = + conn + |> get(Routes.billing_path(conn, :upgrade_to_enterprise_plan)) + |> html_response(200) + + assert doc =~ "Upgrade to Enterprise" + assert doc =~ "prepared a custom enterprise plan for your account with the following limits" + assert doc =~ "Questions?" + assert doc =~ "Contact us" + assert doc =~ "+ VAT if applicable" + assert doc =~ "Click the button below to upgrade" + assert doc =~ "Pay securely via Paddle" + end + + test "displays info about the enterprise plan to upgrade to", %{conn: conn} do + doc = + conn + |> get(Routes.billing_path(conn, :upgrade_to_enterprise_plan)) + |> html_response(200) + + assert doc =~ ~r/Up to\s*\s*50M\s*<\/b>\s*monthly pageviews/ + assert doc =~ ~r/Up to\s*\s*20k\s*<\/b>\s*sites/ + assert doc =~ ~r/Up to\s*\s*5k\s*<\/b>\s*hourly api requests/ + assert doc =~ ~r/The plan is priced at\s*\s*€123\s*<\/b>\s*/ + assert doc =~ "per year" + end + + test "data-product attribute on the checkout link is the paddle_plan_id of the enterprise plan", + %{conn: conn, user: user} do + {:ok, team} = Plausible.Teams.get_by_owner(user) + + doc = + conn + |> get(Routes.billing_path(conn, :upgrade_to_enterprise_plan)) + |> html_response(200) + + assert %{ + "disableLogout" => true, + "email" => user.email, + "passthrough" => "ee:#{ee?()};user:#{user.id};team:#{team.id}", + "product" => @configured_enterprise_plan_paddle_plan_id, + "success" => Routes.billing_path(PlausibleWeb.Endpoint, :upgrade_success), + "theme" => "none" + } == get_paddle_checkout_params(find(doc, "#paddle-button")) + end + end + + describe "GET /upgrade-to-enterprise-plan (active subscription, new enterprise plan configured)" do + setup [:create_user, :log_in, :subscribe_enterprise, :configure_enterprise_plan] + + test "displays basic page content", %{conn: conn} do + doc = + conn + |> get(Routes.billing_path(conn, :upgrade_to_enterprise_plan)) + |> html_response(200) + + assert doc =~ "Change subscription plan" + assert doc =~ "prepared your account for an upgrade to custom limits" + assert doc =~ "+ VAT if applicable" + assert doc =~ "calculate the prorated amount that your card will be charged" + assert doc =~ "Preview changes" + assert doc =~ "Questions?" + assert doc =~ "Contact us" + end + + test "displays info about the enterprise plan to upgrade to", %{conn: conn} do + doc = + conn + |> get(Routes.billing_path(conn, :upgrade_to_enterprise_plan)) + |> html_response(200) + + assert doc =~ ~r/Up to\s*\s*50M\s*<\/b>\s*monthly pageviews/ + assert doc =~ ~r/Up to\s*\s*20k\s*<\/b>\s*sites/ + assert doc =~ ~r/Up to\s*\s*5k\s*<\/b>\s*hourly api requests/ + assert doc =~ ~r/The plan is priced at\s*\s*€123\s*<\/b>\s*/ + assert doc =~ "per year" + end + + test "preview changes links to :change_plan_preview action", %{conn: conn} do + doc = + conn + |> get(Routes.billing_path(conn, :upgrade_to_enterprise_plan)) + |> html_response(200) + + preview_changes_link = find(doc, "#preview-changes") + assert text(preview_changes_link) == "Preview changes" + + assert text_of_attr(preview_changes_link, "href") == + Routes.billing_path( + PlausibleWeb.Endpoint, + :change_plan_preview, + @configured_enterprise_plan_paddle_plan_id + ) + end + end + + describe "GET /billing/change-plan/preview/:plan_id" do + setup [:create_user, :log_in] + + test "renders preview information about the plan change", %{conn: conn, user: user} do + subscribe_to_plan(user, @v4_growth_plan) + + html_response = + conn + |> get(Routes.billing_path(conn, :change_plan_preview, @v4_business_plan)) + |> html_response(200) + + assert html_response =~ + "Your card will be charged a pro-rated amount for the current billing period" + + assert html_response =~ "€-72.6" + assert html_response =~ "Back" + assert html_response =~ ~s[ get(Routes.billing_path(conn, :upgrade_to_enterprise_plan)) + |> html_response(200) + + assert doc =~ "Looking to adjust your plan?" + assert doc =~ "You're currently on a custom plan." + + assert LazyHTML.text(LazyHTML.from_document(doc)) =~ + "please contact us at hello@plausible.io" + end + end + + describe "GET /upgrade-to-enterprise-plan (subscription past_due or paused)" do + setup [:create_user, :log_in, :configure_enterprise_plan] + + test "redirects to /settings when past_due", %{conn: conn} = context do + subscribe_enterprise(context, status: Subscription.Status.past_due()) + conn = get(conn, Routes.billing_path(conn, :upgrade_to_enterprise_plan)) + assert redirected_to(conn) == Routes.settings_path(conn, :subscription) + end + + test "redirects to /settings when paused", %{conn: conn} = context do + subscribe_enterprise(context, status: Subscription.Status.paused()) + conn = get(conn, Routes.billing_path(conn, :upgrade_to_enterprise_plan)) + assert redirected_to(conn) == Routes.settings_path(conn, :subscription) + end + end + + describe "GET /upgrade-to-enterprise-plan (deleted enterprise subscription)" do + setup [:create_user, :log_in, :configure_enterprise_plan] + + setup context do + subscribe_enterprise(context, + paddle_plan_id: @configured_enterprise_plan_paddle_plan_id, + status: Subscription.Status.deleted() + ) + + context + end + + test "displays the same content as for a user without a subscription", %{conn: conn} do + doc = + conn + |> get(Routes.billing_path(conn, :upgrade_to_enterprise_plan)) + |> html_response(200) + + assert doc =~ "Upgrade to Enterprise" + assert doc =~ "prepared a custom enterprise plan for your account with the following limits" + assert doc =~ "Questions?" + assert doc =~ "Contact us" + assert doc =~ "+ VAT if applicable" + assert doc =~ "Click the button below to upgrade" + assert doc =~ "Pay securely via Paddle" + end + + test "still allows to subscribe back to the same plan", %{conn: conn} do + doc = + conn + |> get(Routes.billing_path(conn, :upgrade_to_enterprise_plan)) + |> html_response(200) + + assert doc =~ ~r/Up to\s*\s*50M\s*<\/b>\s*monthly pageviews/ + assert doc =~ ~r/Up to\s*\s*20k\s*<\/b>\s*sites/ + assert doc =~ ~r/Up to\s*\s*5k\s*<\/b>\s*hourly api requests/ + assert doc =~ ~r/The plan is priced at\s*\s*€123\s*<\/b>\s*/ + assert doc =~ "per year" + end + + test "renders paddle button with the correct checkout params", + %{conn: conn, user: user} do + {:ok, team} = Plausible.Teams.get_by_owner(user) + + doc = + conn + |> get(Routes.billing_path(conn, :upgrade_to_enterprise_plan)) + |> html_response(200) + + assert %{ + "disableLogout" => true, + "email" => user.email, + "passthrough" => "ee:#{ee?()};user:#{user.id};team:#{team.id}", + "product" => @configured_enterprise_plan_paddle_plan_id, + "success" => Routes.billing_path(PlausibleWeb.Endpoint, :upgrade_success), + "theme" => "none" + } == get_paddle_checkout_params(find(doc, "#paddle-button")) + end + end + + defp configure_enterprise_plan(%{user: user}) do + subscribe_to_enterprise_plan(user, + paddle_plan_id: "123", + billing_interval: :yearly, + monthly_pageview_limit: 50_000_000, + site_limit: 20_000, + hourly_api_request_limit: 5000, + inserted_at: NaiveDateTime.utc_now() |> NaiveDateTime.shift(hour: 1), + subscription?: false + ) + + :ok + end + + defp subscribe_enterprise(%{user: user}, opts \\ []) do + {paddle_plan_id, opts} = Keyword.pop(opts, :paddle_plan_id, "321") + + opts = Keyword.put_new(opts, :status, Subscription.Status.active()) + + user = subscribe_to_plan(user, paddle_plan_id, opts) + + {:ok, user: user} + end + + defp get_paddle_checkout_params(element) do + with onclick <- text_of_attr(element, "onclick"), + [[_, checkout_params_str]] <- Regex.scan(~r/Paddle\.Checkout\.open\((.*?)\)/, onclick), + {:ok, checkout_params} <- Jason.decode(checkout_params_str) do + checkout_params + end + end +end diff --git a/test/plausible_web/controllers/error_report_controller_test.exs b/test/plausible_web/controllers/error_report_controller_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..15d262547b2125232bd02d025c0af81e0fb755f2 --- /dev/null +++ b/test/plausible_web/controllers/error_report_controller_test.exs @@ -0,0 +1,148 @@ +defmodule PlausibleWeb.ErrorReportControllerTest do + use PlausibleWeb.ConnCase, async: true + @moduletag :ee_only + + use Bamboo.Test + + import Phoenix.View + + alias PlausibleWeb.Endpoint + alias PlausibleWeb.ErrorView + alias PlausibleWeb.EmailView + + describe "building sentry links" do + test "no dsn configured" do + assert EmailView.sentry_link("some-trace") == "" + end + + test "with dsn" do + sample_dsn = "https://foobarbaz@somehost.example.com/1" + + assert EmailView.sentry_link("some-trace", sample_dsn) == + "https://somehost.example.com/organizations/sentry/issues/?query=some-trace" + end + end + + describe "Logged in" do + setup [:create_user, :log_in] + + for error <- ["500", "502", "503", "504"] do + test "renders the form when sentry metadata present: #{error}", %{user: user} do + Sentry.put_last_event_id_and_source("some-event-id", :plug) + action_path = Routes.error_report_path(Endpoint, :submit_error_report) + + assert html = + render_to_string(ErrorView, unquote(error) <> ".html", %{ + current_user: user + }) + + assert form_exists?(html, action_path) + assert submit_button(html, "form[action=\"#{action_path}\"]") + + assert element_exists?( + html, + "input[type=\"hidden\"][name=\"error[trace_id]\"][value=\"some-event-id\"]" + ) + end + end + + test "submitting the feedback form for authenticated user", %{conn: conn, user: user} do + action_path = Routes.error_report_path(Endpoint, :submit_error_report) + + conn = + post(conn, action_path, %{ + "error" => %{"trace_id" => "some-trace-id", "user_feedback" => "Guiz pls fix"} + }) + + assert html = html_response(conn, 200) + assert html =~ "Your report has been submitted" + + reply_to = "#{user.name} <#{user.email}>" + + assert_delivered_email_matches(%{ + to: [nil: "bugs@plausible.io"], + subject: "Feedback to Sentry Trace some-trace-id", + private: %{ + message_params: %{ + "ReplyTo" => ^reply_to + } + } + }) + end + + test "short feedback is not sent", %{conn: conn} do + action_path = Routes.error_report_path(Endpoint, :submit_error_report) + + conn = + post(conn, action_path, %{ + "error" => %{"trace_id" => "some-trace-id", "user_feedback" => "short"} + }) + + assert html = html_response(conn, 200) + assert html =~ "Your report has been submitted" + + refute_email_delivered_with(%{ + subject: "Feedback to Sentry Trace some-trace-id" + }) + end + + test "submitting no feedback for authenticated user", %{conn: conn} do + action_path = Routes.error_report_path(Endpoint, :submit_error_report) + + conn = + post(conn, action_path, %{ + "error" => %{"trace_id" => "some-trace-id"} + }) + + assert html = html_response(conn, 200) + assert html =~ "Your report has been submitted" + + refute_email_delivered_with(%{ + subject: "Feedback to Sentry Trace some-trace-id" + }) + end + + test "email renders properly" do + assert email = PlausibleWeb.Email.error_report("Alice Bob", "some-trace", "hello world") + + text = + email + |> Map.fetch!(:html_body) + |> text() + + assert text =~ "Reported by: Alice Bob" + assert text =~ "Sentry trace: some-trace" + assert text =~ "User feedback: hello world" + end + end + + describe "Not logged in" do + test "submitting the feedback form for unauthenticated user", %{conn: conn} do + action_path = Routes.error_report_path(Endpoint, :submit_error_report) + + conn = + post(conn, action_path, %{ + "error" => %{"trace_id" => "some-trace-id", "user_feedback" => "Guiz pls fix"} + }) + + assert redirected_to(conn) == "/login" + end + + for error <- ["500", "502", "503", "504"] do + test "renders the error page, no form, when sentry metadata absent: #{error}", %{conn: conn} do + conn = + conn + |> bypass_through(PlausibleWeb.Router, [:browser]) + |> get("/") + + assert html = render_to_string(ErrorView, unquote(error) <> ".html", %{conn: conn}) + text = text(html) + assert text =~ "There has been a server error" + assert text =~ "But don't worry, we're on it!" + + action_path = Routes.error_report_path(Endpoint, :submit_error_report) + refute form_exists?(html, action_path) + end + end + end +end diff --git a/test/plausible_web/controllers/google_analytics_controller_test.exs b/test/plausible_web/controllers/google_analytics_controller_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..5ecfc5712bad8f9092d51c5d8c8c33dd06386c04 --- /dev/null +++ b/test/plausible_web/controllers/google_analytics_controller_test.exs @@ -0,0 +1,855 @@ +defmodule PlausibleWeb.GoogleAnalyticsControllerTest do + use PlausibleWeb.ConnCase, async: false + use Oban.Testing, repo: Plausible.Repo + + import Mox + + alias Plausible.HTTPClient + alias Plausible.Imported.SiteImport + + require Plausible.Imported.SiteImport + + on_ce do + @moduletag :capture_log + end + + setup :verify_on_exit! + + describe "GET /:domain/import/google-analytics/property" do + setup [:create_user, :log_in, :create_site] + + test "lists Google Analytics properties", %{conn: conn, site: site} do + expect( + Plausible.HTTPClient.Mock, + :get, + fn _url, _opts -> + body = "fixture/ga4_list_properties.json" |> File.read!() |> Jason.decode!() + {:ok, %Finch.Response{body: body, status: 200}} + end + ) + + response = + conn + |> get("/#{site.domain}/import/google-analytics/property", %{ + "access_token" => "token", + "refresh_token" => "foo", + "expires_at" => "2022-09-22T20:01:37.112777" + }) + |> html_response(200) + + assert response =~ "account.one - GA4 (properties/428685906)" + assert response =~ "GA4 - Flood-It! (properties/153293282)" + assert response =~ "GA4 - Google Merch Shop (properties/213025502)" + end + + test "redirects to imports and exports on rate limit error with flash error", %{ + conn: conn, + site: site + } do + expect( + Plausible.HTTPClient.Mock, + :get, + fn _url, _opts -> + {:error, %HTTPClient.Non200Error{reason: %{status: 429, body: "rate limit exceeded"}}} + end + ) + + conn = + conn + |> get("/#{site.domain}/import/google-analytics/property", %{ + "access_token" => "token", + "refresh_token" => "foo", + "expires_at" => "2022-09-22T20:01:37.112777" + }) + + assert redirected_to(conn, 302) == + PlausibleWeb.Router.Helpers.site_path( + conn, + :settings_imports_exports, + site.domain + ) + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "Google Analytics rate limit has been exceeded. Please try again later." + end + + test "redirects to imports and exports on auth error with flash error", %{ + conn: conn, + site: site + } do + expect( + Plausible.HTTPClient.Mock, + :get, + fn _url, _opts -> + {:error, %HTTPClient.Non200Error{reason: %{status: 403, body: %{}}}} + end + ) + + conn = + conn + |> get("/#{site.domain}/import/google-analytics/property", %{ + "access_token" => "token", + "refresh_token" => "foo", + "expires_at" => "2022-09-22T20:01:37.112777" + }) + + assert redirected_to(conn, 302) == + PlausibleWeb.Router.Helpers.site_path( + conn, + :settings_imports_exports, + site.domain + ) + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "We were unable to authenticate your Google Analytics account" + end + + @tag :ce_build_only + test "redirects to imports and exports on disabled API with flash error", %{ + conn: conn, + site: site + } do + expect( + Plausible.HTTPClient.Mock, + :get, + fn _url, _opts -> + body = "fixture/ga4_api_disabled_error.json" |> File.read!() |> Jason.decode!() + {:error, %HTTPClient.Non200Error{reason: %{status: 403, body: body}}} + end + ) + + conn = + conn + |> get("/#{site.domain}/import/google-analytics/property", %{ + "access_token" => "token", + "refresh_token" => "foo", + "expires_at" => "2022-09-22T20:01:37.112777" + }) + + assert redirected_to(conn, 302) == + PlausibleWeb.Router.Helpers.site_path( + conn, + :settings_imports_exports, + site.domain + ) + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + """ + Google Analytics Admin API has not been used in project 752168887897 before or it is disabled. \ + Enable it by visiting https://console.developers.google.com/apis/api/analyticsadmin.googleapis.com/overview?project=752168887897 then retry. \ + If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry.\ + """ + end + + test "redirects to imports and exports on timeout error with flash error", %{ + conn: conn, + site: site + } do + expect( + Plausible.HTTPClient.Mock, + :get, + fn _url, _opts -> + {:error, %Finch.TransportError{reason: :timeout}} + end + ) + + conn = + conn + |> get("/#{site.domain}/import/google-analytics/property", %{ + "access_token" => "token", + "refresh_token" => "foo", + "expires_at" => "2022-09-22T20:01:37.112777" + }) + + assert redirected_to(conn, 302) == + PlausibleWeb.Router.Helpers.site_path( + conn, + :settings_imports_exports, + site.domain + ) + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "Google Analytics API has timed out." + end + + test "redirects to imports and exports on list retrieval failure with flash error", + %{ + conn: conn, + site: site + } do + expect( + Plausible.HTTPClient.Mock, + :get, + fn _url, _opts -> + {:error, %HTTPClient.Non200Error{reason: %{status: 500, body: "Internal server error"}}} + end + ) + + conn = + conn + |> get("/#{site.domain}/import/google-analytics/property", %{ + "access_token" => "token", + "refresh_token" => "foo", + "expires_at" => "2022-09-22T20:01:37.112777" + }) + + assert redirected_to(conn, 302) == + PlausibleWeb.Router.Helpers.site_path( + conn, + :settings_imports_exports, + site.domain + ) + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "We were unable to list your Google Analytics properties" + end + end + + describe "POST /:domain/import/google-analytics/property" do + setup [:create_user, :log_in, :create_site] + + test "redirects to confirmation", %{conn: conn, site: site} do + expect( + Plausible.HTTPClient.Mock, + :post, + fn _url, _opts, _params -> + body = "fixture/ga4_start_date.json" |> File.read!() |> Jason.decode!() + {:ok, %Finch.Response{body: body, status: 200}} + end + ) + + expect( + Plausible.HTTPClient.Mock, + :post, + fn _url, _opts, _params -> + body = "fixture/ga4_end_date.json" |> File.read!() |> Jason.decode!() + {:ok, %Finch.Response{body: body, status: 200}} + end + ) + + conn = + conn + |> post("/#{site.domain}/import/google-analytics/property", %{ + "property" => "properties/428685906", + "access_token" => "token", + "refresh_token" => "foo", + "expires_at" => "2022-09-22T20:01:37.112777" + }) + + assert redirected_to(conn, 302) =~ + "/#{URI.encode_www_form(site.domain)}/import/google-analytics/confirm" + end + + test "renders error when no time window to import available", %{conn: conn, site: site} do + start_date = ~D[2022-01-12] + end_date = ~D[2024-03-13] + + _existing_import = + insert(:site_import, + site: site, + start_date: start_date, + end_date: end_date, + status: :completed + ) + + expect( + Plausible.HTTPClient.Mock, + :post, + fn _url, _opts, _params -> + body = "fixture/ga4_start_date.json" |> File.read!() |> Jason.decode!() + {:ok, %Finch.Response{body: body, status: 200}} + end + ) + + expect( + Plausible.HTTPClient.Mock, + :post, + fn _url, _opts, _params -> + body = "fixture/ga4_end_date.json" |> File.read!() |> Jason.decode!() + {:ok, %Finch.Response{body: body, status: 200}} + end + ) + + expect( + Plausible.HTTPClient.Mock, + :get, + fn _url, _opts -> + body = "fixture/ga4_list_properties.json" |> File.read!() |> Jason.decode!() + {:ok, %Finch.Response{body: body, status: 200}} + end + ) + + response = + conn + |> post("/#{site.domain}/import/google-analytics/property", %{ + "property" => "properties/428685906", + "access_token" => "token", + "refresh_token" => "foo", + "expires_at" => "2022-09-22T20:01:37.112777" + }) + |> html_response(200) + + assert response =~ + "Imported data time range is completely overlapping with existing data. Nothing to import." + end + + test "renders error when there's no data to import", %{conn: conn, site: site} do + expect( + Plausible.HTTPClient.Mock, + :post, + fn _url, _opts, _params -> + {:ok, %Finch.Response{body: %{"reports" => []}, status: 200}} + end + ) + + expect( + Plausible.HTTPClient.Mock, + :post, + fn _url, _opts, _params -> + {:ok, %Finch.Response{body: %{"reports" => []}, status: 200}} + end + ) + + expect( + Plausible.HTTPClient.Mock, + :get, + fn _url, _opts -> + body = "fixture/ga4_list_properties.json" |> File.read!() |> Jason.decode!() + {:ok, %Finch.Response{body: body, status: 200}} + end + ) + + response = + conn + |> post("/#{site.domain}/import/google-analytics/property", %{ + "property" => "properties/428685906", + "access_token" => "token", + "refresh_token" => "foo", + "expires_at" => "2022-09-22T20:01:37.112777" + }) + |> html_response(200) + + assert response =~ "No data found. Nothing to import." + end + + test "redirects to imports and exports on failed property choice with flash error", + %{ + conn: conn, + site: site + } do + expect( + Plausible.HTTPClient.Mock, + :post, + fn _url, _opts, _params -> + {:error, %HTTPClient.Non200Error{reason: %{status: 500, body: "Internal server error"}}} + end + ) + + conn = + conn + |> post("/#{site.domain}/import/google-analytics/property", %{ + "property" => "properties/428685906", + "access_token" => "token", + "refresh_token" => "foo", + "expires_at" => "2022-09-22T20:01:37.112777" + }) + + assert redirected_to(conn, 302) == + PlausibleWeb.Router.Helpers.site_path( + conn, + :settings_imports_exports, + site.domain + ) + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "We were unable to retrieve information from Google Analytics" + end + + test "redirects to imports and exports on rate limiting with flash error", + %{ + conn: conn, + site: site + } do + expect( + Plausible.HTTPClient.Mock, + :post, + fn _url, _opts, _params -> + {:error, %HTTPClient.Non200Error{reason: %{status: 429, body: "rate limit exceeded"}}} + end + ) + + conn = + conn + |> post("/#{site.domain}/import/google-analytics/property", %{ + "property" => "properties/428685906", + "access_token" => "token", + "refresh_token" => "foo", + "expires_at" => "2022-09-22T20:01:37.112777" + }) + + assert redirected_to(conn, 302) == + PlausibleWeb.Router.Helpers.site_path( + conn, + :settings_imports_exports, + site.domain + ) + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "Google Analytics rate limit has been exceeded. Please try again later." + end + + test "redirects to imports and exports on expired authentication with flash error", + %{ + conn: conn, + site: site + } do + expect( + Plausible.HTTPClient.Mock, + :post, + fn _url, _opts, _params -> + {:error, %HTTPClient.Non200Error{reason: %{status: 403, body: "Access denied"}}} + end + ) + + conn = + conn + |> post("/#{site.domain}/import/google-analytics/property", %{ + "property" => "properties/428685906", + "access_token" => "token", + "refresh_token" => "foo", + "expires_at" => "2022-09-22T20:01:37.112777" + }) + + assert redirected_to(conn, 302) == + PlausibleWeb.Router.Helpers.site_path( + conn, + :settings_imports_exports, + site.domain + ) + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "Google Analytics authentication seems to have expired." + end + + @tag :ce_build_only + test "redirects to imports and exports on disabled API with flash error", %{ + conn: conn, + site: site + } do + expect( + Plausible.HTTPClient.Mock, + :post, + fn _url, _opts, _params -> + body = "fixture/ga4_api_disabled_error.json" |> File.read!() |> Jason.decode!() + {:error, %HTTPClient.Non200Error{reason: %Finch.Response{status: 403, body: body}}} + end + ) + + conn = + conn + |> post("/#{site.domain}/import/google-analytics/property", %{ + "property" => "properties/428685906", + "access_token" => "token", + "refresh_token" => "foo", + "expires_at" => "2022-09-22T20:01:37.112777" + }) + + assert redirected_to(conn, 302) == + PlausibleWeb.Router.Helpers.site_path( + conn, + :settings_imports_exports, + site.domain + ) + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + """ + Google Analytics Admin API has not been used in project 752168887897 before or it is disabled. \ + Enable it by visiting https://console.developers.google.com/apis/api/analyticsadmin.googleapis.com/overview?project=752168887897 then retry. \ + If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry.\ + """ + end + + test "redirects to imports and exports on timeout with flash error", + %{ + conn: conn, + site: site + } do + expect( + Plausible.HTTPClient.Mock, + :post, + fn _url, _opts, _params -> + {:error, %Finch.TransportError{reason: :timeout}} + end + ) + + conn = + conn + |> post("/#{site.domain}/import/google-analytics/property", %{ + "property" => "properties/428685906", + "access_token" => "token", + "refresh_token" => "foo", + "expires_at" => "2022-09-22T20:01:37.112777" + }) + + assert redirected_to(conn, 302) == + PlausibleWeb.Router.Helpers.site_path( + conn, + :settings_imports_exports, + site.domain + ) + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "Google Analytics API has timed out." + end + end + + describe "GET /:domain/import/google-analytics/confirm" do + setup [:create_user, :log_in, :create_site] + + test "renders confirmation form for Google Analytics 4 import", %{conn: conn, site: site} do + expect( + Plausible.HTTPClient.Mock, + :get, + fn _url, _headers -> + body = "fixture/ga4_get_property.json" |> File.read!() |> Jason.decode!() + {:ok, %Finch.Response{body: body, status: 200}} + end + ) + + response = + conn + |> get("/#{site.domain}/import/google-analytics/confirm", %{ + "property" => "properties/428685444", + "access_token" => "token", + "refresh_token" => "foo", + "expires_at" => "2022-09-22T20:01:37.112777", + "start_date" => "2024-02-22", + "end_date" => "2024-02-26" + }) + |> html_response(200) + + action_url = PlausibleWeb.Router.Helpers.google_analytics_path(conn, :import, site.domain) + + assert text_of_attr(response, "form", "action") == action_url + + assert text_of_attr(response, ~s|input[name=access_token]|, "value") == "token" + assert text_of_attr(response, ~s|input[name=refresh_token]|, "value") == "foo" + + assert text_of_attr(response, ~s|input[name=expires_at]|, "value") == + "2022-09-22T20:01:37.112777" + + assert text_of_attr(response, ~s|input[name=property]|, "value") == + "properties/428685444" + + assert text_of_attr(response, ~s|input[name=start_date]|, "value") == "2024-02-22" + + assert text_of_attr(response, ~s|input[name=end_date]|, "value") == "2024-02-26" + end + + test "redirects to imports and exports on failed property retrieval with flash error", + %{ + conn: conn, + site: site + } do + expect( + Plausible.HTTPClient.Mock, + :get, + fn _url, _params -> + {:error, %HTTPClient.Non200Error{reason: %{status: 500, body: "Internal server error"}}} + end + ) + + conn = + conn + |> get("/#{site.domain}/import/google-analytics/confirm", %{ + "property" => "properties/428685906", + "access_token" => "token", + "refresh_token" => "foo", + "expires_at" => "2022-09-22T20:01:37.112777", + "start_date" => "2024-02-22", + "end_date" => "2024-02-26" + }) + + assert redirected_to(conn, 302) == + PlausibleWeb.Router.Helpers.site_path( + conn, + :settings_imports_exports, + site.domain + ) + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "We were unable to retrieve information from Google Analytics" + end + + test "redirects to imports and exports on rate limiting with flash error", + %{ + conn: conn, + site: site + } do + expect( + Plausible.HTTPClient.Mock, + :get, + fn _url, _params -> + {:error, %HTTPClient.Non200Error{reason: %{status: 429, body: "rate limit exceeded"}}} + end + ) + + conn = + conn + |> get("/#{site.domain}/import/google-analytics/confirm", %{ + "property" => "properties/428685906", + "access_token" => "token", + "refresh_token" => "foo", + "expires_at" => "2022-09-22T20:01:37.112777", + "start_date" => "2024-02-22", + "end_date" => "2024-02-26" + }) + + assert redirected_to(conn, 302) == + PlausibleWeb.Router.Helpers.site_path( + conn, + :settings_imports_exports, + site.domain + ) + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "Google Analytics rate limit has been exceeded. Please try again later." + end + + test "redirects to imports and exports on expired authentication with flash error", + %{ + conn: conn, + site: site + } do + expect( + Plausible.HTTPClient.Mock, + :get, + fn _url, _params -> + {:error, %HTTPClient.Non200Error{reason: %{status: 403, body: "Access denied"}}} + end + ) + + conn = + conn + |> get("/#{site.domain}/import/google-analytics/confirm", %{ + "property" => "properties/428685906", + "access_token" => "token", + "refresh_token" => "foo", + "expires_at" => "2022-09-22T20:01:37.112777", + "start_date" => "2024-02-22", + "end_date" => "2024-02-26" + }) + + assert redirected_to(conn, 302) == + PlausibleWeb.Router.Helpers.site_path( + conn, + :settings_imports_exports, + site.domain + ) + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "Google Analytics authentication seems to have expired." + end + + @tag :ce_build_only + test "redirects to imports and exports on disabled API with flash error", + %{ + conn: conn, + site: site + } do + expect( + Plausible.HTTPClient.Mock, + :get, + fn _url, _params -> + body = "fixture/ga4_api_disabled_error.json" |> File.read!() |> Jason.decode!() + {:error, %HTTPClient.Non200Error{reason: %{status: 403, body: body}}} + end + ) + + conn = + conn + |> get("/#{site.domain}/import/google-analytics/confirm", %{ + "property" => "properties/428685906", + "access_token" => "token", + "refresh_token" => "foo", + "expires_at" => "2022-09-22T20:01:37.112777", + "start_date" => "2024-02-22", + "end_date" => "2024-02-26" + }) + + assert redirected_to(conn, 302) == + PlausibleWeb.Router.Helpers.site_path( + conn, + :settings_imports_exports, + site.domain + ) + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + """ + Google Analytics Admin API has not been used in project 752168887897 before or it is disabled. \ + Enable it by visiting https://console.developers.google.com/apis/api/analyticsadmin.googleapis.com/overview?project=752168887897 then retry. \ + If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry.\ + """ + end + + test "redirects to imports and exports on timeout with flash error", + %{ + conn: conn, + site: site + } do + expect( + Plausible.HTTPClient.Mock, + :get, + fn _url, _params -> + {:error, %Finch.TransportError{reason: :timeout}} + end + ) + + conn = + conn + |> get("/#{site.domain}/import/google-analytics/confirm", %{ + "property" => "properties/428685906", + "access_token" => "token", + "refresh_token" => "foo", + "expires_at" => "2022-09-22T20:01:37.112777", + "start_date" => "2024-02-22", + "end_date" => "2024-02-26" + }) + + assert redirected_to(conn, 302) == + PlausibleWeb.Router.Helpers.site_path( + conn, + :settings_imports_exports, + site.domain + ) + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "Google Analytics API has timed out." + end + end + + describe "POST /:domain/settings/google-import" do + setup [:create_user, :log_in, :create_site] + + test "creates Google Analytics 4 site import instance", %{conn: conn, site: site} do + conn = + post(conn, "/#{site.domain}/settings/google-import", %{ + "property" => "properties/123456", + "start_date" => "2018-03-01", + "end_date" => "2022-03-01", + "access_token" => "token", + "refresh_token" => "foo", + "expires_at" => "2022-09-22T20:01:37.112777" + }) + + assert redirected_to(conn, 302) == + PlausibleWeb.Router.Helpers.site_path(conn, :settings_imports_exports, site.domain) + + [site_import] = Plausible.Imported.list_all_imports(site) + + assert site_import.source == :google_analytics_4 + assert site_import.end_date == ~D[2022-03-01] + assert site_import.status == SiteImport.pending() + end + + test "schedules a Google Analytics 4 import job in Oban", %{conn: conn, site: site} do + post(conn, "/#{site.domain}/settings/google-import", %{ + "property" => "properties/123456", + "start_date" => "2018-03-01", + "end_date" => "2022-03-01", + "access_token" => "token", + "refresh_token" => "foo", + "expires_at" => "2022-09-22T20:01:37.112777" + }) + + assert [%{id: import_id, legacy: false}] = Plausible.Imported.list_all_imports(site) + + assert_enqueued( + worker: Plausible.Workers.ImportAnalytics, + args: %{ + "import_id" => import_id, + "property" => "properties/123456", + "start_date" => "2018-03-01", + "end_date" => "2022-03-01", + "access_token" => "token", + "refresh_token" => "foo", + "token_expires_at" => "2022-09-22T20:01:37.112777" + } + ) + end + + test "does not start another import when there's any other in progress for the same site", %{ + conn: conn, + site: site, + user: user + } do + {:ok, job} = + Plausible.Imported.NoopImporter.new_import(site, user, + start_date: ~D[2020-01-02], + end_date: ~D[2021-10-11] + ) + + post(conn, "/#{site.domain}/settings/google-import", %{ + "property" => "properties/123456", + "start_date" => "2018-03-01", + "end_date" => "2022-03-01", + "access_token" => "token", + "refresh_token" => "foo", + "expires_at" => "2022-09-22T20:01:37.112777" + }) + + conn = + post(conn, "/#{site.domain}/settings/google-import", %{ + "property" => "123456", + "start_date" => "2018-03-01", + "end_date" => "2022-03-01", + "access_token" => "token", + "refresh_token" => "foo", + "expires_at" => "2022-09-22T20:01:37.112777" + }) + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "There's another import still in progress." + + assert [%{id: import_id, legacy: false}] = Plausible.Imported.list_all_imports(site) + + assert job.args.import_id == import_id + end + + test "redirects to imports and exports with no time window error flash error", %{ + conn: conn, + site: site + } do + start_date = ~D[2022-01-12] + end_date = ~D[2024-03-13] + + _existing_import = + insert(:site_import, + site: site, + start_date: start_date, + end_date: end_date, + status: :completed + ) + + conn = + post(conn, "/#{site.domain}/settings/google-import", %{ + "property" => "123456", + "start_date" => "2023-03-01", + "end_date" => "2022-03-01", + "access_token" => "token", + "refresh_token" => "foo", + "expires_at" => "2022-09-22T20:01:37.112777" + }) + + assert redirected_to(conn, 302) == + PlausibleWeb.Router.Helpers.site_path( + conn, + :settings_imports_exports, + site.domain + ) + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "Import failed. No data could be imported because date range overlaps with existing data." + end + end +end diff --git a/test/plausible_web/controllers/help_scout_controller_test.exs b/test/plausible_web/controllers/help_scout_controller_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..5bb115f2387525087613bdc3d01b1027c2395de3 --- /dev/null +++ b/test/plausible_web/controllers/help_scout_controller_test.exs @@ -0,0 +1,258 @@ +defmodule PlausibleWeb.HelpScoutControllerTest do + use PlausibleWeb.ConnCase, async: true + + @moduletag :ee_only + + on_ee do + alias Plausible.HelpScout + + describe "callback/2" do + test "returns details on success", %{conn: conn} do + user = insert(:user) + signature_key = Application.fetch_env!(:plausible, HelpScout)[:signature_key] + data = ~s|{"conversation-id":"123","customer-id":"500"}| + + signature = + :hmac + |> :crypto.mac(:sha, signature_key, data) + |> Base.encode64() + |> URI.encode_www_form() + + Req.Test.stub(HelpScout, fn + %{request_path: "/v2/oauth2/token"} = conn -> + Req.Test.json(conn, %{ + "token_type" => "bearer", + "access_token" => "369dbb08be58430086d2f8bd832bc1eb", + "expires_in" => 172_800 + }) + + %{request_path: "/v2/customers/500"} = conn -> + Req.Test.json(conn, %{ + "id" => 500, + "_embedded" => %{ + "emails" => [ + %{ + "id" => 1, + "value" => user.email, + "type" => "home" + } + ] + } + }) + end) + + conn = + get( + conn, + "/helpscout/callback?conversation-id=123&customer-id=500&X-HelpScout-Signature=#{signature}" + ) + + assert [] = Plug.Conn.get_resp_header(conn, "content-security-policy") + + html = html_response(conn, 200) + assert html =~ Routes.customer_support_user_path(PlausibleWeb.Endpoint, :show, user.id) + assert text_of_attr("input[name=token]", "value") != "" + end + + test "returns error on failure", %{conn: conn} do + conn = + get( + conn, + "/helpscout/callback?conversation-id=123&customer-id=500&X-HelpScout-Signature=invalid" + ) + + html = html_response(conn, 200) + + assert html =~ "bad_signature" + refute text_of_attr("input[name=token]", "value") + end + + test "handles invalid parameters gracefully", %{conn: conn} do + conn = + get( + conn, + "/helpscout/callback?customer-id=500&X-HelpScout-Signature=whatever" + ) + + assert html_response(conn, 200) =~ "Missing expected parameters" + end + end + + describe "search/2" do + test "returns results", %{conn: conn} do + insert(:user, email: "hs.match@plausible.test") + insert(:user, email: "hs.nomatch@plausible.test") + + token = sign_conversation_token("123") + + conn = + conn + |> get( + "/helpscout/search?conversation_id=123&customer_id=500&term=hs.match&token=#{token}" + ) + + html = html_response(conn, 200) + + assert html =~ "hs.match@plausible.test" + refute html =~ "hs.nomatch@plausible.test" + end + + test "returns error when token is invalid", %{conn: conn} do + conn = + get( + conn, + "/helpscout/search?conversation_id=123&customer_id=500&term=hs.match&token=invalid" + ) + + assert html_response(conn, 200) =~ "invalid_token" + end + + test "returns error when token does not match", %{conn: conn} do + token = sign_conversation_token("456") + + conn = + conn + |> get( + "/helpscout/search?conversation_id=123&customer_id=500&term=hs.match&token=#{token}" + ) + + assert html_response(conn, 200) =~ "invalid_conversation" + end + end + + describe "show/2" do + test "returns details on success", %{conn: conn} do + user = insert(:user, email: "hs.match@plausible.test", notes: "Some note\nwith new line") + + token = sign_conversation_token("123") + + conn = + conn + |> get( + "/helpscout/show?conversation_id=123&customer_id=500&email=hs.match@plausible.test&token=#{token}" + ) + + assert html = html_response(conn, 200) + assert html =~ Routes.customer_support_user_path(PlausibleWeb.Endpoint, :show, user.id) + assert html =~ "Some note
\nwith new line" + end + + test "returns teams list when the match is an owner in multiple teams", %{conn: conn} do + user = new_user(email: "hs.match@plausible.test", notes: "Some user notes") + other_user = new_user() + _site1 = new_site(owner: user) + _site2 = new_site(owner: other_user) + _team1 = team_of(user) + team2 = team_of(other_user) + + team2 = + team2 + |> Plausible.Teams.complete_setup() + |> Ecto.Changeset.change(name: "HS Integration Test Team") + |> Plausible.Repo.update!() + + add_member(team2, user: user, role: :owner) + + token = sign_conversation_token("123") + + conn = + conn + |> get( + "/helpscout/show?conversation_id=123&customer_id=500&email=hs.match@plausible.test&token=#{token}" + ) + + assert html = html_response(conn, 200) + assert html =~ Routes.customer_support_user_path(PlausibleWeb.Endpoint, :show, user.id) + assert html =~ "Some user notes" + assert html =~ "My personal sites" + assert html =~ "HS Integration Test Team" + end + + test "returns personal team details when identifier passed explicitly", %{conn: conn} do + user = new_user(email: "hs.match@plausible.test", notes: "Some user notes") + other_user = new_user() + _site1 = new_site(owner: user) + _site2 = new_site(owner: other_user) + team1 = team_of(user) + team2 = team_of(other_user) + + team2 = + team2 + |> Plausible.Teams.complete_setup() + |> Ecto.Changeset.change(name: "HS Integration Test Team") + |> Plausible.Repo.update!() + + add_member(team2, user: user, role: :owner) + + token = sign_conversation_token("123") + + conn = + conn + |> get( + "/helpscout/show?conversation_id=123&customer_id=500&email=hs.match@plausible.test&team_identifier=#{team1.identifier}&token=#{token}" + ) + + assert html = html_response(conn, 200) + refute html =~ "HS Integration Test Team" + refute html =~ "My personal sites" + assert html =~ "Some user notes" + end + + test "returns setup team details when identifier passed explicitly", %{conn: conn} do + user = new_user(email: "hs.match@plausible.test", notes: "Some user notes") + other_user = new_user() + _site1 = new_site(owner: user) + _site2 = new_site(owner: other_user) + _team1 = team_of(user) + team2 = team_of(other_user) + + team2 = + team2 + |> Plausible.Teams.complete_setup() + |> Ecto.Changeset.change(name: "HS Integration Test Team") + |> Plausible.Repo.update!() + + add_member(team2, user: user, role: :owner) + + token = sign_conversation_token("123") + + conn = + conn + |> get( + "/helpscout/show?conversation_id=123&customer_id=500&email=hs.match@plausible.test&team_identifier=#{team2.identifier}&token=#{token}" + ) + + assert html = html_response(conn, 200) + assert html =~ "HS Integration Test Team" + refute html =~ "My personal sites" + assert html =~ "Some user notes" + end + + test "returns error when token is invalid", %{conn: conn} do + conn = + get( + conn, + "/helpscout/show?conversation_id=123&customer_id=500&email=hs.match@plausible.test&token=invalid" + ) + + assert html_response(conn, 200) =~ "invalid_token" + end + + test "returns error when token does not match", %{conn: conn} do + token = sign_conversation_token("456") + + conn = + conn + |> get( + "/helpscout/show?conversation_id=123&customer_id=500&email=hs.match@plausible.test&token=#{token}" + ) + + assert html_response(conn, 200) =~ "invalid_conversation" + end + end + + defp sign_conversation_token(conversation_id) do + PlausibleWeb.HelpScoutController.sign_token(conversation_id) + end + end +end diff --git a/test/plausible_web/controllers/invitation_controller_test.exs b/test/plausible_web/controllers/invitation_controller_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..319afa46f46ec11d8b9c3fdcb37f1cb3d3c57d16 --- /dev/null +++ b/test/plausible_web/controllers/invitation_controller_test.exs @@ -0,0 +1,455 @@ +defmodule PlausibleWeb.Site.InvitationControllerTest do + use PlausibleWeb.ConnCase, async: true + use Plausible.Repo + use Bamboo.Test + + alias Plausible.Teams + + setup [:create_user, :log_in] + + describe "POST /sites/invitations/:invitation_id/accept" do + test "converts the invitation into a membership", %{conn: conn, user: user} do + owner = new_user() + site = new_site(owner: owner) + + invitation = + invite_guest(site, user.email, inviter: owner, role: :editor) + + conn = post(conn, "/sites/invitations/#{invitation.invitation_id}/accept") + + assert Phoenix.Flash.get(conn.assigns.flash, :success) == + "You now have access to #{site.domain}" + + assert redirected_to(conn) == Routes.stats_path(PlausibleWeb.Endpoint, :stats, site.domain) + + refute Repo.exists?(from(i in Plausible.Teams.Invitation, where: i.email == ^user.email)) + + assert_guest_membership(site.team, site, user, :editor) + end + + test "converts the team invitation into a team membership", %{conn: conn, user: user} do + owner = new_user() + _site = new_site(owner: owner) + team = team_of(owner) + + invitation = + invite_member(team, user.email, inviter: owner, role: :editor) + + conn = post(conn, "/settings/team/invitations/#{invitation.invitation_id}/accept") + + assert Phoenix.Flash.get(conn.assigns.flash, :success) == + "You now have access to \"#{team.name}\" team" + + assert redirected_to(conn) == "/sites?__team=#{team.identifier}" + + refute Repo.get_by(Teams.Invitation, email: user.email) + + assert_team_membership(user, team, :editor) + end + + test "does not crash if clicked for the 2nd time in another tab", %{conn: conn, user: user} do + owner = new_user() + site = new_site(owner: owner) + invitation = invite_guest(site, user.email, role: :editor, inviter: owner) + + c1 = post(conn, "/sites/invitations/#{invitation.invitation_id}/accept") + assert redirected_to(c1) == Routes.stats_path(PlausibleWeb.Endpoint, :stats, site.domain) + + assert Phoenix.Flash.get(c1.assigns.flash, :success) == + "You now have access to #{site.domain}" + + c2 = post(conn, "/sites/invitations/#{invitation.invitation_id}/accept") + assert redirected_to(c2) == "/sites" + + assert Phoenix.Flash.get(c2.assigns.flash, :error) == + "Invitation missing or already accepted" + end + end + + describe "POST /sites/invitations/:invitation_id/accept - ownership transfer" do + test "downgrades previous owner to admin", %{conn: conn, user: user} do + old_owner = new_user() + site = new_site(owner: old_owner) + + subscribe_to_growth_plan(user) + new_team = team_of(user) + + transfer = invite_transfer(site, user, inviter: old_owner) + + conn = post(conn, "/sites/invitations/#{transfer.transfer_id}/accept") + + assert redirected_to(conn, 302) == + Routes.stats_path(PlausibleWeb.Endpoint, :stats, site.domain) + + assert Phoenix.Flash.get(conn.assigns.flash, :success) =~ + "You now have access to" + + refute Repo.reload(transfer) + + assert_guest_membership(new_team, site, old_owner, :editor) + + assert_team_attached(site, new_team.id) + end + + test "fails when new owner has no permissions for current team", %{conn: conn, user: user} do + old_owner = new_user() + site = new_site(owner: old_owner) + + other_owner = new_user() |> subscribe_to_growth_plan() + new_team = team_of(other_owner) + add_member(new_team, user: user, role: :viewer) + conn = set_current_team(conn, new_team) + + transfer = invite_transfer(site, user, inviter: old_owner) + + conn = post(conn, "/sites/invitations/#{transfer.transfer_id}/accept") + + assert redirected_to(conn, 302) == "/sites" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "You can't add sites in the current team" + end + + test "fails when transferring to the same team", %{conn: conn, user: user} do + current_owner = user |> subscribe_to_growth_plan() + site = new_site(owner: current_owner) + + transfer = invite_transfer(site, current_owner, inviter: current_owner) + + conn = post(conn, "/sites/invitations/#{transfer.transfer_id}/accept") + + assert redirected_to(conn, 302) == "/sites" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "The site is already in the current team" + end + + test "allows transferring between different teams of the same owner", %{ + conn: conn, + user: user + } do + current_owner = user |> subscribe_to_growth_plan() + site = new_site(owner: current_owner) + + another_owner = new_user() |> subscribe_to_growth_plan() + new_team = team_of(another_owner) + add_member(new_team, user: current_owner, role: :owner) + + transfer = invite_transfer(site, current_owner, inviter: current_owner) + + conn = set_current_team(conn, new_team) + + conn = post(conn, "/sites/invitations/#{transfer.transfer_id}/accept") + + assert redirected_to(conn, 302) == + Routes.stats_path(PlausibleWeb.Endpoint, :stats, site.domain) + + assert Phoenix.Flash.get(conn.assigns.flash, :success) =~ + "You now have access to" + + refute Repo.reload(transfer) + + assert_team_membership(current_owner, new_team, :owner) + + assert_team_attached(site, new_team.id) + end + + @tag :ee_only + test "fails when new owner has no plan", %{conn: conn, user: user} do + old_owner = new_user() + site = new_site(owner: old_owner) + + transfer = invite_transfer(site, user, inviter: old_owner) + + conn = post(conn, "/sites/invitations/#{transfer.transfer_id}/accept") + + assert redirected_to(conn, 302) == Routes.site_path(conn, :index) + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "No existing subscription" + end + + @tag :ee_only + test "fails when new owner's plan is unsuitable", %{conn: conn, user: user} do + old_owner = new_user() + site = new_site(owner: old_owner) + + subscribe_to_growth_plan(user) + + # fill site limit quota + for _ <- 1..10, do: new_site(owner: user) + + transfer = invite_transfer(site, user, inviter: old_owner) + + conn = post(conn, "/sites/invitations/#{transfer.transfer_id}/accept") + + assert redirected_to(conn, 302) == Routes.site_path(conn, :index) + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "Plan limits exceeded: site limit." + end + + test "succeeds despite exceeding members limit if parameter provided", %{ + conn: conn, + user: user + } do + current_owner = new_user() + site = new_site(owner: current_owner) + + subscribe_to_growth_plan(user) + new_team = team_of(user) + + # fill site limit quota + for _ <- 1..3, do: add_guest(site, role: :editor) + + transfer = invite_transfer(site, user, inviter: current_owner) + + conn = + post( + conn, + "/sites/invitations/#{transfer.transfer_id}/accept?skip_site_members_transfer=true" + ) + + assert redirected_to(conn, 302) == + Routes.stats_path(PlausibleWeb.Endpoint, :stats, site.domain) + + assert Phoenix.Flash.get(conn.assigns.flash, :success) =~ + "You now have access to" + + refute Repo.reload(transfer) + + assert_team_attached(site, new_team.id) + end + end + + describe "POST /sites/invitations/:invitation_id/reject" do + test "rejects the invitation", %{conn: conn, user: user} do + owner = new_user() + site = new_site(owner: owner) + + invitation = invite_guest(site, user.email, inviter: owner, role: :editor) + + conn = post(conn, "/sites/invitations/#{invitation.invitation_id}/reject") + + assert redirected_to(conn, 302) == "/sites" + + refute Repo.reload(invitation) + end + + test "rejects the team invitation", %{conn: conn, user: user} do + owner = new_user() + _site = new_site(owner: owner) + team = team_of(owner) + + invitation = invite_member(team, user.email, inviter: owner, role: :editor) + + conn = post(conn, "/settings/team/invitations/#{invitation.invitation_id}/reject") + + assert redirected_to(conn, 302) == "/sites" + + refute Repo.reload(invitation) + end + + test "renders error for non-existent invitation", %{conn: conn} do + conn = post(conn, "/sites/invitations/does-not-exist/reject") + + assert redirected_to(conn, 302) == "/sites" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "Invitation missing or already accepted" + end + end + + describe "DELETE /sites/:domain/invitations/:invitation_id" do + test "removes the invitation", %{conn: conn, user: user} do + owner = new_user() + site = new_site(owner: owner) + team = Plausible.Teams.complete_setup(site.team) + add_member(team, user: user, role: :admin) + invitation = invite_guest(site, "jane@example.com", inviter: owner, role: :editor) + + conn = + delete( + conn, + Routes.invitation_path(conn, :remove_invitation, site.domain, invitation.invitation_id) + ) + + assert redirected_to(conn, 302) == "/#{URI.encode_www_form(site.domain)}/settings/people" + + refute Repo.reload(invitation) + end + + test "removes the invitation for ownership transfer", %{conn: conn, user: user} do + owner = new_user() + site = new_site(owner: owner) + team = Plausible.Teams.complete_setup(site.team) + add_member(team, user: user, role: :admin) + transfer = invite_transfer(site, "jane@example.com", inviter: owner) + + conn = + delete( + conn, + Routes.invitation_path(conn, :remove_invitation, site.domain, transfer.transfer_id) + ) + + assert redirected_to(conn, 302) == "/#{URI.encode_www_form(site.domain)}/settings/people" + + refute Repo.reload(transfer) + end + + test "fails to remove an invitation with insufficient permission", %{conn: conn, user: user} do + owner = new_user() + site = new_site(owner: owner) + add_guest(site, user: user, role: :editor) + + invitation = invite_guest(site, "jane@example.com", inviter: owner, role: :editor) + + delete( + conn, + Routes.invitation_path(conn, :remove_invitation, site.domain, invitation.invitation_id) + ) + + assert Repo.reload(invitation) + end + + test "fails to remove an invitation from the outside", %{conn: my_conn, user: me} do + new_site(owner: me) + + other_user = new_user() + + other_site = new_site(owner: other_user) + + invitation = + invite_guest(other_site, "jane@example.com", role: :editor, inviter: other_user) + + remove_invitation_path = + Routes.invitation_path( + my_conn, + :remove_invitation, + other_site.domain, + invitation.invitation_id + ) + + delete(my_conn, remove_invitation_path) + + assert Repo.reload(invitation) + end + + test "renders error for non-existent invitation", %{conn: conn, user: user} do + site = new_site() + team = Plausible.Teams.complete_setup(site.team) + add_member(team, user: user, role: :admin) + + remove_invitation_path = + Routes.invitation_path( + conn, + :remove_invitation, + site.domain, + "does_not_exist" + ) + + conn = delete(conn, remove_invitation_path) + + assert redirected_to(conn, 302) == "/#{URI.encode_www_form(site.domain)}/settings/people" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "Invitation missing or already removed" + end + end + + describe "DELETE /team/invitations/:invitation_id" do + for role <- [:owner, :admin], invitee_role <- Teams.Membership.roles() do + test "#{role} removes the #{invitee_role} team invitation", %{conn: conn, user: user} do + owner = new_user() + _site = new_site(owner: owner) + team = team_of(owner) + add_member(team, user: user, role: unquote(role)) + conn = set_current_team(conn, team) + + invitation = + invite_member(team, "jane@example.com", inviter: owner, role: unquote(invitee_role)) + + conn = + delete( + conn, + Routes.invitation_path(conn, :remove_team_invitation, invitation.invitation_id) + ) + + assert redirected_to(conn, 302) == "/settings/team/general" + + refute Repo.reload(invitation) + end + end + + for role <- Teams.Membership.roles() -- [:owner, :admin] do + test "#{role} can't remove a team invitation", %{conn: conn, user: user} do + owner = new_user() + _site = new_site(owner: owner) + team = team_of(owner) + add_member(team, user: user, role: unquote(role)) + + invitation = + invite_member(team, "jane@example.com", inviter: owner, role: :viewer) + + conn = + delete( + conn, + Routes.invitation_path(conn, :remove_team_invitation, invitation.invitation_id) + ) + + assert redirected_to(conn, 302) == "/settings/team/general" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "You are not allowed to remove invitations" + + assert Repo.reload(invitation) + end + end + + test "fails to remove a team invitation from the outside", %{conn: my_conn, user: me} do + new_site(owner: me) + other_user = new_user() + _other_site = new_site(owner: other_user) + other_team = team_of(other_user) + + invitation = + invite_member(other_team, "jane@example.com", role: :editor, inviter: other_user) + + remove_invitation_path = + Routes.invitation_path( + my_conn, + :remove_team_invitation, + invitation.invitation_id + ) + + my_conn = delete(my_conn, remove_invitation_path) + + assert Phoenix.Flash.get(my_conn.assigns.flash, :error) == + "Invitation missing or already removed" + + assert Repo.reload(invitation) + end + + test "renders error for non-existent team invitation", %{conn: conn, user: user} do + owner = new_user() + _site = new_site(owner: owner) + team = team_of(owner) + add_member(team, user: user, role: :editor) + conn = set_current_team(conn, team) + + remove_invitation_path = + Routes.invitation_path( + conn, + :remove_team_invitation, + "does_not_exist" + ) + + conn = delete(conn, remove_invitation_path) + + assert redirected_to(conn, 302) == "/settings/team/general" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "Invitation missing or already removed" + end + end +end diff --git a/test/plausible_web/controllers/page_controller_test.exs b/test/plausible_web/controllers/page_controller_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..1ba135a5cfc22698a82adc69eb507cd4f763d0de --- /dev/null +++ b/test/plausible_web/controllers/page_controller_test.exs @@ -0,0 +1,19 @@ +defmodule PlausibleWeb.PageControllerTest do + use PlausibleWeb.ConnCase, async: true + + setup {PlausibleWeb.FirstLaunchPlug.Test, :skip} + + describe "GET /" do + setup [:create_user, :log_in] + + test "shows landing page when user not authenticated" do + assert build_conn() |> get("/") |> html_response(200) =~ "Welcome to Plausible!" + end + + test "redirects to /sites if user is authenticated", %{conn: conn} do + assert conn + |> get("/") + |> redirected_to(302) == "/sites" + end + end +end diff --git a/test/plausible_web/controllers/settings_controller_sync_test.exs b/test/plausible_web/controllers/settings_controller_sync_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..cf8a34aeac0356041b7e272caf0c822639af66c1 --- /dev/null +++ b/test/plausible_web/controllers/settings_controller_sync_test.exs @@ -0,0 +1,43 @@ +defmodule PlausibleWeb.SettingsControllerSyncTest do + use PlausibleWeb.ConnCase + use Bamboo.Test + use Plausible.Repo + + alias Plausible.Auth.User + + describe "PUT /settings/email" do + setup [:create_user, :log_in] + + @tag :ce_build_only + test "updates email but DOES NOT force reverification when feature disabled", %{ + conn: conn, + user: user + } do + patch_env(:selfhost, enable_email_verification: false) + + password = "very-long-very-secret-123" + + user + |> User.set_password(password) + |> Repo.update!() + + assert user.email_verified + + conn = + post(conn, Routes.settings_path(conn, :update_email), %{ + "user" => %{"email" => "new" <> user.email, "password" => password} + }) + + assert redirected_to(conn, 302) == + Routes.settings_path(conn, :security) <> "#update-email" + + updated_user = Repo.reload!(user) + + assert updated_user.email == "new" <> user.email + assert updated_user.previous_email == user.email + assert updated_user.email_verified + + assert_no_emails_delivered() + end + end +end diff --git a/test/plausible_web/controllers/settings_controller_test.exs b/test/plausible_web/controllers/settings_controller_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..9aaf3917c31c46d3149d9b97a9a3a93ee1b4c4fd --- /dev/null +++ b/test/plausible_web/controllers/settings_controller_test.exs @@ -0,0 +1,2179 @@ +defmodule PlausibleWeb.SettingsControllerTest do + use PlausibleWeb.ConnCase, async: true + use Bamboo.Test + use Plausible.Repo + + import Mox + + import Ecto.Query + + require Plausible.Billing.Subscription.Status + + alias Plausible.Auth + alias Plausible.Billing.Subscription + + @v3_plan_id "749355" + @v4_plan_id "857097" + @configured_enterprise_plan_paddle_plan_id "123" + + setup [:verify_on_exit!] + + describe "GET /billing/invoices" do + setup [:create_user, :log_in] + + test "redirects to subscription settings", %{conn: conn} do + conn = get(conn, Routes.settings_path(conn, :redirect_invoices)) + assert redirected_to(conn, 302) == Routes.settings_path(conn, :subscription) + end + end + + describe "GET /billing/subscription" do + setup [:create_user, :log_in] + + @tag :ee_only + test "shows subscription", %{conn: conn, user: user} do + subscribe_to_plan(user, "558018") + conn = get(conn, Routes.settings_path(conn, :subscription)) + assert html_response(conn, 200) =~ "10k monthly pageviews" + assert html_response(conn, 200) =~ "/ month" + end + + @tag :ee_only + test "shows yearly subscription", %{conn: conn, user: user} do + subscribe_to_plan(user, "590752") + conn = get(conn, Routes.settings_path(conn, :subscription)) + assert html_response(conn, 200) =~ "100k monthly pageviews" + assert html_response(conn, 200) =~ "/ year" + end + + @tag :ee_only + test "shows free subscription", %{conn: conn, user: user} do + subscribe_to_plan(user, "free_10k") + conn = get(conn, Routes.settings_path(conn, :subscription)) + assert html_response(conn, 200) =~ "10k monthly pageviews" + assert html_response(conn, 200) =~ "N/A" + end + + @tag :ee_only + test "shows enterprise plan subscription", %{conn: conn, user: user} do + configure_enterprise_plan(user) + + conn = get(conn, Routes.settings_path(conn, :subscription)) + assert html_response(conn, 200) =~ "20M monthly pageviews" + assert html_response(conn, 200) =~ "/ year" + end + + @tag :ee_only + test "shows current enterprise plan subscription when user has a new one to upgrade to", %{ + conn: conn, + user: user + } do + configure_enterprise_plan(user) + + subscribe_to_enterprise_plan(user, + paddle_plan_id: "1234", + monthly_pageview_limit: 10_000_000, + billing_interval: :yearly, + subscription?: false + ) + + conn = get(conn, Routes.settings_path(conn, :subscription)) + assert html_response(conn, 200) =~ "20M monthly pageviews" + assert html_response(conn, 200) =~ "/ year" + end + + @tag :ee_only + test "shows trial state without days-left pill when user has no team yet", %{conn: conn} do + doc = + conn + |> get(Routes.settings_path(conn, :subscription)) + |> html_response(200) + + upgrade_link = find(doc, "#upgrade-or-change-plan-link") + + assert text(upgrade_link) =~ "Choose a plan" + assert text_of_attr(upgrade_link, "href") == Routes.billing_path(conn, :choose_plan) + assert doc =~ "Your 30-day trial will start when you add your first site" + refute doc =~ "days left" + end + + @tag :ee_only + test "shows trial state with days-left pill when user is on trial", %{conn: conn, user: user} do + {:ok, team} = Plausible.Teams.get_or_create(user) + + team + |> Ecto.Changeset.change(trial_expiry_date: Date.add(Date.utc_today(), 10)) + |> Plausible.Repo.update!() + + doc = + conn + |> get(Routes.settings_path(conn, :subscription)) + |> html_response(200) + + upgrade_link = find(doc, "#upgrade-or-change-plan-link") + + assert text(upgrade_link) =~ "Choose a plan" + assert text_of_attr(upgrade_link, "href") == Routes.billing_path(conn, :choose_plan) + assert doc =~ "days left" + refute doc =~ "Your 30-day trial will start when you add your first site" + end + + @tag :ee_only + test "renders a link to '/billing/choose-plan' with the text 'Change plan' + cancel link", %{ + conn: conn, + user: user + } do + subscribe_to_plan(user, @v3_plan_id) + + doc = + conn + |> get(Routes.settings_path(conn, :subscription)) + |> html_response(200) + + assert doc =~ "Cancel plan" + + change_plan_link = find(doc, "#upgrade-or-change-plan-link") + + assert text(change_plan_link) == "Change plan" + assert text_of_attr(change_plan_link, "href") == Routes.billing_path(conn, :choose_plan) + end + + @tag :ee_only + test "/billing/choose-plan link does not show up when enterprise subscription is past_due", %{ + conn: conn, + user: user + } do + configure_enterprise_plan(user, subscription: [status: Subscription.Status.past_due()]) + + doc = + conn + |> get(Routes.settings_path(conn, :subscription)) + |> html_response(200) + + refute element_exists?(doc, "#upgrade-or-change-plan-link") + end + + @tag :ee_only + test "/billing/choose-plan link does not show up when enterprise subscription is paused", %{ + conn: conn, + user: user + } do + configure_enterprise_plan(user, subscription: [status: Subscription.Status.paused()]) + + doc = + conn + |> get(Routes.settings_path(conn, :subscription)) + |> html_response(200) + + refute element_exists?(doc, "#upgrade-or-change-plan-link") + end + + @tag :ee_only + test "renders a link to '/billing/choose-plan' with the text 'Upgrade' for a configured enterprise plan", + %{conn: conn, user: user} do + subscribe_to_enterprise_plan(user, + paddle_plan_id: @configured_enterprise_plan_paddle_plan_id, + monthly_pageview_limit: 20_000_000, + billing_interval: :yearly, + subscription?: false + ) + + doc = + conn + |> get(Routes.settings_path(conn, :subscription)) + |> html_response(200) + + upgrade_link = find(doc, "#upgrade-or-change-plan-link") + + assert text(upgrade_link) == "Upgrade" + assert text_of_attr(upgrade_link, "href") == Routes.billing_path(conn, :choose_plan) + end + + @tag :ee_only + test "links to '/billing/choose-plan' with the text 'Change plan' for a configured enterprise plan with an existing subscription + renders cancel button", + %{conn: conn, user: user} do + configure_enterprise_plan(user) + + doc = + conn + |> get(Routes.settings_path(conn, :subscription)) + |> html_response(200) + + assert doc =~ "Cancel plan" + + change_plan_link = find(doc, "#upgrade-or-change-plan-link") + + assert text(change_plan_link) == "Change plan" + + assert text_of_attr(change_plan_link, "href") == + Routes.billing_path(conn, :choose_plan) + end + + @tag :ee_only + test "renders cancelled subscription notice", %{conn: conn, user: user} do + subscribe_to_plan( + user, + @v4_plan_id, + status: :deleted, + next_bill_date: ~D[2023-01-01] + ) + + notice_text = + conn + |> get(Routes.settings_path(conn, :subscription)) + |> html_response(200) + |> text_of_element("#global-subscription-cancelled-notice") + + refute notice_text =~ Plausible.Billing.subscription_cancelled_notice_title() + end + + @tag :ee_only + test "renders cancelled subscription notice with some subscription days still left", %{ + conn: conn, + user: user + } do + subscribe_to_plan(user, @v4_plan_id, + status: :deleted, + next_bill_date: Date.shift(Date.utc_today(), day: 10) + ) + + notice_text = + conn + |> get(Routes.settings_path(conn, :subscription)) + |> html_response(200) + |> text_of_element("#global-subscription-cancelled-notice") + + assert notice_text =~ "Subscription cancelled" + assert notice_text =~ "You have access to your stats until" + assert notice_text =~ "Upgrade your subscription to make sure you don't lose access" + end + + @tag :ee_only + test "renders cancelled subscription notice with a warning about losing grandfathering", %{ + conn: conn, + user: user + } do + subscribe_to_plan( + user, + @v3_plan_id, + status: :deleted, + next_bill_date: Date.shift(Date.utc_today(), day: 10) + ) + + notice_text = + conn + |> get(Routes.settings_path(conn, :subscription)) + |> html_response(200) + |> text_of_element("#global-subscription-cancelled-notice") + + assert notice_text =~ "Subscription cancelled" + assert notice_text =~ "You have access to your stats until" + + assert notice_text =~ + "by letting your subscription expire, you lose access to our grandfathered terms" + end + + @tag :ee_only + test "does not show invoice section for a user with no subscription", %{conn: conn} do + html = + conn + |> get(Routes.settings_path(conn, :subscription)) + |> html_response(200) + + refute element_exists?(html, "#invoices") + end + + @tag :ee_only + test "renders billing cycle usage breakdown", %{ + conn: conn, + user: user + } do + site = new_site(owner: user) + + populate_stats(site, [ + build(:event, name: "pageview", timestamp: DateTime.shift(DateTime.utc_now(), day: -5)), + build(:event, + name: "customevent", + timestamp: DateTime.shift(DateTime.utc_now(), day: -20) + ), + build(:event, name: "pageview", timestamp: DateTime.shift(DateTime.utc_now(), day: -50)), + build(:event, + name: "customevent", + timestamp: DateTime.shift(DateTime.utc_now(), day: -50) + ) + ]) + + last_bill_date = Date.shift(Date.utc_today(), day: -10) + + subscribe_to_plan(user, @v4_plan_id, last_bill_date: last_bill_date, status: :deleted) + + html = + conn + |> get(Routes.settings_path(conn, :subscription)) + |> html_response(200) + + assert html =~ + Date.range(last_bill_date, Date.shift(last_bill_date, month: 1, day: -1)) + |> PlausibleWeb.TextHelpers.format_date_range() + + assert text_of_element(html, "#pageviews_current_cycle") =~ "Pageviews 1" + assert text_of_element(html, "#custom_events_current_cycle") =~ "Custom events 0" + + refute element_exists?(html, "#total_pageviews_last_cycle") + refute element_exists?(html, "#total_pageviews_penultimate_cycle") + end + + @tag :ee_only + test "renders pageview usage per billing cycle for active subscribers", %{ + conn: conn, + user: user + } do + assert_cycles_rendered = fn doc -> + refute element_exists?(doc, "#total_pageviews_last_30_days") + + assert element_exists?(doc, "#total_pageviews_current_cycle") + end + + subscribe_to_plan(user, @v4_plan_id, + status: :active, + last_bill_date: Date.shift(Date.utc_today(), month: -6) + ) + + subscription = + user + |> team_of() + |> Plausible.Teams.with_subscription() + |> Map.fetch!(:subscription) + + get(conn, Routes.settings_path(conn, :subscription)) + |> html_response(200) + |> assert_cycles_rendered.() + + # for a past_due subscription + subscription = + subscription + |> Plausible.Billing.Subscription.changeset(%{status: :past_due}) + |> Repo.update!() + + conn + |> get(Routes.settings_path(conn, :subscription)) + |> html_response(200) + |> assert_cycles_rendered.() + + # for a deleted (but not expired) subscription + subscription + |> Plausible.Billing.Subscription.changeset(%{ + status: :deleted, + next_bill_date: Date.shift(Date.utc_today(), month: 6) + }) + |> Repo.update!() + + conn + |> get(Routes.settings_path(conn, :subscription)) + |> html_response(200) + |> assert_cycles_rendered.() + end + + @tag :ee_only + test "renders last 30 days pageview usage for trials and non-active/free_10k subscriptions", + %{ + conn: conn, + user: user + } do + site = new_site(owner: user) + + populate_stats(site, [ + build(:event, name: "pageview", timestamp: DateTime.shift(DateTime.utc_now(), day: -1)), + build(:event, + name: "customevent", + timestamp: DateTime.shift(DateTime.utc_now(), day: -10) + ), + build(:event, + name: "customevent", + timestamp: DateTime.shift(DateTime.utc_now(), day: -20) + ) + ]) + + assert_usage = fn doc -> + refute element_exists?(doc, "#total_pageviews_current_cycle") + assert text_of_element(doc, "#pageviews_last_30_days") =~ "Pageviews 1" + assert text_of_element(doc, "#custom_events_last_30_days") =~ "Custom events 2" + end + + # for a trial user + trial_html = + conn + |> get(Routes.settings_path(conn, :subscription)) + |> html_response(200) + + assert_usage.(trial_html) + refute element_exists?(trial_html, "#total_pageviews_last_30_days") + + subscribe_to_plan(user, @v4_plan_id, + status: :deleted, + last_bill_date: ~D[2022-01-01], + next_bill_date: ~D[2022-02-01] + ) + + subscription = + user + |> team_of() + |> Plausible.Teams.with_subscription() + |> Map.fetch!(:subscription) + + conn + |> get(Routes.settings_path(conn, :subscription)) + |> html_response(200) + |> assert_usage.() + + # for a paused subscription + subscription = + subscription + |> Plausible.Billing.Subscription.changeset(%{status: :paused}) + |> Repo.update!() + + conn + |> get(Routes.settings_path(conn, :subscription)) + |> html_response(200) + |> assert_usage.() + + # for a free_10k subscription (without a `last_bill_date`) + Repo.delete!(subscription) + + user + |> team_of() + |> Plausible.Billing.Subscription.free() + |> Repo.insert!() + + conn + |> get(Routes.settings_path(conn, :subscription)) + |> html_response(200) + |> assert_usage.() + end + + @tag :ee_only + test "renders sites usage and limit", %{conn: conn, user: user} do + subscribe_to_plan(user, @v3_plan_id) + new_site(owner: user) + + html = + conn + |> get(Routes.settings_path(conn, :subscription)) + |> html_response(200) + + sites_usage_text = text_of_element(html, "[data-test-id='sites-usage']") + assert sites_usage_text =~ "1 / 50" + end + + @tag :ee_only + test "renders team members usage and limit", %{conn: conn, user: user} do + subscribe_to_plan(user, @v4_plan_id) + + html = + conn + |> get(Routes.settings_path(conn, :subscription)) + |> html_response(200) + + team_member_usage_text = text_of_element(html, "[data-test-id='team-member-usage']") + assert team_member_usage_text =~ "0 / 3" + end + + @tag :ee_only + test "renders team member usage with unlimited limit", %{conn: conn, user: user} do + subscribe_to_plan(user, @v3_plan_id) + + html = + conn + |> get(Routes.settings_path(conn, :subscription)) + |> html_response(200) + + team_member_usage_text = text_of_element(html, "[data-test-id='team-member-usage']") + assert team_member_usage_text =~ "/ Unlimited" + end + + test "does not show invoice section for a free subscription", %{conn: conn, user: user} do + new_site(owner: user) + + user + |> team_of() + |> Plausible.Billing.Subscription.free(%{currency_code: "EUR"}) + |> Repo.insert!() + + html = + conn + |> get(Routes.settings_path(conn, :subscription)) + |> html_response(200) + + assert html =~ "You don't have any invoices yet." + end + + @tag :ee_only + test "shows invoices for subscribed user", %{conn: conn, user: user} do + subscribe_to_plan(user, "558018") + + html = + conn + |> get(Routes.settings_path(conn, :subscription)) + |> html_response(200) + + assert html =~ "Dec 24, 2020" + assert html =~ "€11.11" + assert html =~ "Nov 24, 2020" + assert html =~ "$22.00" + end + + @tag :ee_only + test "shows message on failed invoice request'", %{conn: conn, user: user} do + subscribe_to_plan(user, "558018", paddle_subscription_id: "invalid_subscription_id") + + html = + conn + |> get(Routes.settings_path(conn, :subscription)) + |> html_response(200) + + assert html =~ "Invoices" + assert text(html) =~ "We couldn't retrieve your invoices" + end + + @tag :ee_only + test "shows dashboard link to the site when team has exactly one site", %{ + conn: conn, + user: user + } do + new_site(owner: user) + + html = + conn + |> get(Routes.settings_path(conn, :subscription)) + |> html_response(200) + + assert element_exists?(html, "[data-test-id='total-pageviews-dashboard-link']") + end + + @tag :ee_only + test "shows no total dashboard link when team has multiple sites and no consolidated view", %{ + conn: conn, + user: user + } do + new_site(owner: user) + new_site(owner: user) + + html = + conn + |> get(Routes.settings_path(conn, :subscription)) + |> html_response(200) + + refute element_exists?(html, "[data-test-id='total-pageviews-dashboard-link']") + end + + on_ee do + test "shows consolidated view dashboard link when team has a consolidated view", %{ + conn: conn, + user: user + } do + new_site(owner: user) + new_site(owner: user) + team = team_of(user) + new_consolidated_view(team) + + html = + conn + |> set_current_team(team) + |> get(Routes.settings_path(conn, :subscription)) + |> html_response(200) + + assert element_exists?(html, "[data-test-id='total-pageviews-dashboard-link']") + end + end + + @tag :ee_only + test "allows owner of a setup team to access the page", %{conn: conn, user: user} do + team = new_user(team: [setup_complete: true]) |> team_of() + add_member(team, user: user, role: :owner) + + conn = + conn + |> set_current_team(team) + |> get(Routes.settings_path(conn, :subscription)) + + assert html_response(conn, 200) + end + + @tag :ee_only + test "allows billing role of a setup team to access the page", %{conn: conn, user: user} do + owner = new_user(team: [setup_complete: true]) + team = team_of(owner) + add_member(team, user: user, role: :billing) + + conn = + conn + |> set_current_team(team) + |> get(Routes.settings_path(conn, :subscription)) + + assert html_response(conn, 200) + end + + @tag :ee_only + test "redirects admin role of a setup team away from the page", %{conn: conn, user: user} do + owner = new_user(team: [setup_complete: true]) + team = team_of(owner) + add_member(team, user: user, role: :admin) + + conn = + conn + |> set_current_team(team) + |> get(Routes.settings_path(conn, :subscription)) + + assert redirected_to(conn, 302) == Routes.site_path(conn, :index) + end + + @tag :ee_only + test "redirects editor role of a setup team away from the page", %{conn: conn, user: user} do + owner = new_user(team: [setup_complete: true]) + team = team_of(owner) + add_member(team, user: user, role: :editor) + + conn = + conn + |> set_current_team(team) + |> get(Routes.settings_path(conn, :subscription)) + + assert redirected_to(conn, 302) == Routes.site_path(conn, :index) + end + + @tag :ee_only + test "redirects viewer role of a setup team away from the page", %{conn: conn, user: user} do + owner = new_user(team: [setup_complete: true]) + team = team_of(owner) + add_member(team, user: user, role: :viewer) + + conn = + conn + |> set_current_team(team) + |> get(Routes.settings_path(conn, :subscription)) + + assert redirected_to(conn, 302) == Routes.site_path(conn, :index) + end + + test "allows any role on a non-setup (personal) team to access the page", %{ + conn: conn, + user: user + } do + {:ok, team} = Plausible.Teams.get_or_create(user) + + refute Plausible.Teams.setup?(team) + + conn = + conn + |> set_current_team(team) + |> get(Routes.settings_path(conn, :subscription)) + + assert html_response(conn, 200) + end + end + + describe "GET /security" do + setup [:create_user, :log_in] + + test "renders 2FA section in disabled state", %{conn: conn} do + conn = get(conn, Routes.settings_path(conn, :security)) + + assert html_response(conn, 200) =~ "Enable 2FA" + end + + test "renders 2FA in enabled state", %{conn: conn, user: user} do + {:ok, user, _} = Auth.TOTP.initiate(user) + {:ok, _, _} = Auth.TOTP.enable(user, :skip_verify) + + conn = get(conn, Routes.settings_path(conn, :security)) + + assert html_response(conn, 200) =~ "Disable 2FA" + end + + test "renders active user sessions with an option to revoke them", %{conn: conn, user: user} do + now = NaiveDateTime.utc_now(:second) + seventy_minutes_ago = NaiveDateTime.shift(now, minute: -70) + + another_session = + user + |> Auth.UserSession.new_session("Some Device", now: seventy_minutes_ago) + |> Repo.insert!() + + conn = get(conn, Routes.settings_path(conn, :security)) + + assert html = html_response(conn, 200) + + assert html =~ "Unknown" + assert html =~ "Current session" + assert html =~ "Just recently" + assert html =~ "Some Device" + assert html =~ "1 hour ago" + assert html =~ Routes.settings_path(conn, :delete_session, another_session.id) + end + end + + describe "DELETE /security/user-sessions/:id" do + setup [:create_user, :log_in] + + test "deletes session", %{conn: conn, user: user} do + another_session = + user + |> Auth.UserSession.new_session("Some Device") + |> Repo.insert!() + + conn = delete(conn, Routes.settings_path(conn, :delete_session, another_session.id)) + + assert Phoenix.Flash.get(conn.assigns.flash, :success) == "Session logged out successfully" + + assert redirected_to(conn, 302) == + Routes.settings_path(conn, :security) <> "#user-sessions" + + refute Repo.reload(another_session) + end + + test "refuses deletion when not logged in" do + another_session = + insert(:user) + |> Auth.UserSession.new_session("Some Device") + |> Repo.insert!() + + conn = build_conn() + conn = delete(conn, Routes.settings_path(conn, :delete_session, another_session.id)) + + assert redirected_to(conn, 302) == Routes.auth_path(conn, :login_form) + assert Repo.reload(another_session) + end + end + + describe "POST /preferences/name" do + setup [:create_user, :log_in] + + test "updates user's name", %{conn: conn, user: user} do + conn = + post(conn, Routes.settings_path(conn, :update_name), %{"user" => %{"name" => "New name"}}) + + assert redirected_to(conn, 302) == + Routes.settings_path(conn, :preferences) <> "#update-name" + + user = Plausible.Repo.get(Plausible.Auth.User, user.id) + assert user.name == "New name" + end + + test "renders form with error if form validations fail", %{conn: conn} do + conn = post(conn, Routes.settings_path(conn, :update_name), %{"user" => %{"name" => ""}}) + + assert text(html_response(conn, 200)) =~ "can't be blank" + end + end + + on_ee do + describe "POST /preferences/name - SSO user" do + setup [:create_user, :create_site, :create_team, :setup_sso, :provision_sso_user, :log_in] + + test "refuses to update for SSO user", %{conn: conn, user: user} do + conn = + post(conn, Routes.settings_path(conn, :update_name), %{ + "user" => %{"name" => "New name"} + }) + + assert redirected_to(conn, 302) == Routes.site_path(conn, :index) + + assert Repo.reload!(user).name == user.name + end + end + end + + describe "POST /security/password" do + setup [:create_user, :log_in] + + test "updates the password and kills other sessions", %{conn: conn, user: user} do + password = "very-long-very-secret-123" + new_password = "super-long-super-secret-999" + + another_session = + user + |> Auth.UserSession.new_session("Some Device") + |> Repo.insert!() + + original = + user + |> Auth.User.set_password(password) + |> Repo.update!() + + conn = + post(conn, Routes.settings_path(conn, :update_password), %{ + "user" => %{ + "password" => new_password, + "old_password" => password, + "password_confirmation" => new_password + } + }) + + assert redirected_to(conn, 302) == + Routes.settings_path(conn, :security) <> "#update-password" + + current_hash = Repo.reload!(user).password_hash + assert current_hash != original.password_hash + assert Plausible.Auth.Password.match?(new_password, current_hash) + + assert [remaining_session] = Repo.preload(user, :sessions).sessions + assert remaining_session.id != another_session.id + end + + test "fails to update weak password", %{conn: conn} do + password = "very-long-very-secret-123" + new_password = "weak" + + conn = + post(conn, Routes.settings_path(conn, :update_password), %{ + "user" => %{ + "password" => new_password, + "old_password" => password, + "password_confirmation" => new_password + } + }) + + assert html = html_response(conn, 200) + assert html =~ "is too weak" + end + + test "fails to update confirmation mismatch", %{conn: conn} do + password = "very-long-very-secret-123" + new_password = "super-long-super-secret-999" + + conn = + post(conn, Routes.settings_path(conn, :update_password), %{ + "user" => %{ + "password" => new_password, + "old_password" => password, + "password_confirmation" => new_password <> "mismatch" + } + }) + + assert html = html_response(conn, 200) + assert html =~ "does not match confirmation" + end + + test "updates the password when 2FA is enabled", %{conn: conn, user: user} do + password = "very-long-very-secret-123" + new_password = "super-long-super-secret-999" + + original = + user + |> Auth.User.set_password(password) + |> Repo.update!() + + {:ok, user, _} = Auth.TOTP.initiate(user) + {:ok, user, _} = Auth.TOTP.enable(user, :skip_verify) + + code = NimbleTOTP.verification_code(user.totp_secret) + + conn = + post(conn, Routes.settings_path(conn, :update_password), %{ + "user" => %{ + "password" => new_password, + "old_password" => password, + "password_confirmation" => new_password, + "two_factor_code" => code + } + }) + + assert redirected_to(conn, 302) == + Routes.settings_path(conn, :security) <> "#update-password" + + current_hash = Repo.reload!(user).password_hash + assert current_hash != original.password_hash + assert Plausible.Auth.Password.match?(new_password, current_hash) + end + + test "fails to update with wrong 2fa code", %{conn: conn, user: user} do + password = "very-long-very-secret-123" + + user = + user + |> Auth.User.set_password(password) + |> Repo.update!() + + new_password = "super-long-super-secret-999" + + {:ok, user, _} = Auth.TOTP.initiate(user) + {:ok, _, _} = Auth.TOTP.enable(user, :skip_verify) + + conn = + post(conn, Routes.settings_path(conn, :update_password), %{ + "user" => %{ + "password" => new_password, + "old_password" => password, + "password_confirmation" => new_password, + "two_factor_code" => "111111" + } + }) + + assert html = html_response(conn, 200) + assert html =~ "invalid 2FA code" + end + + test "fails to update with missing 2fa code", %{conn: conn, user: user} do + password = "very-long-very-secret-123" + + user = + user + |> Auth.User.set_password(password) + |> Repo.update!() + + new_password = "super-long-super-secret-999" + + {:ok, user, _} = Auth.TOTP.initiate(user) + {:ok, _, _} = Auth.TOTP.enable(user, :skip_verify) + + conn = + post(conn, Routes.settings_path(conn, :update_password), %{ + "user" => %{ + "password" => new_password, + "old_password" => password, + "password_confirmation" => new_password + } + }) + + assert html = html_response(conn, 200) + assert html =~ "invalid 2FA code" + end + + test "fails to update with no input", %{conn: conn} do + conn = + post(conn, Routes.settings_path(conn, :update_password), %{ + "user" => %{ + "password" => "", + "old_password" => "", + "password_confirmation" => "" + } + }) + + assert html = html_response(conn, 200) + assert text(html) =~ "can't be blank" + end + end + + on_ee do + describe "POST /security/password - SSO user" do + setup [:create_user, :create_site, :create_team, :setup_sso, :provision_sso_user, :log_in] + + test "refuses to update for SSO user", %{conn: conn, user: user} do + password = "very-long-very-secret-123" + new_password = "super-long-super-secret-999" + + original = + user + |> Auth.User.set_password(password) + |> Repo.update!() + + conn = + post(conn, Routes.settings_path(conn, :update_password), %{ + "user" => %{ + "password" => new_password, + "old_password" => password, + "password_confirmation" => new_password + } + }) + + assert redirected_to(conn, 302) == Routes.site_path(conn, :index) + + current_hash = Repo.reload!(user).password_hash + assert current_hash == original.password_hash + assert Plausible.Auth.Password.match?(password, current_hash) + end + end + end + + describe "POST /security/email" do + setup [:create_user, :log_in] + + test "updates email and forces reverification", %{conn: conn, user: user} do + password = "very-long-very-secret-123" + + user + |> Auth.User.set_password(password) + |> Repo.update!() + + assert user.email_verified + + conn = + post(conn, Routes.settings_path(conn, :update_email), %{ + "user" => %{"email" => "new" <> user.email, "password" => password} + }) + + assert redirected_to(conn, 302) == Routes.auth_path(conn, :activate) + + updated_user = Repo.reload!(user) + + assert updated_user.email == "new" <> user.email + assert updated_user.previous_email == user.email + refute updated_user.email_verified + + assert_delivered_email_matches(%{to: [{_, user_email}], subject: subject}) + assert user_email == updated_user.email + assert subject =~ "is your Plausible email verification code" + end + + test "renders an error on third change attempt (allows 2 per hour)", %{conn: conn, user: user} do + payload = %{ + "user" => %{"email" => "new" <> user.email, "password" => "badpass"} + } + + resp1 = + conn |> post(Routes.settings_path(conn, :update_email), payload) |> html_response(200) + + assert resp1 =~ "is invalid" + refute resp1 =~ "too many requests, try again in an hour" + + resp2 = + conn |> post(Routes.settings_path(conn, :update_email), payload) |> html_response(200) + + assert resp2 =~ "is invalid" + refute resp2 =~ "too many requests, try again in an hour" + + resp3 = + conn |> post(Routes.settings_path(conn, :update_email), payload) |> html_response(200) + + assert resp3 =~ "is invalid" + assert resp3 =~ "too many requests, try again in an hour" + end + + test "renders form with error on no fields filled", %{conn: conn} do + conn = post(conn, Routes.settings_path(conn, :update_email), %{"user" => %{"email" => ""}}) + + assert text(html_response(conn, 200)) =~ "can't be blank" + end + + test "renders form with error on invalid password", %{conn: conn, user: user} do + conn = + post(conn, Routes.settings_path(conn, :update_email), %{ + "user" => %{"password" => "invalid", "email" => "new" <> user.email} + }) + + assert html_response(conn, 200) =~ "is invalid" + end + + test "renders form with error on already taken email", %{conn: conn, user: user} do + other_user = insert(:user) + + password = "very-long-very-secret-123" + + user + |> Auth.User.set_password(password) + |> Repo.update!() + + conn = + post(conn, Routes.settings_path(conn, :update_email), %{ + "user" => %{"password" => password, "email" => other_user.email} + }) + + assert html_response(conn, 200) =~ "has already been taken" + end + + test "renders form with error when email is identical with the current one", %{ + conn: conn, + user: user + } do + password = "very-long-very-secret-123" + + user + |> Auth.User.set_password(password) + |> Repo.update!() + + conn = + post(conn, Routes.settings_path(conn, :update_email), %{ + "user" => %{"password" => password, "email" => user.email} + }) + + assert html_response(conn, 200) =~ htmlize_quotes("can't be the same") + end + end + + on_ee do + describe "POST /security/email - SSO user" do + setup [:create_user, :create_site, :create_team, :setup_sso, :provision_sso_user, :log_in] + + test "refuses to update for SSO user", %{conn: conn, user: user} do + password = "very-long-very-secret-123" + + user + |> Auth.User.set_password(password) + |> Repo.update!() + + assert user.email_verified + + conn = + post(conn, Routes.settings_path(conn, :update_email), %{ + "user" => %{"email" => "new" <> user.email, "password" => password} + }) + + assert redirected_to(conn, 302) == Routes.site_path(conn, :index) + + updated_user = Repo.reload!(user) + + assert updated_user.email == user.email + assert updated_user.email_verified + end + end + end + + describe "POST /security/email/cancel" do + setup [:create_user, :log_in] + + test "cancels email reverification in progress", %{conn: conn, user: user} do + user = + user + |> Ecto.Changeset.change( + email_verified: false, + email: "new" <> user.email, + previous_email: user.email + ) + |> Repo.update!() + + conn = post(conn, Routes.settings_path(conn, :cancel_update_email)) + + assert redirected_to(conn, 302) == + Routes.settings_path(conn, :security) <> "#update-email" + + updated_user = Repo.reload!(user) + + assert updated_user.email_verified + assert updated_user.email == user.previous_email + refute updated_user.previous_email + end + + test "fails to cancel reverification when previous email is already retaken", %{ + conn: conn, + user: user + } do + user = + user + |> Ecto.Changeset.change( + email_verified: false, + email: "new" <> user.email, + previous_email: user.email + ) + |> Repo.update!() + + _other_user = insert(:user, email: user.previous_email) + + conn = + post(conn, Routes.settings_path(conn, :cancel_update_email)) + + assert redirected_to(conn, 302) == Routes.auth_path(conn, :activate_form) + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "Could not cancel email update" + end + + test "crashes when previous email is empty on cancel (should not happen)", %{ + conn: conn, + user: user + } do + user + |> Ecto.Changeset.change( + email_verified: false, + email: "new" <> user.email, + previous_email: nil + ) + |> Repo.update!() + + assert_raise RuntimeError, ~r/Previous email is empty for user/, fn -> + post(conn, Routes.settings_path(conn, :cancel_update_email)) + end + end + end + + describe "GET /settings/api-keys" do + setup [:create_user, :log_in] + + test "handles user without a team gracefully", %{conn: conn} do + conn = get(conn, Routes.settings_path(conn, :api_keys)) + + assert html_response(conn, 200) + end + + @tag :ee_only + test "lists types of keys", %{conn: conn, user: user} do + user + |> subscribe_to_enterprise_plan( + features: [Plausible.Billing.Feature.StatsAPI, Plausible.Billing.Feature.SitesAPI] + ) + + insert(:api_key, user: user) + insert(:api_key, user: user, scopes: ["sites:provision:*"]) + + conn = get(conn, Routes.settings_path(conn, :api_keys)) + + assert html = html_response(conn, 200) + + assert html =~ "Stats API" + assert html =~ "Sites API" + end + end + + describe "POST /settings/api-keys" do + setup [:create_user, :log_in] + + test "can create an API key", %{conn: conn, user: user} do + new_site(owner: user) + + team = team_of(user) + + conn = + post(conn, Routes.settings_path(conn, :api_keys), %{ + "api_key" => %{ + "user_id" => user.id, + "name" => "all your code are belong to us", + "key" => "swordfish", + "type" => "stats_api" + } + }) + + key = Plausible.Auth.ApiKey |> where(user_id: ^user.id) |> Repo.one() + assert conn.status == 302 + assert key.name == "all your code are belong to us" + assert key.team_id == team.id + end + + test "can create a Sites API key", %{conn: conn, user: user} do + user + |> subscribe_to_enterprise_plan( + features: [ + Plausible.Billing.Feature.StatsAPI, + Plausible.Billing.Feature.SitesAPI + ] + ) + + new_site(owner: user) + + team = team_of(user) + + conn = + post(conn, Routes.settings_path(conn, :api_keys), %{ + "api_key" => %{ + "user_id" => user.id, + "name" => "all your code are belong to us", + "key" => "swordfish", + "type" => "sites_api" + } + }) + + key = Plausible.Auth.ApiKey |> where(user_id: ^user.id) |> Repo.one() + assert conn.status == 302 + assert key.name == "all your code are belong to us" + assert key.team_id == team.id + assert key.scopes == ["sites:provision:*"] + end + + test "can't create a Sites API key without Sites API feature", %{conn: conn, user: user} do + user |> subscribe_to_business_plan() + + new_site(owner: user) + + conn = + post(conn, Routes.settings_path(conn, :api_keys), %{ + "api_key" => %{ + "user_id" => user.id, + "name" => "all your code are belong to us", + "key" => "swordfish", + "type" => "sites_api" + } + }) + + assert redirected_to(conn, 302) == Routes.settings_path(conn, :new_api_key) + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "Your current subscription plan does not include Sites API access" + end + + test "can create an API key when switched to another team", %{conn: conn, user: user} do + new_site(owner: user) + + team = new_site().team |> Plausible.Teams.complete_setup() + + add_member(team, user: user, role: :editor) + + conn = set_current_team(conn, team) + + conn = + post(conn, Routes.settings_path(conn, :api_keys), %{ + "api_key" => %{ + "user_id" => user.id, + "name" => "all your code are belong to us", + "key" => "swordfish", + "type" => "stats_api" + } + }) + + key = Plausible.Auth.ApiKey |> where(user_id: ^user.id) |> Repo.one() + assert conn.status == 302 + assert key.name == "all your code are belong to us" + assert key.team_id == team.id + end + + test "cannot create a duplicate API key", %{conn: conn, user: user} do + new_site(owner: user) + + conn = + post(conn, Routes.settings_path(conn, :api_keys), %{ + "api_key" => %{ + "user_id" => user.id, + "name" => "all your code are belong to us", + "key" => "swordfish", + "type" => "stats_api" + } + }) + + conn2 = + post(conn, Routes.settings_path(conn, :api_keys), %{ + "api_key" => %{ + "user_id" => user.id, + "name" => "all your code are belong to us", + "key" => "swordfish", + "type" => "stats_api" + } + }) + + assert html_response(conn2, 200) =~ "has already been taken" + end + + test "can't create api key into another site", %{conn: conn, user: me} do + _my_site = new_site(owner: me) + other_user = new_user() + _other_site = new_site(owner: other_user) + + conn = + post(conn, Routes.settings_path(conn, :api_keys), %{ + "api_key" => %{ + "user_id" => other_user.id, + "name" => "all your code are belong to us", + "key" => "swordfish", + "type" => "stats_api" + } + }) + + assert conn.status == 302 + + refute Plausible.Auth.ApiKey |> where(user_id: ^other_user.id) |> Repo.one() + end + end + + describe "DELETE /settings/api-keys/:id" do + setup [:create_user, :log_in] + alias Plausible.Auth.ApiKey + + test "can't delete api key that doesn't belong to me", %{conn: conn} do + other_user = new_user() + new_site(owner: other_user) + team = team_of(other_user) + + assert {:ok, %ApiKey{} = api_key} = + %ApiKey{user_id: other_user.id} + |> ApiKey.changeset(team, %{"name" => "other user's key"}) + |> Repo.insert() + + conn = delete(conn, Routes.settings_path(conn, :delete_api_key, api_key.id)) + assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Could not find API Key to delete" + assert Repo.get(ApiKey, api_key.id) + end + end + + describe "GET /settings/danger-zone" do + setup [:create_user, :log_in, :create_team] + + test "without active subscription", %{conn: conn} do + conn = get(conn, Routes.settings_path(conn, :danger_zone)) + + assert html = html_response(conn, 200) + + refute html =~ + "You have an active subscription. To delete your account, cancel your subscription first." + + assert html =~ "Delete my account" + end + + test "with active subscription", %{conn: conn, user: user} do + subscribe_to_growth_plan(user) + conn = get(conn, Routes.settings_path(conn, :danger_zone)) + + assert html = html_response(conn, 200) + + assert html =~ + "You have an active subscription. To delete your account, cancel your subscription first." + + refute html =~ "Delete my account" + end + + test "with a setup team", %{conn: conn, user: user} do + new_site(owner: user) + + _team = + user + |> team_of() + |> Plausible.Teams.complete_setup() + + conn = get(conn, Routes.settings_path(conn, :danger_zone)) + + assert html = html_response(conn, 200) + + assert html =~ "You're the sole owner of one or more teams" + refute html =~ "Delete my account" + end + end + + @menu_items [ + preferences: {"Preferences", "/settings/preferences"}, + security: {"Security", "/settings/security"}, + subscription: {"Subscription", "/settings/billing/subscription"}, + api_keys: {"API keys", "/settings/api-keys"}, + danger_zone: {"Danger zone", "/settings/danger-zone"}, + team_general: {"General", "/settings/team/general"}, + sso: {"Single Sign-On", "/settings/sso/info"}, + team_danger_zone: {"Danger zone", "/settings/team/delete"} + ] + + on_ee do + describe "Account settings - SSO user" do + setup [:create_user, :create_site, :create_team, :setup_sso, :provision_sso_user, :log_in] + + test "shows only expected menu items", %{conn: conn} do + conn = get(conn, Routes.settings_path(conn, :preferences)) + assert html = html_response(conn, 200) + + expected_account_menu = [:preferences, :security, :subscription, :api_keys] + + html + |> refute_unexpected_menu_items([ + :team_general, + :sso, + :team_danger_zone, + :danger_zone + ]) + |> LazyHTML.from_document() + |> assert_sidebar_menu(expected_account_menu) + |> assert_mobile_menu(expected_account_menu) + end + + test "does not allow to update name in preferences", %{conn: conn} do + conn = get(conn, Routes.settings_path(conn, :preferences)) + assert html = html_response(conn, 200) + refute html =~ "Change name" + end + + test "does not allow to update email in security settings", %{conn: conn} do + conn = get(conn, Routes.settings_path(conn, :security)) + assert html = html_response(conn, 200) + refute html =~ "Change email" + end + + test "does not allow to change password in security settings", %{conn: conn} do + conn = get(conn, Routes.settings_path(conn, :security)) + assert html = html_response(conn, 200) + refute html =~ "Change password" + end + + test "does not allow to disable 2FA in security settings", %{conn: conn, user: user} do + {:ok, user, _} = Auth.TOTP.initiate(user) + {:ok, _, _} = Auth.TOTP.enable(user, :skip_verify) + + conn = get(conn, Routes.settings_path(conn, :security)) + assert html = html_response(conn, 200) + assert text_of_element(html, "button[disabled]") =~ "Disable 2FA" + end + end + end + + describe "Team settings" do + setup [:create_user, :log_in] + + test "when no team is assigned & the user doesn't have a subscription, limited account menu is present", + %{conn: conn} do + conn = get(conn, Routes.settings_path(conn, :preferences)) + html = html_response(conn, 200) + refute html =~ "Team" + + expected_account_menu = + if(ee?(), + do: [:preferences, :security, :subscription, :api_keys, :danger_zone], + else: [:preferences, :security, :api_keys, :danger_zone] + ) + + html + |> refute_unexpected_menu_items( + if(ee?(), + do: [:team_general, :sso], + else: [:subscription, :team_general, :sso] + ) + ) + |> LazyHTML.from_document() + |> assert_sidebar_menu(expected_account_menu) + |> assert_mobile_menu(expected_account_menu) + end + + test "when no team is assigned & the user has a subscription, the account menu shows subscription", + %{ + conn: conn, + user: user + } do + subscribe_to_growth_plan(user) + + conn = get(conn, Routes.settings_path(conn, :preferences)) + html = html_response(conn, 200) + + expected_account_menu = + if(ee?(), + do: [:preferences, :security, :subscription, :api_keys, :danger_zone], + else: [:preferences, :security, :api_keys, :danger_zone] + ) + + html + |> refute_unexpected_menu_items( + if(ee?(), + do: [:team_general, :sso], + else: [:subscription, :team_general, :sso] + ) + ) + |> LazyHTML.from_document() + |> assert_sidebar_menu(expected_account_menu) + |> assert_mobile_menu(expected_account_menu) + end + + test "when team is set up & there's no subscription, renders limited account & team menu", + %{ + conn: conn, + user: user + } do + {:ok, team} = Plausible.Teams.get_or_create(user) + team = Plausible.Teams.complete_setup(team) + conn = set_current_team(conn, team) + conn = get(conn, Routes.settings_path(conn, :preferences)) + html = html_response(conn, 200) + assert html =~ ~r/Team.*#{Regex.escape(team.name)}/s + assert html =~ team.name + + expected_account_menu = [ + :preferences, + :security, + :danger_zone + ] + + expected_team_menu = + if(ee?(), + do: [:team_general, :subscription, :api_keys, :sso, :team_danger_zone], + else: [:team_general, :api_keys, :team_danger_zone] + ) + + html + |> LazyHTML.from_document() + |> assert_sidebar_menu(expected_account_menu, expected_team_menu) + |> assert_mobile_menu(expected_account_menu, expected_team_menu) + end + + test "when team is set up, and there's a subscription, renders account & team menu with subscription", + %{ + conn: conn, + user: user + } do + subscribe_to_growth_plan(user) + {:ok, team} = Plausible.Teams.get_or_create(user) + team = Plausible.Teams.complete_setup(team) + conn = set_current_team(conn, team) + + conn = get(conn, Routes.settings_path(conn, :preferences)) + html = html_response(conn, 200) + assert html =~ ~r/Team.*#{Regex.escape(team.name)}/s + assert html =~ team.name + + expected_account_menu = [ + :preferences, + :security, + :danger_zone + ] + + expected_team_menu = + if(ee?(), + do: [ + :team_general, + :subscription, + :api_keys, + :sso, + :team_danger_zone + ], + else: [:team_general, :api_keys, :team_danger_zone] + ) + + html + |> LazyHTML.from_document() + |> assert_sidebar_menu(expected_account_menu, expected_team_menu) + |> assert_mobile_menu(expected_account_menu, expected_team_menu) + end + + test "does not render team settings, when team not set up", %{conn: conn, user: user} do + {:ok, team} = Plausible.Teams.get_or_create(user) + conn = get(conn, Routes.settings_path(conn, :preferences)) + html = html_response(conn, 200) + refute html =~ ~r/Team.*#{Regex.escape(team.name)}/s + refute html =~ team.name + end + + test "GET /settings/team/general", %{conn: conn, user: user} do + {:ok, team} = Plausible.Teams.get_or_create(user) + team = Plausible.Teams.complete_setup(team) + conn = set_current_team(conn, team) + conn = get(conn, Routes.settings_path(conn, :team_general)) + html = html_response(conn, 200) + assert html =~ "Team name" + assert html =~ "Change the name of your team" + assert text_of_attr(html, "input#team_name", "value") == team.name + end + + test "POST /settings/team/general/name", %{conn: conn, user: user} do + {:ok, team} = Plausible.Teams.get_or_create(user) + + conn = + post(conn, Routes.settings_path(conn, :update_team_name), %{ + "team" => %{"name" => "New name"} + }) + + assert redirected_to(conn, 302) == + Routes.settings_path(conn, :team_general) <> "#update-name" + + assert Repo.reload!(team).name == "New name" + end + + test "POST /settings/team/general/name - changeset error", %{conn: conn, user: user} do + {:ok, team} = Plausible.Teams.get_or_create(user) + team = Plausible.Teams.complete_setup(team) + conn = set_current_team(conn, team) + + conn = + post(conn, Routes.settings_path(conn, :update_team_name), %{ + "team" => %{"name" => ""} + }) + + assert text(html_response(conn, 200)) =~ "can't be blank" + end + + test "POST /settings/team/leave", %{conn: conn, user: user} do + {:ok, team} = Plausible.Teams.get_or_create(user) + team = Plausible.Teams.complete_setup(team) + conn = set_current_team(conn, team) + add_member(team, role: :owner) + + conn = post(conn, Routes.settings_path(conn, :leave_team)) + + assert redirected_to(conn, 302) == Routes.site_path(conn, :index, __team: "none") + assert Phoenix.Flash.get(conn.assigns.flash, :success) =~ "You have left" + end + + test "POST /settings/team/leave - only owner", %{conn: conn, user: user} do + {:ok, team} = Plausible.Teams.get_or_create(user) + team = Plausible.Teams.complete_setup(team) + conn = set_current_team(conn, team) + + conn = post(conn, Routes.settings_path(conn, :leave_team)) + + assert redirected_to(conn, 302) == Routes.settings_path(conn, :team_general) + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "You can't leave" + end + + test "GET /settings/team/delete - without active subscription", %{conn: conn, user: user} do + {:ok, team} = Plausible.Teams.get_or_create(user) + + team = + team + |> Plausible.Teams.complete_setup() + |> Ecto.Changeset.change(name: "Foo Crew") + |> Repo.update!() + + conn = set_current_team(conn, team) + conn = get(conn, Routes.settings_path(conn, :team_danger_zone)) + + assert html = html_response(conn, 200) + + refute html =~ "The team cannot be deleted because it has an active subscription" + assert html =~ "Delete \"Foo Crew\"" + end + + test "GET /settings/team/delete - with active subscription", %{conn: conn, user: user} do + user = subscribe_to_growth_plan(user) + team = team_of(user) + + team = + team + |> Plausible.Teams.complete_setup() + |> Ecto.Changeset.change(name: "Foo Crew") + |> Repo.update!() + + conn = set_current_team(conn, team) + conn = get(conn, Routes.settings_path(conn, :team_danger_zone)) + + assert html = html_response(conn, 200) + + assert html =~ "The team cannot be deleted because it has an active subscription" + refute html =~ "Delete \"Foo Crew\"" + end + + test "GET /settings/team/delete - permission denied", %{conn: conn, user: user} do + another_user = new_user() |> subscribe_to_growth_plan() + team = team_of(another_user) + add_member(team, user: user, role: :admin) + conn = set_current_team(conn, team) + conn = get(conn, Routes.settings_path(conn, :team_danger_zone)) + + assert redirected_to(conn, 302) == Routes.site_path(conn, :index) + end + + test "DELETE /settings/team/delete - deletes a team", %{conn: conn, user: user} do + {:ok, team} = Plausible.Teams.get_or_create(user) + + team = + team + |> Plausible.Teams.complete_setup() + |> Ecto.Changeset.change(name: "Foo Crew") + |> Repo.update!() + + conn = set_current_team(conn, team) + conn = delete(conn, Routes.settings_path(conn, :delete_team)) + + assert redirected_to(conn, 302) == Routes.site_path(conn, :index, __team: "none") + + assert Phoenix.Flash.get(conn.assigns.flash, :success) == "Team \"Foo Crew\" deleted" + end + + test "DELETE /settings/team/delete - fails when there's an active subscription", %{ + conn: conn, + user: user + } do + subscribe_to_growth_plan(user) + team = team_of(user) + + team = + team + |> Plausible.Teams.complete_setup() + |> Ecto.Changeset.change(name: "Foo Crew") + |> Repo.update!() + + conn = set_current_team(conn, team) + conn = delete(conn, Routes.settings_path(conn, :delete_team)) + + assert redirected_to(conn, 302) == Routes.settings_path(conn, :team_danger_zone) + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Team has an active subscription" + end + + test "DELETE /settings/team/delete - permission denied", %{conn: conn, user: user} do + another_user = new_user() |> subscribe_to_growth_plan() + team = team_of(another_user) + add_member(team, user: user, role: :admin) + conn = set_current_team(conn, team) + conn = delete(conn, Routes.settings_path(conn, :delete_team)) + + assert redirected_to(conn, 302) == Routes.site_path(conn, :index) + end + end + + describe "POST /team/force_2fa/enable" do + setup [:create_user, :log_in, :create_team, :setup_team] + + test "enables enforcing 2FA", %{conn: conn, team: team} do + refute team.policy.force_2fa + + conn = post(conn, Routes.settings_path(conn, :enable_team_force_2fa)) + + assert redirected_to(conn, 302) == Routes.settings_path(conn, :team_general) + + assert Phoenix.Flash.get(conn.assigns.flash, :success) =~ + "2FA is now required for all team members" + + assert Repo.reload!(team).policy.force_2fa + end + + on_ee do + test "adds entry to audit log", %{conn: conn, team: team, user: user} do + conn = post(conn, Routes.settings_path(conn, :enable_team_force_2fa)) + + assert redirected_to(conn, 302) == Routes.settings_path(conn, :team_general) + + assert_matches [ + %{ + name: "force_2fa_enabled", + user_id: ^user.id, + team_id: ^team.id, + actor_type: :user, + change: %{ + "before" => %{"policy" => %{"force_2fa" => false}}, + "after" => %{"policy" => %{"after" => %{"force_2fa" => true}}} + } + } + ] = + Plausible.Audit.list_entries( + entity: "Plausible.Teams.Team", + entity_id: "#{team.id}" + ) + end + end + + test "sends e-mail to all other team members", %{conn: conn, team: team, user: user} do + site = new_site(team: team) + + member1 = add_member(team, role: :viewer) + member2 = add_member(team, role: :owner) + + member_with_2fa = add_member(team, role: :editor) + + # enable 2FA + {:ok, member_with_2fa, _} = Plausible.Auth.TOTP.initiate(member_with_2fa) + code = NimbleTOTP.verification_code(member_with_2fa.totp_secret) + {:ok, _member_with_2fa, _} = Plausible.Auth.TOTP.enable(member_with_2fa, code) + + guest = add_guest(site, role: :viewer) + + conn = post(conn, Routes.settings_path(conn, :enable_team_force_2fa)) + + assert redirected_to(conn, 302) == Routes.settings_path(conn, :team_general) + + # The email come in order in which they are sent. + # As the logic sending them does not force any order, + # we have to match them in order-independent way. + Enum.reduce(1..2, [member1.email, member2.email], fn _, emails -> + assert assert_delivered_email_matches(%{ + subject: "Your team now requires 2FA", + to: [{_, email}] + }) + + assert email in emails + + List.delete(emails, email) + end) + + # member with 2FA already enabled is not notified + refute_email_delivered_with( + subject: "Your team now requires 2FA", + to: [nil: member_with_2fa.email] + ) + + # guests are not notified because they are not affected + refute_email_delivered_with( + subject: "Your team now requires 2FA", + to: [nil: guest.email] + ) + + # the user enabling the enforcement is not notified + refute_email_delivered_with( + subject: "Your team now requires 2FA", + to: [nil: user.email] + ) + end + + test "is idempotent", %{conn: conn, user: user, team: team} do + {:ok, team} = Plausible.Teams.disable_force_2fa(team, user, "password") + + conn = post(conn, Routes.settings_path(conn, :enable_team_force_2fa)) + + assert redirected_to(conn, 302) == Routes.settings_path(conn, :team_general) + assert Repo.reload!(team).policy.force_2fa + end + + test "can't be enabled by anyone other than owner", %{conn: conn, team: team} do + admin = add_member(team, role: :admin) + {:ok, ctx} = log_in(%{conn: conn, user: admin}) + + conn = + ctx + |> Keyword.fetch!(:conn) + |> set_current_team(team) + + conn = post(conn, Routes.settings_path(conn, :enable_team_force_2fa)) + + assert redirected_to(conn, 302) == Routes.site_path(conn, :index) + refute Repo.reload!(team).policy.force_2fa + end + end + + describe "POST /team/force_2fa/disable" do + setup [:create_user, :log_in, :create_team, :setup_team] + + setup %{user: user} do + # enable 2FA + {:ok, user, _} = Plausible.Auth.TOTP.initiate(user) + code = NimbleTOTP.verification_code(user.totp_secret) + {:ok, _user, _} = Plausible.Auth.TOTP.enable(user, code) + + {:ok, user: user} + end + + test "disables enforcing 2FA", %{conn: conn, team: team, user: user} do + {:ok, team} = Plausible.Teams.enable_force_2fa(team, user) + + conn = + post(conn, Routes.settings_path(conn, :disable_team_force_2fa), %{ + "password" => "password" + }) + + assert redirected_to(conn, 302) == Routes.settings_path(conn, :team_general) + + assert Phoenix.Flash.get(conn.assigns.flash, :success) =~ + "2FA is no longer enforced for team members" + + refute Repo.reload!(team).policy.force_2fa + end + + on_ee do + test "adds entry to audit log", %{conn: conn, user: user, team: team} do + {:ok, team} = Plausible.Teams.enable_force_2fa(team, user) + + conn = + post(conn, Routes.settings_path(conn, :disable_team_force_2fa), %{ + "password" => "password" + }) + + assert redirected_to(conn, 302) == Routes.settings_path(conn, :team_general) + + assert_matches [ + %{ + name: "force_2fa_disabled", + user_id: ^user.id, + team_id: ^team.id, + actor_type: :user, + change: %{ + "before" => %{"policy" => %{"force_2fa" => true}}, + "after" => %{"policy" => %{"after" => %{"force_2fa" => false}}} + } + }, + _ + ] = + Plausible.Audit.list_entries( + entity: "Plausible.Teams.Team", + entity_id: "#{team.id}" + ) + end + end + + test "is idempotent", %{conn: conn, team: team} do + conn = + post(conn, Routes.settings_path(conn, :disable_team_force_2fa), %{ + "password" => "password" + }) + + assert redirected_to(conn, 302) == Routes.settings_path(conn, :team_general) + refute Repo.reload!(team).policy.force_2fa + end + + test "returns error on invalid password", %{conn: conn} do + conn = + post(conn, Routes.settings_path(conn, :disable_team_force_2fa), %{"password" => "invalid"}) + + assert redirected_to(conn, 302) == Routes.settings_path(conn, :team_general) + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Incorrect password provided" + end + + test "can't be disabled by anyone other than owner", %{conn: conn, team: team, user: user} do + {:ok, team} = Plausible.Teams.enable_force_2fa(team, user) + + admin = add_member(team, role: :admin) + + # enable TOTP for admin + {:ok, admin, _} = Plausible.Auth.TOTP.initiate(admin) + code = NimbleTOTP.verification_code(admin.totp_secret) + {:ok, _admin, _} = Plausible.Auth.TOTP.enable(admin, code) + + {:ok, ctx} = log_in(%{conn: conn, user: admin}) + + conn = + ctx + |> Keyword.fetch!(:conn) + |> set_current_team(team) + + conn = + post(conn, Routes.settings_path(conn, :disable_team_force_2fa), %{ + "password" => "password" + }) + + assert redirected_to(conn, 302) == Routes.site_path(conn, :index) + assert Repo.reload!(team).policy.force_2fa + end + end + + describe "GET /settings/team/general - enforce 2FA disabled" do + setup [:create_user, :log_in, :create_team, :setup_team] + + test "is visible to owner", %{conn: conn} do + conn = get(conn, Routes.settings_path(conn, :team_general)) + html = html_response(conn, 200) + + assert element_exists?(html, "div#enable-force-2fa") + refute element_exists?(html, "div#disable-force-2fa") + end + + test "is not visible to anyone other than owner", %{conn: conn, team: team} do + admin = add_member(team, role: :admin) + {:ok, ctx} = log_in(%{conn: conn, user: admin}) + + conn = + ctx + |> Keyword.fetch!(:conn) + |> set_current_team(team) + + conn = get(conn, Routes.settings_path(conn, :team_general)) + html = html_response(conn, 200) + + refute element_exists?(html, "div#force-2fa") + refute element_exists?(html, "div#enable-force-2fa") + refute element_exists?(html, "div#disable-force-2fa") + end + end + + describe "GET /settings/team/general - enforce 2FA enabled" do + setup [:create_user, :log_in, :create_team, :setup_team] + + setup %{user: user, team: team} do + # enable 2FA + {:ok, user, _} = Plausible.Auth.TOTP.initiate(user) + code = NimbleTOTP.verification_code(user.totp_secret) + {:ok, _user, _} = Plausible.Auth.TOTP.enable(user, code) + + {:ok, team} = Plausible.Teams.enable_force_2fa(team, user) + + {:ok, user: user, team: team} + end + + test "is visible to owner", %{conn: conn} do + conn = get(conn, Routes.settings_path(conn, :team_general)) + html = html_response(conn, 200) + + refute element_exists?(html, "div#enable-force-2fa") + assert element_exists?(html, "div#disable-force-2fa") + end + + test "is not visible to anyone other than owner", %{conn: conn, team: team} do + admin = add_member(team, role: :admin) + + # enable TOTP for admin + {:ok, admin, _} = Plausible.Auth.TOTP.initiate(admin) + code = NimbleTOTP.verification_code(admin.totp_secret) + {:ok, _admin, _} = Plausible.Auth.TOTP.enable(admin, code) + + {:ok, ctx} = log_in(%{conn: conn, user: admin}) + + conn = + ctx + |> Keyword.fetch!(:conn) + |> set_current_team(team) + + conn = get(conn, Routes.settings_path(conn, :team_general)) + html = html_response(conn, 200) + + refute element_exists?(html, "div#force-2fa") + refute element_exists?(html, "div#enable-force-2fa") + refute element_exists?(html, "div#disable-force-2fa") + end + end + + describe "account dropdown menu (_header.html)" do + setup [:create_user, :log_in] + + test "renders the 'Create a team' option", %{conn: conn, user: user} do + subscribe_to_growth_plan(user) + conn = get(conn, Routes.settings_path(conn, :preferences)) + html = html_response(conn, 200) + assert text_of_element(html, ~s/[data-test="create-a-team-cta"]/) == "Create a team" + end + + test "does not render the 'Create a team' option if a team is already set up", %{ + conn: conn, + user: user + } do + {:ok, team} = Plausible.Teams.get_or_create(user) + Plausible.Teams.complete_setup(team) + conn = get(conn, Routes.settings_path(conn, :preferences)) + html = html_response(conn, 200) + refute element_exists?(html, ~s/[data-test="create-a-team-cta"]/) + end + end + + defp configure_enterprise_plan(user, attrs \\ []) do + subscribe_to_enterprise_plan( + user, + Keyword.merge( + [ + paddle_plan_id: @configured_enterprise_plan_paddle_plan_id, + monthly_pageview_limit: 20_000_000, + billing_interval: :yearly + ], + attrs + ) + ) + end + + defp assert_sidebar_menu(document, ordered_account_menu_keys, ordered_team_menu_keys \\ []) do + ordered_menu_keys = Enum.concat(ordered_account_menu_keys, ordered_team_menu_keys) + assert get_expected_menu(ordered_menu_keys) == get_sidebar_menu_items(document) + + document + end + + defp assert_mobile_menu( + document, + ordered_account_menu_keys, + ordered_team_menu_keys \\ [] + ) do + expected_account_items = + ordered_account_menu_keys + |> get_expected_menu() + |> Enum.map(fn {text, "/settings/" <> path_fragment} -> + {"Account: #{text}", path_fragment} + end) + + expected_team_items = + ordered_team_menu_keys + |> get_expected_menu() + |> Enum.map(fn {text, "/settings/" <> path_fragment} -> + {"Team: #{text}", path_fragment} + end) + + assert Enum.concat( + expected_account_items, + expected_team_items + ) == + get_mobile_menu_options(document) + + document + end + + defp get_expected_menu(ordered_menu_keys) do + ordered_menu_keys + |> Keyword.new(&{&1, nil}) + |> Keyword.intersect(@menu_items) + |> Keyword.values() + end + + defp refute_unexpected_menu_items(html, unexpected_menu_keys) do + refuted_menu_items = @menu_items |> Keyword.take(unexpected_menu_keys) |> Keyword.values() + + for {text, link} <- refuted_menu_items do + refute html =~ text + refute html =~ link + end + + html + end + + defp get_mobile_menu_options(document) do + LazyHTML.query(document, "[data-testid='mobile-nav-dropdown'] option") + |> Enum.map(&parse_option/1) + end + + defp parse_option(option), + do: {LazyHTML.text(option), LazyHTML.attribute(option, "value") |> List.first()} + + defp get_sidebar_menu_items(document) do + LazyHTML.query(document, "[data-testid='settings-sidebar'] a") + |> Enum.map(&parse_link/1) + end + + defp parse_link(link), + do: {LazyHTML.text(link) |> String.trim(), LazyHTML.attribute(link, "href") |> List.first()} +end diff --git a/test/plausible_web/controllers/site/membership_controller_test.exs b/test/plausible_web/controllers/site/membership_controller_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..cda8f96496b3e358e51c54b433d49bd7cd549a72 --- /dev/null +++ b/test/plausible_web/controllers/site/membership_controller_test.exs @@ -0,0 +1,406 @@ +defmodule PlausibleWeb.Site.MembershipControllerTest do + use PlausibleWeb.ConnCase + use Plausible.Repo + use Bamboo.Test + + @subject_prefix if ee?(), do: "[Plausible Analytics] ", else: "[Plausible CE] " + + setup [:create_user, :log_in] + + describe "GET /sites/:domain/memberships/invite" do + test "shows invite form", %{conn: conn, user: user} do + site = new_site(owner: user) + + html = + conn + |> get("/sites/#{site.domain}/memberships/invite") + |> html_response(200) + + assert html =~ "Invite guest to" + assert element_exists?(html, ~s/button[type=submit]/) + refute element_exists?(html, ~s/button[type=submit][disabled]/) + end + + @tag :ee_only + test "display a notice when is over limit", %{conn: conn, user: user} do + site = new_site(owner: user) + + for _ <- 1..11 do + add_guest(site, role: :viewer) + end + + html = + conn + |> get("/sites/#{site.domain}/memberships/invite") + |> html_response(200) + + assert text_of_element(html, ~s/[data-test="limit-exceeded-notice"]/) =~ + "This account is limited to 10 members" + end + end + + describe "POST /sites/:domain/memberships/invite" do + test "creates invitation", %{conn: conn, user: user} do + site = new_site(owner: user) + + conn = + post(conn, "/sites/#{site.domain}/memberships/invite", %{ + email: "john.doe@example.com", + role: "editor" + }) + + assert_guest_invitation(site.team, site, "john.doe@example.com", :editor) + + assert redirected_to(conn) == "/#{URI.encode_www_form(site.domain)}/settings/people" + end + + @tag :ee_only + test "fails to create invitation when is over limit", %{conn: conn, user: user} do + site = new_site(owner: user) + + for _ <- 1..11 do + add_guest(site, role: :viewer) + end + + conn = + post(conn, "/sites/#{site.domain}/memberships/invite", %{ + email: "john.doe@example.com", + role: "editor" + }) + + assert html_response(conn, 200) =~ + "Your account is limited to 10 team members. You can upgrade your plan to increase this limit." + end + + test "fails to create invitation with insufficient permissions", %{conn: conn, user: user} do + site = new_site() + add_guest(site, user: user, role: :viewer) + + conn = + post(conn, "/sites/#{site.domain}/memberships/invite", %{ + email: "john.doe@example.com", + role: "editor" + }) + + assert conn.status == 404 + end + + test "fails to create invitation if site transfer already exists", %{conn: conn, user: user} do + site = new_site(owner: user) + + new_owner = new_user() + + {:ok, _invitation} = + Plausible.Teams.Invitations.InviteToSite.invite(site, user, new_owner.email, :owner) + + assert_site_transfer(site, new_owner.email) + + conn = + post(conn, "/sites/#{site.domain}/memberships/invite", %{ + email: new_owner.email, + role: "editor" + }) + + conn = get(recycle(conn), redirected_to(conn, 302)) + html = html_response(conn, 200) + assert html =~ "Error" + assert html =~ "This invitation has been already sent" + end + + test "fails to create invitation for a foreign site", %{conn: my_conn, user: me} do + _my_site = new_site(owner: me) + + other_user = new_user() + + other_site = new_site(owner: other_user) + + my_conn = + post(my_conn, "/sites/#{other_site.domain}/memberships/invite", %{ + email: "john.doe@example.com", + role: "editor" + }) + + assert my_conn.status == 404 + + refute Repo.get_by(Plausible.Teams.Invitation, email: "john.doe@example.com") + end + + test "sends invitation email for new user", %{conn: conn, user: user} do + site = new_site(owner: user) + + post(conn, "/sites/#{site.domain}/memberships/invite", %{ + email: "john.doe@example.com", + role: "editor" + }) + + assert_email_delivered_with( + to: [nil: "john.doe@example.com"], + subject: @subject_prefix <> "You've been invited to #{site.domain}" + ) + end + + test "sends invitation email for existing user", %{conn: conn, user: user} do + existing_user = new_user() + site = new_site(owner: user) + + post(conn, "/sites/#{site.domain}/memberships/invite", %{ + email: existing_user.email, + role: "editor" + }) + + assert_email_delivered_with( + to: [nil: existing_user.email], + subject: @subject_prefix <> "You've been invited to #{site.domain}" + ) + end + + test "renders form with error if the invitee is already a member", %{conn: conn, user: user} do + site = new_site(owner: user) + + second_member = new_user() + add_guest(site, user: second_member, role: :viewer) + + conn = + post(conn, "/sites/#{site.domain}/memberships/invite", %{ + email: second_member.email, + role: "editor" + }) + + assert html_response(conn, 200) =~ + "#{second_member.email} is already a member of #{site.domain}" + end + + test "handles repeat invitation gracefully", %{ + conn: conn, + user: user + } do + site = new_site(owner: user) + + _req1 = + post(conn, "/sites/#{site.domain}/memberships/invite", %{ + email: "joe@example.com", + role: "editor" + }) + + assert_email_delivered_with( + to: [nil: "joe@example.com"], + subject: @subject_prefix <> "You've been invited to #{site.domain}" + ) + + req2 = + post(conn, "/sites/#{site.domain}/memberships/invite", %{ + email: "joe@example.com", + role: "editor" + }) + + assert_email_delivered_with( + to: [nil: "joe@example.com"], + subject: @subject_prefix <> "You've been invited to #{site.domain}" + ) + + assert Phoenix.Flash.get(req2.assigns.flash, :success) =~ + "has been invited to" + end + end + + describe "PUT /sites/memberships/:id/role/:new_role" do + test "updates a site member's role by user id", %{conn: conn, user: user} do + site = new_site(owner: user) + collaborator = add_guest(site, role: :editor) + assert_guest_membership(site.team, site, collaborator, :editor) + + put(conn, "/sites/#{site.domain}/memberships/u/#{collaborator.id}/role/viewer") + + assert_guest_membership(site.team, site, collaborator, :viewer) + end + + test "team admin can update a site member's role by user id (from editor to viewer)", %{ + conn: conn, + user: user + } do + site = new_site() + team = Plausible.Teams.complete_setup(site.team) + add_member(team, user: user, role: :admin) + collaborator = add_guest(site, role: :editor) + assert_guest_membership(team, site, collaborator, :editor) + + conn = set_current_team(conn, team) + + put(conn, "/sites/#{site.domain}/memberships/u/#{collaborator.id}/role/viewer") + + assert_guest_membership(team, site, collaborator, :viewer) + end + + test "team admin can update a site member's role by user id (from viewer to editor)", %{ + conn: conn, + user: user + } do + site = new_site() + team = Plausible.Teams.complete_setup(site.team) + add_member(team, user: user, role: :admin) + collaborator = add_guest(site, role: :viewer) + assert_guest_membership(team, site, collaborator, :viewer) + + conn = set_current_team(conn, team) + + put(conn, "/sites/#{site.domain}/memberships/u/#{collaborator.id}/role/editor") + + assert_guest_membership(team, site, collaborator, :editor) + end + + test "team editor can't update site member's role", %{conn: conn, user: user} do + site = new_site() + team = Plausible.Teams.complete_setup(site.team) + add_member(team, user: user, role: :editor) + collaborator = add_guest(site, role: :editor) + assert_guest_membership(team, site, collaborator, :editor) + + conn = set_current_team(conn, team) + + conn = put(conn, "/sites/#{site.domain}/memberships/u/#{collaborator.id}/role/viewer") + + assert html_response(conn, 404) + + assert_guest_membership(team, site, collaborator, :editor) + end + + test "can't update role when an editor", %{ + conn: conn, + user: user + } do + site = new_site() + add_guest(site, user: user, role: :editor) + + conn = put(conn, "/sites/#{site.domain}/memberships/u/#{user.id}/role/viewer") + + assert_guest_membership(site.team, site, user, :editor) + + assert html_response(conn, 404) + end + + test "can't update role when a viewer", %{ + conn: conn, + user: user + } do + site = new_site() + add_guest(site, user: user, role: :viewer) + another_guest = add_guest(site, role: :editor) + + conn = put(conn, "/sites/#{site.domain}/memberships/u/#{another_guest.id}/role/viewer") + + assert_guest_membership(site.team, site, another_guest, :editor) + + assert html_response(conn, 404) + end + + test "owner cannot make anyone else owner", %{ + conn: conn, + user: user + } do + site = new_site(owner: user) + editor = new_user() + add_guest(site, user: editor, role: :editor) + + conn = put(conn, "/sites/#{site.domain}/memberships/u/#{editor.id}/role/owner") + + assert_guest_membership(site.team, site, editor, :editor) + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "You are not allowed to grant the owner role" + end + + test "owner cannot downgrade themselves", %{ + conn: conn, + user: user + } do + site = new_site(owner: user) + + conn = put(conn, "/sites/#{site.domain}/memberships/u/#{user.id}/role/admin") + + membership = Repo.get_by(Plausible.Teams.Membership, user_id: user.id) + + assert membership.role == :owner + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "You are not allowed to grant the admin role" + end + end + + describe "DELETE /sites/:domain/memberships/:id" do + test "removes a member from a site by user id", %{conn: conn, user: user} do + site = new_site(owner: user) + admin = add_guest(site, role: :editor) + + conn = delete(conn, "/sites/#{site.domain}/memberships/u/#{admin.id}") + assert Phoenix.Flash.get(conn.assigns.flash, :success) =~ "has been removed" + + refute Repo.exists?(from tm in Plausible.Teams.Membership, where: tm.user_id == ^admin.id) + end + + test "when members is removed, associated personal segment is deleted", %{ + conn: conn, + user: user + } do + site = new_site(owner: user) + admin = add_guest(site, role: :editor) + + segment = + insert(:segment, + type: :personal, + owner: admin, + site: site, + name: "personal segment" + ) + + delete(conn, "/sites/#{site.domain}/memberships/u/#{admin.id}") + + refute Repo.reload(segment) + end + + test "when members is removed, associated site segment will be owner-less", %{ + conn: conn, + user: user + } do + site = new_site(owner: user) + admin = add_guest(site, role: :editor) + + segment = + insert(:segment, + type: :site, + owner: admin, + site: site, + name: "site segment" + ) + + delete(conn, "/sites/#{site.domain}/memberships/u/#{admin.id}") + + assert Repo.reload(segment).owner_id == nil + end + + test "fails to remove a member from a foreign site (silently)", %{conn: conn, user: user} do + foreign_member = new_user() + foreign_site = new_site() + add_guest(foreign_site, user: foreign_member, role: :editor) + + site = new_site(owner: user) + + conn = delete(conn, "/sites/#{site.domain}/memberships/u/#{foreign_member.id}") + + assert Phoenix.Flash.get(conn.assigns.flash, :success) =~ "has been removed" + + assert_guest_membership(foreign_site.team, foreign_site, foreign_member, :editor) + end + + test "notifies the user who has been removed via email", %{conn: conn, user: user} do + site = new_site(owner: user) + editor = add_guest(site, role: :editor) + + delete(conn, "/sites/#{site.domain}/memberships/u/#{editor.id}") + + assert_email_delivered_with( + to: [nil: editor.email], + subject: @subject_prefix <> "Your access to #{site.domain} has been revoked" + ) + end + end +end diff --git a/test/plausible_web/controllers/site_controller_test.exs b/test/plausible_web/controllers/site_controller_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..bb76d271a8538fc2192460574dda504c1fa9d6f3 --- /dev/null +++ b/test/plausible_web/controllers/site_controller_test.exs @@ -0,0 +1,1901 @@ +defmodule PlausibleWeb.SiteControllerTest do + use PlausibleWeb.ConnCase, async: false + use Plausible.Repo + use Bamboo.Test + use Oban.Testing, repo: Plausible.Repo + + import ExUnit.CaptureLog + import Mox + + alias Plausible.Imported.SiteImport + alias Plausible.Teams + + require Plausible.Imported.SiteImport + + @v4_business_plan_id "857105" + + setup :verify_on_exit! + + describe "GET /sites/new" do + setup [:create_user, :log_in] + + test "shows the site form", %{conn: conn} do + conn = get(conn, "/sites/new") + + assert html_response(conn, 200) =~ "Add website info" + end + + test "shows onboarding steps regardless of sites provisioned", %{conn: conn1, user: user} do + conn = get(conn1, "/sites/new") + + assert html_response(conn, 200) =~ "Add site info" + + new_site(owner: user, domain: "test-site.com") + + conn = get(conn1, "/sites/new") + + assert html_response(conn, 200) =~ "Add site info" + end + + test "does not display limit notice when user is on an enterprise plan", %{ + conn: conn, + user: user + } do + subscribe_to_enterprise_plan(user) + + new_site(owner: user) + new_site(owner: user) + new_site(owner: user) + + conn = get(conn, "/sites/new") + refute html_response(conn, 200) =~ "is limited to" + end + end + + describe "GET /sites" do + setup [:create_user, :log_in] + + test "shows personal sites empty state when there are no sites at all", %{conn: conn} do + conn = get(conn, "/sites") + resp = html_response(conn, 200) + + assert resp =~ "Add your first personal site" + assert resp =~ "Collect simple, privacy-friendly stats to better understand your audience." + refute resp =~ "Go to team sites" + end + + test "shows team sites empty state when team is setup and there are no sites at all", %{ + conn: conn, + user: user + } do + {:ok, team} = Teams.get_or_create(user) + team = Teams.complete_setup(team) + conn = set_current_team(conn, team) + + conn = get(conn, "/sites") + resp = html_response(conn, 200) + + assert resp =~ "Add your first team site" + assert resp =~ "Collect simple, privacy-friendly stats to better understand your audience." + refute resp =~ "Go to team sites" + end + + test "shows team sites empty state when team is setup but has no team sites, and user has personal sites", + %{conn: conn, user: user} do + _personal_site = new_site(owner: user) + + other_user = new_user() + {:ok, other_team} = Teams.get_or_create(other_user) + other_team = Teams.complete_setup(other_team) + add_member(other_team, user: user, role: :admin) + conn = set_current_team(conn, other_team) + + conn = get(conn, "/sites") + resp = html_response(conn, 200) + + assert resp =~ "Add your first team site" + assert resp =~ "Collect simple, privacy-friendly stats to better understand your audience." + refute resp =~ "Go to team sites" + end + + test "shows personal sites empty state when there are team sites but no personal sites", %{ + conn: conn, + user: user + } do + {:ok, team} = Teams.get_or_create(user) + team = Teams.complete_setup(team) + _team_site = new_site(team: team) + + conn = get(conn, "/sites") + resp = html_response(conn, 200) + + assert resp =~ "Add your first personal site" + assert resp =~ "Collect simple, privacy-friendly stats to better understand your audience." + assert resp =~ "Go to team sites" + end + + test "shows empty search state when filter returns no results but there are sites", %{ + conn: conn, + user: user + } do + _site = new_site(domain: "example.com", owner: user) + + conn = get(conn, "/sites", filter_text: "nonexistent") + resp = html_response(conn, 200) + + assert resp =~ "No sites found. Try a different search term." + refute resp =~ "Add your first" + refute resp =~ "Go to team sites" + end + + test "lists all of your sites with last 24h visitors (defaulting to 0 on first mount)", %{ + conn: conn, + user: user + } do + site = new_site(owner: user) + + # will be skipped + populate_stats(site, [build(:pageview)]) + conn = get(conn, "/sites") + + assert resp = html_response(conn, 200) + + site_card = text_of_element(resp, "li[data-domain=\"#{site.domain}\"]") + + refute site_card =~ "0 visitors" + assert site_card =~ site.domain + end + + test "paginates sites", %{conn: initial_conn, user: user} do + for i <- 1..25 do + new_site( + owner: user, + domain: "paginated-site#{String.pad_leading("#{i}", 2, "0")}.example.com" + ) + end + + conn = get(initial_conn, "/sites?sort_by=alnum&sort_direction=asc") + resp = html_response(conn, 200) + + for i <- 1..24 do + assert element_exists?( + resp, + "li[data-domain=\"paginated-site#{String.pad_leading("#{i}", 2, "0")}.example.com\"]" + ) + end + + refute resp =~ "paginated-site25.com" + + next_page_link = text_of_attr(resp, ".pagination-link.active", "href") + next_page = initial_conn |> get(next_page_link) |> html_response(200) + + assert element_exists?( + next_page, + "li[data-domain=\"paginated-site25.example.com\"]" + ) + + prev_page_link = text_of_attr(next_page, ".pagination-link.active", "href") + prev_page = initial_conn |> get(prev_page_link) |> html_response(200) + + assert element_exists?( + prev_page, + "li[data-domain=\"paginated-site04.example.com\"]" + ) + + refute element_exists?( + prev_page, + "li[data-domain=\"paginated-site25.example.com\"]" + ) + end + + @tag :ee_only + test "shows upgrade nag message to expired trial user without subscription", %{ + conn: initial_conn, + user: user + } do + new_site(owner: user) + + conn = get(initial_conn, "/sites") + resp = html_response(conn, 200) + + nag_message = + "To access the sites you own, you need to subscribe to a monthly or yearly payment plan." + + refute resp =~ nag_message + + user + |> team_of() + |> Plausible.Teams.Team.end_trial() + |> Repo.update!() + + conn = get(initial_conn, "/sites") + resp = html_response(conn, 200) + + assert resp =~ nag_message + end + + @tag :ee_only + test "shows upgrade button in header when user is on trial and team is not setup", + %{conn: conn, user: user} do + new_site(owner: user) + + conn = get(conn, "/sites") + resp = html_response(conn, 200) + + assert element_exists?( + resp, + ~s|a[href="#{Routes.settings_path(conn, :subscription)}"]| + ) + + assert text_of_element( + resp, + ~s|a[href="#{Routes.settings_path(conn, :subscription)}"]| + ) =~ "Upgrade" + end + + @tag :ee_only + test "shows upgrade button in header when user is on trial and is owner of a setup team", + %{conn: conn, user: user} do + {:ok, team} = Plausible.Teams.get_or_create(user) + team = Plausible.Teams.complete_setup(team) + conn = set_current_team(conn, team) + + conn = get(conn, "/sites") + resp = html_response(conn, 200) + + assert element_exists?( + resp, + ~s|a[href="#{Routes.settings_path(conn, :subscription)}"]| + ) + end + + @tag :ee_only + test "shows upgrade button in header when user is on trial and has billing role in a setup team", + %{conn: base_conn} do + member = new_user() + owner = new_user(trial_expiry_date: Date.add(Date.utc_today(), 30)) + {:ok, team} = Plausible.Teams.get_or_create(owner) + team = Plausible.Teams.complete_setup(team) + add_member(team, user: member, role: :billing) + + {:ok, conn: conn} = log_in(%{user: member, conn: base_conn}) + conn = set_current_team(conn, team) + + conn = get(conn, "/sites") + resp = html_response(conn, 200) + + assert element_exists?( + resp, + ~s|a[href="#{Routes.settings_path(conn, :subscription)}"]| + ) + end + + @tag :ee_only + test "does not show upgrade button in header when user is on trial but has non-billing role in a setup team", + %{conn: base_conn} do + for role <- [:admin, :editor, :viewer] do + member = new_user() + owner = new_user() + {:ok, team} = Plausible.Teams.get_or_create(owner) + team = Plausible.Teams.complete_setup(team) + add_member(team, user: member, role: role) + + {:ok, conn: conn} = log_in(%{user: member, conn: base_conn}) + conn = set_current_team(conn, team) + resp = conn |> get("/sites") |> html_response(200) + + refute element_exists?( + resp, + ~s|a[href="#{Routes.settings_path(conn, :subscription)}"]| + ), + "expected no Upgrade button for role #{role}" + end + end + + test "filters by domain", %{conn: conn, user: user} do + _site1 = new_site(domain: "alpha.example.com", owner: user) + _site2 = new_site(domain: "beta.example.com", owner: user) + _rogue_site = new_site() + + inviter = new_user() + + site3 = new_site(owner: inviter, domain: "alpha-another.example.com") + add_guest(site3, user: user, role: :viewer) + + conn = get(conn, "/sites", filter_text: "alpha") + resp = html_response(conn, 200) + + assert resp =~ "alpha.example.com" + assert resp =~ "alpha-another.example.com" + refute resp =~ "beta.example.com" + end + + test "does not show empty state when filter returns empty but there are sites", %{ + conn: conn, + user: user + } do + _site1 = new_site(domain: "example.com", owner: user) + + conn = get(conn, "/sites", filter_text: "none") + resp = html_response(conn, 200) + + refute resp =~ "second.example.com" + assert html_response(conn, 200) =~ "No sites found. Try a different search term." + refute html_response(conn, 200) =~ "You don't have any sites yet." + end + + test "shows settings on sites when user is an admin", %{ + conn: conn, + user: user + } do + site = new_site(domain: "example.com", owner: user) + conn = get(conn, "/sites") + resp = html_response(conn, 200) + + assert resp =~ "/#{site.domain}/settings" + end + + test "does not show settings on sites when user is not an admin or owner", %{ + conn: conn, + user: user + } do + site = new_site(domain: "example.com") + add_guest(site, user: user, role: :viewer) + + conn = get(conn, "/sites") + resp = html_response(conn, 200) + + refute resp =~ "/#{site.domain}/settings" + end + end + + describe "POST /sites" do + setup [:create_user, :log_in] + + test "creates the site with valid params", %{conn: conn} do + conn = + post(conn, "/sites", %{ + "site" => %{ + "domain" => "éxample.com", + "timezone" => "Europe/London" + } + }) + + assert redirected_to(conn) == + "/#{URI.encode_www_form("éxample.com")}/installation?site_created=true&flow=" + + assert site = Repo.get_by(Plausible.Site, domain: "éxample.com") + assert site.timezone == "Europe/London" + assert site.ingest_rate_limit_scale_seconds == 60 + assert site.ingest_rate_limit_threshold == 1_000_000 + end + + test "fails to create the site if only http:// provided", %{conn: conn} do + conn = + post(conn, "/sites", %{ + "site" => %{ + "domain" => "http://", + "timezone" => "Europe/London" + } + }) + + assert html_response(conn, 200) =~ htmlize_quotes("can't be blank") + end + + test "fails to create site when not allowed to in selected team", %{conn: conn, user: user} do + site = new_site() + add_member(site.team, user: user, role: :viewer) + conn = set_current_team(conn, site.team) + + conn = + post(conn, "/sites", %{ + "site" => %{ + "domain" => "example.com", + "timezone" => "Europe/London" + } + }) + + assert html_response(conn, 200) =~ "You are not permitted to add sites in the current team" + end + + test "starts trial if user does not have trial yet", %{conn: conn, user: user} do + refute team_of(user) + + post(conn, "/sites", %{ + "site" => %{ + "domain" => "example.com", + "timezone" => "Europe/London" + } + }) + + assert Repo.reload!(team_of(user)).trial_expiry_date + end + + test "sends welcome email if this is the user's first site", %{conn: conn} do + post(conn, "/sites", %{ + "site" => %{ + "domain" => "example.com", + "timezone" => "Europe/London" + } + }) + + assert_email_delivered_with(subject: "Welcome to Plausible") + end + + test "does not send welcome email if user already has a previous site", %{ + conn: conn, + user: user + } do + new_site(owner: user) + + post(conn, "/sites", %{ + "site" => %{ + "domain" => "example.com", + "timezone" => "Europe/London" + } + }) + + assert_no_emails_delivered() + end + + @tag :ee_only + test "does not allow site creation when the user is at their site limit", %{ + conn: conn, + user: user + } do + for _ <- 1..10, do: new_site(owner: user) + + conn = + post(conn, "/sites", %{ + "site" => %{ + "domain" => "over-limit.example.com", + "timezone" => "Europe/London" + } + }) + + assert html = html_response(conn, 200) + + assert text_of_element(html, ~s/[data-test="limit-exceeded-notice"]/) =~ + "This account is limited to 10 sites" + + refute Repo.get_by(Plausible.Site, domain: "over-limit.example.com") + end + + test "does not limit accounts registered before 2021-05-05", %{ + conn: conn, + user: user + } do + subscribe_to_plan(user, @v4_business_plan_id) + + for _ <- 1..51, do: new_site(owner: user) + + Ecto.Changeset.change(team_of(user), %{inserted_at: ~N[2021-05-04 00:00:00]}) + |> Repo.update() + + conn = + post(conn, "/sites", %{ + "site" => %{ + "domain" => "example.com", + "timezone" => "Europe/London" + } + }) + + assert redirected_to(conn) == "/example.com/installation?site_created=true&flow=" + assert Repo.get_by(Plausible.Site, domain: "example.com") + end + + test "does not limit enterprise accounts", %{ + conn: conn, + user: user + } do + subscribe_to_enterprise_plan(user, site_limit: 1) + team = team_of(user) + new_site(owner: user) + new_site(owner: user) + + conn = + post(conn, "/sites", %{ + "site" => %{ + "domain" => "example.com", + "timezone" => "Europe/London" + } + }) + + assert redirected_to(conn) == "/example.com/installation?site_created=true&flow=" + assert Plausible.Teams.Billing.site_usage(team) == 3 + end + + for url <- ["https://Example.com/", "HTTPS://EXAMPLE.COM/", "/Example.com/", "//Example.com/"] do + test "cleans up an url like #{url}", %{conn: conn} do + conn = + post(conn, "/sites", %{ + "site" => %{ + "domain" => unquote(url), + "timezone" => "Europe/London" + } + }) + + assert redirected_to(conn) == "/example.com/installation?site_created=true&flow=" + assert Repo.get_by(Plausible.Site, domain: "example.com") + end + end + + test "renders form again when domain is missing", %{conn: conn} do + conn = + post(conn, "/sites", %{ + "site" => %{ + "domain" => "", + "timezone" => "Europe/London" + } + }) + + assert html_response(conn, 200) =~ htmlize_quotes("can't be blank") + end + + test "only alphanumeric characters and slash allowed in domain", %{conn: conn} do + conn = + post(conn, "/sites", %{ + "site" => %{ + "timezone" => "Europe/London", + "domain" => "!@£.com" + } + }) + + assert html_response(conn, 200) =~ + "only letters, numbers, slashes, underscores and period allowed" + end + + test "underscores are allowed in the domain", %{conn: conn} do + conn = + post(conn, "/sites", %{ + "site" => %{ + "timezone" => "Europe/London", + "domain" => "example.com/some_blog_site" + } + }) + + assert redirected_to(conn) == + "/example.com%2Fsome_blog_site/installation?site_created=true&flow=" + end + + test "renders form again when it is a duplicate domain", %{conn: conn} do + new_site(domain: "example.com") + + conn = + post(conn, "/sites", %{ + "site" => %{ + "domain" => "example.com", + "timezone" => "Europe/London" + } + }) + + assert html_response(conn, 200) =~ + "This domain cannot be registered. Perhaps one of your colleagues registered it?" + + if ee?() do + assert html_response(conn, 200) =~ "support@plausible.io" + else + refute html_response(conn, 200) =~ "support@plausible.io" + end + end + + test "renders form again when domain was changed from elsewhere", %{conn: conn} do + :site + |> insert(domain: "example.com") + |> Plausible.Site.Domain.change("new.example.com") + + conn = + post(conn, "/sites", %{ + "site" => %{ + "domain" => "example.com", + "timezone" => "Europe/London" + } + }) + + assert html_response(conn, 200) =~ + "This domain cannot be registered. Perhaps one of your colleagues registered it?" + + if ee?() do + assert html_response(conn, 200) =~ "support@plausible.io" + else + refute html_response(conn, 200) =~ "support@plausible.io" + end + end + + test "allows creating the site if domain was changed by the owner", %{ + conn: conn, + user: user + } do + new_site(domain: "example.com", owner: user) + |> Plausible.Site.Domain.change("new.example.com") + + conn = + post(conn, "/sites", %{ + "site" => %{ + "domain" => "example.com", + "timezone" => "Europe/London" + } + }) + + assert redirected_to(conn) == + "/example.com/installation?site_created=true&flow=" + end + + for role <- [:owner, :admin, :editor] do + test "redirects to stats when user already has #{role} access to the duplicate domain", + %{ + conn: conn, + user: user + } do + site = new_site(domain: "example.com") + add_member(site.team, user: user, role: unquote(role)) + + conn = + post(conn, "/sites", %{ + "site" => %{ + "domain" => "example.com", + "timezone" => "Europe/London" + } + }) + + assert redirected_to(conn) == "/example.com" + end + end + + for role <- Plausible.Teams.Membership.roles() -- [:owner, :admin, :editor] do + test "#{role} trying access the duplicate domain, an error is shown", + %{ + conn: conn, + user: user + } do + site = new_site(domain: "example.com") + add_member(site.team, user: user, role: unquote(role)) + + conn = + post(conn, "/sites", %{ + "site" => %{ + "domain" => "example.com", + "timezone" => "Europe/London" + } + }) + + assert html_response(conn, 200) =~ + "This domain cannot be registered. Perhaps one of your colleagues registered it?" + end + end + end + + describe "GET /:domain/settings/general" do + setup [:create_user, :log_in, :create_site] + + setup_patch_env(:google, client_id: "some", api_url: "https://www.googleapis.com") + + test "shows settings form", %{conn: conn, site: site} do + conn = get(conn, Routes.site_path(conn, :settings_general, site.domain)) + resp = html_response(conn, 200) + + assert resp =~ "Settings for #{site.domain}" + assert resp =~ "Site domain" + assert resp =~ "Change domain" + assert resp =~ Routes.site_path(conn, :change_domain, site.domain) + + assert resp =~ "Site timezone" + + assert resp =~ "Site installation" + end + + on_ee do + test "renders only timezone section for a consolidated site", %{conn: conn, user: user} do + team = team_of(user) + new_site(team: team) + consolidated_view = new_consolidated_view(team) + conn = get(conn, "/#{consolidated_view.domain}/settings/general") + resp = html_response(conn, 200) + + assert [tile_element] = find(resp, ~s|div[data-test-id="settings-tile"]|) |> Enum.into([]) + assert text(tile_element) =~ "Site timezone" + end + end + + @tag :ee_only + test "all site settings sidebar items are working links", %{ + conn: conn, + user: user + } do + site = new_site(owner: user, domain: "example.com") + conn = get(conn, "/#{site.domain}/settings/general") + resp = html_response(conn, 200) + + items = + resp + |> find("[data-testid=site_settings_sidebar] a") + |> Enum.map(fn a -> {text(a), text_of_attr(a, "href")} end) + + assert items == [ + {"General", "/#{site.domain}/settings/general"}, + {"People", "/#{site.domain}/settings/people"}, + {"Visibility", "/#{site.domain}/settings/visibility"}, + {"Goals", "/#{site.domain}/settings/goals"}, + {"Funnels", "/#{site.domain}/settings/funnels"}, + {"Custom properties", "/#{site.domain}/settings/properties"}, + {"Integrations", "/#{site.domain}/settings/integrations"}, + {"Imports & exports", "/#{site.domain}/settings/imports-exports"}, + {"Shields", nil}, + {"IP addresses", "/#{site.domain}/settings/shields/ip_addresses"}, + {"Countries", "/#{site.domain}/settings/shields/countries"}, + {"Pages", "/#{site.domain}/settings/shields/pages"}, + {"Hostnames", "/#{site.domain}/settings/shields/hostnames"}, + {"Email reports", "/#{site.domain}/settings/email-reports"}, + {"Danger zone", "/#{site.domain}/settings/danger-zone"} + ] + end + + @tag :ce_build_only + test "all site settings sidebar items are working links on CE", %{ + conn: conn, + user: user + } do + site = new_site(owner: user, domain: "example.com") + conn = get(conn, "/#{site.domain}/settings/general") + resp = html_response(conn, 200) + + items = + resp + |> find("[data-testid=site_settings_sidebar] a") + |> Enum.map(fn a -> {text(a), text_of_attr(a, "href")} end) + + assert items == [ + {"General", "/#{site.domain}/settings/general"}, + {"People", "/#{site.domain}/settings/people"}, + {"Visibility", "/#{site.domain}/settings/visibility"}, + {"Goals", "/#{site.domain}/settings/goals"}, + {"Custom properties", "/#{site.domain}/settings/properties"}, + {"Integrations", "/#{site.domain}/settings/integrations"}, + {"Imports & exports", "/#{site.domain}/settings/imports-exports"}, + {"Shields", nil}, + {"IP addresses", "/#{site.domain}/settings/shields/ip_addresses"}, + {"Countries", "/#{site.domain}/settings/shields/countries"}, + {"Pages", "/#{site.domain}/settings/shields/pages"}, + {"Hostnames", "/#{site.domain}/settings/shields/hostnames"}, + {"Email reports", "/#{site.domain}/settings/email-reports"}, + {"Danger zone", "/#{site.domain}/settings/danger-zone"} + ] + end + + on_ee do + test "consolidated view settings sidebar items", %{ + conn: conn, + user: user + } do + team = user |> team_of() + new_site(team: team) + site = new_consolidated_view(team) + conn = get(conn, "/#{site.domain}/settings/general") + resp = html_response(conn, 200) + + items = + resp + |> find("[data-testid=site_settings_sidebar] a") + |> Enum.map(fn a -> {text(a), text_of_attr(a, "href")} end) + + assert resp =~ "Settings for consolidated view" + + assert items == [ + {"General", "/#{site.domain}/settings/general"}, + {"Goals", "/#{site.domain}/settings/goals"}, + {"Custom properties", "/#{site.domain}/settings/properties"}, + {"Email reports", "/#{site.domain}/settings/email-reports"} + ] + end + end + + test "header and footer are shown", %{conn: conn, site: site, user: user} do + conn = get(conn, "/#{site.domain}/settings/general") + resp = html_response(conn, 200) + assert resp =~ user.name + assert resp =~ "Getting started" + end + end + + describe "GET /:domain/settings/people" do + setup [:create_user, :log_in, :create_site] + + on_ee do + test "returns 404 for consolidated view", %{conn: conn, user: user} do + {:ok, team} = Plausible.Teams.get_or_create(user) + new_site(team: team) + new_site(team: team) + consolidated_view = new_consolidated_view(team) + + conn = get(conn, "/#{consolidated_view.domain}/settings/people") + assert html_response(conn, 404) + end + end + + test "lists current members", %{conn: conn, user: user} do + site = new_site(owner: user) + editor = add_guest(site, role: :editor) + viewer = add_guest(site, role: :viewer) + conn = get(conn, "/#{site.domain}/settings/people") + resp = html_response(conn, 200) + + owner_row = + text_of_element(resp, "#membership-#{user.id}") + + editor_row = text_of_element(resp, "#membership-#{editor.id}") + editor_row_button = text_of_element(resp, "#membership-#{editor.id} button") + viewer_row = text_of_element(resp, "#membership-#{viewer.id}") + viewer_row_button = text_of_element(resp, "#membership-#{viewer.id} button") + + assert owner_row =~ user.email + assert owner_row =~ "Owner" + + assert editor_row =~ editor.email + assert editor_row_button == "Guest Editor" + refute editor_row =~ "Owner" + + assert viewer_row =~ viewer.email + assert viewer_row_button == "Guest Viewer" + refute viewer_row =~ "Owner" + end + + test "lists pending invitations", %{conn: conn, user: user} do + site = new_site(owner: user) + i1 = invite_guest(site, "admin@example.com", role: :editor, inviter: user) + i2 = invite_guest(site, "viewer@example.com", role: :viewer, inviter: user) + conn = get(conn, "/#{site.domain}/settings/people") + resp = html_response(conn, 200) + + assert text_of_element(resp, "#invitation-#{i1.invitation_id}") == + "admin@example.com Editor" + + assert text_of_element(resp, "#invitation-#{i2.invitation_id}") == + "viewer@example.com Viewer" + end + + test "lists pending site transfer", %{conn: conn, user: user} do + site = new_site(owner: user) + new_owner = new_user() + + st = invite_transfer(site, new_owner, inviter: user) + + conn = get(conn, "/#{site.domain}/settings/people") + resp = html_response(conn, 200) + + assert text_of_element(resp, "#invitation-#{st.transfer_id}") == + "#{new_owner.email} Owner" + end + + test "renders distinct team management notices to owner", %{conn: conn, user: user} do + site = new_site(owner: user) + resp = conn |> get("/#{site.domain}/settings/people") |> html_response(200) + + refute resp =~ "A Better Way of Inviting People to a Team" + assert resp =~ "A Better Way of Inviting People to Your Team" + refute resp =~ "Team members automatically have access to this site." + + team = team_of(user) + Teams.complete_setup(team) + conn = set_current_team(conn, team) + + resp = conn |> get("/#{site.domain}/settings/people") |> html_response(200) + refute resp =~ "A Better Way of Inviting People to Your Team" + assert resp =~ "Team members automatically have access to this site." + end + + test "renders distinct team management notices to editors", %{conn: conn, user: user} do + # this can go away once we support multiple teams + user |> team_of() |> Repo.delete!() + owner = new_user() + site = new_site(owner: owner) + add_member(team_of(owner), user: user, role: :editor) + + resp = conn |> get("/#{site.domain}/settings/people") |> html_response(200) + + assert resp =~ "A Better Way of Inviting People to a Team" + refute resp =~ "A Better Way of Inviting People to Your Team" + + owner |> team_of() |> Teams.Team.setup_changeset() |> Repo.update!() + + resp = conn |> get("/#{site.domain}/settings/people") |> html_response(200) + + refute resp =~ "A Better Way of Inviting People to a Team" + refute resp =~ "Team members automatically have access to this site." + end + + @tag :ee_only + test "does not render team management notice if Teams feature unavailable", %{ + conn: conn, + user: user + } do + subscribe_to_starter_plan(user) + + site = new_site(owner: user) + resp = conn |> get("/#{site.domain}/settings/people") |> html_response(200) + + refute resp =~ "A Better Way of Inviting People" + end + end + + describe "GET /:domain/settings/visibility" do + setup [:create_user, :log_in, :create_site] + + setup %{user: user} do + subscribe_to_growth_plan(user) + :ok + end + + test "shows shared links section", %{conn: conn, site: site} do + conn = get(conn, "/#{site.domain}/settings/visibility") + + resp = html_response(conn, 200) + assert resp =~ "Add shared link" + assert element_exists?(resp, ~s/button#add-shared-link-button/) + end + + test "lists shared links with actions", %{conn: conn, site: site} do + link1 = insert(:shared_link, site: site, name: "Link 1") + link2 = insert(:shared_link, site: site, name: "Link 2") + + conn = get(conn, "/#{site.domain}/settings/visibility") + resp = html_response(conn, 200) + + assert resp =~ "Link 1" + assert resp =~ "Link 2" + + assert element_exists?( + resp, + ~s/button[phx-click="edit-shared-link"][phx-value-slug="#{link1.slug}"]/ + ) + + assert element_exists?( + resp, + ~s/button[phx-click="delete-shared-link"][phx-value-slug="#{link2.slug}"]/ + ) + end + + test "shows message when no shared links are configured", %{conn: conn, site: site} do + conn = get(conn, "/#{site.domain}/settings/visibility") + resp = html_response(conn, 200) + + assert resp =~ "Create your first shared link" + end + + test "does not render shared links with special names", %{conn: conn, site: site} do + for special_name <- Plausible.Sites.shared_link_special_names() do + insert(:shared_link, name: special_name, site: site) + + html = + conn + |> get("/#{site.domain}/settings/visibility") + |> html_response(200) + + refute text_of_element(html, "#shared-links-table") =~ special_name + end + end + end + + describe "GET /:domain/settings/goals" do + setup [:create_user, :log_in, :create_site] + + test "lists goals for the site", %{conn: conn, site: site} do + insert(:goal, site: site, event_name: "Custom event") + insert(:goal, site: site, page_path: "/register") + + conn = get(conn, "/#{site.domain}/settings/goals") + + assert html_response(conn, 200) =~ "Custom event" + assert html_response(conn, 200) =~ "Visit /register" + + insert(:goal, site: site, event_name: "") + + conn = get(conn, "/#{site.domain}/settings/goals") + + resp = html_response(conn, 200) + assert resp =~ "<some_event>" + refute resp =~ "" + end + end + + describe "PUT /:domain/settings" do + setup [:create_user, :log_in, :create_site] + + test "updates the timezone", %{conn: conn, site: site} do + conn = + put(conn, "/#{site.domain}/settings", %{ + "site" => %{ + "timezone" => "Europe/London" + } + }) + + updated = Repo.get(Plausible.Site, site.id) + assert updated.timezone == "Europe/London" + assert redirected_to(conn, 302) == "/#{URI.encode_www_form(site.domain)}/settings/general" + end + end + + describe "POST /sites/:domain/make-public" do + setup [:create_user, :log_in, :create_site] + + test "makes the site public", %{conn: conn, site: site} do + conn = post(conn, "/sites/#{site.domain}/make-public") + + updated = Repo.get(Plausible.Site, site.id) + assert updated.public + + assert redirected_to(conn, 302) == + "/#{URI.encode_www_form(site.domain)}/settings/visibility" + end + + test "fails to make site public with insufficient permissions", %{conn: conn, user: user} do + site = new_site() + add_guest(site, user: user, role: :viewer) + conn = post(conn, "/sites/#{site.domain}/make-public") + assert conn.status == 404 + refute Repo.get(Plausible.Site, site.id).public + end + + test "fails to make foreign site public", %{conn: my_conn, user: me} do + _my_site = new_site(owner: me) + other_user = new_user() + other_site = new_site(owner: other_user) + + my_conn = post(my_conn, "/sites/#{other_site.domain}/make-public") + assert my_conn.status == 404 + refute Repo.get(Plausible.Site, other_site.id).public + end + end + + describe "POST /sites/:domain/make-private" do + setup [:create_user, :log_in, :create_site] + + test "makes the site private", %{conn: conn, site: site} do + conn = post(conn, "/sites/#{site.domain}/make-private") + + updated = Repo.get(Plausible.Site, site.id) + refute updated.public + + assert redirected_to(conn, 302) == + "/#{URI.encode_www_form(site.domain)}/settings/visibility" + end + end + + describe "DELETE /:domain" do + setup [:create_user, :log_in, :create_site] + + test "deletes the site", %{conn: conn, user: user} do + site = new_site(owner: user) + insert(:google_auth, user: user, site: site) + insert(:spike_notification, site: site) + insert(:drop_notification, site: site) + + delete(conn, "/#{site.domain}") + + refute Repo.exists?(from(s in Plausible.Site, where: s.id == ^site.id)) + end + + test "fails to delete a site with insufficient permissions", %{conn: conn, user: user} do + site = new_site() + add_guest(site, user: user, role: :viewer) + insert(:google_auth, user: user, site: site) + insert(:spike_notification, site: site) + + conn = delete(conn, "/#{site.domain}") + + assert conn.status == 404 + assert Repo.exists?(from(s in Plausible.Site, where: s.id == ^site.id)) + end + + test "fails to delete a foreign site", %{conn: my_conn, user: me} do + _my_site = new_site(owner: me) + other_user = new_user() + other_site = new_site(owner: other_user) + + insert(:google_auth, user: other_user, site: other_site) + insert(:spike_notification, site: other_site) + + my_conn = delete(my_conn, "/#{other_site.domain}") + assert my_conn.status == 404 + assert Repo.exists?(from(s in Plausible.Site, where: s.id == ^other_site.id)) + end + end + + describe "PUT /:domain/settings/google" do + setup [:create_user, :log_in, :create_site] + + test "updates google auth property", %{conn: conn, user: user, site: site} do + insert(:google_auth, user: user, site: site) + + conn = + put(conn, "/#{site.domain}/settings/google", %{ + "google_auth" => %{"property" => "some-new-property.com"} + }) + + updated_auth = Repo.one(Plausible.Site.GoogleAuth) + assert updated_auth.property == "some-new-property.com" + + assert redirected_to(conn, 302) == + "/#{URI.encode_www_form(site.domain)}/settings/integrations" + end + end + + describe "DELETE /:domain/settings/google" do + setup [:create_user, :log_in, :create_site] + + test "deletes associated google auth", %{conn: conn, user: user, site: site} do + insert(:google_auth, user: user, site: site) + conn = delete(conn, "/#{site.domain}/settings/google-search") + + refute Repo.exists?(Plausible.Site.GoogleAuth) + + assert redirected_to(conn, 302) == + "/#{URI.encode_www_form(site.domain)}/settings/integrations" + end + + test "won't crash if associated google auth has been already deleted", %{ + conn: conn1, + user: user, + site: site + } do + insert(:google_auth, user: user, site: site) + delete(conn1, "/#{site.domain}/settings/google-search") + conn = delete(conn1, "/#{site.domain}/settings/google-search") + + refute Repo.exists?(Plausible.Site.GoogleAuth) + + assert redirected_to(conn, 302) == + "/#{URI.encode_www_form(site.domain)}/settings/integrations" + end + + test "fails to delete associated google auth from the outside", %{ + conn: conn, + user: user + } do + other_site = new_site() + insert(:google_auth, user: user, site: other_site) + conn = delete(conn, "/#{URI.encode_www_form(other_site.domain)}/settings/google-search") + + assert conn.status == 404 + assert Repo.exists?(Plausible.Site.GoogleAuth) + end + end + + describe "GET /:domain/settings/imports-exports" do + setup [:create_user, :log_in, :create_site, :maybe_fake_minio] + + test "renders empty imports list", %{conn: conn, site: site} do + conn = get(conn, "/#{site.domain}/settings/imports-exports") + resp = html_response(conn, 200) + + assert element_exists?(resp, ~s|a[href^="https://accounts.google.com/o/oauth2/"]|) + + assert(resp =~ "Import data") + assert resp =~ "Import your first data" + assert resp =~ "Export data" + end + + test "renders imports in import list", %{conn: conn, site: site} do + _site_import1 = insert(:site_import, site: site, status: SiteImport.pending()) + _site_import2 = insert(:site_import, site: site, status: SiteImport.importing()) + + site_import3 = + insert(:site_import, label: "123456", site: site, status: SiteImport.completed()) + + _site_import4 = insert(:site_import, site: site, status: SiteImport.failed()) + + populate_stats(site, site_import3.id, [ + build(:imported_visitors, pageviews: 7777), + build(:imported_visitors, pageviews: 2221) + ]) + + conn = get(conn, "/#{site.domain}/settings/imports-exports") + resp = html_response(conn, 200) + + assert elem_count(resp, ~s|a[data-method="delete"]|) == 4 + + assert resp =~ "Google Analytics (123456)" + assert resp =~ "9.9k" + end + + test "disables import buttons when imports are at maximum", %{conn: conn, site: site} do + insert_list(Plausible.Imported.max_complete_imports(), :site_import, + site: site, + status: SiteImport.completed() + ) + + conn = get(conn, "/#{site.domain}/settings/imports-exports") + + assert html_response(conn, 200) =~ + "Maximum of #{Plausible.Imported.max_complete_imports()} imports is reached." + end + + test "considers older legacy imports when showing pageview count", %{conn: conn, site: site} do + _site_import = + insert(:site_import, site: site, legacy: true, status: SiteImport.completed()) + + populate_stats(site, [ + build(:imported_visitors, pageviews: 7777), + build(:imported_visitors, pageviews: 2221) + ]) + + conn = get(conn, "/#{site.domain}/settings/imports-exports") + + resp = html_response(conn, 200) + + assert resp =~ "9.9k" + end + + test "disables import buttons when there's import in progress", %{conn: conn, site: site} do + _site_import1 = insert(:site_import, site: site, status: SiteImport.completed()) + _site_import2 = insert(:site_import, site: site, status: SiteImport.importing()) + + conn = get(conn, "/#{site.domain}/settings/imports-exports") + assert html_response(conn, 200) =~ "No new imports can be started" + end + + test "enables import buttons when all imports are in completed or failed state", %{ + conn: conn, + site: site + } do + _site_import1 = insert(:site_import, site: site, status: SiteImport.completed()) + _site_import2 = insert(:site_import, site: site, status: SiteImport.failed()) + + conn = get(conn, "/#{site.domain}/settings/imports-exports") + refute html_response(conn, 200) =~ "No new imports can be started" + end + + test "displays notice when import in progress is running for over 5 minutes", %{ + conn: conn, + site: site + } do + six_minutes_ago = NaiveDateTime.add(NaiveDateTime.utc_now(), -360) + + _site_import1 = insert(:site_import, site: site, status: SiteImport.completed()) + + _site_import2 = + insert(:site_import, + site: site, + status: SiteImport.importing(), + updated_at: six_minutes_ago + ) + + conn = get(conn, "/#{site.domain}/settings/imports-exports") + response = html_response(conn, 200) + assert response =~ "No new imports can be started" + assert response =~ "The import process might be taking longer due to the amount of data" + assert response =~ "and rate limiting enforced by Google Analytics" + end + + test "displays CSV export button", %{conn: conn, site: site} do + assert conn |> get("/#{site.domain}/settings/imports-exports") |> html_response(200) =~ + "Prepare download" + end + end + + describe "GET /:domain/settings/imports-exports when object storage is unreachable" do + setup [:create_user, :log_in, :create_site] + + setup tags do + if tags[:async] do + raise "this test modifies application environment and can't be run asynchronously" + end + + prev_env = Application.get_env(:ex_aws, :s3) + new_env = Keyword.update!(prev_env, :port, fn prev_port -> prev_port + 1 end) + Application.put_env(:ex_aws, :s3, new_env) + on_exit(fn -> Application.put_env(:ex_aws, :s3, prev_env) end) + end + + @tag capture_log: true, ee_only: true + test "displays error message", %{conn: conn, site: site} do + assert conn |> get("/#{site.domain}/settings/imports-exports") |> html_response(200) =~ + "Something went wrong when fetching exports" + end + end + + describe "GET /:domain/settings/integrations for self-hosting" do + setup [:create_user, :log_in, :create_site] + + setup_patch_env(:google, + client_id: nil, + client_secret: nil, + api_url: "https://www.googleapis.com" + ) + + test "display search console settings", %{conn: conn, site: site} do + conn = get(conn, "/#{site.domain}/settings/integrations") + resp = html_response(conn, 200) + assert resp =~ "An extra step is needed" + assert resp =~ "Google Search Console integration" + assert resp =~ "google-integration" + end + + @tag :ee_only + test "renders looker studio integration section", %{conn: conn, site: site} do + conn = get(conn, "/#{site.domain}/settings/integrations") + resp = html_response(conn, 200) + assert resp =~ "Google Looker Studio Connector" + end + + @tag :ce_build_only + test "does not render looker studio integration section", %{conn: conn, site: site} do + conn = get(conn, "/#{site.domain}/settings/integrations") + resp = html_response(conn, 200) + refute resp =~ "Google Looker Studio Connector" + end + end + + describe "GET /:domain/integrations (search-console)" do + setup [:create_user, :log_in, :create_site] + + setup_patch_env(:google, client_id: "some", api_url: "https://www.googleapis.com") + + setup %{site: site, user: user} = context do + insert(:google_auth, user: user, site: site, property: "sc-domain:#{site.domain}") + context + end + + test "displays Continue with Google link", %{conn: conn, user: user} do + site = new_site(domain: "notconnectedyet.example.com", owner: user) + + conn = get(conn, "/#{site.domain}/settings/integrations") + resp = html_response(conn, 200) + + assert button = find(resp, "a#search-console-connect") + assert text(button) == "Continue with Google" + assert text_of_attr(button, "href") =~ "https://accounts.google.com/o/oauth2/v2/auth?" + assert text_of_attr(button, "href") =~ "webmasters.readonly" + refute text_of_attr(button, "href") =~ "analytics.readonly" + end + + test "displays appropriate error in case of google account `google_auth_error`", %{ + conn: conn, + site: site + } do + expect( + Plausible.HTTPClient.Mock, + :get, + fn + "https://www.googleapis.com/webmasters/v3/sites", + [{"Content-Type", "application/json"}, {"Authorization", "Bearer 123"}] -> + {:error, %{reason: %Finch.Response{status: Enum.random([401, 403])}}} + end + ) + + conn = get(conn, "/#{site.domain}/settings/integrations") + resp = html_response(conn, 200) + assert resp =~ "Your Search Console account hasn't been connected successfully" + assert resp =~ "Please unlink your Google account and try linking it again" + end + + test "displays docs link error in case of `invalid_grant`", %{ + conn: conn, + site: site + } do + expect( + Plausible.HTTPClient.Mock, + :get, + fn + "https://www.googleapis.com/webmasters/v3/sites", + [{"Content-Type", "application/json"}, {"Authorization", "Bearer 123"}] -> + {:error, %{reason: %Finch.Response{status: 400, body: %{"error" => "invalid_grant"}}}} + end + ) + + conn = get(conn, "/#{site.domain}/settings/integrations") + resp = html_response(conn, 200) + + assert resp =~ + "https://plausible.io/docs/google-search-console-integration#i-get-the-invalid-grant-error" + end + + test "displays generic error in case of random error code returned by google", %{ + conn: conn, + site: site + } do + expect( + Plausible.HTTPClient.Mock, + :get, + fn + "https://www.googleapis.com/webmasters/v3/sites", + [{"Content-Type", "application/json"}, {"Authorization", "Bearer 123"}] -> + {:error, %{reason: %Finch.Response{status: 503, body: %{"error" => "some_error"}}}} + end + ) + + conn = get(conn, "/#{site.domain}/settings/integrations") + resp = html_response(conn, 200) + + assert resp =~ "Something went wrong, but looks temporary" + assert resp =~ "try re-linking your Google account" + end + + test "displays generic error and logs a message, in case of random HTTP failure calling google", + %{ + conn: conn, + site: site + } do + expect( + Plausible.HTTPClient.Mock, + :get, + fn + "https://www.googleapis.com/webmasters/v3/sites", + [{"Content-Type", "application/json"}, {"Authorization", "Bearer 123"}] -> + {:error, :nxdomain} + end + ) + + log = + capture_log(fn -> + conn = get(conn, "/#{site.domain}/settings/integrations") + resp = html_response(conn, 200) + + assert resp =~ "Something went wrong, but looks temporary" + assert resp =~ "try re-linking your Google account" + end) + + assert log =~ "Google Analytics: failed to list sites: :nxdomain" + end + end + + describe "POST /sites/:domain/weekly-report/enable" do + setup [:create_user, :log_in, :create_site] + + test "creates a weekly report record with the user email", %{ + conn: conn, + site: site, + user: user + } do + conn = post(conn, "/sites/#{site.domain}/weekly-report/enable") + assert Phoenix.Flash.get(conn.assigns.flash, :success) =~ "You will receive an email report" + + report = Repo.get_by(Plausible.Site.WeeklyReport, site_id: site.id) + assert report.recipients == [user.email] + end + + test "creates a weekly report record twice (e.g. from a second tab)", %{ + conn: conn, + site: site, + user: user + } do + post(conn, "/sites/#{site.domain}/weekly-report/enable") + conn = post(conn, "/sites/#{site.domain}/weekly-report/enable") + assert Phoenix.Flash.get(conn.assigns.flash, :success) =~ "You will receive an email report" + + report = Repo.get_by(Plausible.Site.WeeklyReport, site_id: site.id) + assert report.recipients == [user.email] + end + end + + describe "POST /sites/:domain/weekly-report/disable" do + setup [:create_user, :log_in, :create_site] + + test "deletes the weekly report record", %{conn: conn, site: site} do + insert(:weekly_report, site: site) + + post(conn, "/sites/#{site.domain}/weekly-report/disable") + + refute Repo.get_by(Plausible.Site.WeeklyReport, site_id: site.id) + end + + test "fails to delete the weekly report record for a foreign site", %{conn: conn} do + site = new_site() + insert(:weekly_report, site: site) + + post(conn, "/sites/#{site.domain}/weekly-report/disable") + + assert Repo.get_by(Plausible.Site.WeeklyReport, site_id: site.id) + end + end + + describe "POST /sites/:domain/weekly-report/recipients" do + setup [:create_user, :log_in, :create_site] + + test "adds a recipient to the weekly report", %{conn: conn, site: site} do + insert(:weekly_report, site: site) + + post(conn, "/sites/#{site.domain}/weekly-report/recipients", recipient: "user@email.com") + + report = Repo.get_by(Plausible.Site.WeeklyReport, site_id: site.id) + assert report.recipients == ["user@email.com"] + end + end + + describe "DELETE /sites/:domain/weekly-report/recipients/:recipient" do + setup [:create_user, :log_in, :create_site] + + test "removes a recipient from the weekly report", %{conn: conn, site: site} do + insert(:weekly_report, site: site, recipients: ["recipient@email.com"]) + + delete(conn, "/sites/#{site.domain}/weekly-report/recipients/recipient@email.com") + + report = Repo.get_by(Plausible.Site.WeeklyReport, site_id: site.id) + assert report.recipients == [] + end + + test "fails to remove a recipient from the weekly report in a foreign website", %{conn: conn} do + site = new_site() + insert(:weekly_report, site: site, recipients: ["recipient@email.com"]) + + conn1 = delete(conn, "/sites/#{site.domain}/weekly-report/recipients/recipient@email.com") + assert conn1.status == 404 + + conn2 = delete(conn, "/sites/#{site.domain}/weekly-report/recipients/recipient%40email.com") + assert conn2.status == 404 + + report = Repo.get_by(Plausible.Site.WeeklyReport, site_id: site.id) + assert [_] = report.recipients + end + end + + describe "POST /sites/:domain/monthly-report/enable" do + setup [:create_user, :log_in, :create_site] + + test "creates a monthly report record with the user email", %{ + conn: conn, + site: site, + user: user + } do + conn = post(conn, "/sites/#{site.domain}/monthly-report/enable") + + report = Repo.get_by(Plausible.Site.MonthlyReport, site_id: site.id) + assert report.recipients == [user.email] + assert Phoenix.Flash.get(conn.assigns.flash, :success) =~ "You will receive an email report" + end + + test "enable monthly report twice (e.g. from a second tab)", %{ + conn: conn, + site: site, + user: user + } do + post(conn, "/sites/#{site.domain}/monthly-report/enable") + conn = post(conn, "/sites/#{site.domain}/monthly-report/enable") + + assert Phoenix.Flash.get(conn.assigns.flash, :success) =~ "You will receive an email report" + report = Repo.get_by(Plausible.Site.MonthlyReport, site_id: site.id) + assert report.recipients == [user.email] + end + end + + describe "POST /sites/:domain/monthly-report/disable" do + setup [:create_user, :log_in, :create_site] + + test "deletes the monthly report record", %{conn: conn, site: site} do + insert(:monthly_report, site: site) + + post(conn, "/sites/#{site.domain}/monthly-report/disable") + + refute Repo.get_by(Plausible.Site.MonthlyReport, site_id: site.id) + end + end + + describe "POST /sites/:domain/monthly-report/recipients" do + setup [:create_user, :log_in, :create_site] + + test "adds a recipient to the monthly report", %{conn: conn, site: site} do + insert(:monthly_report, site: site) + + post(conn, "/sites/#{site.domain}/monthly-report/recipients", recipient: "user@email.com") + + report = Repo.get_by(Plausible.Site.MonthlyReport, site_id: site.id) + assert report.recipients == ["user@email.com"] + end + end + + describe "DELETE /sites/:domain/monthly-report/recipients/:recipient" do + setup [:create_user, :log_in, :create_site] + + test "removes a recipient from the monthly report", %{conn: conn, site: site} do + insert(:monthly_report, site: site, recipients: ["recipient@email.com"]) + + delete(conn, "/sites/#{site.domain}/monthly-report/recipients/recipient@email.com") + + report = Repo.get_by(Plausible.Site.MonthlyReport, site_id: site.id) + assert report.recipients == [] + end + + test "fails to remove a recipient from the monthly report in a foreign website", %{ + conn: conn + } do + site = new_site() + insert(:monthly_report, site: site, recipients: ["recipient@email.com"]) + + conn1 = delete(conn, "/sites/#{site.domain}/monthly-report/recipients/recipient@email.com") + assert conn1.status == 404 + + conn2 = + delete(conn, "/sites/#{site.domain}/monthly-report/recipients/recipient%40email.com") + + assert conn2.status == 404 + + report = Repo.get_by(Plausible.Site.MonthlyReport, site_id: site.id) + assert [_] = report.recipients + end + end + + for type <- [:spike, :drop] do + describe "POST /sites/:domain/traffic-change-notification/#{type}/enable" do + setup [:create_user, :log_in, :create_site] + + test "creates a #{type} notification record with the user email", %{ + conn: conn, + site: site, + user: user + } do + post(conn, "/sites/#{site.domain}/traffic-change-notification/#{unquote(type)}/enable") + + notification = + Repo.get_by(Plausible.Site.TrafficChangeNotification, + site_id: site.id, + type: unquote(type) + ) + + assert notification.recipients == [user.email] + end + + test "does not allow duplicate #{type} notification to be created", %{ + conn: conn, + site: site + } do + post(conn, "/sites/#{site.domain}/traffic-change-notification/#{unquote(type)}/enable") + post(conn, "/sites/#{site.domain}/traffic-change-notification/#{unquote(type)}/enable") + + assert Repo.aggregate( + from(s in Plausible.Site.TrafficChangeNotification, + where: s.site_id == ^site.id and s.type == ^unquote(type) + ), + :count + ) == 1 + end + end + + describe "POST /sites/:domain/traffic-change-notification/#{type}/disable" do + setup [:create_user, :log_in, :create_site] + + test "deletes the #{type} notification record", %{conn: conn, site: site} do + insert(:"#{unquote(type)}_notification", site: site) + + post(conn, "/sites/#{site.domain}/traffic-change-notification/#{unquote(type)}/disable") + + refute Repo.get_by(Plausible.Site.TrafficChangeNotification, site_id: site.id) + end + end + + describe "PUT /sites/:domain/traffic-change-notification/#{type}" do + setup [:create_user, :log_in, :create_site] + + test "updates #{type} notification threshold", %{conn: conn, site: site} do + insert(:"#{unquote(type)}_notification", site: site, threshold: 10) + + put(conn, "/sites/#{site.domain}/traffic-change-notification/#{unquote(type)}", %{ + "traffic_change_notification" => %{"threshold" => "15"} + }) + + notification = + Repo.get_by(Plausible.Site.TrafficChangeNotification, + site_id: site.id, + type: unquote(type) + ) + + assert notification.threshold == 15 + end + end + + describe "POST /sites/:domain/traffic-change-notification/#{type}/recipients" do + setup [:create_user, :log_in, :create_site] + + test "adds a recipient to the #{type} notification", %{conn: conn, site: site} do + insert(:"#{unquote(type)}_notification", site: site) + + post( + conn, + "/sites/#{site.domain}/traffic-change-notification/#{unquote(type)}/recipients", + recipient: "user@email.com" + ) + + report = + Repo.get_by(Plausible.Site.TrafficChangeNotification, + site_id: site.id, + type: unquote(type) + ) + + assert report.recipients == ["user@email.com"] + end + end + + describe "DELETE /sites/:domain/traffic-change-notification/#{type}/recipients/:recipient" do + setup [:create_user, :log_in, :create_site] + + test "removes a recipient from the #{type} notification", %{conn: conn, site: site} do + insert(:"#{unquote(type)}_notification", site: site, recipients: ["recipient@email.com"]) + + delete( + conn, + "/sites/#{site.domain}/traffic-change-notification/#{unquote(type)}/recipients/recipient@email.com" + ) + + report = + Repo.get_by(Plausible.Site.TrafficChangeNotification, + site_id: site.id, + type: unquote(type) + ) + + assert report.recipients == [] + end + + test "fails to remove a recipient from the #{type} notification in a foreign website", %{ + conn: conn + } do + site = new_site() + insert(:"#{unquote(type)}_notification", site: site, recipients: ["recipient@email.com"]) + + conn = + delete( + conn, + "/sites/#{site.domain}/traffic-change-notification/#{unquote(type)}/recipients/recipient@email.com" + ) + + assert conn.status == 404 + + conn = + delete( + conn, + "/sites/#{site.domain}/traffic-change-notification/recipients/#{unquote(type)}/recipient%40email.com" + ) + + assert conn.status == 404 + + report = + Repo.get_by(Plausible.Site.TrafficChangeNotification, + site_id: site.id, + type: unquote(type) + ) + + assert [_] = report.recipients + end + end + end + + describe "DELETE /:domain/settings/:forget_import/:import_id" do + setup [:create_user, :log_in, :create_site, :create_legacy_site_import] + + test "removes site import, associated data and cancels oban job for a particular import", %{ + conn: conn, + user: user, + site: site + } do + {:ok, job} = + Plausible.Imported.NoopImporter.new_import( + site, + user, + start_date: ~D[2022-01-01], + end_date: Date.utc_today() + ) + + %{args: %{import_id: import_id}} = job + + # legacy stats + populate_stats(site, [ + build(:imported_visitors, pageviews: 12) + ]) + + populate_stats(site, import_id, [ + build(:imported_visitors, pageviews: 10) + ]) + + imports = Plausible.Imported.list_all_imports(site) + + assert Enum.find(imports, &(&1.id == import_id)) + + assert eventually(fn -> + count = Plausible.Stats.Clickhouse.imported_pageview_count(site) + {count == 22, count} + end) + + delete(conn, "/#{site.domain}/settings/forget-import/#{import_id}") + + assert eventually(fn -> + count = Plausible.Stats.Clickhouse.imported_pageview_count(site) + {count == 12, count} + end) + + assert Repo.reload(job).state == "cancelled" + end + + test "removes all legacy site import data when instructed", %{ + conn: conn, + site: site, + site_import: legacy_site_import + } do + other_site_import = insert(:site_import, site: site) + + # legacy stats + populate_stats(site, [ + build(:imported_visitors, pageviews: 12) + ]) + + populate_stats(site, other_site_import.id, [ + build(:imported_visitors, pageviews: 10) + ]) + + assert eventually(fn -> + count = Plausible.Stats.Clickhouse.imported_pageview_count(site) + {count == 22, count} + end) + + delete(conn, "/#{site.domain}/settings/forget-import/#{legacy_site_import.id}") + + assert eventually(fn -> + count = Plausible.Stats.Clickhouse.imported_pageview_count(site) + {count == 10, count} + end) + end + end + + describe "DELETE /:domain/settings/forget_imported" do + setup [:create_user, :log_in, :create_site] + + test "removes actual imported data from Clickhouse", %{conn: conn, user: user, site: site} do + Plausible.Imported.NoopImporter.new_import( + site, + user, + start_date: ~D[2022-01-01], + end_date: Date.utc_today() + ) + + populate_stats(site, [ + build(:imported_visitors, pageviews: 10) + ]) + + delete(conn, "/#{site.domain}/settings/forget-imported") + + assert eventually(fn -> + count = Plausible.Stats.Clickhouse.imported_pageview_count(site) + {count == 0, count} + end) + end + + test "cancels Oban job if it exists", %{conn: conn, user: user, site: site} do + {:ok, job} = + Plausible.Imported.NoopImporter.new_import( + site, + user, + start_date: ~D[2022-01-01], + end_date: Date.utc_today() + ) + + populate_stats(site, [ + build(:imported_visitors, pageviews: 10) + ]) + + delete(conn, "/#{site.domain}/settings/forget-imported") + + assert Repo.reload(job).state == "cancelled" + end + end + + describe "reset stats" do + setup [:create_user, :log_in, :create_site] + + test "resets native_stats_start_date", %{conn: conn, site: site} do + Plausible.Site.set_stats_start_date(site, ~D[2023-01-01]) + |> Repo.update!() + + delete(conn, Routes.site_path(conn, :reset_stats, site.domain)) + + assert Repo.reload(site).stats_start_date == nil + end + end + + describe "settings danger zone" do + setup [:create_user, :log_in, :create_site] + + test "renders the transfer tile", %{conn: conn, site: site} do + conn = get(conn, Routes.site_path(conn, :settings_danger_zone, site.domain)) + html = html_response(conn, 200) + assert html =~ "Danger zone" + assert html =~ "Transfer site" + assert html =~ "Delete #{site.domain}" + end + end +end diff --git a/test/plausible_web/controllers/sso_controller_sync_test.exs b/test/plausible_web/controllers/sso_controller_sync_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..e9e3306a15f51c4e24e6523ba7ce50245891cca5 --- /dev/null +++ b/test/plausible_web/controllers/sso_controller_sync_test.exs @@ -0,0 +1,479 @@ +defmodule PlausibleWeb.SSOControllerSyncTest do + use PlausibleWeb.ConnCase + + @moduletag :ee_only + + on_ee do + alias Plausible.Auth + alias Plausible.Auth.SSO + alias Plausible.Repo + + @cert_pem """ + -----BEGIN CERTIFICATE----- + MIICmjCCAYICCQDX5sKPsYV3+jANBgkqhkiG9w0BAQsFADAPMQ0wCwYDVQQDDAR0 + ZXN0MB4XDTE5MTIyMzA5MDI1MVoXDTIwMDEyMjA5MDI1MVowDzENMAsGA1UEAwwE + dGVzdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMdtDJ278DQTp84O + 5Nq5F8s5YOR34GFOGI2Swb/3pU7X7918lVljiKv7WVM65S59nJSyXV+fa15qoXLf + sdRnq3yw0hTSTs2YDX+jl98kK3ksk3rROfYh1LIgByj4/4NeNpExgeB6rQk5Ay7Y + S+ARmMzEjXa0favHxu5BOdB2y6WvRQyjPS2lirT/PKWBZc04QZepsZ56+W7bd557 + tdedcYdY/nKI1qmSQClG2qgslzgqFOv1KCOw43a3mcK/TiiD8IXyLMJNC6OFW3xT + L/BG6SOZ3dQ9rjQOBga+6GIaQsDjC4Xp7Kx+FkSvgaw0sJV8gt1mlZy+27Sza6d+ + hHD2pWECAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAm2fk1+gd08FQxK7TL04O8EK1 + f0bzaGGUxWzlh98a3Dm8+OPhVQRi/KLsFHliLC86lsZQKunYdDB+qd0KUk2oqDG6 + tstG/htmRYD/S/jNmt8gyPAVi11dHUqW3IvQgJLwxZtoAv6PNs188hvT1WK3VWJ4 + YgFKYi5XQYnR5sv69Vsr91lYAxyrIlMKahjSW1jTD3ByRfAQghsSLk6fV0OyJHyh + uF1TxOVBVf8XOdaqfmvD90JGIPGtfMLPUX4m35qaGAU48PwCL7L3cRHYs9wZWc0i + fXZcBENLtHYCLi5txR8c5lyHB9d3AQHzKHMFNjLswn5HsckKg83RH7+eVqHqGw== + -----END CERTIFICATE----- + """ + + @other_cert_pem """ + -----BEGIN CERTIFICATE----- + MIIFdTCCA12gAwIBAgIUNcATm3CidmlEMMsZa9KBZpWYCVcwDQYJKoZIhvcNAQEL + BQAwYzELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM + GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDEcMBoGA1UEAwwTc29tZWlkcC5leGFt + cGxlLmNvbTAeFw0yNTA1MjExMjI5MzVaFw0yNjA1MjExMjI5MzVaMGMxCzAJBgNV + BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX + aWRnaXRzIFB0eSBMdGQxHDAaBgNVBAMME3NvbWVpZHAuZXhhbXBsZS5jb20wggIi + MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC1N6Drbjed+lFXpOYvE6Efgndy + W7kYiO8LqQTr4UwVrp9ArxgYuK4TrcNRh2rhS08xAzNTo+NqnJOm95baG97ADYk1 + TqVIKxzaFurv+L/Na0wVXyeNUtxIVKF59uElsg2YLm5eQhL9fmN8jVINCvwDPzxc + Ihm6mQOaL/i/0DGINOqwHG9MGMZ11AeOM0wKMuXJ2+aKjHOCedhMYVuOaHZgLkcX + Zzgiv7itm3+JpCjL474MMfibiqKHR0e3QRNcsEC13f/LD8BAGOwsKLznFC8Uctms + 48EDNbxxLG01jVbnJSxRrcDN3RUDjtCdHyaTCCFJAgmldHKKua3VQEynOwJIkFMC + fpL1LpLvATzIt0cT1ESb1RHIlgacmESVn/TW2QjO5tp4FAu7GJK+5xY7jPvI6saG + oUHsk0zo9obLK8WYneF19ln+Ea5ZCl9PcTi559AKGpYzpL/9uxoPT1zxxTn6c2lt + 4xkxkuHtYqi/ENHGdo4CLBL93GDZEilSVmZjD/9N9990yWbPXXQ0eNoFckYSZuls + HaWz8W5c046/ob8mASI6wzAUCkO9Zz4WbIj9A+mNZB32hMZbMA02gU//ffvNkFjL + DGlNbROCg2DX64rvGs/RuqhuDVCnVfid9B36Cgs76GWI8dCInEfyZMtiqUb7E8Oe + BPVwtTscz1StlF/0cQIDAQABoyEwHzAdBgNVHQ4EFgQU9lvXH4X04v99rrwKNzsw + pNQP/dUwDQYJKoZIhvcNAQELBQADggIBAJD0MD+OK58vlP2HEoKLQYAKYM/4NsBz + vSK1PtZsEj0fqiuu66ceH0wlKlGquRad4Z+LXMptu1DzMNmJsf0zSQfleGFks3xI + 86hgkQ7f0qjs+YJzjJUxF9H8zX4jJk5poOqOJwStHBCDLsUmIxnT7/il3jlT0Nj4 + cVs4946pCg7rP1kR9jojFD5yvzKoRBJG3/qvFnzAi8cDv9CRjSgoDTZyzZmwdCgu + NioW7YeFCtvYxvY7HDXinwq/w8Gn3n8zdISoAqSpYrt5Y5ygJGiEYVDWdA50a6PC + gq5xt8RCizz1L7a5BUJFMCQ0pyAUuODTndPUGLT8i7jFgzhamFPD72zFMk2+IabE + Dutyt2GFeTQ75wL8QvfsKm29Vd5EjAsdfmup3hCpLGqF3g8Sh0aXDrj8KPqIecuS + gkL69M9iXfnwZhTo23zUuFjBNoAIPXkNKXiJS7p9IEpYRVnlPYLToSEnnzptoPPQ + zMBb8x/UMMtNYkyehSLhuIPrRLvv3eth7Hq3hA7tOCRyyf78tReVm+VoRx6AK68v + 5ufxMKBFRTNoLIN3sD+DmSUNY+CaHxRMDhSESy0Ac/95J2yKi+Y1Kml2GV53pSlT + 6FPm8B0R9YXM7lHhTLyL7DYqnvklkLh2bUqCLyBowynPyGqdYV4DbFSiST14fGXR + mNEYF78kg0IA + -----END CERTIFICATE----- + """ + + describe "saml_signin/2 (real SAML)" do + setup do + patch_env(:sso_saml_adapter, PlausibleWeb.SSO.RealSAMLAdapter) + + team = new_site().team |> Plausible.Teams.complete_setup() + integration = SSO.initiate_saml_integration(team) + + {:ok, integration} = + SSO.update_integration(integration, %{ + idp_signin_url: "http://localhost:8080/simplesaml/saml2/idp/SSOService.php", + idp_entity_id: "http://localhost:8080/simplesaml/saml2/idp/metadata.php", + idp_cert_pem: @cert_pem + }) + + domain = "example-#{Enum.random(1..10_000)}.com" + + {:ok, sso_domain} = SSO.Domains.add(integration, domain) + sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true) + + {:ok, team: team, integration: integration, domain: domain, sso_domain: sso_domain} + end + + test "redirects to IdP", %{conn: conn, domain: domain, integration: integration} do + email = "paul@" <> domain + + conn = + get( + conn, + Routes.sso_path(conn, :saml_signin, integration.identifier, + email: email, + return_to: "/sites" + ) + ) + + session = fetch_cookies(conn, encrypted: ["session_saml"]).cookies["session_saml"] + + assert String.length(session.relay_state) > 0 + assert session.return_to == "/sites" + + assert url = redirected_to(conn, 302) + + assert {:ok, uri} = URI.new(url) + + assert uri.host == "localhost" + assert uri.port == 8080 + assert uri.scheme == "http" + + assert %{ + "RelayState" => relay_state, + "SAMLEncoding" => "urn:oasis:names:tc:SAML:2.0:bindings:URL-Encoding:DEFLATE", + "SAMLRequest" => saml_request, + "login_hint" => ^email + } = URI.decode_query(uri.query) + + assert relay_state == session.relay_state + + xml = saml_request |> Base.decode64!() |> :zlib.unzip() + + assert {:ok, root_node} = SimpleXml.parse(xml) + assert {:ok, issuer_node} = SimpleXml.XmlNode.first_child(root_node, "saml:Issuer") + assert {:ok, acs_url} = SimpleXml.XmlNode.text(issuer_node) + + assert acs_url == SSO.SAMLConfig.entity_id(integration) + end + + test "redirects to login if integration not found", %{conn: conn, domain: domain} do + email = "paul@" <> domain + + conn = + get( + conn, + Routes.sso_path(conn, :saml_signin, Ecto.UUID.generate(), + email: email, + return_to: "/sites" + ) + ) + + assert redirected_to(conn, 302) == Routes.sso_path(conn, :login_form, return_to: "/sites") + + assert Phoenix.Flash.get(conn.assigns.flash, :login_error) == + "We couldn't find a Single Sign-On account for that email." + end + end + + @assertion File.read!("extra/fixture/assertion.xml") + @assertion_missing_email File.read!("extra/fixture/assertion_missing_email.xml") + @assertion_invalid_email File.read!("extra/fixture/assertion_invalid_email.xml") + @assertion_missing_name File.read!("extra/fixture/assertion_missing_name.xml") + + describe "saml_consume/2 (real SAML)" do + setup %{conn: conn} do + patch_env(:sso_saml_adapter, PlausibleWeb.SSO.RealSAMLAdapter) + + team = new_site().team |> Plausible.Teams.complete_setup() + integration = SSO.initiate_saml_integration(team) + + {:ok, integration} = + SSO.update_integration(integration, %{ + idp_signin_url: "http://localhost:8080/simplesaml/saml2/idp/SSOService.php", + idp_entity_id: "http://localhost:8080/simplesaml/saml2/idp/metadata.php", + idp_cert_pem: @cert_pem + }) + + domain = "plausible.test" + + {:ok, sso_domain} = SSO.Domains.add(integration, domain) + sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true) + + {:ok, root_node} = @assertion |> SimpleXml.parse() + {:ok, assertion_node} = SimpleXml.XmlNode.first_child(root_node, "*:Assertion") + {:ok, subject_node} = SimpleXml.XmlNode.first_child(assertion_node, "*:Subject") + {:ok, name_id_node} = SimpleXml.XmlNode.first_child(subject_node, "*:NameID") + {:ok, name_id} = SimpleXml.XmlNode.text(name_id_node) + + email = "user@plausible.test" + + conn = + get( + conn, + Routes.sso_path(conn, :saml_signin, integration.identifier, + email: email, + return_to: "/sites" + ) + ) + + saml_session = fetch_cookies(conn, encrypted: ["session_saml"]).cookies["session_saml"] + relay_state = saml_session.relay_state + + conn = + conn + |> recycle() + |> prepare_conn() + + {:ok, + team: team, + integration: integration, + domain: domain, + sso_domain: sso_domain, + email: email, + conn: conn, + name_id: name_id, + relay_state: relay_state} + end + + test "provisions identity and logs in", %{ + conn: conn, + team: team, + integration: integration, + sso_domain: sso_domain, + relay_state: relay_state, + name_id: name_id + } do + params = %{ + "SAMLResponse" => Base.encode64(@assertion), + "RelayState" => relay_state + } + + conn = post(conn, Routes.sso_path(conn, :saml_consume, integration.identifier), params) + + assert redirected_to(conn, 302) == "/sites" + + session = get_session(conn) + + assert session["current_team_id"] == team.identifier + + assert {:ok, user_session} = Auth.UserSessions.get_by_token(session["user_token"]) + + assert user_session.user.email == "user@plausible.test" + assert user_session.user.type == :sso + assert user_session.user.name == "Jane Smith" + assert user_session.user.sso_integration_id == integration.id + assert user_session.user.sso_domain_id == sso_domain.id + assert user_session.user.sso_identity_id == name_id + + timeout_minutes = team.policy.sso_session_timeout_minutes + + timeout_threshold_lower = + NaiveDateTime.add(NaiveDateTime.utc_now(:second), timeout_minutes - 2, :minute) + + timeout_threshold_upper = + NaiveDateTime.add(NaiveDateTime.utc_now(:second), timeout_minutes + 2, :minute) + + assert NaiveDateTime.compare(user_session.timeout_at, timeout_threshold_lower) == :gt + assert NaiveDateTime.compare(user_session.timeout_at, timeout_threshold_upper) == :lt + + assert entry = audited_entry("sso_login_success", team_id: integration.team_id) + assert entry.change["email"] == "user@plausible.test" + assert entry.change["name"] == "Jane Smith" + assert entry.change["integration_id"] == integration.identifier + end + + test "redirects with error when no matching integration found", %{ + conn: conn, + relay_state: relay_state + } do + params = %{ + "SAMLResponse" => Base.encode64(@assertion), + "RelayState" => relay_state + } + + conn = post(conn, Routes.sso_path(conn, :saml_consume, Ecto.UUID.generate()), params) + + assert redirected_to(conn, 302) == Routes.sso_path(conn, :login_form, return_to: "/sites") + + assert Phoenix.Flash.get(conn.assigns.flash, :login_error) == + "We couldn't find a Single Sign-On account for that email." + end + + test "redirects with error on mismatch of RelayState", %{ + conn: conn, + integration: integration + } do + params = %{ + "SAMLResponse" => Base.encode64(@assertion), + "RelayState" => Ecto.UUID.generate() + } + + conn = post(conn, Routes.sso_path(conn, :saml_consume, integration.identifier), params) + + assert redirected_to(conn, 302) == Routes.sso_path(conn, :login_form, return_to: "/sites") + + assert Phoenix.Flash.get(conn.assigns.flash, :login_error) == + "Authentication failed (reason: :invalid_relay_state)" + + assert audited_entry("sso_login_failure", + team_id: integration.team_id, + change: %{ + "error" => ":invalid_relay_state" + } + ) + end + + test "redirects with error on missing relay state", %{ + conn: conn, + integration: integration + } do + params = %{"SAMLResponse" => Base.encode64(@assertion)} + + conn = post(conn, Routes.sso_path(conn, :saml_consume, integration.identifier), params) + + assert redirected_to(conn, 302) == Routes.sso_path(conn, :login_form, return_to: "/sites") + + assert Phoenix.Flash.get(conn.assigns.flash, :login_error) == + "Authentication failed (reason: :invalid_relay_state)" + + assert audited_entry("sso_login_failure", + team_id: integration.team_id, + change: %{ + "error" => ":invalid_relay_state" + } + ) + end + + test "redirects with error on malformed assertion", %{ + conn: conn, + integration: integration, + relay_state: relay_state + } do + params = %{ + "SAMLResponse" => "malformed", + "RelayState" => relay_state + } + + conn = post(conn, Routes.sso_path(conn, :saml_consume, integration.identifier), params) + + assert redirected_to(conn, 302) == Routes.sso_path(conn, :login_form, return_to: "/sites") + + assert Phoenix.Flash.get(conn.assigns.flash, :login_error) == + "Authentication failed (reason: :base64_decoding_failed)" + + assert audited_entry("sso_login_failure", + team_id: integration.team_id, + change: %{ + "error" => ":base64_decoding_failed" + } + ) + end + + test "redirects with error on malformed certificate in config (should not happen)", %{ + conn: conn, + integration: integration, + relay_state: relay_state + } do + Repo.query!( + """ + UPDATE sso_integrations SET config = jsonb_set(config, '{idp_cert_pem}', '"invalid"') + WHERE id = $1 + """, + [integration.id] + ) + + params = %{ + "SAMLResponse" => Base.encode64(@assertion), + "RelayState" => relay_state + } + + conn = post(conn, Routes.sso_path(conn, :saml_consume, integration.identifier), params) + + assert redirected_to(conn, 302) == Routes.sso_path(conn, :login_form, return_to: "/sites") + + assert Phoenix.Flash.get(conn.assigns.flash, :login_error) == + "Authentication failed (reason: :malformed_certificate)" + + assert audited_entry("sso_login_failure", + team_id: integration.team_id, + change: %{ + "error" => ":malformed_certificate" + } + ) + end + + test "redirects with error on mismatched certificate in config", %{ + conn: conn, + integration: integration, + relay_state: relay_state + } do + {:ok, integration} = SSO.update_integration(integration, %{idp_cert_pem: @other_cert_pem}) + + params = %{ + "SAMLResponse" => Base.encode64(@assertion), + "RelayState" => relay_state + } + + conn = post(conn, Routes.sso_path(conn, :saml_consume, integration.identifier), params) + + assert redirected_to(conn, 302) == Routes.sso_path(conn, :login_form, return_to: "/sites") + + assert Phoenix.Flash.get(conn.assigns.flash, :login_error) == + "Authentication failed (reason: :digest_verification_failed)" + + assert audited_entry("sso_login_failure", + team_id: integration.team_id, + change: %{ + "error" => ":digest_verification_failed" + } + ) + end + + test "redirects with error on missing email attribute in assertion", %{ + conn: conn, + integration: integration, + relay_state: relay_state + } do + params = %{ + "SAMLResponse" => Base.encode64(@assertion_missing_email), + "RelayState" => relay_state + } + + conn = post(conn, Routes.sso_path(conn, :saml_consume, integration.identifier), params) + + assert redirected_to(conn, 302) == Routes.sso_path(conn, :login_form, return_to: "/sites") + + assert Phoenix.Flash.get(conn.assigns.flash, :login_error) == + "Authentication failed (reason: :missing_email_attribute)" + + assert audited_entry("sso_login_failure", + team_id: integration.team_id, + change: %{ + "error" => ":missing_email_attribute" + } + ) + end + + test "redirects with error on invalid email attribute in assertion", %{ + conn: conn, + integration: integration, + relay_state: relay_state + } do + params = %{ + "SAMLResponse" => Base.encode64(@assertion_invalid_email), + "RelayState" => relay_state + } + + conn = post(conn, Routes.sso_path(conn, :saml_consume, integration.identifier), params) + + assert redirected_to(conn, 302) == Routes.sso_path(conn, :login_form, return_to: "/sites") + + assert Phoenix.Flash.get(conn.assigns.flash, :login_error) == + "Authentication failed (reason: :invalid_email_attribute)" + + assert audited_entry("sso_login_failure", + team_id: integration.team_id, + change: %{ + "error" => ":invalid_email_attribute" + } + ) + end + + test "redirects with error on missing name attributes in assertion", %{ + conn: conn, + integration: integration, + relay_state: relay_state + } do + params = %{ + "SAMLResponse" => Base.encode64(@assertion_missing_name), + "RelayState" => relay_state + } + + conn = post(conn, Routes.sso_path(conn, :saml_consume, integration.identifier), params) + + assert redirected_to(conn, 302) == Routes.sso_path(conn, :login_form, return_to: "/sites") + + assert Phoenix.Flash.get(conn.assigns.flash, :login_error) == + "Authentication failed (reason: :missing_name_attributes)" + + assert audited_entry("sso_login_failure", + team_id: integration.team_id, + change: %{ + "error" => ":missing_name_attributes" + } + ) + end + end + end +end diff --git a/test/plausible_web/controllers/sso_controller_test.exs b/test/plausible_web/controllers/sso_controller_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..487c4199fb71f63ea077bf16eabbeedf00cdf991 --- /dev/null +++ b/test/plausible_web/controllers/sso_controller_test.exs @@ -0,0 +1,473 @@ +defmodule PlausibleWeb.SSOControllerTest do + use PlausibleWeb.ConnCase, async: true + + @moduletag :ee_only + + on_ee do + alias Plausible.Auth + alias Plausible.Auth.SSO + alias Plausible.Repo + + setup do + owner = new_user() + team = new_site(owner: owner).team |> Plausible.Teams.complete_setup() + integration = SSO.initiate_saml_integration(team) + domain = "example-#{Enum.random(1..10_000)}.com" + + {:ok, sso_domain} = SSO.Domains.add(integration, domain) + sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true) + + {:ok, + owner: owner, team: team, integration: integration, domain: domain, sso_domain: sso_domain} + end + + describe "settings item visibility" do + test "sso team settings item renders when SSO feature plan is added", %{conn: conn} do + user = + new_user() |> subscribe_to_enterprise_plan(features: [Plausible.Billing.Feature.SSO]) + + team = new_site(owner: user).team |> Plausible.Teams.complete_setup() + {:ok, conn: conn} = log_in(%{conn: conn, user: user}) + conn = set_current_team(conn, team) + + conn = get(conn, Routes.settings_path(conn, :team_general)) + + assert html = html_response(conn, 200) + + assert html =~ "/settings/sso/general" + refute html =~ "/settings/sso/info" + end + + test "sso team settings item is hidden when there's no SSO plan feature", %{conn: conn} do + user = new_user() + team = new_site(owner: user).team |> Plausible.Teams.complete_setup() + {:ok, conn: conn} = log_in(%{conn: conn, user: user}) + conn = set_current_team(conn, team) + + conn = get(conn, Routes.settings_path(conn, :team_general)) + + assert html = html_response(conn, 200) + + refute html =~ "/settings/sso/general" + assert html =~ "/settings/sso/info" + end + end + + describe "login_form/2" do + test "renders login view", %{conn: conn} do + conn = get(conn, Routes.sso_path(conn, :login_form, prefer: "sso")) + + assert html = html_response(conn, 200) + + assert html =~ "Sign in with SSO" + assert element_exists?(html, "input[name=email]") + assert text_of_attr(html, ~s|input[name="return_to"]|, "value") == nil + end + + test "renders autosubmit js snippet when instructed", %{conn: conn} do + conn = + get( + conn, + Routes.sso_path(conn, :login_form, + prefer: "sso", + email: "user@example.com", + autosubmit: true + ) + ) + + assert html = html_response(conn, 200) + + assert html =~ "Sign in with SSO" + assert text_of_attr(html, "input[name=email]", "value") == "user@example.com" + assert html =~ ~s|document.getElementById("sso-login-form").submit()| + end + + test "passes return_to parameter to form", %{conn: conn} do + conn = get(conn, Routes.sso_path(conn, :login_form, return_to: "/sites", prefer: "sso")) + + assert html = html_response(conn, 200) + + assert text_of_attr(html, "input[name=return_to]", "value") == "/sites" + end + + test "renders error if provided in login_error flash message", %{conn: conn} do + conn = + conn + |> init_session() + |> fetch_session() + |> fetch_flash() + |> put_flash(:login_error, "We couldn't find a Single Sign-On account for that email.") + + conn = get(conn, Routes.sso_path(conn, :login_form, return_to: "/sites")) + + assert html = html_response(conn, 200) + + assert html =~ htmlize_quotes("We couldn't find a Single Sign-On account for that email.") + assert element_exists?(html, "input[name=email]") + assert text_of_attr(html, "input[name=return_to]", "value") == "/sites" + end + end + + describe "login/2" do + test "redirects to SAML signin on matching integration", %{ + conn: conn, + domain: domain, + integration: integration + } do + email = "paul@" <> domain + + conn = post(conn, Routes.sso_path(conn, :login), %{"email" => email}) + + assert redirected_to(conn, 302) == + Routes.sso_path(conn, :saml_signin, integration.identifier, + email: email, + return_to: "" + ) + end + + test "passes redirect path if provided", %{ + conn: conn, + domain: domain, + integration: integration + } do + email = "claire@" <> domain + + conn = + post(conn, Routes.sso_path(conn, :login), %{"email" => email, "return_to" => "/sites"}) + + assert redirected_to(conn, 302) == + Routes.sso_path(conn, :saml_signin, integration.identifier, + email: email, + return_to: "/sites" + ) + end + + test "renders login form with error on no matching integration", %{conn: conn} do + conn = + post(conn, Routes.sso_path(conn, :login), %{ + "email" => "nomatch@example.com", + "return_to" => "/sites" + }) + + assert redirected_to(conn, 302) == Routes.sso_path(conn, :login_form) + + assert Phoenix.Flash.get(conn.assigns.flash, :login_error) == + "We couldn't find a Single Sign-On account for that email." + end + + test "limits login attempts to 5 per minute", %{conn: conn} do + conn = put_req_header(conn, "x-forwarded-for", "1.2.3.9") + email = "invalid@example.com" + + response = + eventually( + fn -> + Enum.each(1..5, fn _ -> + post(conn, Routes.sso_path(conn, :login), %{"email" => email}) + end) + + conn = post(conn, Routes.sso_path(conn, :login), %{"email" => email}) + + {conn.status == 429, conn} + end, + 500 + ) + + assert html_response(response, 429) =~ "Too many login attempts" + end + end + + describe "saml_signin/2 (fake SAML)" do + test "renders autosubmitted form", %{conn: conn, domain: domain, integration: integration} do + email = "jesper@" <> domain + + conn = + get( + conn, + Routes.sso_path(conn, :saml_signin, integration.identifier, + email: email, + return_to: "/sites" + ) + ) + + assert html = html_response(conn, 200) + + assert html =~ "Processing Single Sign-On request..." + + assert text_of_attr(html, "form#sso-req-form", "action") == + Routes.sso_path(conn, :saml_consume, integration.identifier) + + assert text_of_attr(html, "input[name=email]", "value") == email + assert text_of_attr(html, "input[name=return_to]", "value") == "/sites" + end + end + + describe "saml_consume/2 (fake SAML)" do + test "provisions identity for new user", %{ + conn: conn, + domain: domain, + integration: integration + } do + email = "dana.lake@" <> domain + + conn = + post(conn, Routes.sso_path(conn, :saml_consume, integration.identifier), %{ + "email" => email, + "return_to" => "/sites" + }) + + assert redirected_to(conn, 302) == "/sites" + + assert %{sessions: [sso_session]} = + user = Repo.get_by(Plausible.Auth.User, email: email) |> Repo.preload(:sessions) + + assert user.type == :sso + assert user.email == email + assert user.name == "Dana Lake" + assert get_session(conn, :user_token) == sso_session.token + end + + test "provisions identity for existing user", %{ + conn: conn, + team: team, + domain: domain, + integration: integration + } do + email = "dana@" <> domain + + existing_user = new_user(name: "Dana Woodworth", email: email) + + add_member(team, user: existing_user, role: :admin) + + conn = + post(conn, Routes.sso_path(conn, :saml_consume, integration.identifier), %{ + "email" => email, + "return_to" => "/sites" + }) + + assert redirected_to(conn, 302) == "/sites" + + assert %{sessions: [sso_session]} = + user = Repo.get_by(Plausible.Auth.User, email: email) |> Repo.preload(:sessions) + + assert user.type == :sso + assert user.email == email + assert user.name == "Dana Woodworth" + assert get_session(conn, :user_token) == sso_session.token + end + + test "redirects to login when no matching integration found", %{conn: conn} do + conn = + post(conn, Routes.sso_path(conn, :saml_consume, Ecto.UUID.generate()), %{ + "email" => "missed@example.com", + "return_to" => "/sites" + }) + + assert redirected_to(conn, 302) == Routes.sso_path(conn, :login_form, return_to: "/sites") + + assert Phoenix.Flash.get(conn.assigns.flash, :login_error) == + "We couldn't find a Single Sign-On account for that email." + end + end + + describe "sso_settings/2" do + setup [:create_user, :log_in, :create_team] + + test "redirects when team is not setup", %{conn: conn, team: team} do + conn = set_current_team(conn, team) + conn = get(conn, Routes.sso_path(conn, :sso_settings)) + + assert redirected_to(conn, 302) == "/sites" + end + + test "redirects when team lacks SSO plan feature", %{conn: conn, team: team} do + team = Plausible.Teams.complete_setup(team) + conn = set_current_team(conn, team) + conn = get(conn, Routes.sso_path(conn, :sso_settings)) + + assert redirected_to(conn, 302) == "/sites" + end + + test "renders when team has SSO plan feature", %{conn: conn, team: team, user: user} do + user |> subscribe_to_enterprise_plan(features: [Plausible.Billing.Feature.SSO]) + team = Plausible.Teams.complete_setup(team) + conn = set_current_team(conn, team) + conn = get(conn, Routes.sso_path(conn, :sso_settings)) + + assert html = html_response(conn, 200) + + assert html =~ "Configure and manage Single Sign-On for your team" + end + end + + describe "provision_notice/2" do + test "renders the notice", %{conn: conn} do + conn = get(conn, Routes.sso_path(conn, :provision_notice)) + + assert html = html_response(conn, 200) + + assert html =~ "Single Sign-On required" + assert html =~ "has turned off email and password logins" + assert html =~ "To access this team," + assert html =~ "sign out" + assert html =~ "and sign in with SSO" + end + end + + describe "provision_issue/2" do + test "renders issue for not_a_member", %{conn: conn} do + conn = get(conn, Routes.sso_path(conn, :provision_issue, issue: "not_a_member")) + + assert html = html_response(conn, 200) + + assert html =~ "Single Sign-On required" + assert html =~ "To access this team, you must be added as a team member first" + end + + test "renders issue for multiple_memberships", %{conn: conn} do + conn = get(conn, Routes.sso_path(conn, :provision_issue, issue: "multiple_memberships")) + + assert html = html_response(conn, 200) + + assert html =~ "Single Sign-On required" + assert html =~ "To access this team, you must leave all other teams first" + end + + test "renders issue for multiple_memberships_noforce", %{conn: conn} do + conn = + get( + conn, + Routes.sso_path(conn, :provision_issue, issue: "multiple_memberships_noforce") + ) + + assert html = html_response(conn, 200) + + assert html =~ "Single Sign-On required" + assert html =~ "To sign in with SSO, you must leave all other teams first" + + assert html =~ "Sign in with email and password" + assert html =~ "to resolve the issue" + end + + test "renders issue for active_personal_team", %{conn: conn} do + conn = get(conn, Routes.sso_path(conn, :provision_issue, issue: "active_personal_team")) + + assert html = html_response(conn, 200) + + assert html =~ "Single Sign-On required" + assert html =~ "To access this team, remove or transfer all sites under" + assert html =~ "My Personal Sites" + assert html =~ "cancel its subscription if active" + end + + test "renders issue for active_personal_team_noforce", %{conn: conn} do + conn = + get( + conn, + Routes.sso_path(conn, :provision_issue, issue: "active_personal_team_noforce") + ) + + assert html = html_response(conn, 200) + + assert html =~ "Single Sign-On required" + assert html =~ "To sign in with SSO, remove or transfer all sites under" + assert html =~ "My Personal Sites" + assert html =~ "cancel its subscription if active" + + assert html =~ "Sign in with email and password" + assert html =~ "to resolve the issue" + end + end + + describe "team_sessions/2" do + setup %{conn: conn, team: team, owner: owner} do + %{conn: conn} = + %{conn: conn, user: owner} + |> setup_do(&log_in/1) + + conn = set_current_team(conn, team) + + {:ok, conn: conn} + end + + test "lists SSO sessions", %{conn: conn, domain: domain, integration: integration} do + now = NaiveDateTime.utc_now(:second) + + %{user: user1} = + %{user: %{name: "Frank Rubin", email: "frank@" <> domain}, sso_integration: integration} + |> setup_do(&provision_sso_user/1) + + Auth.UserSessions.create!(user1, "Device 1", now: NaiveDateTime.shift(now, hour: -3)) + + %{user: user2} = + %{ + user: %{name: "Grace Holmes", email: "grace@" <> domain}, + sso_integration: integration + } + |> setup_do(&provision_sso_user/1) + + Auth.UserSessions.create!(user2, "Device 2") + Auth.UserSessions.create!(user2, "Device 3", now: NaiveDateTime.shift(now, hour: -6)) + + %{user: user3} = + %{user: %{name: "Kate Loselet", email: "kate@" <> domain}, sso_integration: integration} + |> setup_do(&provision_sso_user/1) + + Auth.UserSessions.create!(user3, "Device 4", now: NaiveDateTime.shift(now, hour: -2)) + + conn = get(conn, Routes.sso_path(conn, :team_sessions)) + + assert html = html_response(conn, 200) + + assert ["Grace Holmes", "Kate Loselet", "Frank Rubin", "Grace Holmes"] = + find(html, "table#sso-sessions-list tr td:nth-of-type(1)") + |> Enum.map(&text/1) + + assert ["Device 2", "Device 4", "Device 1", "Device 3"] = + find(html, "table#sso-sessions-list tr td:nth-of-type(2)") + |> Enum.map(&text/1) + end + + test "shows empty state when there are no sessions", %{conn: conn} do + conn = get(conn, Routes.sso_path(conn, :team_sessions)) + + assert html = html_response(conn, 200) + + assert html =~ "There are currently no active SSO sessions" + end + end + + describe "delete_session/2" do + setup %{conn: conn, team: team, owner: owner} do + %{conn: conn} = + %{conn: conn, user: owner} + |> setup_do(&log_in/1) + + conn = set_current_team(conn, team) + + {:ok, conn: conn} + end + + test "revokes session and redirects back to sessions list", %{ + conn: conn, + domain: domain, + integration: integration + } do + %{user: user} = + %{user: %{name: "Frank Rubin", email: "frank@" <> domain}, sso_integration: integration} + |> setup_do(&provision_sso_user/1) + + session = Auth.UserSessions.create!(user, "Unknown") + + conn = delete(conn, Routes.sso_path(conn, :delete_session, session.id)) + + assert redirected_to(conn, 302) == Routes.sso_path(conn, :team_sessions) + + assert Phoenix.Flash.get(conn.assigns.flash, :success) == + "Session logged out successfully" + + refute Repo.reload(session) + end + end + end +end diff --git a/test/plausible_web/controllers/stats_controller_test.exs b/test/plausible_web/controllers/stats_controller_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..38155954a91df198aa116760db1a94e911823a84 --- /dev/null +++ b/test/plausible_web/controllers/stats_controller_test.exs @@ -0,0 +1,957 @@ +defmodule PlausibleWeb.StatsControllerTest do + use PlausibleWeb.ConnCase, async: false + use Plausible.Repo + + @react_container "div#stats-react-container" + + describe "GET /:domain - anonymous user" do + test "public site - shows site stats", %{conn: conn} do + site = new_site(public: true) + populate_stats(site, [build(:pageview)]) + + conn = get(conn, "/#{site.domain}") + resp = html_response(conn, 200) + assert element_exists?(resp, @react_container) + + assert text_of_attr(resp, @react_container, "data-domain") == site.domain + assert text_of_attr(resp, @react_container, "data-is-dbip") == "false" + assert text_of_attr(resp, @react_container, "data-has-goals") == "false" + assert text_of_attr(resp, @react_container, "data-conversions-opted-out") == "false" + assert text_of_attr(resp, @react_container, "data-funnels-opted-out") == "false" + assert text_of_attr(resp, @react_container, "data-props-opted-out") == "false" + assert text_of_attr(resp, @react_container, "data-props-available") == "true" + assert text_of_attr(resp, @react_container, "data-site-segments-available") == "true" + assert text_of_attr(resp, @react_container, "data-funnels-available") == "true" + assert text_of_attr(resp, @react_container, "data-exploration-available") == "true" + assert text_of_attr(resp, @react_container, "data-has-props") == "false" + assert text_of_attr(resp, @react_container, "data-logged-in") == "false" + assert text_of_attr(resp, @react_container, "data-current-user-role") == "public" + assert text_of_attr(resp, @react_container, "data-current-user-id") == "null" + assert text_of_attr(resp, @react_container, "data-embedded") == "" + assert text_of_attr(resp, @react_container, "data-is-consolidated-view") == "false" + assert text_of_attr(resp, @react_container, "data-consolidated-view-available") == "false" + assert text_of_attr(resp, @react_container, "data-team-identifier") == site.team.identifier + + assert "noindex, nofollow" == + resp + |> find("meta[name=robots]") + |> text_of_attr("content") + + assert to_string(Plausible.InternalStatsApiVersion.api_version()) == + resp + |> find("meta[name=x-api-version]") + |> text_of_attr("content") + + assert text_of_element(resp, "title") == "Plausible · #{site.domain}" + end + + test "public site - all segments (personal or site) are stuffed into dataset, without their owner_id and owner_name", + %{conn: conn} do + user = new_user() + site = new_site(owner: user, public: true) + + populate_stats(site, [build(:pageview)]) + + emea_site_segment = + insert(:segment, + site: site, + owner: user, + type: :site, + name: "EMEA region" + ) + + foo_personal_segment = + insert(:segment, + site: site, + owner: user, + type: :personal, + name: "FOO" + ) + + conn = get(conn, "/#{site.domain}") + resp = html_response(conn, 200) + assert element_exists?(resp, @react_container) + + assert text_of_attr(resp, @react_container, "data-segments") == + Jason.encode!([ + Plausible.Segments.to_response_map( + %{foo_personal_segment | owner_id: nil}, + site + ), + Plausible.Segments.to_response_map(%{emea_site_segment | owner_id: nil}, site) + ]) + end + + @tag :ee_only + test "plausible.io live demo - shows site stats, header and footer", %{conn: conn} do + site = new_site(domain: "plausible.io", public: true) + populate_stats(site, [build(:pageview)]) + + conn = get(conn, "/#{site.domain}") + resp = html_response(conn, 200) + assert element_exists?(resp, @react_container) + + assert "index, nofollow" == + resp + |> find("meta[name=robots]") + |> text_of_attr("content") + + assert text_of_element(resp, "title") == "Plausible Analytics: Live Demo" + assert resp =~ "Login" + assert resp =~ "You just saw how Plausible tracks plausible.io" + assert resp =~ "Start free trial" + assert resp =~ "See pricing" + assert resp =~ "Getting started" + end + + test "public site - redirect to /login when no stats because verification requires it", %{ + conn: conn + } do + new_site(domain: "some-other-public-site.io", public: true) + + conn = get(conn, conn |> get("/some-other-public-site.io") |> redirected_to()) + + assert redirected_to(conn) == + Routes.auth_path(conn, :login_form, + return_to: "/some-other-public-site.io/verification" + ) + end + + test "public site - no stats with skip_to_dashboard", %{ + conn: conn + } do + new_site(domain: "some-other-public-site.io", public: true) + + conn = get(conn, "/some-other-public-site.io?skip_to_dashboard=true") + resp = html_response(conn, 200) + + assert text_of_attr(resp, @react_container, "data-logged-in") == "false" + end + + test "can not view stats of a private website", %{conn: conn} do + _ = insert(:user) + conn = get(conn, "/test-site.com") + assert html_response(conn, 404) =~ "There's nothing here" + end + end + + describe "GET /:domain - as a logged in user" do + setup [:create_user, :log_in, :create_site] + + test "can view stats of a website I've created", %{conn: conn, site: site, user: user} do + populate_stats(site, [build(:pageview)]) + conn = get(conn, "/" <> site.domain) + resp = html_response(conn, 200) + assert text_of_attr(resp, @react_container, "data-logged-in") == "true" + assert text_of_attr(resp, @react_container, "data-current-user-role") == "owner" + assert text_of_attr(resp, @react_container, "data-current-user-id") == "#{user.id}" + end + + test "can view stats of a website I've created, enforcing pageviews check skip", %{ + conn: conn, + site: site + } do + resp = conn |> get(conn |> get("/" <> site.domain) |> redirected_to()) |> html_response(200) + refute text_of_attr(resp, @react_container, "data-logged-in") == "true" + + resp = conn |> get("/" <> site.domain <> "?skip_to_dashboard=true") |> html_response(200) + assert text_of_attr(resp, @react_container, "data-logged-in") == "true" + end + + on_ee do + test "can't see exploration funnel UI if funnels feature unavailable", %{ + conn: conn, + site: site, + user: user + } do + subscribe_to_growth_plan(user) + populate_stats(site, [build(:pageview)]) + conn = get(conn, "/" <> site.domain) + resp = html_response(conn, 200) + assert text_of_attr(resp, @react_container, "data-exploration-available") == "false" + end + + test "can see exploration funnel UI on trial", %{conn: conn, site: site} do + populate_stats(site, [build(:pageview)]) + conn = get(conn, "/" <> site.domain) + resp = html_response(conn, 200) + assert text_of_attr(resp, @react_container, "data-exploration-available") == "true" + end + + test "can see exploration funnel UI past trial with funnels feature enabled", %{ + conn: conn, + site: site, + user: user + } do + populate_stats(site, [build(:pageview)]) + + site.team + |> Plausible.Teams.Team.end_trial() + |> Plausible.Repo.update!() + + subscribe_to_enterprise_plan(user, features: [Plausible.Billing.Feature.Funnels]) + + conn = get(conn, "/" <> site.domain) + resp = html_response(conn, 200) + assert text_of_attr(resp, @react_container, "data-exploration-available") == "true" + end + end + + on_ee do + test "first view of a consolidated dashboard sets stats_start_date and native_stats_start_at according to native_stats_start_at of the earliest team site", + %{ + conn: conn, + site: site, + user: user + } do + team = team_of(user) + now = NaiveDateTime.utc_now(:second) + ten_days_ago = NaiveDateTime.add(now, -10, :day) + twenty_days_ago = NaiveDateTime.add(now, -20, :day) + + site + |> Plausible.Site.set_native_stats_start_at(ten_days_ago) + |> Plausible.Repo.update!() + + new_site(team: team, native_stats_start_at: twenty_days_ago) + cv = new_consolidated_view(team) + + conn = get(conn, "/" <> cv.domain) + resp = html_response(conn, 200) + + assert text_of_attr(resp, @react_container, "data-domain") == cv.domain + assert text_of_attr(resp, @react_container, "data-logged-in") == "true" + assert text_of_attr(resp, @react_container, "data-current-user-role") == "owner" + assert text_of_attr(resp, @react_container, "data-current-user-id") == "#{user.id}" + + cv = Plausible.Repo.reload(cv) + assert cv.stats_start_date == NaiveDateTime.to_date(twenty_days_ago) + assert cv.native_stats_start_at == twenty_days_ago + end + + test "does not redirect consolidated views to verification", %{ + conn: conn, + user: user + } do + new_site(owner: user) + new_site(owner: user) + cv = user |> team_of() |> new_consolidated_view() + + conn = get(conn, "/" <> cv.domain) + resp = html_response(conn, 200) + + assert text_of_attr(resp, @react_container, "data-domain") == cv.domain + assert text_of_attr(resp, @react_container, "data-logged-in") == "true" + assert text_of_attr(resp, @react_container, "data-current-user-role") == "owner" + assert text_of_attr(resp, @react_container, "data-current-user-id") == "#{user.id}" + end + + test "redirects to /sites if for some reason ineligible anymore", %{ + conn: conn, + user: user + } do + new_site(owner: user) + new_site(owner: user) + cv = user |> team_of() |> new_consolidated_view() + + user + |> team_of() + |> Plausible.Teams.Team.end_trial() + |> Plausible.Repo.update!() + + conn = get(conn, "/" <> cv.domain) + assert redirected_to(conn, 302) == "/sites" + end + end + + @tag :ee_only + test "header, stats are shown; footer is not shown", %{conn: conn, site: site, user: user} do + populate_stats(site, [build(:pageview)]) + conn = get(conn, "/" <> site.domain) + resp = html_response(conn, 200) + assert resp =~ user.name + assert text_of_attr(resp, @react_container, "data-logged-in") == "true" + refute resp =~ "Getting started" + end + + @tag :ce_build_only + test "header, stats, footer are shown", %{conn: conn, site: site, user: user} do + populate_stats(site, [build(:pageview)]) + conn = get(conn, "/" <> site.domain) + resp = html_response(conn, 200) + assert resp =~ user.name + assert text_of_attr(resp, @react_container, "data-logged-in") == "true" + assert resp =~ "Getting started" + end + + test "shows locked page if site is locked", %{conn: conn, user: user} do + locked_site = new_site(owner: user) + locked_site.team |> Ecto.Changeset.change(locked: true) |> Repo.update!() + conn = get(conn, "/" <> locked_site.domain) + resp = html_response(conn, 200) + assert resp =~ "Your dashboard is unavailable" + assert resp =~ "Upgrade to the appropriate plan to restore access" + end + + test "shows locked page if site is locked for billing role", %{conn: conn, user: user} do + other_user = new_user() + locked_site = new_site(owner: other_user) + locked_site.team |> Ecto.Changeset.change(locked: true) |> Repo.update!() + add_member(team_of(other_user), user: user, role: :billing) + + conn = get(conn, "/" <> locked_site.domain) + resp = html_response(conn, 200) + assert resp =~ "Your dashboard is unavailable" + assert resp =~ "Upgrade to the appropriate plan to restore access" + end + + test "shows locked page if site is locked for viewer role", %{conn: conn, user: user} do + other_user = new_user() + locked_site = new_site(owner: other_user) + locked_site.team |> Ecto.Changeset.change(locked: true) |> Repo.update!() + add_member(team_of(other_user), user: user, role: :viewer) + + conn = get(conn, "/" <> locked_site.domain) + resp = html_response(conn, 200) + assert resp =~ "Your dashboard is unavailable" + refute resp =~ "Upgrade to the appropriate plan to restore access" + assert resp =~ "The owner of this site must upgrade their subscription plan" + end + + test "shows locked page for anonymous" do + locked_site = new_site(public: true) + locked_site.team |> Ecto.Changeset.change(locked: true) |> Repo.update!() + conn = get(build_conn(), "/" <> locked_site.domain) + resp = html_response(conn, 200) + assert resp =~ "Your dashboard is unavailable" + assert resp =~ "You can check back later or contact the site owner" + end + + test "can not view stats of someone else's website", %{conn: conn} do + site = new_site() + conn = get(conn, "/" <> site.domain) + assert html_response(conn, 404) =~ "There's nothing here" + end + + test "does not show CRM link to the site", %{conn: conn, site: site} do + conn = get(conn, conn |> get("/" <> site.domain) |> redirected_to()) + + refute html_response(conn, 200) =~ "/cs/sites" + end + + test "all segments (personal or site) are stuffed into dataset, with their associated owner_id and owner_name", + %{conn: conn, site: site, user: user} do + populate_stats(site, [build(:pageview)]) + + emea_site_segment = + insert(:segment, + site: site, + owner: user, + type: :site, + name: "EMEA region" + ) + + foo_personal_segment = + insert(:segment, + site: site, + owner: user, + type: :personal, + name: "FOO" + ) + + conn = get(conn, "/#{site.domain}") + resp = html_response(conn, 200) + assert element_exists?(resp, @react_container) + + assert text_of_attr(resp, @react_container, "data-segments") == + Jason.encode!([ + Plausible.Segments.to_response_map(foo_personal_segment, site), + Plausible.Segments.to_response_map(emea_site_segment, site) + ]) + end + end + + describe "GET /:domain - as a super admin" do + @describetag :ee_only + setup [:create_user, :make_user_super_admin, :log_in] + + test "can view a private dashboard with stats", %{conn: conn, user: user} do + site = new_site() + populate_stats(site, [build(:pageview)]) + + conn = get(conn, "/" <> site.domain) + resp = html_response(conn, 200) + assert resp =~ "stats-react-container" + assert text_of_attr(resp, @react_container, "data-logged-in") == "true" + assert text_of_attr(resp, @react_container, "data-current-user-role") == "super_admin" + assert text_of_attr(resp, @react_container, "data-current-user-id") == "#{user.id}" + end + + test "can enter verification when site is without stats", %{conn: conn} do + site = new_site() + + conn = get(conn, conn |> get("/" <> site.domain) |> redirected_to()) + assert html_response(conn, 200) =~ "Verifying your installation" + end + + test "can view a private locked dashboard with stats", %{conn: conn} do + user = new_user() + site = new_site(owner: user) + site.team |> Ecto.Changeset.change(locked: true) |> Repo.update!() + populate_stats(site, [build(:pageview)]) + + conn = get(conn, "/" <> site.domain) + resp = html_response(conn, 200) + assert resp =~ "This dashboard is actually locked" + end + + test "can view private locked verification without stats", %{conn: conn} do + user = new_user() + site = new_site(owner: user) + site.team |> Ecto.Changeset.change(locked: true) |> Repo.update!() + + conn = get(conn, conn |> get("/#{site.domain}") |> redirected_to()) + assert html_response(conn, 200) =~ "Verifying your installation" + end + + test "can view a locked public dashboard", %{conn: conn} do + site = new_site(public: true) + site.team |> Ecto.Changeset.change(locked: true) |> Repo.update!() + populate_stats(site, [build(:pageview)]) + + conn = get(conn, "/" <> site.domain) + resp = html_response(conn, 200) + assert resp =~ "This dashboard is actually locked" + end + + on_ee do + test "shows CRM link to the site", %{conn: conn} do + site = new_site() + conn = get(conn, conn |> get("/" <> site.domain) |> redirected_to()) + + assert html_response(conn, 200) =~ + Routes.customer_support_site_path(PlausibleWeb.Endpoint, :show, site.id) + end + end + end + + defp make_user_super_admin(%{user: user}) do + Application.put_env(:plausible, :super_admin_user_ids, [user.id]) + end + + describe "GET /share/:domain?auth=:auth" do + test "prompts a password for a password-protected link", %{conn: conn} do + site = new_site() + + link = + insert(:shared_link, site: site, password_hash: Plausible.Auth.Password.hash("password")) + + conn = get(conn, "/share/#{site.domain}?auth=#{link.slug}") + assert response(conn, 200) =~ "Enter password" + end + + test "if the shared link is not protected with a password, passes user immediately to dashboard", + %{ + conn: conn + } do + site = new_site(domain: "test-site.com") + link = insert(:shared_link, site: site) + + conn = get(conn, "/share/test-site.com/?auth=#{link.slug}") + resp = html_response(conn, 200) + assert resp =~ "stats-react-container" + assert text_of_attr(resp, @react_container, "data-logged-in") == "false" + assert text_of_attr(resp, @react_container, "data-current-user-id") == "null" + assert text_of_attr(resp, @react_container, "data-current-user-role") == "public" + end + + test "if the shared link is limited to a segment, only that segment is stuffed into data-segments", + %{ + conn: conn + } do + site = new_site(domain: "test-site.com") + emea_site_segment = insert(:segment, name: "EMEA", site: site, type: :site) + apac_site_segment = insert(:segment, name: "APAC", site: site, type: :site) + link = insert(:shared_link, site: site, segment: emea_site_segment) + + conn = get(conn, "/share/test-site.com/?auth=#{link.slug}") + resp = html_response(conn, 200) + assert resp =~ "stats-react-container" + + assert text_of_attr(resp, @react_container, "data-limited-to-segment-id") == + "#{emea_site_segment.id}" + + assert text_of_attr(resp, @react_container, "data-segments") == + emea_site_segment + |> Plausible.Segments.to_response_map(site) + |> List.wrap() + |> Jason.encode!() + + refute resp =~ apac_site_segment.name + + assert text_of_attr(resp, @react_container, "data-current-user-role") == "public" + end + + test "footer and header are shown when accessing shared link dashboard", %{ + conn: conn + } do + site = new_site(domain: "test-site.com") + link = insert(:shared_link, site: site) + + conn = get(conn, "/share/test-site.com/?auth=#{link.slug}") + resp = html_response(conn, 200) + assert resp =~ "stats-react-container" + assert text_of_attr(resp, @react_container, "data-logged-in") == "false" + assert text_of_attr(resp, @react_container, "data-current-user-id") == "null" + assert text_of_attr(resp, @react_container, "data-current-user-role") == "public" + assert resp =~ "Login" + assert resp =~ "Getting started" + end + + test "returns page with X-Frame-Options disabled so it can be embedded in an iframe", %{ + conn: conn + } do + site = new_site(domain: "test-site.com") + link = insert(:shared_link, site: site) + + conn = get(conn, "/share/test-site.com/?auth=#{link.slug}") + resp = html_response(conn, 200) + assert text_of_attr(resp, @react_container, "data-embedded") == "false" + assert Plug.Conn.get_resp_header(conn, "x-frame-options") == [] + end + + test "returns page embedded page", %{ + conn: conn + } do + site = new_site(domain: "test-site.com") + link = insert(:shared_link, site: site) + + conn = get(conn, "/share/test-site.com/?auth=#{link.slug}&embed=true") + resp = html_response(conn, 200) + assert text_of_attr(resp, @react_container, "data-embedded") == "true" + assert text_of_attr(resp, @react_container, "data-logged-in") == "false" + assert text_of_attr(resp, @react_container, "data-current-user-id") == "null" + assert text_of_attr(resp, @react_container, "data-current-user-role") == "public" + assert Plug.Conn.get_resp_header(conn, "x-frame-options") == [] + end + + test "does not show header, does not show footer on embedded pages", %{conn: conn} do + site = new_site(domain: "test-site.com") + link = insert(:shared_link, site: site) + + conn = get(conn, "/share/test-site.com/?auth=#{link.slug}&embed=true") + resp = html_response(conn, 200) + assert text_of_attr(resp, @react_container, "data-embedded") == "true" + refute resp =~ "Login" + refute resp =~ "Getting started" + end + + test "shows locked page if page is locked", %{conn: conn} do + site = new_site(domain: "test-site.com") + site.team |> Ecto.Changeset.change(locked: true) |> Repo.update!() + link = insert(:shared_link, site: site) + + conn = get(conn, "/share/test-site.com/?auth=#{link.slug}") + + assert html_response(conn, 200) =~ "Your dashboard is unavailable" + refute String.contains?(html_response(conn, 200), "Back to my sites") + end + + test "shows locked page if shared link is locked due to insufficient team subscription", %{ + conn: conn + } do + site = new_site(domain: "test-site.com") + link = insert(:shared_link, site: site) + + insert(:starter_subscription, team: site.team) + + conn = get(conn, "/share/test-site.com/?auth=#{link.slug}") + + assert html_response(conn, 200) =~ "Shared link unavailable" + refute String.contains?(html_response(conn, 200), "Back to my sites") + end + + for special_name <- Plausible.Sites.shared_link_special_names() do + test "shows dashboard if team subscription insufficient but shared link name is '#{special_name}'", + %{conn: conn} do + site = new_site(domain: "test-site.com") + link = insert(:shared_link, site: site, name: unquote(special_name)) + + insert(:starter_subscription, team: site.team) + + html = + conn + |> get("/share/test-site.com/?auth=#{link.slug}") + |> html_response(200) + + assert element_exists?(html, @react_container) + refute html =~ "Shared Link Unavailable" + end + end + + test "renders 404 not found when no auth parameter supplied", %{conn: conn} do + conn = get(conn, "/share/example.com") + assert response(conn, 404) =~ "nothing here" + end + + test "renders 404 not found when non-existent auth parameter is supplied", %{conn: conn} do + conn = get(conn, "/share/example.com?auth=bad-token") + assert response(conn, 404) =~ "nothing here" + end + + test "renders 404 not found when auth parameter for another site is supplied", %{conn: conn} do + site1 = insert(:site, domain: "test-site-1.com") + site2 = insert(:site, domain: "test-site-2.com") + site1_link = insert(:shared_link, site: site1) + + conn = get(conn, "/share/#{site2.domain}/?auth=#{site1_link.slug}") + assert response(conn, 404) =~ "nothing here" + end + + test "all segments (personal or site) are stuffed into dataset, without their owner_id and owner_name", + %{conn: conn} do + user = new_user() + site = new_site(domain: "test-site.com", owner: user) + link = insert(:shared_link, site: site) + + emea_site_segment = + insert(:segment, + site: site, + owner: user, + type: :site, + name: "EMEA region" + ) + + foo_personal_segment = + insert(:segment, + site: site, + owner: user, + type: :personal, + name: "FOO" + ) + + conn = get(conn, "/share/#{site.domain}/?auth=#{link.slug}") + resp = html_response(conn, 200) + + assert text_of_attr(resp, @react_container, "data-segments") == + Jason.encode!([ + Plausible.Segments.to_response_map( + %{foo_personal_segment | owner_id: nil}, + site + ), + Plausible.Segments.to_response_map(%{emea_site_segment | owner_id: nil}, site) + ]) + end + end + + describe "GET /share/:slug - backwards compatibility" do + test "it redirects to new shared link format for historical links", %{conn: conn} do + site = insert(:site, domain: "test-site.com") + site_link = insert(:shared_link, site: site, inserted_at: ~N[2021-12-31 00:00:00]) + + conn = get(conn, "/share/#{site_link.slug}") + assert redirected_to(conn, 302) == "/share/#{site.domain}/?auth=#{site_link.slug}" + end + + test "it does nothing for newer links", %{conn: conn} do + site = insert(:site, domain: "test-site.com") + site_link = insert(:shared_link, site: site, inserted_at: ~N[2022-01-01 00:00:00]) + + conn = get(conn, "/share/#{site_link.slug}") + assert response(conn, 404) =~ "nothing here" + end + end + + describe "POST /share/:slug/authenticate" do + test "logs anonymous user in with correct password", %{conn: conn} do + site = new_site(domain: "test-site.com") + + link = + insert(:shared_link, site: site, password_hash: Plausible.Auth.Password.hash("password")) + + conn = post(conn, "/share/#{link.slug}/authenticate", %{password: "password"}) + assert redirected_to(conn, 302) == "/share/#{site.domain}/?auth=#{link.slug}" + + conn = get(conn, "/share/#{site.domain}?auth=#{link.slug}") + assert html_response(conn, 200) =~ "stats-react-container" + end + + test "shows form again with wrong password", %{conn: conn} do + site = insert(:site, domain: "test-site.com") + + link = + insert(:shared_link, site: site, password_hash: Plausible.Auth.Password.hash("password")) + + conn = post(conn, "/share/#{link.slug}/authenticate", %{password: "WRONG!"}) + assert html_response(conn, 200) =~ "Enter password" + end + + test "only gives access to the correct dashboard", %{conn: conn} do + site = new_site(domain: "test-site.com") + site2 = new_site(domain: "test-site2.com") + + link = + insert(:shared_link, site: site, password_hash: Plausible.Auth.Password.hash("password")) + + link2 = + insert(:shared_link, + site: site2, + password_hash: Plausible.Auth.Password.hash("password1") + ) + + conn = post(conn, "/share/#{link.slug}/authenticate", %{password: "password"}) + assert redirected_to(conn, 302) == "/share/#{site.domain}/?auth=#{link.slug}" + + conn = get(conn, "/share/#{site2.domain}?auth=#{link2.slug}") + assert html_response(conn, 200) =~ "Enter password" + end + + test "preserves query parameters during password authentication", %{conn: conn} do + site = new_site() + + link = + insert(:shared_link, site: site, password_hash: Plausible.Auth.Password.hash("password")) + + filters = "f=is,country,EE&l=EE,Estonia&f=is,browser,Firefox" + + conn = + get( + conn, + "/share/#{URI.encode_www_form(site.domain)}?auth=#{link.slug}&#{filters}" + ) + + assert html_response(conn, 200) =~ "Enter password" + html = html_response(conn, 200) + + expected_action_string = + "/share/#{URI.encode_www_form(link.slug)}/authenticate?auth=#{link.slug}&#{filters}" + + assert text_of_attr(html, "form", "action") == expected_action_string + + conn = + post( + conn, + expected_action_string, + %{password: "WRONG!"} + ) + + html = html_response(conn, 200) + assert html =~ "Enter password" + assert html =~ "Incorrect password" + + assert text_of_attr(html, "form", "action") == expected_action_string + + conn = + post( + conn, + expected_action_string, + %{password: "password"} + ) + + expected_redirect = + "/share/#{URI.encode_www_form(site.domain)}/?auth=#{link.slug}&#{filters}" + + assert redirected_to(conn, 302) == expected_redirect + + conn = get(conn, expected_redirect) + assert html_response(conn, 200) =~ "stats-react-container" + end + end + + test "handles return_to during password authentication", %{conn: conn} do + site = new_site() + + link = + insert(:shared_link, site: site, password_hash: Plausible.Auth.Password.hash("password")) + + filters = "f=is,country,EE&l=EE,Estonia&f=is,browser,Firefox" + + deep_path = "/filter/source" + + conn = + get( + conn, + "/share/#{URI.encode_www_form(site.domain)}#{deep_path}?auth=#{link.slug}&#{filters}" + ) + + assert html_response(conn, 200) =~ "Enter password" + html = html_response(conn, 200) + + expected_action_string = + "/share/#{link.slug}/authenticate?auth=#{link.slug}&#{filters}&#{URI.encode_query(%{"return_to" => deep_path})}" + + assert text_of_attr(html, "form", "action") == expected_action_string + + conn = + post( + conn, + expected_action_string, + %{password: "password"} + ) + + assert redirected_to(conn, 302) == + "/share/#{URI.encode_www_form(site.domain)}#{deep_path}?auth=#{link.slug}&#{filters}" + end + + test "return_to from query_params is discarded", %{conn: conn} do + site = new_site() + + link = + insert(:shared_link, site: site, password_hash: Plausible.Auth.Password.hash("password")) + + conn = + get( + conn, + "/share/#{URI.encode_www_form(site.domain)}/pages?auth=#{link.slug}&return_to=%2Ffoobar" + ) + + assert html_response(conn, 200) =~ "Enter password" + html = html_response(conn, 200) + + expected_action_string = + "/share/#{link.slug}/authenticate?auth=#{link.slug}&return_to=%2Fpages" + + assert text_of_attr(html, "form", "action") == expected_action_string + + conn = + post( + conn, + expected_action_string, + %{password: "password"} + ) + + assert redirected_to(conn, 302) == + "/share/#{URI.encode_www_form(site.domain)}/pages?auth=#{link.slug}" + end + + test "return_to doesn't allow navigating out of dashboard context", %{conn: conn} do + site = new_site() + + link = + insert(:shared_link, site: site, password_hash: Plausible.Auth.Password.hash("password")) + + deep_path = "/../../settings/api-keys" + cleaned_deep_path = "/settings/api-keys" + + conn = + get( + conn, + "/share/#{URI.encode_www_form(site.domain)}#{deep_path}?auth=#{link.slug}&theme=dark" + ) + + assert html_response(conn, 200) =~ "Enter password" + html = html_response(conn, 200) + + expected_action_string = + "/share/#{link.slug}/authenticate?auth=#{link.slug}&theme=dark&#{URI.encode_query(%{"return_to" => deep_path})}" + + assert text_of_attr(html, "form", "action") == expected_action_string + + conn = + post( + conn, + expected_action_string, + %{password: "password"} + ) + + assert redirected_to(conn, 302) == + "/share/#{URI.encode_www_form(site.domain)}#{cleaned_deep_path}?auth=#{link.slug}&theme=dark" + end + + describe "dogfood tracking" do + @describetag :ee_only + + test "does not set domain_to_replace on live demo dashboard", %{conn: conn} do + site = new_site(domain: "plausible.io", public: true) + populate_stats(site, [build(:pageview)]) + conn = get(conn, "/#{site.domain}") + script_params = html_response(conn, 200) |> get_script_params() + + assert %{ + "location_override" => nil, + "domain_to_replace" => nil + } = script_params + end + + test "sets domain_to_replace on any other dashboard", %{conn: conn} do + site = new_site(domain: "öö.ee", public: true) + populate_stats(site, [build(:pageview)]) + conn = get(conn, "/#{site.domain}") + script_params = html_response(conn, 200) |> get_script_params() + + assert %{ + "location_override" => nil, + "domain_to_replace" => "%C3%B6%C3%B6.ee" + } = script_params + end + + test "sets domain_to_replace on live demo shared link", %{conn: conn} do + site = new_site(domain: "plausible.io", public: true) + link = insert(:shared_link, site: site) + + populate_stats(site, [build(:pageview)]) + + conn = get(conn, "/share/#{site.domain}/?auth=#{link.slug}") + script_params = html_response(conn, 200) |> get_script_params() + + assert %{ + "location_override" => nil, + "domain_to_replace" => "plausible.io" + } = script_params + end + + test "sets location_override on a locked dashboard", %{conn: conn} do + locked_site = new_site(public: true) + locked_site.team |> Ecto.Changeset.change(locked: true) |> Repo.update!() + + conn = get(conn, "/" <> locked_site.domain) + html = html_response(conn, 200) + + script_params = html |> get_script_params() + + assert html =~ "Your dashboard is unavailable" + assert script_params["location_override"] == PlausibleWeb.Endpoint.url() <> "/:dashboard" + end + + test "sets location_override on a locked shared link", %{conn: conn} do + locked_site = new_site() + link = insert(:shared_link, site: locked_site) + + insert(:starter_subscription, team: locked_site.team) + + conn = get(conn, "/share/#{locked_site.domain}/?auth=#{link.slug}") + html = html_response(conn, 200) + + script_params = get_script_params(html) + + assert html =~ "Shared link unavailable" + + assert script_params["location_override"] == + PlausibleWeb.Endpoint.url() <> "/share/:dashboard" + end + + test "sets location_override on shared_link_password.html", %{conn: conn} do + site = new_site() + + link = + insert(:shared_link, site: site, password_hash: Plausible.Auth.Password.hash("password")) + + conn = get(conn, "/share/#{site.domain}?auth=#{link.slug}") + html = html_response(conn, 200) + + script_params = get_script_params(html) + + assert html =~ "Enter password" + + assert script_params["location_override"] == + PlausibleWeb.Endpoint.url() <> "/share/:dashboard" + end + end + + defp get_script_params(html) do + html + |> find("#dogfood-script") + |> text_of_attr("data-script-params") + |> JSON.decode!() + end +end diff --git a/test/plausible_web/controllers/unsubscribe_controller_test.exs b/test/plausible_web/controllers/unsubscribe_controller_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..4dfdd6aaab73b4f61d28322bc374664c74c2bf5e --- /dev/null +++ b/test/plausible_web/controllers/unsubscribe_controller_test.exs @@ -0,0 +1,64 @@ +defmodule PlausibleWeb.UnsubscribeControllerTest do + use PlausibleWeb.ConnCase, async: true + use Plausible.Repo + + setup {PlausibleWeb.FirstLaunchPlug.Test, :skip} + + describe "GET /sites/:domain/weekly-report/unsubscribe" do + test "removes a recipient from the weekly report without them having to log in", %{conn: conn} do + site = new_site() + insert(:weekly_report, site: site, recipients: ["recipient@email.com"]) + + conn = + get(conn, "/sites/#{site.domain}/weekly-report/unsubscribe?email=recipient@email.com") + + assert html_response(conn, 200) =~ "Unsubscribe successful" + + report = Repo.get_by(Plausible.Site.WeeklyReport, site_id: site.id) + assert report.recipients == [] + end + + test "renders success if site or weekly report does not exist in the database", %{conn: conn} do + conn = + get(conn, "/sites/nonexistent.com/weekly-report/unsubscribe?email=recipient@email.com") + + assert html_response(conn, 200) =~ "Unsubscribe successful" + end + + test "renders failure if email parameter not provided", %{conn: conn} do + conn = + get(conn, "/sites/nonexistent.com/weekly-report/unsubscribe") + + assert html_response(conn, 400) =~ "Bad Request" + end + end + + describe "GET /sites/:domain/monthly-report/unsubscribe" do + test "removes a recipient from the weekly report without them having to log in", %{conn: conn} do + site = new_site() + insert(:monthly_report, site: site, recipients: ["recipient@email.com"]) + + conn = + get(conn, "/sites/#{site.domain}/monthly-report/unsubscribe?email=recipient@email.com") + + assert html_response(conn, 200) =~ "Unsubscribe successful" + + report = Repo.get_by(Plausible.Site.MonthlyReport, site_id: site.id) + assert report.recipients == [] + end + + test "renders success if site or weekly report does not exist in the database", %{conn: conn} do + conn = + get(conn, "/sites/nonexistent.com/monthly-report/unsubscribe?email=recipient@email.com") + + assert html_response(conn, 200) =~ "Unsubscribe successful" + end + + test "renders failure if email parameter not provided", %{conn: conn} do + conn = + get(conn, "/sites/nonexistent.com/monthly-report/unsubscribe") + + assert html_response(conn, 400) =~ "Bad Request" + end + end +end diff --git a/test/plausible_web/email_test.exs b/test/plausible_web/email_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..d7b2c1bf0d6aee1b41bec9ec477217f58a34147c --- /dev/null +++ b/test/plausible_web/email_test.exs @@ -0,0 +1,451 @@ +defmodule PlausibleWeb.EmailTest do + use Plausible.DataCase, async: true + + import Plausible.Factory + + alias PlausibleWeb.Email + + describe "base_email layout" do + test "greets user by first name if user in template assigns" do + email = + Email.base_email() + |> Email.render("welcome_email.html", %{ + user: build(:user, name: "John Doe"), + code: "123" + }) + + assert email.html_body =~ "Hey John," + assert email.text_body =~ "Hey John," + end + + test "greets impersonally when user not in template assigns" do + email = + Email.base_email() + |> Email.render("welcome_email.html") + + assert email.html_body =~ "Hey," + assert email.text_body =~ "Hey," + end + + test "renders plausible link" do + email = + Email.base_email() + |> Email.render("welcome_email.html") + + assert email.html_body =~ plausible_link() + assert email.text_body =~ plausible_url() + end + + @tag :ee_only + test "renders unsubscribe placeholder" do + email = + Email.base_email() + |> Email.render("welcome_email.html") + + assert email.html_body =~ "{{{ pm:unsubscribe }}}" + end + + test "can be disabled with a nil layout" do + email = + Email.base_email(%{layout: nil}) + |> Email.render("welcome_email.html", %{ + user: build(:user, name: "John Doe") + }) + + refute email.html_body =~ "Hey John," + + refute email.text_body =~ "Hey John," + end + end + + describe "priority email layout" do + @tag :ee_only + test "uses the `priority` message stream in Postmark in EE" do + email = + Email.priority_email() + |> Email.render("activation_email.html", %{ + user: build(:user, name: "John Doe"), + code: "123" + }) + + assert %{"MessageStream" => "priority"} = email.private[:message_params] + end + + @tag :ce_build_only + test "doesn't use the `priority` message stream in Postmark in CE" do + email = + Email.priority_email() + |> Email.render("activation_email.html", %{ + user: build(:user, name: "John Doe"), + code: "123" + }) + + refute email.private[:message_params]["MessageStream"] + end + + test "greets user by first name if user in template assigns" do + email = + Email.priority_email() + |> Email.render("activation_email.html", %{ + user: build(:user, name: "John Doe"), + code: "123" + }) + + assert email.html_body =~ "Hey John," + assert email.text_body =~ "Hey John," + end + + test "greets impersonally when user not in template assigns" do + email = + Email.priority_email() + |> Email.render("password_reset_email.html", %{ + reset_link: "imaginary" + }) + + assert email.html_body =~ "Hey," + assert email.text_body =~ "Hey," + end + + test "renders plausible link" do + email = + Email.priority_email() + |> Email.render("password_reset_email.html", %{ + reset_link: "imaginary" + }) + + assert email.html_body =~ plausible_link() + assert email.text_body =~ plausible_url() + end + + test "does not render unsubscribe placeholder" do + email = + Email.priority_email() + |> Email.render("password_reset_email.html", %{ + reset_link: "imaginary" + }) + + refute email.html_body =~ "{{{ pm:unsubscribe }}}" + end + + test "can be disabled with a nil layout" do + email = + Email.priority_email(%{layout: nil}) + |> Email.render("password_reset_email.html", %{ + reset_link: "imaginary" + }) + + refute email.html_body =~ "Hey John," + refute email.html_body =~ plausible_link() + + refute email.text_body =~ "Hey John," + refute email.text_body =~ plausible_url() + end + end + + describe "over_limit_email/3" do + test "renders usage, suggested plan, and links to upgrade and account settings" do + user = build(:user) + team = build(:team, identifier: Ecto.UUID.generate()) + penultimate_cycle = Date.range(~D[2023-03-01], ~D[2023-03-31]) + last_cycle = Date.range(~D[2023-04-01], ~D[2023-04-30]) + + usage = %{ + penultimate_cycle: %{date_range: penultimate_cycle, total: 12_300}, + last_cycle: %{date_range: last_cycle, total: 32_100} + } + + %{html_body: html_body, subject: subject} = + PlausibleWeb.Email.over_limit_email(user, team, usage, "100k") + + assert subject == "[Action required] You have outgrown your Plausible subscription tier" + + assert html_body =~ PlausibleWeb.TextHelpers.format_date_range(last_cycle) + assert html_body =~ "We recommend you upgrade to the 100k pageviews/month plan" + assert html_body =~ "your account recorded 32,100 billable pageviews" + + assert html_body =~ + "cycle before that (#{PlausibleWeb.TextHelpers.format_date_range(penultimate_cycle)}), your account used 12,300 billable pageviews" + + assert text_of_element( + html_body, + ~s|a[href$="/billing/choose-plan?__team=#{team.identifier}"]| + ) == + "Click here to upgrade your subscription" + + assert text_of_element( + html_body, + ~s|a[href$="/settings/billing/subscription?__team=#{team.identifier}"]| + ) == + "account settings" + + assert html_body =~ + PlausibleWeb.Router.Helpers.billing_url(PlausibleWeb.Endpoint, :choose_plan) <> + "?__team=#{team.identifier}" + end + + test "asks enterprise level usage to contact us" do + user = build(:user) + team = build(:team, identifier: Ecto.UUID.generate()) + penultimate_cycle = Date.range(~D[2023-03-01], ~D[2023-03-31]) + last_cycle = Date.range(~D[2023-04-01], ~D[2023-04-30]) + suggested_plan = :enterprise + + usage = %{ + penultimate_cycle: %{date_range: penultimate_cycle, total: 12_300}, + last_cycle: %{date_range: last_cycle, total: 32_100} + } + + %{html_body: html_body} = + PlausibleWeb.Email.over_limit_email(user, team, usage, suggested_plan) + + refute html_body =~ "Click here to upgrade your subscription" + assert html_body =~ "Your usage exceeds our standard plans, so please reply back" + end + end + + describe "dashboard_locked/3" do + test "renders usage, suggested plan, and links to upgrade and account settings" do + user = build(:user) + team = build(:team, identifier: Ecto.UUID.generate()) + penultimate_cycle = Date.range(~D[2023-03-01], ~D[2023-03-31]) + last_cycle = Date.range(~D[2023-04-01], ~D[2023-04-30]) + + usage = %{ + penultimate_cycle: %{date_range: penultimate_cycle, total: 12_300}, + last_cycle: %{date_range: last_cycle, total: 32_100} + } + + %{html_body: html_body, subject: subject} = + PlausibleWeb.Email.dashboard_locked(user, team, usage, "100k") + + assert subject == "[Action required] Your Plausible dashboard is now locked" + + assert html_body =~ PlausibleWeb.TextHelpers.format_date_range(last_cycle) + assert html_body =~ "We recommend you upgrade to the 100k pageviews/month plan" + assert html_body =~ "your account recorded 32,100 billable pageviews" + + assert html_body =~ + "cycle before that (#{PlausibleWeb.TextHelpers.format_date_range(penultimate_cycle)}), the usage was 12,300 billable pageviews" + + assert text_of_element( + html_body, + ~s|a[href$="/billing/choose-plan?__team=#{team.identifier}"]| + ) == + "Click here to upgrade your subscription" + + assert text_of_element( + html_body, + ~s|a[href$="/settings/billing/subscription?__team=#{team.identifier}"]| + ) == + "account settings" + + assert html_body =~ + PlausibleWeb.Router.Helpers.billing_url(PlausibleWeb.Endpoint, :choose_plan) <> + "?__team=#{team.identifier}" + end + + test "asks enterprise level usage to contact us" do + user = build(:user) + team = build(:team, identifier: Ecto.UUID.generate()) + penultimate_cycle = Date.range(~D[2023-03-01], ~D[2023-03-31]) + last_cycle = Date.range(~D[2023-04-01], ~D[2023-04-30]) + suggested_plan = :enterprise + + usage = %{ + penultimate_cycle: %{date_range: penultimate_cycle, total: 12_300}, + last_cycle: %{date_range: last_cycle, total: 32_100} + } + + %{html_body: html_body} = + PlausibleWeb.Email.dashboard_locked(user, team, usage, suggested_plan) + + refute html_body =~ "Click here to upgrade your subscription" + assert html_body =~ "Your usage exceeds our standard plans, so please reply back" + end + end + + describe "enterprise_over_limit_internal_email/4" do + test "renders pageview usage by billing cycles + sites usage/limit" do + user = build(:user) + penultimate_cycle = Date.range(~D[2023-03-01], ~D[2023-03-31]) + last_cycle = Date.range(~D[2023-04-01], ~D[2023-04-30]) + + pageview_usage = %{ + penultimate_cycle: %{date_range: penultimate_cycle, total: 100_141_888}, + last_cycle: %{date_range: last_cycle, total: 100_222_999} + } + + %{html_body: html_body, subject: subject} = + PlausibleWeb.Email.enterprise_over_limit_internal_email(user, pageview_usage, 80, 50) + + assert subject == "#{user.email} has outgrown their enterprise plan" + + assert html_body =~ + "Last billing cycle: #{PlausibleWeb.TextHelpers.format_date_range(last_cycle)}" + + assert html_body =~ "Last cycle pageview usage: 100,222,999 billable pageviews" + + assert html_body =~ + "Penultimate billing cycle: #{PlausibleWeb.TextHelpers.format_date_range(penultimate_cycle)}" + + assert html_body =~ "Penultimate cycle pageview usage: 100,141,888 billable pageviews" + assert html_body =~ "Site usage: 80 / 50 allowed sites" + end + end + + describe "approaching accept_traffic_until" do + test "renders first warning" do + user = build(:user, id: 123, name: "John Doe") + team = build(:team, identifier: Ecto.UUID.generate()) + + notification = %{ + id: user.id, + email: user.email, + deadline: Date.add(Date.utc_today(), 7), + site_ids: [1, 2, 3], + name: user.name, + team: team + } + + %{html_body: body, subject: subject} = + PlausibleWeb.Email.approaching_accept_traffic_until(notification) + + assert subject == "Your stats stop collecting soon" + assert body =~ plausible_link(team: team, label: "start a Plausible subscription") + assert body =~ "Hey John," + + assert body =~ + "Your sites are still sending us data, but your account is no longer active. We'll stop counting your stats next week." + end + + test "renders final warning" do + user = build(:user, id: 123, name: "John Doe") + team = build(:team, identifier: Ecto.UUID.generate()) + + notification = %{ + id: user.id, + email: user.email, + deadline: Date.add(Date.utc_today(), 1), + site_ids: [1, 2, 3], + name: user.name, + team: team + } + + %{html_body: body, subject: subject} = + PlausibleWeb.Email.approaching_accept_traffic_until_tomorrow(notification) + + assert subject == "Your stats stop tomorrow" + assert body =~ plausible_link(team: team, label: "start a Plausible subscription") + + assert body =~ + "Your sites are still sending us data, but your account is no longer active. We'll stop counting your stats tomorrow." + end + end + + describe "site setup emails" do + setup do + trial_user = new_user(trial_expiry_date: Date.add(Date.utc_today(), 100)) + site = new_site(owner: trial_user) + + emails = [ + PlausibleWeb.Email.create_site_email(trial_user), + PlausibleWeb.Email.site_setup_help(trial_user, site.team, site), + PlausibleWeb.Email.site_setup_success(trial_user, site) + ] + + {:ok, emails: emails, user: trial_user, site: site} + end + + test "create site email", %{user: user} do + email = PlausibleWeb.Email.create_site_email(user) + + assert email.html_body =~ + "You’ve created your Plausible account but haven’t added a site yet." + end + + test "site setup help email", %{user: user, site: site} do + email = PlausibleWeb.Email.site_setup_help(user, site.team, site) + + assert email.html_body =~ + "We haven't recorded any traffic for #{site.domain} yet." + end + + test "site setup success email", %{user: user, site: site} do + email = PlausibleWeb.Email.site_setup_success(user, site) + + assert email.html_body =~ "Your first visitor is now visible in Plausible." + end + end + + describe "import emails" do + setup do + user = new_user() + site = new_site(owner: user) + + {:ok, user: user, site: site} + end + + for {source, success_phrase, failure_phrase} <- [ + {:google_analytics_4, "Your Google Analytics import has completed successfully", + "Unfortunately, your Google Analytics import for"}, + {:universal_analytics, "Your Google Analytics import has completed successfully", + "Unfortunately, your Google Analytics import for"}, + {:csv, "Your CSV import has completed successfully", + "Unfortunately, your CSV import for"} + ] do + test "success email for #{source}", %{user: user, site: site} do + site_import = insert(:site_import, site: site, source: unquote(source)) + PlausibleWeb.Email.import_success(site_import, user) + + email = PlausibleWeb.Email.import_success(site_import, user) + + assert email.text_body =~ unquote(success_phrase) + end + + test "failure email for #{source}", %{user: user, site: site} do + site_import = insert(:site_import, site: site, source: unquote(source)) + + email = PlausibleWeb.Email.import_failure(site_import, user) + + assert email.text_body =~ unquote(failure_phrase) + end + end + end + + describe "text_body" do + test "welcome email" do + email = + Email.base_email() + |> Email.render("welcome_email.html", %{ + user: build(:user, name: "John Doe"), + code: "123" + }) + + assert email.text_body =~ "Welcome to Plausible." + end + end + + def plausible_url do + PlausibleWeb.EmailView.plausible_url() + end + + def plausible_link(opts \\ []) do + suffix = + if team = Keyword.get(opts, :team) do + "?__team=#{team.identifier}" + else + "" + end + + plausible_url = plausible_url() + + label = + if label = Keyword.get(opts, :label) do + label + else + plausible_url + end + + "
suffix}\">#{label}" + end +end diff --git a/test/plausible_web/live/change_domain_test.exs b/test/plausible_web/live/change_domain_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..80c5a430c687e13cf3331727925114978a5c013d --- /dev/null +++ b/test/plausible_web/live/change_domain_test.exs @@ -0,0 +1,473 @@ +defmodule PlausibleWeb.Live.ChangeDomainTest do + use PlausibleWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + + on_ee do + import Mox + end + + alias Plausible.Repo + + describe "ChangeDomain LiveView" do + setup [:create_user, :log_in, :create_site] + + on_ee do + setup do + # mock all domains resolve + Plausible.DnsLookup.Mock + |> stub(:lookup, fn _domain, _type, _record, _opts, _timeout -> + [{192, 168, 1, 2}] + end) + + # Stub detection by default to prevent async task race conditions + # Tests that need specific detection results can override this stub + stub_detection_result(%{ + "v1Detected" => false, + "gtmLikely" => false, + "wordpressLikely" => false, + "wordpressPlugin" => false + }) + + :ok + end + end + + test "mounts and renders form", %{conn: conn, site: site} do + {:ok, _lv, html} = live(conn, "/#{site.domain}/change-domain") + + assert html =~ "Change your website domain" + end + + test "form submission when no change is made", %{conn: conn, site: site} do + {:ok, lv, _html} = live(conn, "/#{site.domain}/change-domain") + + html = + lv + |> element("form") + |> render_submit(%{site: %{domain: site.domain}}) + + assert html =~ "New domain must be different than the current one" + end + + test "form submission to an existing domain", %{conn: conn, site: site} do + another_site = insert(:site) + {:ok, lv, _html} = live(conn, "/#{site.domain}/change-domain") + + html = + lv + |> element("form") + |> render_submit(%{site: %{domain: another_site.domain}}) + + assert html =~ "This domain cannot be registered" + + site = Repo.reload!(site) + assert site.domain != another_site.domain + assert is_nil(site.domain_changed_from) + end + + test "form submission to a domain already owned by the same team shows a targeted error", + %{conn: conn, site: site, user: user} do + owned_site = new_site(owner: user) + {:ok, lv, _html} = live(conn, "/#{site.domain}/change-domain") + + html = + lv + |> element("form") + |> render_submit(%{site: %{domain: owned_site.domain}}) + + assert html =~ "You already own this site" + + expected_settings_link = + Routes.site_path(conn, :settings_general, owned_site.domain) + + assert html =~ expected_settings_link + + site = Repo.reload!(site) + assert site.domain != owned_site.domain + assert is_nil(site.domain_changed_from) + end + + test "form submission to a domain in transition period", %{conn: conn, site: site} do + _another_site = insert(:site, domain_changed_from: "foo.example.com") + {:ok, lv, _html} = live(conn, "/#{site.domain}/change-domain") + + html = + lv + |> element("form") + |> render_submit(%{site: %{domain: "foo.example.com"}}) + + assert html =~ "This domain cannot be registered" + + site = Repo.reload!(site) + assert site.domain != "foo.example.com" + assert is_nil(site.domain_changed_from) + end + + test "domain swap is allowed when both sites belong to the same team", + %{conn: conn, site: site, user: user} do + other_site = new_site(owner: user) + + {:ok, lv, _html} = live(conn, "/#{site.domain}/change-domain") + + new_domain = "tmp.#{site.domain}" + + lv + |> element("form") + |> render_submit(%{site: %{domain: new_domain}}) + + on_ee do + render_async(lv, 500) + end + + assert_patch(lv, "/#{URI.encode_www_form(new_domain)}/change-domain/success") + + # Now swap: rename other_site to the original domain of site + original_domain = site.domain + other_original_domain = other_site.domain + {:ok, lv2, _html} = live(conn, "/#{other_original_domain}/change-domain") + + lv2 + |> element("form") + |> render_submit(%{site: %{domain: original_domain}}) + + on_ee do + render_async(lv2, 500) + end + + assert_patch(lv2, "/#{URI.encode_www_form(original_domain)}/change-domain/success") + + assert Repo.reload!(other_site).domain == original_domain + end + + for {role, membership_type} <- [ + {:editor, :site_guest}, + {:editor, :team_member}, + {:admin, :team_member}, + {:owner, :team_member} + ] do + test "#{Phoenix.Naming.humanize(membership_type)} with role #{role} can submit the form and it changes the record in the database", + %{conn: conn, user: user} do + site = new_site() + + add_site_guest_or_team_member(site, + user: user, + role: unquote(role), + membership_type: unquote(membership_type) + ) + + original_domain = site.domain + new_domain = "new.#{site.domain}" + {:ok, lv, _html} = live(conn, "/#{site.domain}/change-domain") + + lv + |> element("form") + |> render_submit(%{site: %{domain: new_domain}}) + + on_ee do + render_async(lv, 500) + end + + site = Repo.reload!(site) + assert site.domain == new_domain + assert site.domain_changed_from == original_domain + end + end + + test "successful form submission navigates to success page", %{conn: conn, site: site} do + on_ee do + stub_detection_result(%{ + "v1Detected" => false, + "gtmLikely" => false, + "wordpressLikely" => false, + "wordpressPlugin" => false + }) + end + + original_domain = site.domain + new_domain = "new.#{site.domain}" + {:ok, lv, _html} = live(conn, "/#{site.domain}/change-domain") + + lv + |> element("form") + |> render_submit(%{site: %{domain: new_domain}}) + + assert_patch(lv, "/#{URI.encode_www_form(new_domain)}/change-domain/success") + + html = render_async(lv, 500) + assert html =~ "Domain Changed Successfully" + assert html =~ original_domain + assert html =~ new_domain + end + + test "form validation shows error for empty domain", %{conn: conn, site: site} do + {:ok, lv, _html} = live(conn, "/#{site.domain}/change-domain") + + html = + lv + |> element("form") + |> render_submit(%{site: %{domain: ""}}) + + assert html =~ htmlize_quotes("can't be blank") + end + + test "form validation shows error for invalid domain format", %{conn: conn, site: site} do + {:ok, lv, _html} = live(conn, "/#{site.domain}/change-domain") + + html = + lv + |> element("form") + |> render_submit(%{site: %{domain: "invalid domain with spaces"}}) + + assert html =~ "only letters, numbers, slashes, underscores and period allowed" + end + + test "renders back to settings link with correct path", %{conn: conn, site: site} do + {:ok, _lv, html} = live(conn, "/#{site.domain}/change-domain") + + expected_link = Routes.site_path(conn, :settings_general, site.domain) + assert html =~ expected_link + end + + @tag :ee_only + test "success page shows WordPress plugin notice when detected", %{conn: conn, site: site} do + stub_detection_result(%{ + "v1Detected" => true, + "gtmLikely" => false, + "wordpressLikely" => true, + "wordpressPlugin" => true + }) + + new_domain = "new.#{site.domain}" + {:ok, lv, _html} = live(conn, "/#{site.domain}/change-domain") + + lv + |> element("form") + |> render_submit(%{site: %{domain: new_domain}}) + + assert_patch(lv, "/#{URI.encode_www_form(new_domain)}/change-domain/success") + + html = render_async(lv, 500) + assert html =~ "using our WordPress plugin" + assert html =~ "also update the site" + assert html =~ "Plausible Wordpress Plugin settings" + assert html =~ "within 72 hours" + + assert element_exists?( + html, + "a[href='#{PlausibleWeb.Live.ChangeDomain.change_domain_docs_link()}']" + ) + end + + @tag :ee_only + test "success page shows generic v1 notice when detected but not WordPress", %{ + conn: conn, + site: site + } do + stub_detection_result(%{ + "v1Detected" => true, + "gtmLikely" => false, + "wordpressLikely" => false, + "wordpressPlugin" => false + }) + + new_domain = "new.#{site.domain}" + {:ok, lv, _html} = live(conn, "/#{site.domain}/change-domain") + + lv + |> element("form") + |> render_submit(%{site: %{domain: new_domain}}) + + assert_patch(lv, "/#{URI.encode_www_form(new_domain)}/change-domain/success") + + html = render_async(lv, 500) + assert html =~ "using our legacy script" + assert html =~ "update the site domain" + assert html =~ "Plausible installation" + assert html =~ "within 72 hours" + refute html =~ "Wordpress" + + assert element_exists?( + html, + "a[href='#{PlausibleWeb.Live.ChangeDomain.change_domain_docs_link()}']" + ) + end + + @tag :ee_only + test "success page shows 'tracking works' notice when no v1 tracking detected", %{ + conn: conn, + site: site + } do + stub_detection_result(%{ + "v1Detected" => false, + "gtmLikely" => false, + "wordpressLikely" => false, + "wordpressPlugin" => false + }) + + new_domain = "new.#{site.domain}" + {:ok, lv, _html} = live(conn, "/#{site.domain}/change-domain") + + lv + |> element("form") + |> render_submit(%{site: %{domain: new_domain}}) + + assert_patch(lv, "/#{URI.encode_www_form(new_domain)}/change-domain/success") + + html = render_async(lv, 500) + assert html =~ "Your new domain should be tracking nicely" + assert html =~ "domain change checklist" + + assert element_exists?( + html, + "a[href='#{PlausibleWeb.Live.ChangeDomain.change_domain_checklist_docs_link()}']" + ) + + assert element_exists?( + html, + "a[href='#{PlausibleWeb.Live.ChangeDomain.change_domain_docs_link()}']" + ) + end + + @tag :ee_only + test "success page shows npm notice when detected", %{conn: conn, site: site} do + stub_detection_result(%{ + "v1Detected" => false, + "gtmLikely" => false, + "npm" => true, + "wordpressLikely" => false, + "wordpressPlugin" => false + }) + + new_domain = "new.#{site.domain}" + {:ok, lv, _html} = live(conn, "/#{site.domain}/change-domain") + + lv + |> element("form") + |> render_submit(%{site: %{domain: new_domain}}) + + assert_patch(lv, "/#{URI.encode_www_form(new_domain)}/change-domain/success") + + html = render_async(lv, 500) + assert html =~ "using our @plausible-analytics/tracker module" + assert html =~ "Plausible installation" + assert html =~ "within 72 hours" + + assert element_exists?( + html, + "a[href='#{PlausibleWeb.Live.ChangeDomain.change_domain_docs_link()}']" + ) + end + + @tag ee_only: true, capture_log: true + test "ratelimit is respected: browserless request isn't made and the notice is generic", %{ + conn: conn, + site: site + } do + new_domain = "new.#{site.domain}" + + # exceed the rate limit for site detection + Plausible.RateLimit.check_rate( + Plausible.RateLimit, + "site_detection:#{new_domain}", + :timer.minutes(60), + 1, + 100 + ) + + # stub won't be used, if it were used, the output would be different + stub_detection_result(%{ + "v1Detected" => false, + "gtmLikely" => false, + "wordpressLikely" => false, + "wordpressPlugin" => false + }) + + {:ok, lv, _html} = live(conn, "/#{site.domain}/change-domain") + + lv + |> element("form") + |> render_submit(%{site: %{domain: new_domain}}) + + assert_patch(lv, "/#{URI.encode_www_form(new_domain)}/change-domain/success") + + html = render_async(lv, 500) + assert html =~ "We could not reach your new domain" + assert html =~ "Additional action may be required" + + assert element_exists?( + html, + "a[href='#{PlausibleWeb.Live.ChangeDomain.change_domain_docs_link()}']" + ) + end + + @tag ee_only: true, capture_log: true + test "success page handles detection error gracefully", %{conn: conn, site: site} do + stub_detection_error() + + new_domain = "new.#{site.domain}" + {:ok, lv, _html} = live(conn, "/#{site.domain}/change-domain") + + lv + |> element("form") + |> render_submit(%{site: %{domain: new_domain}}) + + assert_patch(lv, "/#{URI.encode_www_form(new_domain)}/change-domain/success") + + html = render_async(lv, 500) + assert html =~ "We could not reach your new domain" + assert html =~ "Additional action may be required" + + assert element_exists?( + html, + "a[href='#{PlausibleWeb.Live.ChangeDomain.change_domain_docs_link()}']" + ) + end + + @tag :ce_build_only + test "success page shows generic v1 notice for CE", %{ + conn: conn, + site: site + } do + new_domain = "new.#{site.domain}" + {:ok, lv, _html} = live(conn, "/#{site.domain}/change-domain") + + lv + |> element("form") + |> render_submit(%{site: %{domain: new_domain}}) + + assert_patch(lv, "/#{URI.encode_www_form(new_domain)}/change-domain/success") + + html = render_async(lv, 500) + + assert html =~ "Additional action may be required" + assert html =~ "using our legacy snippet" + assert html =~ "must also update the site domain" + assert html =~ "72 hours" + + assert element_exists?( + html, + "a[href='#{PlausibleWeb.Live.ChangeDomain.change_domain_docs_link()}']" + ) + end + end + + defp stub_detection_result(js_data) do + Req.Test.stub(Plausible.InstallationSupport.Checks.Detection, fn conn -> + conn + |> put_resp_content_type("application/json") + |> send_resp(200, Jason.encode!(%{"data" => Map.put(js_data, "completed", true)})) + end) + end + + defp stub_detection_error do + Req.Test.stub(Plausible.InstallationSupport.Checks.Detection, fn conn -> + conn + |> put_resp_content_type("application/json") + |> send_resp( + 200, + Jason.encode!(%{"data" => %{"error" => %{"message" => "Simulated browser error"}}}) + ) + end) + end +end diff --git a/test/plausible_web/live/choose_plan_test.exs b/test/plausible_web/live/choose_plan_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..098e2762696a36312ad597a14d2c8191e03d33ac --- /dev/null +++ b/test/plausible_web/live/choose_plan_test.exs @@ -0,0 +1,1398 @@ +defmodule PlausibleWeb.Live.ChoosePlanTest do + use PlausibleWeb.ConnCase, async: true + + on_ee do + @moduletag :ee_only + + import Phoenix.LiveViewTest + + require Plausible.Billing.Subscription.Status + alias Plausible.{Repo, Billing, Billing.Subscription} + + @v1_10k_yearly_plan_id "572810" + @v1_50m_yearly_plan_id "650653" + @v2_20m_yearly_plan_id "653258" + @v5_starter_5m_monthly_plan_id "910425" + @v4_growth_10k_monthly_plan_id "857097" + @v4_growth_200k_yearly_plan_id "857081" + @v5_growth_10k_yearly_plan_id "910430" + @v5_growth_200k_yearly_plan_id "910434" + @v4_business_5m_monthly_plan_id "857111" + @v5_business_5m_monthly_plan_id "910457" + @v3_business_10k_monthly_plan_id "857481" + + @monthly_interval_button ~s/label[phx-click="set_interval"][phx-value-interval="monthly"]/ + @yearly_interval_button ~s/label[phx-click="set_interval"][phx-value-interval="yearly"]/ + @interval_button_active_class "bg-indigo-600 text-white" + @slider_input ~s/input[name="slider"]/ + @slider_value "#slider-value" + + @starter_plan_box "#starter-plan-box" + @starter_plan_tooltip "#starter-plan-box [data-testid='plan-tooltip']" + @starter_price_tag_amount "#starter-price-tag-amount" + @starter_price_tag_interval "#starter-price-tag-interval" + @starter_discount_price_tag_amount "#starter-discount-price-tag-amount" + @starter_discount_price_tag_strikethrough_amount "#starter-discount-price-tag-strikethrough-amount" + @starter_vat_notice "#starter-vat-notice" + @starter_highlight_pill "#{@starter_plan_box} #highlight-pill" + @starter_checkout_button "#starter-checkout" + + @growth_plan_box "#growth-plan-box" + @growth_plan_tooltip "#growth-plan-box [data-testid='plan-tooltip']" + @growth_price_tag_amount "#growth-price-tag-amount" + @growth_price_tag_interval "#growth-price-tag-interval" + @growth_discount_price_tag_amount "#growth-discount-price-tag-amount" + @growth_discount_price_tag_strikethrough_amount "#growth-discount-price-tag-strikethrough-amount" + @growth_vat_notice "#growth-vat-notice" + @growth_highlight_pill "#{@growth_plan_box} #highlight-pill" + @growth_checkout_button "#growth-checkout" + + @business_plan_box "#business-plan-box" + @business_price_tag_amount "#business-price-tag-amount" + @business_price_tag_interval "#business-price-tag-interval" + @business_discount_price_tag_amount "#business-discount-price-tag-amount" + @business_discount_price_tag_strikethrough_amount "#business-discount-price-tag-strikethrough-amount" + @business_vat_notice "#business-vat-notice" + @business_highlight_pill "#{@business_plan_box} #highlight-pill" + @business_checkout_button "#business-checkout" + + @enterprise_plan_box "#enterprise-plan-box" + @enterprise_highlight_pill "#enterprise-highlight-pill" + + @slider_volumes ["10k", "100k", "200k", "500k", "1M", "2M", "5M", "10M", "10M+"] + + describe "for a user with no subscription" do + setup [:create_user, :create_site, :log_in] + + test "displays basic page content", %{conn: conn} do + {:ok, _lv, doc} = get_liveview(conn) + + assert doc =~ "Upgrade your trial" + assert doc =~ "Back to settings" + assert doc =~ "You have used" + assert doc =~ "0" + assert doc =~ "billable pageviews in the last 30 days" + assert doc =~ "Any other questions?" + assert doc =~ "What happens if I go over my monthly pageview limit?" + assert doc =~ "Enterprise" + assert doc =~ "+ VAT" + end + + test "does not render any global notices", %{conn: conn} do + {:ok, _lv, doc} = get_liveview(conn) + check_notice_titles(doc, []) + end + + test "displays plan benefits", %{conn: conn} do + {:ok, _lv, doc} = get_liveview(conn) + + starter_box = text_of_element(doc, @starter_plan_box) + growth_box = text_of_element(doc, @growth_plan_box) + business_box = text_of_element(doc, @business_plan_box) + enterprise_box = text_of_element(doc, @enterprise_plan_box) + + assert starter_box =~ "Intuitive, fast and privacy-friendly dashboard" + assert starter_box =~ "Email/Slack reports" + assert starter_box =~ "Google Analytics import" + assert starter_box =~ "Goals and custom events" + assert starter_box =~ "One site" + assert starter_box =~ "3 years of data retention" + + assert growth_box =~ "Up to 3 team members" + assert growth_box =~ "Up to 3 sites" + assert growth_box =~ "Team Management" + assert growth_box =~ "Shared Links" + assert growth_box =~ "Shared Segments" + + assert business_box =~ "Everything in Growth" + assert business_box =~ "Up to 10 team members" + assert business_box =~ "Up to 10 sites" + assert business_box =~ "Stats API (600 requests per hour)" + assert business_box =~ "Looker Studio Connector" + assert business_box =~ "Custom Properties" + assert business_box =~ "Funnels" + assert business_box =~ "Ecommerce revenue attribution" + + refute business_box =~ "Goals and custom events" + + assert enterprise_box =~ "Everything in Business" + assert enterprise_box =~ "10+ team members" + assert enterprise_box =~ "10+ sites" + assert enterprise_box =~ "600+ Stats API requests per hour" + assert enterprise_box =~ "Sites API access for" + assert enterprise_box =~ "Single Sign-On (SSO)" + assert enterprise_box =~ "Priority support" + + assert text_of_attr(find(doc, "#{@enterprise_plan_box} p a"), "href") =~ + "https://plausible.io/white-label-web-analytics" + end + + test "default billing interval is yearly, and can switch to monthly", %{conn: conn} do + {:ok, lv, doc} = get_liveview(conn) + + assert class_of_element(doc, @yearly_interval_button) =~ @interval_button_active_class + refute class_of_element(doc, @monthly_interval_button) =~ @interval_button_active_class + + doc = element(lv, @monthly_interval_button) |> render_click() + + refute class_of_element(doc, @yearly_interval_button) =~ @interval_button_active_class + assert class_of_element(doc, @monthly_interval_button) =~ @interval_button_active_class + end + + test "default pageview limit is 10k", %{conn: conn} do + {:ok, _lv, doc} = get_liveview(conn) + assert text_of_element(doc, @slider_value) == "10k" + assert text_of_element(doc, @starter_price_tag_amount) == "€90" + assert text_of_element(doc, @growth_price_tag_amount) == "€140" + assert text_of_element(doc, @business_price_tag_amount) == "€190" + end + + test "pageview slider changes selected volume and prices shown", %{conn: conn} do + {:ok, lv, _doc} = get_liveview(conn) + + doc = set_slider(lv, "100k") + assert text_of_element(doc, @slider_value) == "100k" + assert text_of_element(doc, @starter_price_tag_amount) == "€190" + assert text_of_element(doc, @growth_price_tag_amount) == "€290" + assert text_of_element(doc, @business_price_tag_amount) == "€390" + + doc = set_slider(lv, "200k") + assert text_of_element(doc, @slider_value) == "200k" + assert text_of_element(doc, @starter_price_tag_amount) == "€290" + assert text_of_element(doc, @growth_price_tag_amount) == "€440" + assert text_of_element(doc, @business_price_tag_amount) == "€590" + + doc = set_slider(lv, "500k") + assert text_of_element(doc, @slider_value) == "500k" + assert text_of_element(doc, @starter_price_tag_amount) == "€490" + assert text_of_element(doc, @growth_price_tag_amount) == "€740" + assert text_of_element(doc, @business_price_tag_amount) == "€990" + + doc = set_slider(lv, "1M") + assert text_of_element(doc, @slider_value) == "1M" + assert text_of_element(doc, @starter_price_tag_amount) == "€690" + assert text_of_element(doc, @growth_price_tag_amount) == "€1,040" + assert text_of_element(doc, @business_price_tag_amount) == "€1,390" + + doc = set_slider(lv, "2M") + assert text_of_element(doc, @slider_value) == "2M" + assert text_of_element(doc, @starter_price_tag_amount) == "€890" + assert text_of_element(doc, @growth_price_tag_amount) == "€1,340" + assert text_of_element(doc, @business_price_tag_amount) == "€1,790" + + doc = set_slider(lv, "5M") + assert text_of_element(doc, @slider_value) == "5M" + assert text_of_element(doc, @starter_price_tag_amount) == "€1,290" + assert text_of_element(doc, @growth_price_tag_amount) == "€1,940" + assert text_of_element(doc, @business_price_tag_amount) == "€2,590" + + doc = set_slider(lv, "10M") + assert text_of_element(doc, @slider_value) == "10M" + assert text_of_element(doc, @starter_price_tag_amount) == "€1,690" + assert text_of_element(doc, @growth_price_tag_amount) == "€2,540" + assert text_of_element(doc, @business_price_tag_amount) == "€3,390" + end + + test "displays monthly discount for yearly plans", %{conn: conn} do + {:ok, lv, _doc} = get_liveview(conn) + + doc = set_slider(lv, "200k") + + assert text_of_element(doc, @starter_price_tag_amount) == "€290" + assert text_of_element(doc, @starter_discount_price_tag_amount) == "€24.17" + assert text_of_element(doc, @starter_discount_price_tag_strikethrough_amount) == "€29" + assert text_of_element(doc, @starter_vat_notice) == "+ VAT if applicable" + + assert text_of_element(doc, @growth_price_tag_amount) == "€440" + assert text_of_element(doc, @growth_discount_price_tag_amount) == "€36.67" + assert text_of_element(doc, @growth_discount_price_tag_strikethrough_amount) == "€44" + assert text_of_element(doc, @growth_vat_notice) == "+ VAT if applicable" + + assert text_of_element(doc, @business_price_tag_amount) == "€590" + assert text_of_element(doc, @business_discount_price_tag_amount) == "€49.17" + assert text_of_element(doc, @business_discount_price_tag_strikethrough_amount) == "€59" + assert text_of_element(doc, @business_vat_notice) == "+ VAT if applicable" + end + + test "renders contact links for all tiers when enterprise-level volume selected", + %{ + conn: conn + } do + {:ok, lv, _doc} = get_liveview(conn) + + doc = set_slider(lv, "10M+") + + assert text_of_element(doc, "#starter-custom-price") =~ "Custom" + assert text_of_element(doc, @starter_plan_box) =~ "Contact us" + assert text_of_element(doc, "#growth-custom-price") =~ "Custom" + assert text_of_element(doc, @growth_plan_box) =~ "Contact us" + assert text_of_element(doc, "#business-custom-price") =~ "Custom" + assert text_of_element(doc, @business_plan_box) =~ "Contact us" + + doc = set_slider(lv, "10M") + + refute text_of_element(doc, "#starter-custom-price") =~ "Custom" + refute text_of_element(doc, @starter_plan_box) =~ "Contact us" + refute text_of_element(doc, "#growth-custom-price") =~ "Custom" + refute text_of_element(doc, @growth_plan_box) =~ "Contact us" + refute text_of_element(doc, "#business-custom-price") =~ "Custom" + refute text_of_element(doc, @business_plan_box) =~ "Contact us" + end + + test "switching billing interval changes prices", %{conn: conn} do + {:ok, lv, doc} = get_liveview(conn) + + assert text_of_element(doc, @starter_price_tag_amount) == "€90" + assert text_of_element(doc, @starter_price_tag_interval) == "/year" + + assert text_of_element(doc, @growth_price_tag_amount) == "€140" + assert text_of_element(doc, @growth_price_tag_interval) == "/year" + + assert text_of_element(doc, @business_price_tag_amount) == "€190" + assert text_of_element(doc, @business_price_tag_interval) == "/year" + + doc = element(lv, @monthly_interval_button) |> render_click() + + assert text_of_element(doc, @starter_price_tag_amount) == "€9" + assert text_of_element(doc, @starter_price_tag_interval) == "/month" + + assert text_of_element(doc, @growth_price_tag_amount) == "€14" + assert text_of_element(doc, @growth_price_tag_interval) == "/month" + + assert text_of_element(doc, @business_price_tag_amount) == "€19" + assert text_of_element(doc, @business_price_tag_interval) == "/month" + end + + test "checkout buttons are 'paddle buttons' with dynamic onclick attribute", %{ + conn: conn, + user: user + } do + {:ok, lv, _doc} = get_liveview(conn) + {:ok, team} = Plausible.Teams.get_by_owner(user) + + set_slider(lv, "200k") + doc = element(lv, @yearly_interval_button) |> render_click() + + assert %{ + "disableLogout" => true, + "email" => user.email, + "passthrough" => "ee:true;user:#{user.id};team:#{team.id}", + "product" => @v5_growth_200k_yearly_plan_id, + "success" => Routes.billing_path(PlausibleWeb.Endpoint, :upgrade_success), + "theme" => "none" + } == get_paddle_checkout_params(find(doc, @growth_checkout_button)) + + set_slider(lv, "5M") + doc = element(lv, @monthly_interval_button) |> render_click() + + assert get_paddle_checkout_params(find(doc, @starter_checkout_button))["product"] == + @v5_starter_5m_monthly_plan_id + + assert get_paddle_checkout_params(find(doc, @business_checkout_button))["product"] == + @v5_business_5m_monthly_plan_id + end + + test "warns about losing access to a feature", %{conn: conn, site: site} do + Plausible.Props.allow(site, ["author"]) + + {:ok, _lv, doc} = get_liveview(conn) + + assert text_of_attr(find(doc, @growth_checkout_button), "onclick") =~ + "if (confirm(\"This plan does not support Custom Properties, which you have been using. By subscribing to this plan, you will not have access to this feature.\")) {Paddle.Checkout.open" + end + + test "recommends Starter", %{conn: conn} do + {:ok, _lv, doc} = get_liveview(conn) + + assert text_of_element(doc, @starter_highlight_pill) == "Recommended" + refute element_exists?(doc, @growth_highlight_pill) + refute element_exists?(doc, @business_highlight_pill) + end + + test "recommends Growth", %{conn: conn, site: site} do + for _ <- 1..3, do: add_guest(site, role: :viewer) + + {:ok, _lv, doc} = get_liveview(conn) + + refute element_exists?(doc, @starter_highlight_pill) + assert text_of_element(doc, @growth_highlight_pill) == "Recommended" + refute element_exists?(doc, @business_highlight_pill) + end + + test "recommends Business", %{conn: conn, site: site} do + insert(:goal, site: site, currency: :USD, event_name: "Purchase") + + {:ok, _lv, doc} = get_liveview(conn) + + refute element_exists?(doc, @starter_highlight_pill) + assert text_of_element(doc, @business_highlight_pill) == "Recommended" + refute element_exists?(doc, @growth_highlight_pill) + end + + test "recommends Business when pending ownership site used a premium feature", %{ + conn: conn, + user: user + } do + previous_owner = insert(:user) + site = new_site(owner: previous_owner) + + insert(:goal, site: site, currency: :USD, event_name: "Purchase") + + invite_transfer(site, user, inviter: previous_owner) + + {:ok, _lv, doc} = get_liveview(conn) + + assert text_of_element(doc, @business_highlight_pill) == "Recommended" + refute element_exists?(doc, @growth_highlight_pill) + end + + test "recommends Business when team member limit for Growth exceeded due to pending ownerships", + %{conn: conn, user: user} do + owned_site = new_site(owner: user) + add_guest(owned_site, role: :editor) + add_guest(owned_site, role: :editor) + + previous_owner = new_user() + + pending_ownership_site = new_site(owner: previous_owner) + add_guest(pending_ownership_site, role: :viewer) + + invite_transfer(pending_ownership_site, user, inviter: previous_owner) + + {:ok, _lv, doc} = get_liveview(conn) + + assert text_of_element(doc, @business_highlight_pill) == "Recommended" + refute element_exists?(doc, @growth_highlight_pill) + end + + test "recommends Business when Growth site limit exceeded due to a pending ownership", %{ + conn: conn, + user: user + } do + for _ <- 1..2, do: new_site(owner: user) + assert user |> team_of() |> Plausible.Teams.Billing.site_usage() == 3 + + another_user = new_user() + pending_ownership_site = new_site(owner: another_user) + + invite_transfer(pending_ownership_site, user, inviter: another_user) + + {:ok, _lv, doc} = get_liveview(conn) + + assert text_of_element(doc, @business_highlight_pill) == "Recommended" + refute element_exists?(doc, @growth_highlight_pill) + end + + @tag :slow + test "allows upgrade to a 100k plan with a pageview allowance margin of 0.15 when trial is active", + %{conn: conn, site: site, user: user} do + Plausible.Teams.get_or_create(user) + + generate_usage_for(site, 115_000) + + {:ok, lv, _doc} = get_liveview(conn) + doc = set_slider(lv, "100k") + + refute class_of_element(doc, @starter_checkout_button) =~ "pointer-events-none" + refute class_of_element(doc, @growth_checkout_button) =~ "pointer-events-none" + refute class_of_element(doc, @business_checkout_button) =~ "pointer-events-none" + refute element_exists?(doc, @growth_plan_tooltip) + + generate_usage_for(site, 1) + + {:ok, lv, _doc} = get_liveview(conn) + doc = set_slider(lv, "100k") + + assert class_of_element(doc, @starter_checkout_button) =~ "pointer-events-none" + assert class_of_element(doc, @growth_checkout_button) =~ "pointer-events-none" + assert class_of_element(doc, @business_checkout_button) =~ "pointer-events-none" + + assert text_of_element(doc, @growth_plan_tooltip) == + "Your usage exceeds the following limit(s): Monthly pageview limit" + end + + test "allows upgrade to a 10k plan with a pageview allowance margin of 0.3 when trial ended 10 days ago", + %{conn: conn, site: site, user: user} do + user + |> team_of() + |> Ecto.Changeset.change(trial_expiry_date: Date.shift(Date.utc_today(), day: -10)) + |> Repo.update!() + + generate_usage_for(site, 13_000) + + {:ok, lv, _doc} = get_liveview(conn) + doc = set_slider(lv, "10k") + + refute class_of_element(doc, @growth_checkout_button) =~ "pointer-events-none" + refute class_of_element(doc, @business_checkout_button) =~ "pointer-events-none" + + generate_usage_for(site, 1) + + {:ok, lv, _doc} = get_liveview(conn) + doc = set_slider(lv, "10k") + + assert class_of_element(doc, @growth_checkout_button) =~ "pointer-events-none" + assert class_of_element(doc, @business_checkout_button) =~ "pointer-events-none" + end + + test "pageview allowance margin on upgrade is 0.1 when trial ended more than 10 days ago", + %{ + conn: conn, + site: site, + user: user + } do + user + |> team_of() + |> Ecto.Changeset.change(trial_expiry_date: Date.shift(Date.utc_today(), day: -11)) + |> Repo.update!() + + generate_usage_for(site, 11_000) + + {:ok, lv, _doc} = get_liveview(conn) + doc = set_slider(lv, "10k") + + refute class_of_element(doc, @growth_checkout_button) =~ "pointer-events-none" + refute class_of_element(doc, @business_checkout_button) =~ "pointer-events-none" + + generate_usage_for(site, 1) + + {:ok, lv, _doc} = get_liveview(conn) + doc = set_slider(lv, "10k") + + assert class_of_element(doc, @growth_checkout_button) =~ "pointer-events-none" + assert class_of_element(doc, @business_checkout_button) =~ "pointer-events-none" + end + end + + describe "for a user with an active v5 growth subscription plan" do + setup [:create_user, :create_site, :log_in, :subscribe_v5_growth] + + test "displays basic page content", %{conn: conn} do + {:ok, _lv, doc} = get_liveview(conn) + + assert doc =~ "Change your subscription plan" + assert doc =~ "Any other questions?" + assert doc =~ "What happens if I go over my monthly pageview limit?" + end + + test "does not render any global notices", %{conn: conn} do + {:ok, _lv, doc} = get_liveview(conn) + check_notice_titles(doc, []) + end + + test "displays plan benefits", %{conn: conn} do + {:ok, _lv, doc} = get_liveview(conn) + + starter_box = text_of_element(doc, @starter_plan_box) + growth_box = text_of_element(doc, @growth_plan_box) + business_box = text_of_element(doc, @business_plan_box) + enterprise_box = text_of_element(doc, @enterprise_plan_box) + + assert starter_box =~ "Intuitive, fast and privacy-friendly dashboard" + assert starter_box =~ "Email/Slack reports" + assert starter_box =~ "Google Analytics import" + assert starter_box =~ "Goals and custom events" + assert starter_box =~ "One site" + assert starter_box =~ "3 years of data retention" + + assert growth_box =~ "Up to 3 team members" + assert growth_box =~ "Up to 3 sites" + assert growth_box =~ "Team Management" + assert growth_box =~ "Shared Links" + assert growth_box =~ "Shared Segments" + + assert business_box =~ "Everything in Growth" + assert business_box =~ "Up to 10 team members" + assert business_box =~ "Up to 10 sites" + assert business_box =~ "Stats API (600 requests per hour)" + assert business_box =~ "Looker Studio Connector" + assert business_box =~ "Custom Properties" + assert business_box =~ "Funnels" + assert business_box =~ "Ecommerce revenue attribution" + assert business_box =~ "5 years of data retention" + + refute business_box =~ "Goals and custom events" + + assert enterprise_box =~ "Everything in Business" + assert enterprise_box =~ "10+ team members" + assert enterprise_box =~ "10+ sites" + assert enterprise_box =~ "600+ Stats API requests per hour" + assert enterprise_box =~ "Sites API access for" + assert enterprise_box =~ "Single Sign-On (SSO)" + assert enterprise_box =~ "Priority support" + assert enterprise_box =~ "5+ years of data retention" + + assert text_of_attr(find(doc, "#{@enterprise_plan_box} p a"), "href") =~ + "https://plausible.io/white-label-web-analytics" + end + + test "displays usage in the last cycle", %{conn: conn, site: site} do + yesterday = NaiveDateTime.utc_now() |> NaiveDateTime.add(-1, :day) + + populate_stats(site, [ + build(:pageview, timestamp: yesterday), + build(:pageview, timestamp: yesterday) + ]) + + {:ok, _lv, doc} = get_liveview(conn) + assert doc =~ "You have used" + assert doc =~ "2" + assert doc =~ "billable pageviews in the last billing cycle" + end + + test "renders notice about pending ownerships and counts their usage", %{ + conn: conn, + user: user, + site: site + } do + yesterday = NaiveDateTime.utc_now() |> NaiveDateTime.add(-1, :day) + + populate_stats(site, [ + build(:pageview, timestamp: yesterday) + ]) + + another_user = new_user() + + pending_site = new_site(owner: another_user) + add_guest(pending_site, role: :editor) + add_guest(pending_site, role: :viewer) + add_guest(pending_site, role: :viewer) + + populate_stats(pending_site, [ + build(:pageview, timestamp: yesterday) + ]) + + invite_transfer(pending_site, user, inviter: another_user) + + {:ok, _lv, doc} = get_liveview(conn) + + check_notice_titles(doc, [Billing.pending_site_ownerships_notice_title()]) + assert doc =~ "Your account has been invited to become the owner of a site" + + assert text_of_element(doc, @starter_plan_tooltip) =~ + "Your usage exceeds the following limit(s):" + + assert text_of_element(doc, @starter_plan_tooltip) =~ + "Team member limit" + + assert text_of_element(doc, @starter_plan_tooltip) =~ + "Site limit" + + assert text_of_element(doc, @growth_plan_tooltip) == + "Your usage exceeds the following limit(s): Team member limit" + + assert doc =~ "2" + assert doc =~ "billable pageviews in the last billing cycle" + end + + test "warns about losing access to a feature used by a pending ownership site and recommends business tier", + %{ + conn: conn, + user: user + } do + another_user = new_user() + pending_site = new_site(owner: another_user) + + Plausible.Props.allow(pending_site, ["author"]) + + invite_transfer(pending_site, user, inviter: another_user) + + {:ok, _lv, doc} = get_liveview(conn) + + assert doc =~ "Your account has been invited to become the owner of a site" + + assert text_of_attr(find(doc, @growth_checkout_button), "onclick") =~ + "if (confirm(\"This plan does not support Custom Properties, which you have been using. By subscribing to this plan, you will not have access to this feature.\")) {window.location =" + + assert text_of_element(doc, @business_highlight_pill) == "Recommended" + refute element_exists?(doc, @growth_highlight_pill) + end + + test "gets default selected interval from current subscription plan", %{ + conn: conn, + user: user + } do + {:ok, _lv, doc} = get_liveview(conn) + assert class_of_element(doc, @yearly_interval_button) =~ @interval_button_active_class + + subscribe_to_plan(user, @v4_growth_10k_monthly_plan_id) + + {:ok, _lv, doc} = get_liveview(conn) + assert class_of_element(doc, @monthly_interval_button) =~ @interval_button_active_class + end + + test "sets pageview slider according to last cycle usage", %{conn: conn} do + {:ok, _lv, doc} = get_liveview(conn) + assert text_of_element(doc, @slider_value) == "10k" + end + + test "pageview slider changes selected volume", %{conn: conn} do + {:ok, lv, _doc} = get_liveview(conn) + + doc = set_slider(lv, "100k") + assert text_of_element(doc, @slider_value) == "100k" + + doc = set_slider(lv, "10k") + assert text_of_element(doc, @slider_value) == "10k" + end + + test "makes it clear that the user is currently on a growth tier", %{conn: conn} do + {:ok, _lv, doc} = get_liveview(conn) + + class = class_of_element(doc, @growth_plan_box) + + assert class =~ "ring-2" + assert class =~ "ring-indigo-600" + assert text_of_element(doc, @growth_highlight_pill) == "Current" + end + + test "checkout button text and click-disabling CSS classes are dynamic", %{conn: conn} do + {:ok, lv, _doc} = get_liveview(conn) + + doc = set_slider(lv, "200k") + + assert text_of_element(doc, @starter_checkout_button) == "Downgrade to Starter" + assert text_of_element(doc, @growth_checkout_button) == "Currently on this plan" + assert text_of_element(doc, @business_checkout_button) == "Upgrade to Business" + + assert class_of_element(doc, @growth_checkout_button) =~ "pointer-events-none bg-gray-400" + + doc = element(lv, @monthly_interval_button) |> render_click() + + assert text_of_element(doc, @starter_checkout_button) == "Downgrade to Starter" + assert text_of_element(doc, @growth_checkout_button) == "Change billing interval" + assert text_of_element(doc, @business_checkout_button) == "Upgrade to Business" + + doc = set_slider(lv, "1M") + + assert text_of_element(doc, @starter_checkout_button) == "Downgrade to Starter" + assert text_of_element(doc, @growth_checkout_button) == "Upgrade" + assert text_of_element(doc, @business_checkout_button) == "Upgrade to Business" + + doc = set_slider(lv, "100k") + + assert text_of_element(doc, @starter_checkout_button) == "Downgrade to Starter" + assert text_of_element(doc, @growth_checkout_button) == "Downgrade" + assert text_of_element(doc, @business_checkout_button) == "Upgrade to Business" + end + + test "checkout buttons are dynamic links to /billing/change-plan/preview/", %{ + conn: conn + } do + {:ok, lv, doc} = get_liveview(conn) + + growth_checkout_button = find(doc, @growth_checkout_button) + + assert text_of_attr(growth_checkout_button, "onclick") =~ + "if (true) {window.location = '#{Routes.billing_path(conn, :change_plan_preview, @v5_growth_10k_yearly_plan_id)}'}" + + set_slider(lv, "5M") + doc = element(lv, @monthly_interval_button) |> render_click() + + starter_checkout_button = find(doc, @starter_checkout_button) + + assert text_of_attr(starter_checkout_button, "onclick") =~ + "if (true) {window.location = '#{Routes.billing_path(conn, :change_plan_preview, @v5_starter_5m_monthly_plan_id)}'}" + + business_checkout_button = find(doc, @business_checkout_button) + + assert text_of_attr(business_checkout_button, "onclick") =~ + "if (true) {window.location = '#{Routes.billing_path(conn, :change_plan_preview, @v5_business_5m_monthly_plan_id)}'}" + end + end + + describe "for a user with an active v4 business subscription plan" do + setup [:create_user, :create_site, :log_in, :subscribe_v4_business] + + test "sets pageview slider according to last cycle usage", %{conn: conn} do + {:ok, _lv, doc} = get_liveview(conn) + assert text_of_element(doc, @slider_value) == "10k" + end + + test "does not render any global notices", %{conn: conn} do + {:ok, _lv, doc} = get_liveview(conn) + check_notice_titles(doc, []) + end + + test "highlights Business box as the 'Current' tier", %{ + conn: conn, + site: site + } do + insert(:goal, site: site, currency: :USD, event_name: "Purchase") + + {:ok, _lv, doc} = get_liveview(conn) + + class = class_of_element(doc, @business_plan_box) + + assert class =~ "ring-2" + assert class =~ "ring-indigo-600" + assert text_of_element(doc, @business_highlight_pill) == "Current" + + refute element_exists?(doc, @starter_highlight_pill) + refute element_exists?(doc, @growth_highlight_pill) + end + + test "recommends Enterprise when site limit exceeds Business tier due to pending ownerships", + %{ + conn: conn, + user: user + } do + team = team_of(user) + + for _ <- 1..49 do + new_site(owner: user) + end + + assert 50 = Plausible.Teams.Billing.quota_usage(team).sites + + another_user = new_user() + pending_ownership_site = new_site(owner: another_user) + + invite_transfer(pending_ownership_site, user, inviter: another_user) + + {:ok, _lv, doc} = get_liveview(conn) + + assert text_of_element(doc, @enterprise_highlight_pill) == "Recommended" + refute element_exists?(doc, @starter_highlight_pill) + refute element_exists?(doc, @business_highlight_pill) + refute element_exists?(doc, @growth_highlight_pill) + end + + test "checkout button text and click-disabling CSS classes are dynamic", %{conn: conn} do + {:ok, lv, _doc} = get_liveview(conn) + + doc = set_slider(lv, "5M") + + assert text_of_element(doc, @starter_checkout_button) == "Downgrade to Starter" + assert text_of_element(doc, @growth_checkout_button) == "Downgrade to Growth" + assert text_of_element(doc, @business_checkout_button) == "Currently on this plan" + + assert class_of_element(doc, @business_checkout_button) =~ + "pointer-events-none bg-gray-400" + + doc = element(lv, @yearly_interval_button) |> render_click() + + assert text_of_element(doc, @starter_checkout_button) == "Downgrade to Starter" + assert text_of_element(doc, @growth_checkout_button) == "Downgrade to Growth" + assert text_of_element(doc, @business_checkout_button) == "Change billing interval" + + doc = set_slider(lv, "10M") + + assert text_of_element(doc, @starter_checkout_button) == "Downgrade to Starter" + assert text_of_element(doc, @growth_checkout_button) == "Downgrade to Growth" + assert text_of_element(doc, @business_checkout_button) == "Upgrade" + + doc = set_slider(lv, "100k") + + assert text_of_element(doc, @starter_checkout_button) == "Downgrade to Starter" + assert text_of_element(doc, @growth_checkout_button) == "Downgrade to Growth" + assert text_of_element(doc, @business_checkout_button) == "Downgrade" + end + + test "checkout is disabled when team member usage exceeds rendered plan limit", %{ + conn: conn, + site: site + } do + for _ <- 1..4, do: add_guest(site, role: :viewer) + + {:ok, _lv, doc} = get_liveview(conn) + + assert text_of_element(doc, @starter_plan_box) =~ "Your usage exceeds this plan" + assert class_of_element(doc, @starter_checkout_button) =~ "pointer-events-none" + + assert text_of_element(doc, @starter_plan_tooltip) == + "Your usage exceeds the following limit(s): Team member limit" + + assert text_of_element(doc, @growth_plan_box) =~ "Your usage exceeds this plan" + assert class_of_element(doc, @growth_checkout_button) =~ "pointer-events-none" + + assert text_of_element(doc, @growth_plan_tooltip) == + "Your usage exceeds the following limit(s): Team member limit" + end + + test "checkout is disabled when sites usage exceeds rendered plan limit", %{ + conn: conn, + user: user + } do + for _ <- 1..11, do: new_site(owner: user) + + {:ok, _lv, doc} = get_liveview(conn) + + assert text_of_element(doc, @starter_plan_box) =~ "Your usage exceeds this plan" + assert class_of_element(doc, @starter_checkout_button) =~ "pointer-events-none" + + assert text_of_element(doc, @starter_plan_tooltip) == + "Your usage exceeds the following limit(s): Site limit" + + assert text_of_element(doc, @growth_plan_box) =~ "Your usage exceeds this plan" + assert class_of_element(doc, @growth_checkout_button) =~ "pointer-events-none" + + assert text_of_element(doc, @growth_plan_tooltip) == + "Your usage exceeds the following limit(s): Site limit" + end + + test "when more than one limit is exceeded, the tooltip enumerates them", %{ + conn: conn, + user: user + } do + for _ <- 1..11, do: new_site(owner: user) + + site = new_site(owner: user) + for _ <- 1..4, do: add_guest(site, role: :viewer) + + {:ok, _lv, doc} = get_liveview(conn) + + assert text_of_element(doc, @starter_plan_tooltip) =~ "Team member limit" + assert text_of_element(doc, @starter_plan_tooltip) =~ "Site limit" + + assert text_of_element(doc, @growth_plan_tooltip) =~ "Team member limit" + assert text_of_element(doc, @growth_plan_tooltip) =~ "Site limit" + end + + test "checkout is not disabled when pageview usage exceeded but next upgrade allowed by override", + %{ + conn: conn, + user: user, + site: site + } do + now = NaiveDateTime.utc_now() + + generate_usage_for(site, 11_000, NaiveDateTime.shift(now, day: -5)) + generate_usage_for(site, 11_000, NaiveDateTime.shift(now, day: -35)) + + user + |> team_of() + |> Ecto.Changeset.change(allow_next_upgrade_override: true) + |> Plausible.Repo.update!() + + {:ok, lv, _doc} = get_liveview(conn) + + doc = set_slider(lv, "10k") + + refute text_of_element(doc, @growth_plan_box) =~ "Your usage exceeds this plan" + refute class_of_element(doc, @growth_checkout_button) =~ "pointer-events-none" + end + + test "warns about losing access to a feature", %{conn: conn, user: user, site: site} do + Plausible.Props.allow(site, ["author"]) + insert(:goal, currency: :USD, site: site, event_name: "Purchase") + insert(:api_key, user: user) + + {:ok, _lv, doc} = get_liveview(conn) + + assert text_of_attr(find(doc, @growth_checkout_button), "onclick") =~ + "if (confirm(\"This plan does not support Custom Properties, Revenue Goals and Stats API, which you have been using. By subscribing to this plan, you will not have access to these features.\")) {window.location = " + end + end + + describe "for a user with a v3 business (unlimited team members) subscription plan" do + setup [:create_user, :create_site, :log_in] + + setup %{user: user} = context do + create_subscription_for(user, paddle_plan_id: @v3_business_10k_monthly_plan_id) + {:ok, context} + end + + test "does not render any global notices", %{conn: conn} do + {:ok, _lv, doc} = get_liveview(conn) + check_notice_titles(doc, []) + end + + test "displays plan benefits", %{conn: conn} do + {:ok, _lv, doc} = get_liveview(conn) + + starter_box = text_of_element(doc, @starter_plan_box) + growth_box = text_of_element(doc, @growth_plan_box) + business_box = text_of_element(doc, @business_plan_box) + enterprise_box = text_of_element(doc, @enterprise_plan_box) + + assert starter_box =~ "Intuitive, fast and privacy-friendly dashboard" + assert starter_box =~ "Email/Slack reports" + assert starter_box =~ "Google Analytics import" + assert starter_box =~ "Goals and custom events" + assert starter_box =~ "One site" + assert starter_box =~ "3 years of data retention" + + assert growth_box =~ "Up to 3 team members" + assert growth_box =~ "Up to 3 sites" + assert growth_box =~ "Team Management" + assert growth_box =~ "Shared Links" + assert growth_box =~ "Shared Segments" + + assert business_box =~ "Everything in Growth" + assert business_box =~ "Unlimited team members" + assert business_box =~ "Up to 50 sites" + assert business_box =~ "Stats API (600 requests per hour)" + assert business_box =~ "Looker Studio Connector" + assert business_box =~ "Custom Properties" + assert business_box =~ "Funnels" + assert business_box =~ "Ecommerce revenue attribution" + + refute business_box =~ "Goals and custom events" + + assert enterprise_box =~ "Everything in Business" + assert enterprise_box =~ "50+ sites" + assert enterprise_box =~ "600+ Stats API requests per hour" + assert enterprise_box =~ "Sites API access for" + assert enterprise_box =~ "Single Sign-On (SSO)" + assert enterprise_box =~ "Priority support" + + refute enterprise_box =~ "team members" + + assert text_of_attr(find(doc, "#{@enterprise_plan_box} p a"), "href") =~ + "https://plausible.io/white-label-web-analytics" + end + end + + describe "for a user with a past_due subscription" do + setup [:create_user, :create_site, :log_in, :create_past_due_subscription] + + test "renders failed payment notice and link to update billing details", %{conn: conn} do + {:ok, _lv, doc} = get_liveview(conn) + check_notice_titles(doc, [Billing.subscription_past_due_notice_title()]) + assert doc =~ "There was a problem with your latest payment" + assert doc =~ "https://update.billing.details" + end + + test "checkout buttons are disabled + notice about billing details (unless plan owned already)", + %{conn: conn} do + {:ok, lv, _doc} = get_liveview(conn) + + doc = set_slider(lv, "200k") + + assert class_of_element(doc, @growth_checkout_button) =~ "pointer-events-none bg-gray-400" + assert text_of_element(doc, @growth_checkout_button) =~ "Currently on this plan" + refute element_exists?(doc, "#{@growth_checkout_button} + div") + + assert class_of_element(doc, @business_checkout_button) =~ + "pointer-events-none bg-gray-400" + + assert text_of_element(doc, "#{@business_checkout_button} + div") =~ + "Please update your billing details first" + + doc = set_slider(lv, "1M") + + assert class_of_element(doc, @growth_checkout_button) =~ "pointer-events-none bg-gray-400" + + assert text_of_element(doc, "#{@growth_checkout_button} + div") =~ + "Please update your billing details first" + end + end + + describe "for a user with a paused v4 subscription" do + setup [:create_user, :create_site, :log_in, :create_paused_subscription] + + test "renders subscription paused notice and link to update billing details", %{conn: conn} do + {:ok, _lv, doc} = get_liveview(conn) + check_notice_titles(doc, [Billing.subscription_paused_notice_title()]) + assert doc =~ "Your subscription is paused due to failed payments" + assert doc =~ "https://update.billing.details" + end + + test "checkout buttons are disabled + notice about billing details when plan not owned already", + %{conn: conn} do + {:ok, lv, _doc} = get_liveview(conn) + + doc = set_slider(lv, "200k") + + assert class_of_element(doc, @growth_checkout_button) =~ "pointer-events-none bg-gray-400" + assert text_of_element(doc, @growth_checkout_button) =~ "Currently on this plan" + refute element_exists?(doc, "#{@growth_checkout_button} + div") + + assert class_of_element(doc, @business_checkout_button) =~ + "pointer-events-none bg-gray-400" + + assert text_of_element(doc, "#{@business_checkout_button} + div") =~ + "Please update your billing details first" + + doc = set_slider(lv, "1M") + + assert class_of_element(doc, @growth_checkout_button) =~ "pointer-events-none bg-gray-400" + + assert text_of_element(doc, "#{@growth_checkout_button} + div") =~ + "Please update your billing details first" + end + end + + describe "for a user with a cancelled, but still active v4 subscription" do + setup [:create_user, :create_site, :log_in, :create_cancelled_subscription] + + test "does not render any global notices", %{conn: conn} do + {:ok, _lv, doc} = get_liveview(conn) + check_notice_titles(doc, []) + end + + test "checkout buttons are paddle buttons", %{conn: conn} do + {:ok, _lv, doc} = get_liveview(conn) + + assert text_of_attr(find(doc, @growth_checkout_button), "onclick") =~ + "Paddle.Checkout.open" + + assert text_of_attr(find(doc, @business_checkout_button), "onclick") =~ + "Paddle.Checkout.open" + end + + test "currently owned tier is highlighted", %{conn: conn} do + {:ok, _lv, doc} = get_liveview(conn) + assert text_of_element(doc, @growth_highlight_pill) == "Current" + end + + test "can subscribe again to the currently owned (but cancelled) plan", %{conn: conn} do + {:ok, _lv, doc} = get_liveview(conn) + refute class_of_element(doc, @growth_checkout_button) =~ "pointer-events-none" + end + end + + describe "for a user with a cancelled and expired subscription" do + setup [:create_user, :create_site, :log_in, :create_cancelled_subscription] + + setup %{user: user} do + user + |> team_of() + |> Repo.preload(:subscription) + |> Map.fetch!(:subscription) + |> Subscription.changeset(%{ + next_bill_date: DateTime.shift(DateTime.utc_now(), month: -2) + }) + |> Repo.update!() + + :ok + end + + test "does not render any global notices", %{conn: conn} do + {:ok, _lv, doc} = get_liveview(conn) + check_notice_titles(doc, []) + end + + test "highlights recommended tier", %{conn: conn} do + {:ok, _lv, doc} = get_liveview(conn) + assert text_of_element(doc, @starter_highlight_pill) == "Recommended" + refute element_exists?(doc, @growth_highlight_pill) + refute element_exists?(doc, @business_highlight_pill) + end + end + + describe "for a grandfathered user with a high volume plan" do + setup [:create_user, :create_site, :log_in] + + test "does not render any global notices", %{conn: conn, user: user} do + create_subscription_for(user, paddle_plan_id: @v1_50m_yearly_plan_id) + {:ok, _lv, doc} = get_liveview(conn) + check_notice_titles(doc, []) + end + + test "on a 50M v1 plan, Starter plans are not rendered, Growth plans are available at 20M, 50M, 50M+, and Business plans are up to 10M", + %{conn: conn, user: user} do + create_subscription_for(user, paddle_plan_id: @v1_50m_yearly_plan_id) + + {:ok, lv, doc} = get_liveview(conn) + + refute element_exists?(doc, @starter_plan_box) + + doc = set_slider(lv, 8) + assert text_of_element(doc, @slider_value) == "20M" + assert text_of_element(doc, @business_plan_box) =~ "Contact us" + assert text_of_element(doc, @growth_price_tag_amount) == "€1,800" + assert text_of_element(doc, @growth_price_tag_interval) == "/year" + + doc = set_slider(lv, 9) + assert text_of_element(doc, @slider_value) == "50M" + assert text_of_element(doc, @business_plan_box) =~ "Contact us" + assert text_of_element(doc, @growth_price_tag_amount) == "€2,640" + assert text_of_element(doc, @growth_price_tag_interval) == "/year" + + doc = set_slider(lv, 10) + assert text_of_element(doc, @slider_value) == "50M+" + assert text_of_element(doc, @business_plan_box) =~ "Contact us" + assert text_of_element(doc, @growth_plan_box) =~ "Contact us" + + doc = set_slider(lv, 7) + assert text_of_element(doc, @slider_value) == "10M" + refute text_of_element(doc, @business_plan_box) =~ "Contact us" + refute text_of_element(doc, @growth_plan_box) =~ "Contact us" + end + + test "on a 20M v2 plan, Growth tiers are available at 20M and 20M+, but not 50M", + %{conn: conn, user: user} do + create_subscription_for(user, paddle_plan_id: @v2_20m_yearly_plan_id) + + {:ok, lv, _doc} = get_liveview(conn) + + doc = set_slider(lv, 8) + assert text_of_element(doc, @slider_value) == "20M" + assert text_of_element(doc, @growth_price_tag_amount) == "€2,250" + assert text_of_element(doc, @growth_price_tag_interval) == "/year" + + doc = set_slider(lv, 9) + assert text_of_element(doc, @slider_value) == "20M+" + assert text_of_element(doc, @growth_plan_box) =~ "Contact us" + end + end + + describe "for a grandfathered user on a v1 10k plan" do + setup [:create_user, :create_site, :log_in] + + setup %{user: user} = context do + create_subscription_for(user, paddle_plan_id: @v1_10k_yearly_plan_id) + {:ok, context} + end + + test "does not render any global notices", %{conn: conn} do + {:ok, _lv, doc} = get_liveview(conn) + check_notice_titles(doc, []) + end + + test "v1 20M and 50M Growth plans are not available", + %{conn: conn} do + {:ok, lv, _doc} = get_liveview(conn) + + doc = set_slider(lv, 8) + assert text_of_element(doc, @slider_value) == "10M+" + assert text_of_element(doc, @growth_plan_box) =~ "Contact us" + end + + test "displays plan benefits", %{conn: conn} do + {:ok, _lv, doc} = get_liveview(conn) + + refute element_exists?(doc, @starter_plan_box) + growth_box = text_of_element(doc, @growth_plan_box) + business_box = text_of_element(doc, @business_plan_box) + enterprise_box = text_of_element(doc, @enterprise_plan_box) + + assert growth_box =~ "Up to 50 sites" + assert growth_box =~ "Unlimited team members" + assert growth_box =~ "Team Management" + assert growth_box =~ "Saved Segments" + assert growth_box =~ "Annotations" + assert growth_box =~ "Goals and custom events" + assert growth_box =~ "Custom Properties" + assert growth_box =~ "Stats API (600 requests per hour)" + assert growth_box =~ "Looker Studio Connector" + assert growth_box =~ "Shared Links" + assert growth_box =~ "Embedded Dashboards" + + assert business_box =~ "Everything in Growth" + assert business_box =~ "Funnels" + assert business_box =~ "Ecommerce revenue attribution" + + refute business_box =~ "Goals and custom events" + refute business_box =~ "Unlimited team members" + refute business_box =~ "Up to 50 sites" + refute business_box =~ "Stats API (600 requests per hour)" + refute business_box =~ "Looker Studio Connector" + refute business_box =~ "Custom Properties" + + assert enterprise_box =~ "Everything in Business" + assert enterprise_box =~ "50+ sites" + assert enterprise_box =~ "600+ Stats API requests per hour" + assert enterprise_box =~ "Sites API access for" + assert enterprise_box =~ "Single Sign-On (SSO)" + assert enterprise_box =~ "Priority support" + + assert text_of_attr(find(doc, "#{@enterprise_plan_box} p a"), "href") =~ + "https://plausible.io/white-label-web-analytics" + + refute enterprise_box =~ "10+ team members" + refute enterprise_box =~ "Unlimited team members" + end + end + + describe "for a user without a trial_expiry_date (invited user) who owns a site (transferred)" do + setup [:create_user, :create_site, :log_in] + + setup %{user: user} do + user + |> team_of() + |> Ecto.Changeset.change(trial_expiry_date: nil) + |> Repo.update!() + + :ok + end + + test "does not render any global notices", %{conn: conn} do + {:ok, _lv, doc} = get_liveview(conn) + check_notice_titles(doc, []) + end + + test "allows to upgrade", %{conn: conn} do + {:ok, lv, _doc} = get_liveview(conn) + doc = set_slider(lv, "100k") + + refute class_of_element(doc, @starter_checkout_button) =~ "pointer-events-none" + refute class_of_element(doc, @growth_checkout_button) =~ "pointer-events-none" + refute class_of_element(doc, @business_checkout_button) =~ "pointer-events-none" + end + end + + describe "for a free_10k subscription" do + setup [:create_user, :create_site, :log_in, :subscribe_free_10k] + + test "recommends starter tier", %{conn: conn} do + {:ok, _lv, doc} = get_liveview(conn) + assert element_exists?(doc, @starter_highlight_pill) + refute element_exists?(doc, @growth_highlight_pill) + refute element_exists?(doc, @business_highlight_pill) + end + + test "recommends Business tier when premium features used", %{conn: conn, site: site} do + insert(:goal, currency: :USD, site: site, event_name: "Purchase") + + {:ok, _lv, doc} = get_liveview(conn) + + assert text_of_element(doc, @business_plan_box) =~ "Recommended" + refute text_of_element(doc, @growth_plan_box) =~ "Recommended" + refute text_of_element(doc, @starter_plan_box) =~ "Recommended" + end + + test "renders Paddle upgrade buttons", %{conn: conn, user: user} do + {:ok, lv, _doc} = get_liveview(conn) + {:ok, team} = Plausible.Teams.get_by_owner(user) + + set_slider(lv, "200k") + doc = element(lv, @yearly_interval_button) |> render_click() + + assert %{ + "disableLogout" => true, + "email" => user.email, + "passthrough" => "ee:true;user:#{user.id};team:#{team.id}", + "product" => @v5_growth_200k_yearly_plan_id, + "success" => Routes.billing_path(PlausibleWeb.Endpoint, :upgrade_success), + "theme" => "none" + } == get_paddle_checkout_params(find(doc, @growth_checkout_button)) + end + end + + describe "for a user with no sites" do + setup [:create_user, :log_in] + + test "does not allow to subscribe and renders notice", %{conn: conn} do + {:ok, _lv, doc} = get_liveview(conn) + + check_notice_titles(doc, [Billing.upgrade_ineligible_notice_title()]) + + assert text_of_element(doc, "#upgrade-eligible-notice") =~ + "You cannot start a subscription" + + assert class_of_element(doc, @starter_checkout_button) =~ "pointer-events-none" + assert class_of_element(doc, @growth_checkout_button) =~ "pointer-events-none" + assert class_of_element(doc, @business_checkout_button) =~ "pointer-events-none" + end + end + + describe "for a user with no sites but pending ownership transfer" do + setup [:create_user, :log_in] + + setup %{user: user} do + old_owner = new_user() + site = new_site(owner: old_owner) + invite_transfer(site, user, inviter: old_owner) + + :ok + end + + test "renders only the pending ownership transfer notice", %{conn: conn} do + {:ok, _lv, doc} = get_liveview(conn) + check_notice_titles(doc, [Billing.pending_site_ownerships_notice_title()]) + end + + test "allows to subscribe", %{conn: conn} do + {:ok, _lv, doc} = get_liveview(conn) + + assert text_of_element(doc, @starter_plan_box) =~ "Recommended" + refute class_of_element(doc, @growth_checkout_button) =~ "pointer-events-none" + refute class_of_element(doc, @business_checkout_button) =~ "pointer-events-none" + end + end + + # Checks the given HTML document for the presence of all possible billing + # notices. For those expected, we assert that only one is present. Others + # should not appear in the document. + defp check_notice_titles(doc, expected) do + [ + Billing.dashboard_locked_notice_title(), + Billing.active_grace_period_notice_title(), + Billing.subscription_cancelled_notice_title(), + Billing.subscription_past_due_notice_title(), + Billing.subscription_paused_notice_title(), + Billing.upgrade_ineligible_notice_title(), + Billing.pending_site_ownerships_notice_title() + ] + |> Enum.each(fn title -> + if title in expected do + assert length(String.split(doc, title)) == 2 + else + refute doc =~ title + end + end) + end + + defp subscribe_v5_growth(%{user: user}) do + create_subscription_for(user, paddle_plan_id: @v5_growth_200k_yearly_plan_id) + end + + defp subscribe_v4_business(%{user: user}) do + create_subscription_for(user, paddle_plan_id: @v4_business_5m_monthly_plan_id) + end + + defp create_past_due_subscription(%{user: user}) do + create_subscription_for(user, + paddle_plan_id: @v4_growth_200k_yearly_plan_id, + status: Subscription.Status.past_due(), + update_url: "https://update.billing.details" + ) + end + + defp create_paused_subscription(%{user: user}) do + create_subscription_for(user, + paddle_plan_id: @v4_growth_200k_yearly_plan_id, + status: Subscription.Status.paused(), + update_url: "https://update.billing.details" + ) + end + + defp create_cancelled_subscription(%{user: user}) do + create_subscription_for(user, + paddle_plan_id: @v4_growth_200k_yearly_plan_id, + status: Subscription.Status.deleted() + ) + end + + defp create_subscription_for(user, subscription_opts) do + {paddle_plan_id, subscription_opts} = Keyword.pop(subscription_opts, :paddle_plan_id) + + user = + subscribe_to_plan(user, paddle_plan_id, subscription_opts) + + {:ok, user: user} + end + + defp subscribe_free_10k(%{user: user}) do + user = subscribe_to_plan(user, "free_10k") + {:ok, user: user} + end + + defp get_liveview(conn) do + conn = assign(conn, :live_module, PlausibleWeb.Live.ChoosePlan) + {:ok, _lv, _doc} = live(conn, Routes.billing_path(conn, :choose_plan)) + end + + defp get_paddle_checkout_params(element) do + with onclick <- text_of_attr(element, "onclick"), + [[_, checkout_params_str]] <- Regex.scan(~r/Paddle\.Checkout\.open\((.*?)\)/, onclick), + {:ok, checkout_params} <- Jason.decode(checkout_params_str) do + checkout_params + end + end + + defp set_slider(lv, volume) when is_binary(volume) do + index = Enum.find_index(@slider_volumes, &(&1 == volume)) + set_slider(lv, index) + end + + defp set_slider(lv, index) do + lv + |> element(@slider_input) + |> render_change(%{slider: index}) + end + end +end diff --git a/test/plausible_web/live/components/combo_box/static_search_test.exs b/test/plausible_web/live/components/combo_box/static_search_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..81ed0319698c752bb67cb76d0543b33d941bf644 --- /dev/null +++ b/test/plausible_web/live/components/combo_box/static_search_test.exs @@ -0,0 +1,52 @@ +defmodule PlausibleWeb.Live.Components.ComboBox.StaticSearchTest do + use ExUnit.Case, async: true + + alias PlausibleWeb.Live.Components.ComboBox.StaticSearch + + describe "autosuggest algorithm" do + test "favours exact match" do + options = fake_options(["yellow", "hello", "cruel hello world"]) + + assert [{_, "hello"}, {_, "cruel hello world"}, {_, "yellow"}] = + StaticSearch.suggest("hello", options) + end + + test "skips entries shorter than input" do + options = fake_options(["yellow", "hello", "cruel hello world"]) + + assert [{_, "cruel hello world"}] = StaticSearch.suggest("cruel hello", options) + end + + test "favours similarity" do + options = fake_options(["melon", "hello", "yellow"]) + assert [{_, "hello"}, {_, "yellow"}, {_, "melon"}] = StaticSearch.suggest("hell", options) + end + + test "allows fuzzy matching" do + options = fake_options(["/url/0xC0FFEE", "/url/0xDEADBEEF", "/url/other"]) + assert [{_, "/url/0xC0FFEE"}] = StaticSearch.suggest("0x FF", options) + end + + test "filters out bad matches" do + options = fake_options(["OS", "Version", "Logged In"]) + assert [] = StaticSearch.suggest("cow", options) + end + + test "uses custom to_string/1 function" do + options = fake_options([%{key: "OS"}, %{key: "Version"}, %{key: "Logged In"}]) + assert [{_, %{key: "Version"}}] = StaticSearch.suggest("vers", options, to_string: & &1.key) + end + + test "accepts custom jaro distance threshold" do + options = fake_options(["Joanna", "Joel", "John"]) + assert [_, _, _] = StaticSearch.suggest("Joa", options, weight_threshold: 0.5) + assert [{_, "Joanna"}] = StaticSearch.suggest("Joa", options, weight_threshold: 0.9) + end + end + + defp fake_options(option_names) do + option_names + |> Enum.shuffle() + |> Enum.with_index(fn element, index -> {index, element} end) + end +end diff --git a/test/plausible_web/live/components/combo_box_test.exs b/test/plausible_web/live/components/combo_box_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..f3c8cf4fbaa8b15aecbd22ee81ee86962699d47c --- /dev/null +++ b/test/plausible_web/live/components/combo_box_test.exs @@ -0,0 +1,433 @@ +defmodule PlausibleWeb.Live.Components.ComboBoxTest do + use PlausibleWeb.ConnCase, async: true + import Phoenix.LiveViewTest + + alias PlausibleWeb.Live.Components.ComboBox + + @ul "ul#dropdown-test-component[x-show=isOpen][x-ref=suggestions]" + + describe "static rendering" do + test "renders suggestions" do + assert doc = render_sample_component(new_options(10)) + + assert element_exists?( + doc, + ~s/input#test-component[name="display-test-component"][phx-change="search"]/ + ) + + assert element_exists?(doc, @ul) + + for i <- 1..10 do + assert element_exists?(doc, suggestion_li(i)) + end + end + + test "renders preselected default value (tuple)" do + options = new_options(10) + assert doc = render_sample_component(options, selected: List.last(options)) + + assert element_exists?(doc, ~s|input[type="hidden"][name="test-submit-name"][value="10"]|) + + assert element_exists?( + doc, + ~s|input[type="text"][name="display-test-component"][value="TestOption 10"]| + ) + end + + test "renders preselected default value (string)" do + options = [{"a", "a"}, {"b", "b"}, {"c", "c"}] + assert doc = render_sample_component(options, selected: "b") + + assert element_exists?(doc, "input[type=hidden][name=test-submit-name][value=b]") + + assert element_exists?( + doc, + ~s|input[type=text][name=display-test-component][value="b"]| + ) + end + + test "renders up to 15 suggestions by default" do + assert doc = render_sample_component(new_options(20)) + + assert element_exists?(doc, suggestion_li(14)) + assert element_exists?(doc, suggestion_li(15)) + + refute element_exists?(doc, suggestion_li(16)) + refute element_exists?(doc, suggestion_li(17)) + + assert text(doc) =~ "Max results reached" + end + + test "renders up to n suggestions if provided" do + assert doc = render_sample_component(new_options(20), suggestions_limit: 10) + + assert element_exists?(doc, suggestion_li(9)) + assert element_exists?(doc, suggestion_li(10)) + + refute element_exists?(doc, suggestion_li(11)) + refute element_exists?(doc, suggestion_li(12)) + end + + test "Alpine.js: renders attrs focusing suggestion elements" do + assert doc = render_sample_component(new_options(10)) + li1 = doc |> find(suggestion_li(1)) + li2 = doc |> find(suggestion_li(2)) + + assert text_of_attr(li1, "@mouseenter") == "setFocus(1)" + assert text_of_attr(li2, "@mouseenter") == "setFocus(2)" + + assert text_of_attr(li1, "x-bind:class") =~ "focus === 1" + assert text_of_attr(li2, "x-bind:class") =~ "focus === 2" + end + + test "Alpine.js: component sets up keyboard navigation" do + main = + new_options(2) + |> render_sample_component() + |> find("div#input-picker-main-test-component") + + assert text_of_attr(main, "x-on:keydown.arrow-up.prevent") == "focusPrev" + assert text_of_attr(main, "x-on:keydown.arrow-down.prevent") == "focusNext" + assert text_of_attr(main, "x-on:keydown.enter.prevent") == "select" + end + + test "Alpine.js: component sets up close on click-away" do + assert new_options(2) + |> render_sample_component() + |> find("div#input-picker-main-test-component div div") + |> text_of_attr("@click.away") == "close" + end + + test "Alpine.js: component sets up open on focusing the display input" do + assert new_options(2) + |> render_sample_component() + |> find("input#test-component") + |> text_of_attr("x-on:focus") == "open" + end + + test "Alpine.js: dropdown is annotated and shows when isOpen is true" do + dropdown = + new_options(2) + |> render_sample_component() + |> find("#dropdown-test-component") + + assert text_of_attr(dropdown, "x-show") == "isOpen" + assert text_of_attr(dropdown, "x-ref") == "suggestions" + end + + test "Dropdown shows a notice when no suggestions exist" do + doc = render_sample_component([]) + + assert text_of_element(doc, "#dropdown-test-component") == + "No matches found. Try searching for something different." + end + + test "dropdown suggests user input when creatable" do + doc = + render_sample_component([{"USD", "US Dollar"}, {"EUR", "Euro"}], + creatable: true, + display_value: "Brazilian Real" + ) + + assert text_of_element(doc, "#dropdown-test-component-option-0") == + ~s(Create "Brazilian Real") + + assert text_of_element(doc, "#dropdown-test-component-option-1") == "US Dollar" + assert text_of_element(doc, "#dropdown-test-component-option-2") == "Euro" + + refute text_of_element(doc, "#dropdown-test-component") == + "No matches found. Try searching for something different." + end + + test "when no options available, hints the user to create one by typing" do + doc = + render_sample_component([], + creatable: true + ) + + assert text_of_element(doc, "#dropdown-test-component div") == "Create an item by typing." + end + + test "makes the html input required when required option is passed" do + input_query = "input[type=text][required]" + assert render_sample_component([], required: true) |> element_exists?(input_query) + refute render_sample_component([]) |> element_exists?(input_query) + end + + test "adds class to html element when class option is passed" do + assert render_sample_component([], class: "animate-spin") + |> element_exists?("#input-picker-main-test-component.animate-spin") + end + end + + describe "integration" do + defmodule SampleView do + use Phoenix.LiveView + + defmodule SampleSuggest do + def suggest("Echo me", options) do + [{length(options), "Echo me"}] + end + + def suggest("all", options) do + options + end + end + + def render(assigns) do + ~H""" + <.live_component + submit_name="some_submit_name" + module={PlausibleWeb.Live.Components.ComboBox} + suggest_fun={&SampleSuggest.suggest/2} + id="test-component" + options={for i <- 1..20, do: {i, "Option #{i}"}} + suggestions_limit={7} + /> + """ + end + end + + test "uses the suggestions module", %{conn: conn} do + {:ok, lv, _html} = live_isolated(conn, SampleView, session: %{}) + doc = type_into_combo(lv, "test-component", "Echo me") + assert text_of_element(doc, "#dropdown-test-component-option-1") == "Echo me" + end + + test "stores selected value", %{conn: conn} do + {:ok, lv, _html} = live_isolated(conn, SampleView, session: %{}) + type_into_combo(lv, "test-component", "Echo me") + + doc = + lv + |> element("li#dropdown-test-component-option-1 a") + |> render_click() + + assert element_exists?(doc, ~s|input[type="hidden"][name="some_submit_name"][value="20"]|) + end + + test "limits the suggestions", %{conn: conn} do + {:ok, lv, _html} = live_isolated(conn, SampleView, session: %{}) + doc = type_into_combo(lv, "test-component", "all") + + assert element_exists?(doc, suggestion_li(6)) + assert element_exists?(doc, suggestion_li(7)) + + refute element_exists?(doc, suggestion_li(8)) + refute element_exists?(doc, suggestion_li(9)) + end + + test "clearing search input resets to all options", %{conn: conn} do + {:ok, lv, _html} = live_isolated(conn, SampleView, session: %{}) + type_into_combo(lv, "test-component", "Echo me") + doc = type_into_combo(lv, "test-component", "") + + for i <- 1..7, do: assert(element_exists?(doc, suggestion_li(i))) + end + end + + describe "creatable integration" do + defmodule CreatableView do + use Phoenix.LiveView + + def render(assigns) do + ~H""" + <.live_component + submit_name="some_submit_name" + module={PlausibleWeb.Live.Components.ComboBox} + suggest_fun={&ComboBox.StaticSearch.suggest/2} + id="test-creatable-component" + options={for i <- 1..20, do: {i, "Option #{i}"}} + creatable + /> + """ + end + end + + test "stores selected value from suggestion", %{conn: conn} do + {:ok, lv, _html} = live_isolated(conn, CreatableView, session: %{}) + type_into_combo(lv, "test-creatable-component", "option 20") + + doc = + lv + |> element("li#dropdown-test-creatable-component-option-1 a") + |> render_click() + + assert element_exists?(doc, ~s|input[type="hidden"][name="some_submit_name"][value="20"]|) + end + + test "suggests creating custom value", %{conn: conn} do + {:ok, lv, _html} = live_isolated(conn, CreatableView, session: %{}) + + assert lv + |> type_into_combo("test-creatable-component", "my new option") + |> text_of_element("li#dropdown-test-creatable-component-option-0 a") == + ~s(Create "my new option") + end + + test "does not suggest creating value when input exact matches a suggestion", %{conn: conn} do + {:ok, lv, _html} = live_isolated(conn, CreatableView, session: %{}) + + assert lv + |> type_into_combo("test-creatable-component", "Option 1") + |> text_of_element("li#dropdown-test-creatable-component-option-1 a") == ~s(Option 1) + end + + test "stores new value by clicking on the dropdown custom option", %{conn: conn} do + {:ok, lv, _html} = live_isolated(conn, CreatableView, session: %{}) + type_into_combo(lv, "test-creatable-component", "my new option") + + doc = + lv + |> element("li#dropdown-test-creatable-component-option-0 a") + |> render_click() + + assert element_exists?( + doc, + "input[type=hidden][name=some_submit_name][value=\"my new option\"]" + ) + end + + test "stores new value while typing", %{conn: conn} do + {:ok, lv, _html} = live_isolated(conn, CreatableView, session: %{}) + + assert lv + |> type_into_combo("test-creatable-component", "my new option") + |> element_exists?( + "input[type=hidden][name=some_submit_name][value=\"my new option\"]" + ) + end + + test "selectionInProgress doesn't apply when no server roundtrip is needed", %{conn: conn} do + {:ok, lv, html} = live_isolated(conn, CreatableView, session: %{}) + regular_option = find(html, "li#dropdown-test-creatable-component-option-1 a") + + assert text_of_attr(regular_option, "x-on:click") == "selectionInProgress = true" + + creatable_option = + lv + |> type_into_combo("test-creatable-component", "my new option") + |> find("input[type=hidden][name=some_submit_name][value=\"my new option\"]") + + assert text_of_attr(creatable_option, "x-on:click") == nil + end + end + + describe "async suggestions" do + defmodule SampleViewAsync do + use Phoenix.LiveView + + defmodule SampleSuggest do + def suggest("", []) do + :timer.sleep(600) + [{1, "One"}, {2, "Two"}, {3, "Three"}] + end + + def suggest("Echo me", _options) do + :timer.sleep(510) + [{1, "Echo me"}] + end + end + + def render(assigns) do + ~H""" + <.live_component + submit_name="some_submit_name" + module={PlausibleWeb.Live.Components.ComboBox} + suggest_fun={&SampleSuggest.suggest/2} + id="test-component" + async={true} + suggestions_limit={7} + /> + """ + end + end + + test "options are empty at immediate render" do + doc = + render_component( + ComboBox, + submit_name: "test-submit-name", + id: "test-component", + suggest_fun: &ComboBox.StaticSearch.suggest/2, + async: true + ) + + refute element_exists?(doc, "#dropdown-test-component-option-1") + end + + @tag :slow + test "pre-fills the suggestions asynchronously", %{conn: conn} do + {:ok, lv, doc} = live_isolated(conn, SampleViewAsync, session: %{}) + refute element_exists?(doc, "#dropdown-test-component-option-1") + + assert eventually( + fn -> + doc = render(lv) + + { + text_of_element(doc, "#dropdown-test-component-option-1") == "One" && + text_of_element(doc, "#dropdown-test-component-option-2") == "Two" && + text_of_element(doc, "#dropdown-test-component-option-3") == "Three", + true + } + end, + 50, + 50 + ) + end + + @tag :slow + test "uses the suggestions function asynchronously", %{conn: conn} do + {:ok, lv, _html} = live_isolated(conn, SampleViewAsync, session: %{}) + doc = type_into_combo(lv, "test-component", "Echo me") + refute element_exists?(doc, "#dropdown-test-component-option-1") + + assert eventually( + fn -> + doc = render(lv) + + { + element_exists?(doc, "#dropdown-test-component-option-1") && + text_of_element(doc, "#dropdown-test-component-option-1") == "Echo me", + true + } + end, + 50, + 50 + ) + end + end + + defp render_sample_component(options, extra_opts \\ []) do + render_component( + ComboBox, + Keyword.merge( + [ + options: options, + submit_name: "test-submit-name", + id: "test-component", + suggest_fun: &ComboBox.StaticSearch.suggest/2 + ], + extra_opts + ) + ) + end + + defp new_options(n) do + Enum.map(1..n, &{&1, "TestOption #{&1}"}) + end + + defp suggestion_li(idx) do + ~s/#{@ul} li#dropdown-test-component-option-#{idx}/ + end + + defp type_into_combo(lv, id, text) do + lv + |> element("input##{id}") + |> render_change(%{ + "_target" => ["display-#{id}"], + "display-#{id}" => "#{text}" + }) + end +end diff --git a/test/plausible_web/live/components/form_test.exs b/test/plausible_web/live/components/form_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..de293f4690bbc49a88ee03250923d8eb440856c2 --- /dev/null +++ b/test/plausible_web/live/components/form_test.exs @@ -0,0 +1,181 @@ +defmodule PlausibleWeb.Live.Components.FormTest do + use PlausibleWeb.ConnCase, async: true + import Phoenix.LiveViewTest, only: [render_component: 2] + + alias Plausible.Auth.User + alias PlausibleWeb.Live.Components.Form + + describe "password_input_with_strength/1" do + test "renders for correct, strong password" do + doc = render_password_input_with_strength("very-secret-and-very-long-123") + + assert element_exists?(doc, ~s/input#user_password[type="password"][name="user[password]"]/) + refute element_exists?(doc, "label") + assert text_of_element(doc, "p") =~ "Password strength:" + assert text_of_element(doc, "p") =~ "Strong" + end + + test "renders with label when passed" do + doc = + render_password_input_with_strength( + "very-secret-and-very-long-123", + label: "New password" + ) + + assert element_exists?(doc, ~s/input#user_password[type="password"][name="user[password]"]/) + assert element_exists?(doc, ~s/label[for="user_password"]/) + assert text_of_element(doc, ~s/label[for="user_password"]/) == "New password" + end + + test "renders weak password warning and hints when password too short" do + doc = render_password_input_with_strength("too-short") + + assert text_of_element(doc, "p:first-of-type") == "Password is too weak." + assert text_of_element(doc, "p:last-of-type") != "" + end + + test "does not render hints and suggestions paragraph when there's none" do + doc = + render_password_input_with_strength("very-secret-but-too-short", + strength: %{ + score: 2, + warning: "", + suggestions: [] + } + ) + + assert warning_p = find(doc, "p") + assert text(warning_p) == "Password is too weak." + end + + test "renders too long password case gracefully" do + too_long_password = String.duplicate("very-long-very-secret-1234567890", 10) + doc = render_password_input_with_strength(too_long_password) + + assert error_p = find(doc, "p") + assert text(error_p) =~ "cannot be longer than" + end + end + + describe "password_length_hint/1" do + test "renders for long enough password" do + doc = render_password_length_hint("very-secret-and-very-long-123", 12) + + assert p_hint = find(doc, "p") + assert text_of_attr(p_hint, "class") =~ "text-gray-500" + assert text(p_hint) == "At least 12 characters" + end + + test "renders for too short password" do + doc = render_password_length_hint("too-short", 12) + + assert p_hint = find(doc, "p") + assert text_of_attr(p_hint, "class") =~ "text-red-500" + assert text(p_hint) == "At least 12 characters" + end + + test "renders gracefully for too long password" do + too_long_password = String.duplicate("very-long-very-secret-1234567890", 10) + doc = render_password_length_hint(too_long_password, 12) + + assert p_hint = find(doc, "p") + assert text_of_attr(p_hint, "class") =~ "text-gray-500" + assert text(p_hint) == "At least 12 characters" + end + end + + describe "strength_meter/1" do + test "renders too weak level" do + doc = render_component(&Form.strength_meter/1, score: 0, warning: "", suggestions: []) + + refute element_exists?(doc, "div.rounded-full") + assert text_of_element(doc, "p") == "Password is too weak." + end + + test "renders very weak level" do + doc = render_component(&Form.strength_meter/1, score: 1, warning: "", suggestions: []) + + assert text_of_element(doc, "p") == "Password is too weak." + end + + test "renders somewhat weak level" do + doc = render_component(&Form.strength_meter/1, score: 2, warning: "", suggestions: []) + + assert text_of_element(doc, "p") == "Password is too weak." + end + + test "renders strong level" do + doc = render_component(&Form.strength_meter/1, score: 3, warning: "", suggestions: []) + + assert text_of_element(doc, "p") =~ "Password strength:" + assert text_of_element(doc, "p") =~ "Good" + end + + test "renders very strong level" do + doc = render_component(&Form.strength_meter/1, score: 4, warning: "", suggestions: []) + + assert text_of_element(doc, "p") =~ "Password strength:" + assert text_of_element(doc, "p") =~ "Strong" + end + + test "renders hints paragraph when warning hint is present" do + doc = + render_component(&Form.strength_meter/1, + score: 2, + warning: "Test warning hint", + suggestions: [] + ) + + assert text_of_element(doc, "p:last-of-type") == "Test warning hint." + end + + test "renders only first suggestion when no warning present" do + doc = + render_component(&Form.strength_meter/1, + score: 2, + warning: "", + suggestions: ["Test suggestion 1.", "Test suggestion 2."] + ) + + assert text_of_element(doc, "p:last-of-type") == "Test suggestion 1." + end + + @tag :slow + test "favors hint warning over suggestion when both present" do + doc = + render_component(&Form.strength_meter/1, + score: 2, + warning: "Test warning hint", + suggestions: ["Test suggestion 1.", "Test suggestion 2."] + ) + + assert text_of_element(doc, "p:last-of-type") == "Test warning hint." + end + end + + defp render_password_input_with_strength(password, attrs \\ []) do + changeset = + %{"password" => password} + |> User.new() + |> Map.put(:action, :validate) + + strength = User.password_strength(changeset) + form = Phoenix.Component.to_form(changeset) + + render_component( + &Form.password_input_with_strength/1, + Keyword.merge([field: form[:password], strength: strength], attrs) + ) + end + + defp render_password_length_hint(password, minimum) do + changeset = + %{"password" => password} + |> User.new() + |> Map.put(:action, :validate) + + form = Phoenix.Component.to_form(changeset) + + render_component(&Form.password_length_hint/1, field: form[:password], minimum: minimum) + end +end diff --git a/test/plausible_web/live/components/verification_test.exs b/test/plausible_web/live/components/verification_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..2645de32cc4ed1d96403d01be250da809d0692e2 --- /dev/null +++ b/test/plausible_web/live/components/verification_test.exs @@ -0,0 +1,162 @@ +defmodule PlausibleWeb.Live.Components.VerificationTest do + use PlausibleWeb.ConnCase, async: true + + on_ee do + import Phoenix.LiveViewTest, only: [render_component: 2] + + alias Plausible.InstallationSupport.{State, Verification} + + @moduletag :capture_log + + @component PlausibleWeb.Live.Components.Verification + @progress ~s|#verification-ui p#progress| + + @pulsating_circle ~s|div#verification-ui div.pulsating-circle| + @check_circle ~s|div#verification-ui #check-circle| + @error_circle ~s|div#verification-ui #error-circle| + @recommendations ~s|#recommendation| + @super_admin_report ~s|#super-admin-report| + + test "renders initial state" do + html = render_component(@component, domain: "example.com") + assert element_exists?(html, @progress) + + assert text_of_element(html, @progress) == + "We're visiting your site to ensure that everything is working" + + assert element_exists?(html, @pulsating_circle) + refute class_of_element(html, @pulsating_circle) =~ "hidden" + refute element_exists?(html, @recommendations) + refute element_exists?(html, @check_circle) + refute element_exists?(html, @super_admin_report) + end + + test "renders error badge on error" do + html = render_component(@component, domain: "example.com", success?: false, finished?: true) + refute element_exists?(html, @pulsating_circle) + refute element_exists?(html, @check_circle) + refute element_exists?(html, @recommendations) + assert element_exists?(html, @error_circle) + end + + test "renders diagnostic interpretation" do + interpretation = + Verification.Checks.interpret_diagnostics(%State{ + url: "https://example.com", + data_domain: "example.com", + diagnostics: %Verification.Diagnostics{service_error: %{code: :domain_not_found}} + }) + + html = + render_component(@component, + domain: "example.com", + success?: false, + finished?: true, + interpretation: interpretation + ) + + assert [recommendation] = html |> find(@recommendations) |> Enum.map(&text/1) + assert recommendation =~ "check that the domain you entered is correct" + + refute element_exists?(html, @super_admin_report) + end + + test "renders super-admin report" do + state = %State{ + url: "https://example.com", + data_domain: "example.com", + diagnostics: %Verification.Diagnostics{} + } + + interpretation = Verification.Checks.interpret_diagnostics(state) + + html = + render_component(@component, + domain: "example.com", + success?: false, + finished?: true, + interpretation: interpretation, + verification_state: state, + super_admin?: true + ) + + assert element_exists?(html, @super_admin_report) + assert text_of_element(html, @super_admin_report) =~ "Plausible is on window: nil" + end + + test "hides pulsating circle when finished, shows check circle" do + html = + render_component(@component, + domain: "example.com", + success?: true, + finished?: true + ) + + refute element_exists?(html, @pulsating_circle) + assert element_exists?(html, @check_circle) + end + + test "renders a progress message" do + html = render_component(@component, domain: "example.com", message: "Arbitrary message") + + assert text_of_element(html, @progress) == "Arbitrary message" + end + + test "renders contact link on >3 attempts" do + html = render_component(@component, domain: "example.com", attempts: 2, finished?: true) + refute html =~ "Need further help with your installation?" + refute element_exists?(html, ~s|a[href="https://plausible.io/contact"]|) + + html = render_component(@component, domain: "example.com", attempts: 3, finished?: true) + assert html =~ "Need further help with your installation?" + assert element_exists?(html, ~s|a[href="https://plausible.io/contact"]|) + end + + test "renders link to verify installation at a different URL" do + interpretation = + Verification.Checks.interpret_diagnostics(%State{ + url: "example.com", + diagnostics: %Verification.Diagnostics{ + plausible_is_on_window: false, + plausible_is_initialized: false, + service_error: %{code: :domain_not_found} + } + }) + + assert interpretation.data.offer_custom_url_input == true + + expected_link_href = + PlausibleWeb.Router.Helpers.site_path(PlausibleWeb.Endpoint, :verification, "example.com") + + html = + render_component(@component, + domain: "example.com", + finished?: true, + success?: false, + interpretation: interpretation + ) + + assert text_of_element(html, "#verify-custom-url-link") =~ "different URL?" + assert text_of_attr(html, "#verify-custom-url-link a", "href") =~ expected_link_href + assert text_of_attr(html, "#verify-custom-url-link a", "href") =~ "custom_url=true" + end + + test "offers escape paths: settings and installation instructions on failure" do + html = + render_component(@component, + domain: "example.com", + success?: false, + finished?: true, + installation_type: "wordpress", + flow: PlausibleWeb.Flows.review() + ) + + assert element_exists?(html, ~s|a[href="/example.com/settings/general"]|) + + assert element_exists?( + html, + ~s|a[href="/example.com/installation?flow=review&installation_type=wordpress"]| + ) + end + end +end diff --git a/test/plausible_web/live/customer_support/sites_test.exs b/test/plausible_web/live/customer_support/sites_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..e64771090143d32418d1a3b7ce322ead807db2f9 --- /dev/null +++ b/test/plausible_web/live/customer_support/sites_test.exs @@ -0,0 +1,117 @@ +defmodule PlausibleWeb.Live.CustomerSupport.SitesTest do + use PlausibleWeb.ConnCase, async: false + @moduletag :ee_only + + on_ee do + use Bamboo.Test, shared: true + + import Phoenix.LiveViewTest + + defp open_site(id, opts \\ []) do + Routes.customer_support_site_path(PlausibleWeb.Endpoint, :show, id, opts) + end + + describe "overview" do + setup [:create_user, :log_in, :create_site] + + setup %{user: user} do + patch_env(:super_admin_user_ids, [user.id]) + end + + test "renders", %{conn: conn, site: site} do + {:ok, _lv, html} = live(conn, open_site(site.id)) + assert text(html) =~ site.domain + assert element_exists?(html, ~s|a[href^="https://plausible.grafana.net"]|) + + assert element_exists?( + html, + ~s|a[href$="#{URI.encode_www_form(site.domain)}/settings/general"]| + ) + + assert element_exists?( + html, + ~s|a[href="#{Routes.stats_path(PlausibleWeb.Endpoint, :stats, site.domain)}"]| + ) + end + + test "404", %{conn: conn} do + assert_raise Ecto.NoResultsError, fn -> + {:ok, _lv, _html} = live(conn, open_site(9999)) + end + end + + test "delete site", %{conn: conn, site: site} do + {:ok, lv, _html} = live(conn, open_site(site.id)) + + lv + |> element(~s|button[phx-click="delete-site"]|) + |> render_click() + + assert_redirect(lv, Routes.customer_support_path(PlausibleWeb.Endpoint, :index)) + + refute Plausible.Sites.get_by_domain(site.domain) + end + end + + describe "rescue zone" do + setup [:create_user, :log_in, :create_site] + + setup %{user: user} do + patch_env(:super_admin_user_ids, [user.id]) + end + + test "form renders", %{site: site, conn: conn} do + {:ok, lv, _html} = live(conn, open_site(site.id, tab: "rescue-zone")) + html = render(lv) + + form = ~s|form[phx-submit="init-transfer"]| + + assert element_exists?(html, ~s|#{form} input#inviter_email|) + + assert element_exists?(html, ~s|#{form} input#invitee_email|) + + assert element_exists?(html, ~s|#{form} input#submit-inviter_email|) + + assert element_exists?(html, ~s|#{form} input#submit-invitee_email|) + + assert element_exists?(html, ~s|#{form} button[type="submit"]|) + end + + @subject_prefix if ee?(), do: "[Plausible Analytics] ", else: "[Plausible CE] " + test "form submission creates invitation", %{user: user, site: site, conn: conn} do + {:ok, lv, _html} = live(conn, open_site(site.id, tab: "rescue-zone")) + render(lv) + + type_into_combo(lv, "inviter_email", user.name) + + lv + |> element("li#dropdown-inviter_email-option-1 a") + |> render_click() + + type_into_combo(lv, "invitee_email", "arbitrary@example.com") + + lv + |> element("li#dropdown-invitee_email-option-0 a") + |> render_click() + + form = ~s|form[phx-submit="init-transfer"]| + lv |> element(form) |> render_submit() + + assert_email_delivered_with( + to: [nil: "arbitrary@example.com"], + subject: @subject_prefix <> "Request to transfer ownership of #{site.domain}", + html_body: ~r/#{user.email}/ + ) + end + end + + defp type_into_combo(lv, id, text) do + lv + |> element("input##{id}") + |> render_change(%{ + "_target" => ["display-#{id}"], + "display-#{id}" => "#{text}" + }) + end + end +end diff --git a/test/plausible_web/live/customer_support/teams_test.exs b/test/plausible_web/live/customer_support/teams_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..2ffe3743f7024bf683fd705b2c0698a15d8e7362 --- /dev/null +++ b/test/plausible_web/live/customer_support/teams_test.exs @@ -0,0 +1,1085 @@ +defmodule PlausibleWeb.Live.CustomerSupport.TeamsTest do + use PlausibleWeb.ConnCase, async: false + @moduletag :ee_only + + on_ee do + import Phoenix.LiveViewTest + + alias Plausible.Auth.SSO + + require Plausible.Billing.Subscription.Status + + defp open_team(id, qs \\ []) do + Routes.customer_support_team_path(PlausibleWeb.Endpoint, :show, id, qs) + end + + setup [:create_user, :log_in, :create_site] + + setup %{user: user} do + patch_env(:super_admin_user_ids, [user.id]) + end + + describe "overview" do + test "renders", %{conn: conn, user: user} do + team = team_of(user) + new_site(owner: user) + new_site(owner: user, consolidated: true) + add_member(team, role: :editor) + + {:ok, _lv, html} = live(conn, open_team(team.id)) + text = text(html) + + assert text =~ team.name + assert text =~ "Sites (2/10)" + assert text =~ "Members (1/10)" + end + + test "renders unlimited limits", %{conn: conn, user: user} do + owner = new_user(team: [inserted_at: ~N[2021-01-01 00:00:00]]) + subscribe_to_enterprise_plan(owner, team_member_limit: :unlimited) + team = team_of(owner) + add_member(team, user: user, role: :editor) + new_site(owner: owner) + + {:ok, _lv, html} = live(conn, open_team(team.id)) + text = text(html) + + assert text =~ team.name + assert text =~ "Sites (1/unlimited)" + assert text =~ "Members (1/unlimited)" + end + + test "delete team", %{conn: conn, user: user} do + team = team_of(user) + {:ok, lv, _html} = live(conn, open_team(team.id)) + + lv + |> element(~s|button[phx-click="delete-team"]|) + |> render_click() + + assert_redirect(lv, Routes.customer_support_path(PlausibleWeb.Endpoint, :index)) + + refute Plausible.Repo.get(Plausible.Teams.Team, team.id) + end + + test "delete team with active subscription", %{conn: conn, user: user} do + user = subscribe_to_growth_plan(user) + team = team_of(user) + {:ok, lv, _html} = live(conn, open_team(team.id)) + + lv + |> element(~s|button[phx-click="delete-team"]|) + |> render_click() + + text = lv |> render() |> text() + + assert text =~ "The team has an active subscription which must be canceled first" + + assert Plausible.Repo.get(Plausible.Teams.Team, team.id) + end + + test "grace period handling", %{conn: conn, user: user} do + team = team_of(user) + {:ok, _, html} = live(conn, open_team(team.id)) + refute text(html) =~ "Lock" + refute text(html) =~ "Unlock" + + Plausible.Teams.start_grace_period(team) + + {:ok, lv, html} = live(conn, open_team(team.id)) + + assert element_exists?(html, ~s|a[phx-click="lock"]|) + assert element_exists?(html, ~s|a[phx-click="unlock"]|) + + refute Plausible.Repo.reload!(team).locked + + lv |> element(~s|a[phx-click="lock"]|) |> render_click() + + team = Plausible.Repo.reload!(team) + assert team.locked + assert team.grace_period.is_over + + lv |> element(~s|a[phx-click="unlock"]|) |> render_click() + + team = Plausible.Repo.reload!(team) + refute team.locked + refute team.grace_period + end + + test "refund lock handling", %{conn: conn, user: user} do + team = team_of(user) + {:ok, _lv, html} = live(conn, open_team(team.id)) + refute text(html) =~ "Refund Lock" + refute text(html) =~ "Locked" + + subscribe_to_growth_plan(user, + status: Plausible.Billing.Subscription.Status.deleted() + ) + + {:ok, lv, html} = live(conn, open_team(team.id)) + + assert text(html) =~ "Refund Lock" + lv |> element(~s|a[phx-click="refund-lock"]|) |> render_click() + + assert text(render(lv)) =~ "Locked" + + team = Plausible.Repo.reload!(team) + assert team.locked + refute team.grace_period + + assert Date.diff( + Plausible.Teams.with_subscription(team).subscription.next_bill_date, + Date.utc_today() + ) == -1 + + # make sure this team doesn't unlock automatically + Plausible.Workers.LockSites.perform(nil) + team = Plausible.Repo.reload!(team) + assert team.locked + end + + test "trial expiry date update, updates accept traffic until too", %{conn: conn, user: user} do + team = team_of(user) + {:ok, lv, _html} = live(conn, open_team(team.id)) + + lv + |> element("form") + |> render_submit(%{ + "team" => %{"trial_expiry_date" => "2029-01-01"} + }) + + html = render(lv) + + assert text_of_attr(html, "#team_trial_expiry_date", "value") == "2029-01-01" + assert text_of_attr(html, "#team_accept_traffic_until", "value") == "2029-01-15" + end + + test "404", %{conn: conn} do + assert_raise Ecto.NoResultsError, fn -> + {:ok, _lv, _html} = live(conn, open_team(9999)) + end + end + end + + describe "sites" do + @arrow_down "↓" + @arrow_up "↑" + @arrow_up_down "↕" + + test "lists sites belonging to a team", %{conn: conn, user: user} do + team = team_of(user) + new_site(owner: user, domain: "primary.example.com/test") + new_site(owner: user, domain: "consolidated.example.com", consolidated: true) + new_site(owner: user, domain: "secondary.example.com") + # other + new_site(domain: "other.example.com") + + {:ok, lv, _html} = live(conn, open_team(team.id, tab: "sites")) + html = render(lv) + text = text(html) + + assert element_exists?(html, ~s|a[href="/primary.example.com%2Ftest"]|) + assert element_exists?(html, ~s|a[href="/primary.example.com%2Ftest/settings/general"]|) + + assert text =~ "primary.example.com/test" + assert text =~ "secondary.example.com" + refute text =~ "condolidated.example.com" + refute text =~ "other.example.com" + end + + test "paginates sites", %{conn: conn, user: user} do + team = team_of(user) + + for i <- 1..24 do + new_site(owner: user, domain: "site-#{String.pad_leading("#{i}", 2, "0")}.example.com") + end + + {:ok, lv, _html} = live(conn, open_team(team.id, tab: "sites")) + html = render(lv) + + assert element_exists?(html, ~s|nav[aria-label="Pagination"]|) + assert text(html) =~ "Total of 25 sites" + assert text(html) =~ "Page 1 of 2" + + lv |> render_patch(open_team(team.id, tab: "sites", page: 2)) + html = render(lv) + + assert text(html) =~ "Page 2 of 2" + + lv |> render_patch(open_team(team.id, tab: "sites", page: 1)) + html = render(lv) + + assert text(html) =~ "Page 1 of 2" + end + + test "persists sort order across paginated entries", %{conn: conn, user: user} do + team = team_of(user) + + for i <- 1..24 do + new_site(owner: user, domain: "site-#{String.pad_leading("#{i}", 2, "0")}.example.com") + end + + {:ok, lv, _html} = live(conn, open_team(team.id, tab: "sites")) + + # sort alphabetically ascending + lv |> element(~s|th[phx-value-by="alnum"]|) |> render_click() + + html = render(lv) + page1_domains = extract_domains(html) + assert page1_domains == Enum.sort(page1_domains) + + lv |> render_patch(open_team(team.id, tab: "sites", page: 2)) + html = render(lv) + page2_domains = extract_domains(html) + + # should still be alphabetically ascending on page 2 + assert page2_domains == Enum.sort(page2_domains) + assert Enum.max(page1_domains) < Enum.min(page2_domains) + end + + test "shows no pagination when sites fit on one page", %{conn: conn, user: user} do + team = team_of(user) + new_site(owner: user, domain: "only-site.example.com") + + {:ok, lv, _html} = live(conn, open_team(team.id, tab: "sites")) + html = render(lv) + + refute element_exists?(html, ~s|nav[aria-label="Pagination"]|) + assert text(html) =~ "only-site.example.com" + end + + test "sorts by domain ascending when Domain header is clicked", %{conn: conn, user: user} do + team = team_of(user) + new_site(owner: user, domain: "zebra.example.com") + new_site(owner: user, domain: "apple.example.com") + new_site(owner: user, domain: "mango.example.com") + + {:ok, lv, _html} = live(conn, open_team(team.id, tab: "sites")) + + lv + |> element(~s|th[phx-value-by="alnum"]|) + |> render_click() + + html = render(lv) + domains = extract_domains(html) + + assert domains == Enum.sort(domains) + end + + test "flips to descending when Domain header is clicked twice", %{conn: conn, user: user} do + team = team_of(user) + new_site(owner: user, domain: "zebra.example.com") + new_site(owner: user, domain: "apple.example.com") + new_site(owner: user, domain: "mango.example.com") + + {:ok, lv, _html} = live(conn, open_team(team.id, tab: "sites")) + + lv |> element(~s|th[phx-value-by="alnum"]|) |> render_click() + lv |> element(~s|th[phx-value-by="alnum"]|) |> render_click() + + html = render(lv) + domains = extract_domains(html) + + assert domains == Enum.sort(domains, :desc) + end + + test "flips direction when Traffic header is clicked twice", %{conn: conn, user: user} do + team = team_of(user) + new_site(owner: user, domain: "zebra.example.com") + new_site(owner: user, domain: "apple.example.com") + + {:ok, lv, _html} = live(conn, open_team(team.id, tab: "sites")) + + # default is traffic desc, click to flip + html_after_first = lv |> element(~s|th[phx-value-by="traffic"]|) |> render_click() + html_after_second = lv |> element(~s|th[phx-value-by="traffic"]|) |> render_click() + + assert text(html_after_first) =~ @arrow_up + assert text(html_after_second) =~ @arrow_down + end + + test "switches from traffic sort to domain sort", %{conn: conn, user: user} do + team = team_of(user) + new_site(owner: user, domain: "zebra.example.com") + new_site(owner: user, domain: "apple.example.com") + + {:ok, lv, _html} = live(conn, open_team(team.id, tab: "sites")) + + # default active column is traffic down + assert text(render(lv)) =~ @arrow_down + + lv |> element(~s|th[phx-value-by="alnum"]|) |> render_click() + + html = render(lv) + domains = extract_domains(html) + + # now sorted alphabetically + assert domains == Enum.sort(domains) + end + + test "shows sort arrow only on active column", %{conn: conn, user: user} do + team = team_of(user) + new_site(owner: user, domain: "apple.example.com") + + {:ok, lv, _html} = live(conn, open_team(team.id, tab: "sites")) + + html = render(lv) + traffic_th = text_of_element(html, ~s|th[phx-value-by="traffic"]|) + domain_th = text_of_element(html, ~s|th[phx-value-by="alnum"]|) + + # traffic is the default active sort, has a directional arrow + assert traffic_th =~ @arrow_down + refute traffic_th =~ @arrow_up_down + + # domain sort is inactive + assert domain_th =~ @arrow_up_down + refute domain_th =~ @arrow_up + refute domain_th =~ @arrow_down + end + end + + @create_consolidated_view_button ~s|button[phx-click="create-consolidated-view"]| + @delete_consolidated_view_button ~s|button[phx-click="delete-consolidated-view"]| + @consolidated_views_tab_content ~s|div[data-test-id="consolidated-views-tab-content"]| + + describe "consolidated views" do + test "renders button to create one if none exist yet", %{conn: conn, user: user} do + team = team_of(user) + + {:ok, lv, _html} = live(conn, open_team(team.id, tab: "consolidated_views")) + html = render(lv) + + assert text_of_element(html, @consolidated_views_tab_content) =~ + "This team does not have a consolidated view yet" + + assert text_of_element(html, @create_consolidated_view_button) == "Create one" + end + + test "can create a consolidated view for team", %{conn: conn, user: user} do + new_site(owner: user) + new_site(owner: user) + team = user |> team_of() |> Plausible.Teams.complete_setup() + + {:ok, lv, _html} = live(conn, open_team(team.id, tab: "consolidated_views")) + + lv |> element(@create_consolidated_view_button) |> render_click() + + assert Plausible.ConsolidatedView.get(team) + end + + test "renders existing consolidated view", %{conn: conn, user: user} do + new_site(owner: user) + team = team_of(user) + new_consolidated_view(team) + + {:ok, lv, _html} = live(conn, open_team(team.id, tab: "consolidated_views")) + html = render(lv) + + table_text = text_of_element(html, @consolidated_views_tab_content) + + assert table_text =~ team.identifier + assert table_text =~ "Dashboard" + + assert element_exists?(html, @delete_consolidated_view_button) + end + + test "can delete consolidated view", %{conn: conn, user: user} do + new_site(owner: user) + team = team_of(user) + new_consolidated_view(team) + + assert Plausible.ConsolidatedView.get(team) + + {:ok, lv, _html} = live(conn, open_team(team.id, tab: "consolidated_views")) + + lv |> element(@delete_consolidated_view_button) |> render_click() + + refute Plausible.ConsolidatedView.get(team) + end + end + + describe "billing" do + test "renders custom plan form", %{conn: conn, user: user} do + lv = open_custom_plan(conn, team_of(user)) + html = render(lv) + + assert element_exists?( + html, + ~s|form#save-plan[phx-submit="save-plan"][phx-change="estimate-cost"]| + ) + end + + test "estimates the price", %{conn: conn, user: user} do + lv = open_custom_plan(conn, team_of(user)) + + lv + |> element(~s|form#save-plan|) + |> render_change(%{ + "enterprise_plan" => %{ + "billing_interval" => "yearly", + "monthly_pageview_limit" => "20000000", + "site_limit" => "1000", + "team_member_limit" => "30", + "hourly_api_request_limit" => "1000", + "features[]" => %{ + "stats_api" => "false", + "outbound_links" => "false", + "form_submissions" => "false", + "file_downloads" => "false", + "funnels" => "false", + "props" => "false", + "shared_segments" => "false", + "revenue_goals" => "false", + "site_segments" => "false", + "site_annotations" => "false", + "shared_links" => "true", + "sites_api" => "true" + } + } + }) + + html = render(lv) + assert text_of_attr(html, ~s|#cost-estimate|, "value") == "12380.00" + end + + test "saves custom plan", %{conn: conn, user: user} do + lv = open_custom_plan(conn, team_of(user)) + + lv + |> element(~s|form#save-plan|) + |> render_change(%{ + "enterprise_plan" => %{ + "paddle_plan_id" => "1111", + "billing_interval" => "yearly", + "monthly_pageview_limit" => "20000000", + "site_limit" => "1000", + "team_member_limit" => "30", + "hourly_api_request_limit" => "1000", + "features[]" => %{ + "stats_api" => "false", + "outbound_links" => "false", + "form_submissions" => "false", + "file_downloads" => "false", + "funnels" => "false", + "props" => "false", + "shared_segments" => "false", + "revenue_goals" => "false", + "site_segments" => "false", + "site_annotations" => "false", + "shared_links" => "true", + "sites_api" => "true", + "sso" => "false", + "consolidated_view" => "true" + } + } + }) + + lv |> element("form#save-plan") |> render_submit() + html = render(lv) + assert text(html) =~ "Plan saved" + + team_id = team_of(user).id + + assert [ + %Plausible.Billing.EnterprisePlan{ + billing_interval: :yearly, + features: [ + Plausible.Billing.Feature.ConsolidatedView, + Plausible.Billing.Feature.SharedLinks, + Plausible.Billing.Feature.SitesAPI + ], + hourly_api_request_limit: 1000, + monthly_pageview_limit: 20_000_000, + paddle_plan_id: "1111", + site_limit: 1000, + team_id: ^team_id, + team_member_limit: 30 + } + ] = Plausible.Repo.all(Plausible.Billing.EnterprisePlan) + end + + test "handles unlimited team members", %{conn: conn, user: user} do + user |> subscribe_to_enterprise_plan(team_member_limit: :unlimited) + lv = open_custom_plan(conn, team_of(user)) + + html = render(lv) + + assert text_of_attr(html, ~s|input[name="enterprise_plan[team_member_limit]"]|, "value") == + "unlimited" + + lv + |> element(~s|form#save-plan|) + |> render_change(%{ + "enterprise_plan" => %{ + "paddle_plan_id" => "1111", + "billing_interval" => "yearly", + "monthly_pageview_limit" => "20000000", + "site_limit" => "1000", + "team_member_limit" => "unlimited", + "hourly_api_request_limit" => "1000" + } + }) + + lv |> element("form#save-plan") |> render_submit() + html = render(lv) + assert text(html) =~ "Plan saved" + end + + test "displays existing custom plan with edit button", %{conn: conn, user: user} do + team = team_of(user) + + plan = + insert(:enterprise_plan, + team: team, + paddle_plan_id: "existing-plan-123", + billing_interval: :yearly, + monthly_pageview_limit: 5_000_000, + site_limit: 200, + team_member_limit: 25, + hourly_api_request_limit: 2000, + features: [Plausible.Billing.Feature.StatsAPI, Plausible.Billing.Feature.Funnels] + ) + + {:ok, lv, _html} = live(conn, open_team(team.id, tab: "billing")) + html = render(lv) + + assert text(html) =~ "existing-plan-123" + assert text(html) =~ "yearly" + assert text(html) =~ "5,000,000" + assert text(html) =~ "200" + assert text(html) =~ "25" + assert text(html) =~ "2,000" + + assert element_exists?(html, ~s|button[phx-click="edit-plan"][phx-value-id="#{plan.id}"]|) + end + + test "edit plan loads existing values into form", %{conn: conn, user: user} do + team = team_of(user) + + plan = + insert(:enterprise_plan, + team: team, + paddle_plan_id: "edit-test-plan", + billing_interval: :monthly, + monthly_pageview_limit: 10_000_000, + site_limit: 300, + team_member_limit: 50, + hourly_api_request_limit: 5000, + features: [Plausible.Billing.Feature.StatsAPI, Plausible.Billing.Feature.Funnels] + ) + + {:ok, lv, _html} = live(conn, open_team(team.id, tab: :billing)) + + lv + |> element(~s|button[phx-click="edit-plan"][phx-value-id="#{plan.id}"]|) + |> render_click() + + html = render(lv) + + assert element_exists?(html, ~s|form#save-plan[phx-submit="update-plan"]|) + + assert text_of_attr(html, ~s|input[name="enterprise_plan[paddle_plan_id]"]|, "value") == + "edit-test-plan" + + assert text_of_element( + html, + ~s|select[name="enterprise_plan[billing_interval]"] option[selected]| + ) == + "monthly" + + assert text_of_attr( + html, + ~s|input[name="enterprise_plan[monthly_pageview_limit]"]|, + "value" + ) == "10000000" + + assert text_of_attr(html, ~s|input[name="enterprise_plan[site_limit]"]|, "value") == "300" + + assert text_of_attr(html, ~s|input[name="enterprise_plan[team_member_limit]"]|, "value") == + "50" + + assert text_of_attr( + html, + ~s|input[name="enterprise_plan[hourly_api_request_limit]"]|, + "value" + ) == "5000" + + assert element_exists?( + html, + ~s|input[name="enterprise_plan[features[]][stats_api]"][checked]| + ) + + assert element_exists?( + html, + ~s|input[name="enterprise_plan[features[]][funnels]"][checked]| + ) + + refute element_exists?( + html, + ~s|input[name="enterprise_plan[features[]][revenue_goals]"][checked]| + ) + + assert text(html) =~ "Update Plan" + refute text(html) =~ "Save Custom Plan" + end + + test "successfully updates existing plan", %{conn: conn, user: user} do + team = team_of(user) + + plan = + insert(:enterprise_plan, + team: team, + paddle_plan_id: "original-plan", + billing_interval: :monthly, + monthly_pageview_limit: 1_000_000, + site_limit: 100, + team_member_limit: 10, + hourly_api_request_limit: 1000, + features: [Plausible.Billing.Feature.StatsAPI] + ) + + {:ok, lv, _html} = live(conn, open_team(team.id, tab: :billing)) + + lv + |> element(~s|button[phx-click="edit-plan"][phx-value-id="#{plan.id}"]|) + |> render_click() + + lv + |> element(~s|form#save-plan|) + |> render_change(%{ + "enterprise_plan" => %{ + "paddle_plan_id" => "updated-plan-456", + "billing_interval" => "yearly", + "monthly_pageview_limit" => "15000000", + "site_limit" => "500", + "team_member_limit" => "100", + "hourly_api_request_limit" => "8000", + "features[]" => %{ + "stats_api" => "true", + "funnels" => "true", + "props" => "false", + "revenue_goals" => "false", + "site_segments" => "false", + "site_annotations" => "false", + "shared_links" => "false", + "sites_api" => "false", + "sso" => "false" + } + } + }) + + lv |> element("form#save-plan") |> render_submit() + + html = render(lv) + assert text(html) =~ "Plan updated" + + updated_plan = Plausible.Repo.reload!(plan) + assert updated_plan.paddle_plan_id == "updated-plan-456" + assert updated_plan.billing_interval == :yearly + assert updated_plan.monthly_pageview_limit == 15_000_000 + assert updated_plan.site_limit == 500 + assert updated_plan.team_member_limit == 100 + assert updated_plan.hourly_api_request_limit == 8000 + + feature_names = Enum.map(updated_plan.features, & &1.name()) + assert :stats_api in feature_names + assert :funnels in feature_names + refute :shared_links in feature_names + + refute element_exists?(html, ~s|form#save-plan|) + + assert text(html) =~ "updated-plan-456" + assert text(html) =~ "yearly" + assert text(html) =~ "15,000,000" + assert text(html) =~ "500" + assert text(html) =~ "100" + assert text(html) =~ "8,000" + end + + test "handles validation errors when updating plan", %{conn: conn, user: user} do + team = team_of(user) + + plan = + insert(:enterprise_plan, + team: team, + paddle_plan_id: "valid-plan", + billing_interval: :monthly, + monthly_pageview_limit: 1_000_000, + site_limit: 100, + team_member_limit: 10, + hourly_api_request_limit: 1000 + ) + + {:ok, lv, _html} = live(conn, open_team(team.id, tab: :billing)) + + lv + |> element(~s|button[phx-click="edit-plan"][phx-value-id="#{plan.id}"]|) + |> render_click() + + lv + |> element(~s|form#save-plan|) + |> render_submit(%{ + "enterprise_plan" => %{ + "paddle_plan_id" => "", + "billing_interval" => "monthly", + "monthly_pageview_limit" => "1000000", + "site_limit" => "100", + "team_member_limit" => "10", + "hourly_api_request_limit" => "1000" + } + }) + + html = render(lv) + + assert text(html) =~ "Error updating plan" + assert element_exists?(html, ~s|form#save-plan|) + + unchanged_plan = Plausible.Repo.reload!(plan) + assert unchanged_plan.paddle_plan_id == "valid-plan" + end + + test "cancel edit returns to plan list", %{conn: conn, user: user} do + team = team_of(user) + + plan = insert(:enterprise_plan, team: team) + + {:ok, lv, _html} = live(conn, open_team(team.id, tab: :billing)) + + lv + |> element(~s|button[phx-click="edit-plan"][phx-value-id="#{plan.id}"]|) + |> render_click() + + html = render(lv) + assert element_exists?(html, ~s|form#save-plan|) + + lv |> element(~s|button[phx-click="hide-plan-form"]|) |> render_click() + + html = render(lv) + refute element_exists?(html, ~s|form#save-plan|) + assert element_exists?(html, ~s|button#new-custom-plan|) + end + + test "multiple plans can be displayed and edited independently", %{conn: conn, user: user} do + team = team_of(user) + + plan1 = + insert(:enterprise_plan, + team: team, + paddle_plan_id: "plan-1", + monthly_pageview_limit: 1_000_000 + ) + + plan2 = + insert(:enterprise_plan, + team: team, + paddle_plan_id: "plan-2", + monthly_pageview_limit: 5_000_000 + ) + + {:ok, lv, _html} = live(conn, open_team(team.id, tab: :billing)) + html = render(lv) + + assert text(html) =~ "plan-1" + assert text(html) =~ "plan-2" + assert text(html) =~ "1,000,000" + assert text(html) =~ "5,000,000" + + assert element_exists?( + html, + ~s|button[phx-click="edit-plan"][phx-value-id="#{plan1.id}"]| + ) + + assert element_exists?( + html, + ~s|button[phx-click="edit-plan"][phx-value-id="#{plan2.id}"]| + ) + + lv + |> element(~s|button[phx-click="edit-plan"][phx-value-id="#{plan2.id}"]|) + |> render_click() + + html = render(lv) + + assert text_of_attr(html, ~s|input[name="enterprise_plan[paddle_plan_id]"]|, "value") == + "plan-2" + + assert text_of_attr( + html, + ~s|input[name="enterprise_plan[monthly_pageview_limit]"]|, + "value" + ) == "5000000" + end + + test "current plan is annotated and delete button is available", %{conn: conn, user: user} do + team = team_of(user) + + user + |> subscribe_to_enterprise_plan( + team_member_limit: :unlimited, + paddle_plan_id: "plan-current" + ) + + insert(:enterprise_plan, + team: team, + paddle_plan_id: "plan-another", + monthly_pageview_limit: 1_000_000 + ) + + {:ok, lv, _html} = live(conn, open_team(team.id, tab: :billing)) + + html = render(lv) + + current_selector = ~s|[data-test-id="plan-entry-plan-current"]| + other_selector = ~s|[data-test-id="plan-entry-plan-another"]| + + assert element_exists?(html, current_selector) + assert element_exists?(html, other_selector) + + current = text_of_element(html, current_selector) + other = text_of_element(html, other_selector) + + assert current =~ "CURRENT" + refute other =~ "CURRENT" + + refute element_exists?( + html, + ~s|button[phx-click="delete-plan"][data-test-id="delete-plan-plan-current"]| + ) + + assert element_exists?( + html, + ~s|button[phx-click="delete-plan"][data-test-id="delete-plan-plan-another"]| + ) + end + + test "plan can be deleted", %{conn: conn, user: user} do + team = team_of(user) + + user |> subscribe_to_enterprise_plan(team_member_limit: :unlimited) + + inactive_plan = + insert(:enterprise_plan, + team: team, + paddle_plan_id: "plan-another", + monthly_pageview_limit: 1_000_000 + ) + + {:ok, lv, _html} = live(conn, open_team(team.id, tab: :billing)) + + html = render(lv) + + assert element_exists?(html, ~s|[data-test-id="plan-entry-plan-another"]|) + + lv + |> element(~s|button[phx-click="delete-plan"][data-test-id="delete-plan-plan-another"]|) + |> render_click() + + html = render(lv) + + refute Plausible.Repo.get(Plausible.Billing.EnterprisePlan, inactive_plan.id) + refute element_exists?(html, ~s|[data-test-id="plan-entry-plan-another"]|) + end + + defp open_custom_plan(conn, team) do + {:ok, lv, _html} = live(conn, open_team(team.id, tab: :billing)) + render(lv) + lv |> element("button#new-custom-plan") |> render_click() + lv + end + end + + describe "sso" do + test "sso tab normally won't render", %{conn: conn, user: user} do + team = team_of(user) + {:ok, _lv, html} = live(conn, open_team(team.id)) + + refute element_exists?(html, ~s|a[href="?tab=sso"]|) + end + + test "tab renders when there's sso integration", %{conn: conn, user: user} do + team = team_of(user) + + SSO.initiate_saml_integration(team) + + {:ok, _lv, html} = live(conn, open_team(team.id)) + + assert element_exists?(html, ~s|a[href="?tab=sso"]|) + end + + test "sso tab displays domains and policy/idp config", %{conn: conn, user: user} do + team = team_of(user) + + integration = SSO.initiate_saml_integration(team) + + SSO.Domains.add(integration, "sso1.example.com") + SSO.Domains.add(integration, "sso2.example.com") + + {:ok, lv, _html} = live(conn, open_team(team.id, tab: :sso)) + + text = lv |> render() |> text() + + assert text =~ "sso1.example.com" + assert text =~ "sso2.example.com" + assert text =~ "configured? false" + assert text =~ "sso_session_timeout_minutes 360" + end + + test "delete domain", %{conn: conn, user: user} do + team = team_of(user) + integration = SSO.initiate_saml_integration(team) + {:ok, domain} = SSO.Domains.add(integration, "sso1.example.com") + {:ok, lv, _html} = live(conn, open_team(team.id, tab: :sso)) + + lv |> element("button#remove-sso-domain-#{domain.identifier}") |> render_click() + refute render(lv) =~ "sso1.example.com" + end + + test "deprovisioning users", %{conn: conn, user: user} do + team = team_of(user) |> Plausible.Teams.complete_setup() + integration = SSO.initiate_saml_integration(team) + {:ok, sso_domain} = SSO.Domains.add(integration, "example.com") + + _sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true) + + {:ok, :standard, team, user} = + SSO.provision_user(%SSO.Identity{ + id: Ecto.UUID.generate(), + integration_id: integration.identifier, + name: user.name, + email: user.email, + expires_at: NaiveDateTime.add(NaiveDateTime.utc_now(:second), 6, :hour) + }) + + # need to re-authenticate for SSO to take effect + {:ok, conn: conn} = log_in(%{user: user, conn: conn}) + + {:ok, lv, _html} = live(conn, open_team(team.id, tab: :members)) + + html = render(lv) + + assert text(html) =~ "SSO membership" + lv |> element("#deprovision-sso-user-#{user.id}") |> render_click() + + assert Plausible.Repo.reload!(user).type == :standard + end + + test "removing integration", %{conn: conn, user: user} do + team = team_of(user) + + SSO.initiate_saml_integration(team) + + {:ok, lv, _html} = live(conn, open_team(team.id, tab: :sso)) + + assert {:error, {:live_redirect, %{to: to}}} = + lv |> element("button#remove-sso-integration") |> render_click() + + assert to == Routes.customer_support_team_path(PlausibleWeb.Endpoint, :show, team.id) + end + end + + describe "audit" do + test "audit tab is present", %{conn: conn, user: user} do + team = team_of(user) + {:ok, _lv, html} = live(conn, open_team(team.id)) + assert element_exists?(html, ~s|a[href="?tab=audit"]|) + end + + test "shows audit entries", %{conn: conn, user: user} do + team = team_of(user) + + entry = + %Plausible.Audit.Entry{ + name: "Reveal Test", + entity: "Plausible.Teams.Team", + entity_id: to_string(team.id), + change: %{"foo" => "bar"}, + team_id: team.id, + datetime: NaiveDateTime.utc_now() + } + |> Plausible.Repo.insert!() + + {:ok, lv, _html} = live(conn, open_team(team.id, tab: :audit)) + + html = render(lv) + + assert html =~ "Reveal Test" + + html = + lv + |> element(~s|button[phx-click="reveal-audit-entry"][phx-value-id="#{entry.id}"]|) + |> render_click() + + assert text_of_element(html, ~s|textarea|) == ~s|{ "foo": "bar" }| + end + + test "shows audit entries when user id does not exists", %{conn: conn, user: user} do + team = team_of(user) + + %Plausible.Audit.Entry{ + name: "Reveal Test", + entity: "Plausible.Auth.User", + entity_id: "666111", + team_id: team.id, + datetime: NaiveDateTime.utc_now(), + user_id: 666_111, + actor_type: :user + } + |> Plausible.Repo.insert!() + + {:ok, lv, _html} = live(conn, open_team(team.id, tab: :audit)) + + text = lv |> render() |> text() + + assert text =~ "(N/A) (N/A)" + end + + test "paginates audit entries", %{conn: conn, user: user} do + team = team_of(user) + + now = NaiveDateTime.utc_now() + + for i <- 1..8 do + %Plausible.Audit.Entry{ + name: "Entry (#{i})", + entity: "Plausible.Teams.Team", + entity_id: to_string(team.id), + team_id: team.id, + datetime: NaiveDateTime.shift(now, second: 8 - i) + } + |> Plausible.Repo.insert!() + end + + {:ok, lv, _html} = live(conn, open_team(team.id, tab: :audit, limit: 3)) + text = lv |> render() |> text() + + for i <- 4..8, do: refute(text =~ "Entry (#{i})") + for i <- 1..3, do: assert(text =~ "Entry (#{i})") + + lv |> element("button#next-page") |> render_click() + text = lv |> render() |> text() + + for i <- 4..6, do: assert(text =~ "Entry (#{i})") + for i <- 1..3, do: refute(text =~ "Entry (#{i})") + for i <- 7..8, do: refute(text =~ "Entry (#{i})") + + lv |> element("button#prev-page") |> render_click() + text = lv |> render() |> text() + + for i <- 4..8, do: refute(text =~ "Entry (#{i})") + for i <- 1..3, do: assert(text =~ "Entry (#{i})") + end + end + + defp extract_domains(html) do + domains = + html + |> find("tbody tr td:first-child a") + |> Enum.map(&text/1) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + + assert [_ | _] = domains + domains + end + end +end diff --git a/test/plausible_web/live/customer_support/users_test.exs b/test/plausible_web/live/customer_support/users_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..9406490c36b75e3f206fef0687b68518edac36ca --- /dev/null +++ b/test/plausible_web/live/customer_support/users_test.exs @@ -0,0 +1,157 @@ +defmodule PlausibleWeb.Live.CustomerSupport.UsersTest do + use PlausibleWeb.ConnCase, async: false + @moduletag :ee_only + + on_ee do + import Phoenix.LiveViewTest + + defp open_user(id, qs \\ []) do + Routes.customer_support_user_path(PlausibleWeb.Endpoint, :show, id, qs) + end + + describe "overview" do + setup [:create_user, :log_in, :create_site] + + setup %{user: user} do + patch_env(:super_admin_user_ids, [user.id]) + end + + test "renders", %{conn: conn, user: user} do + {:ok, _lv, html} = live(conn, open_user(user.id)) + text = text(html) + assert text =~ user.name + assert text =~ user.email + + assert uid = find(html, "#user-identifier") + assert text_of_attr(uid, "value") == "#{user.id}" + + team = team_of(user) + + assert element_exists?( + html, + ~s|a[href="#{Routes.customer_support_team_path(PlausibleWeb.Endpoint, :show, team.id)}"]| + ) + end + + test "404", %{conn: conn} do + assert_raise Ecto.NoResultsError, fn -> + {:ok, _lv, _html} = live(conn, open_user(9999)) + end + end + + test "delete user", %{conn: conn, user: user} do + {:ok, lv, _html} = live(conn, open_user(user.id)) + + lv + |> element(~s|button[phx-click="delete-user"]|) + |> render_click() + + assert_redirect(lv, Routes.customer_support_path(PlausibleWeb.Endpoint, :index)) + + refute Plausible.Repo.get(Plausible.Auth.User, user.id) + end + + test "delete user with active subscription", %{conn: conn, user: user} do + user |> subscribe_to_growth_plan() + + {:ok, lv, _html} = live(conn, open_user(user.id)) + + lv + |> element(~s|button[phx-click="delete-user"]|) + |> render_click() + + text = lv |> render() |> text() + + assert text =~ "Cannot delete user with active subscription" + + assert Plausible.Repo.get(Plausible.Auth.User, user.id) + end + + test "delete user when they're sole team owner", %{conn: conn, user: user} do + site = new_site(owner: user) + Plausible.Teams.complete_setup(site.team) + + {:ok, lv, _html} = live(conn, open_user(user.id)) + + lv + |> element(~s|button[phx-click="delete-user"]|) + |> render_click() + + text = lv |> render() |> text() + + assert text =~ "Failed to delete user: :is_only_team_owner" + + assert Plausible.Repo.get(Plausible.Auth.User, user.id) + end + end + + describe "keys" do + setup [:create_user, :log_in, :create_site] + + setup %{user: user} do + patch_env(:super_admin_user_ids, [user.id]) + end + + test "renders", %{conn: conn, user: user} do + k1 = insert(:api_key, user: user) + k2 = insert(:api_key, user: user, team: team_of(user)) + k3 = insert(:api_key, user: new_user()) + + {:ok, lv, html} = live(conn, open_user(user.id, tab: :keys)) + + assert text(html) =~ "API Keys (2)" + + html = lv |> render() |> text() + + assert html =~ k1.key_prefix + assert html =~ k2.key_prefix + refute html =~ k3.key_prefix + end + end + + describe "2FA management" do + setup [:create_user, :log_in, :create_site] + + setup %{user: user} do + patch_env(:super_admin_user_ids, [user.id]) + end + + test "shows 2FA disabled status when user has no 2FA", %{conn: conn, user: user} do + {:ok, _lv, html} = live(conn, open_user(user.id)) + + assert text(html) =~ "Two-Factor Authentication: Disabled" + refute text(html) =~ "Force Disable 2FA" + end + + test "shows 2FA enabled status and disable button when user has 2FA", %{ + conn: conn, + user: user + } do + {:ok, user, _} = Plausible.Auth.TOTP.initiate(user) + {:ok, user, _} = Plausible.Auth.TOTP.enable(user, :skip_verify) + + {:ok, _lv, html} = live(conn, open_user(user.id)) + + assert text(html) =~ "Two-Factor Authentication: Enabled" + assert text(html) =~ "Force Disable 2FA" + end + + test "force disables 2FA when button is clicked", %{conn: conn, user: user} do + {:ok, user, _} = Plausible.Auth.TOTP.initiate(user) + {:ok, user, _} = Plausible.Auth.TOTP.enable(user, :skip_verify) + + {:ok, lv, _html} = live(conn, open_user(user.id)) + + lv |> element("[phx-click='force-disable-2fa']") |> render_click() + + html = render(lv) + assert text(html) =~ "2FA has been force disabled" + assert text(html) =~ "Two-Factor Authentication: Disabled" + refute text(html) =~ "Force Disable 2FA" + + updated_user = Plausible.Repo.reload(user) + refute Plausible.Auth.TOTP.enabled?(updated_user) + end + end + end +end diff --git a/test/plausible_web/live/customer_support_test.exs b/test/plausible_web/live/customer_support_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..1e17697ad559fb786df71408e145675216de5dce --- /dev/null +++ b/test/plausible_web/live/customer_support_test.exs @@ -0,0 +1,224 @@ +defmodule PlausibleWeb.Live.CustomerSupportTest do + use PlausibleWeb.ConnCase, async: false + + @moduletag :ee_only + + on_ee do + @cs_index Routes.customer_support_path(PlausibleWeb.Endpoint, :index) + + import Phoenix.LiveViewTest + + alias Plausible.Auth.SSO + + describe "unauthenticated" do + test "not allowed if not logged in", %{conn: conn} do + conn = get(conn, @cs_index) + assert response(conn, 403) == "Not allowed" + end + end + + describe "authenticated regular user" do + test "not allowed if not superadmin", %{conn: conn} do + conn = get(conn, @cs_index) + assert response(conn, 403) == "Not allowed" + end + end + + describe "authenticated superadmin" do + setup [:create_user, :log_in, :create_site] + + setup %{user: user} do + patch_env(:super_admin_user_ids, [user.id]) + end + + test "lists resources", %{conn: conn, site: site, user: user} do + conn = get(conn, @cs_index) + resp = html_response(conn, 200) + team = team_of(user) + consolidated_site = new_site(owner: user, consolidated: true) + assert_search_result(resp, "site", site.id) + assert_search_result(resp, "team", team.id) + assert_search_result(resp, "user", user.id) + refute_search_result(resp, "site", consolidated_site.id) + end + + test "filters as you type", %{conn: conn, site: site, user: user} do + site2 = new_site(owner: user, domain: "hello.example.com") + {:ok, lv, _html} = live(conn, @cs_index) + type_into_input(lv, "filter-text", "hello") + + html = render(lv) + + assert_search_result(html, "site", site2.id) + refute_search_result(html, "site", site.id) + + type_into_input(lv, "filter-text", "user:hello") + html = render(lv) + + refute_search_result(html, "site", site2.id) + + type_into_input(lv, "filter-text", "site:hello") + html = render(lv) + + assert_search_result(html, "site", site2.id) + end + + test "search teams by name", %{conn: conn} do + team1 = new_user(team: [name: "Team One"]) |> team_of() + team2 = new_user(team: [name: "Team Two"]) |> team_of() + team3 = new_user(team: [name: "Team Three"]) |> team_of() + + {:ok, lv, _html} = live(conn, @cs_index) + + type_into_input(lv, "filter-text", "team:Team") + html = render(lv) + + assert_search_result(html, "team", team1.id) + assert_search_result(html, "team", team2.id) + assert_search_result(html, "team", team3.id) + end + + test "search teams by partial name", %{conn: conn} do + team1 = new_user(team: [name: "Team One"]) |> team_of() + team2 = new_user(team: [name: "Team Two"]) |> team_of() + team3 = new_user(team: [name: "Team Three"]) |> team_of() + + {:ok, lv, _html} = live(conn, @cs_index) + + type_into_input(lv, "filter-text", "team:Team T") + html = render(lv) + + refute_search_result(html, "team", team1.id) + assert_search_result(html, "team", team2.id) + assert_search_result(html, "team", team3.id) + end + + test "search teams with subscription filter", %{conn: conn} do + team1 = new_user(team: [name: "Team One"]) |> team_of() + team2 = new_user(team: [name: "Team Two"]) |> subscribe_to_growth_plan() |> team_of() + team3 = new_user(team: [name: "Team Three"]) |> team_of() + + {:ok, lv, _html} = live(conn, @cs_index) + + type_into_input(lv, "filter-text", "team:Team T +sub") + html = render(lv) + + refute_search_result(html, "team", team1.id) + assert_search_result(html, "team", team2.id) + refute_search_result(html, "team", team3.id) + end + + test "search teams with sso filter before integration", %{conn: conn} do + team1 = new_user(team: [name: "Team One"]) |> team_of() + team2 = new_user(team: [name: "Team Two"]) |> subscribe_to_growth_plan() |> team_of() + team3 = new_user(team: [name: "Team Three"]) |> team_of() + + {:ok, lv, _html} = live(conn, @cs_index) + + type_into_input(lv, "filter-text", "team:Team T +sso") + html = render(lv) + refute_search_result(html, "team", team1.id) + refute_search_result(html, "team", team2.id) + refute_search_result(html, "team", team3.id) + end + + test "search teams with sso filter after integration", %{conn: conn} do + team1 = new_user(team: [name: "Team One"]) |> team_of() + team2 = new_user(team: [name: "Team Two"]) |> subscribe_to_growth_plan() |> team_of() + team3 = new_user(team: [name: "Team Three"]) |> team_of() + + integration = SSO.initiate_saml_integration(team2) + SSO.Domains.add(integration, "some-sso.example.com") + SSO.initiate_saml_integration(team3) + + {:ok, lv, _html} = live(conn, @cs_index) + + type_into_input(lv, "filter-text", "team:Team T +sso") + html = render(lv) + refute_search_result(html, "team", team1.id) + assert_search_result(html, "team", team2.id) + assert_search_result(html, "team", team3.id) + end + + test "search teams by sso domain", %{conn: conn} do + team1 = new_user(team: [name: "Team One"]) |> team_of() + team2 = new_user(team: [name: "Team Two"]) |> subscribe_to_growth_plan() |> team_of() + team3 = new_user(team: [name: "Team Three"]) |> team_of() + + integration = SSO.initiate_saml_integration(team2) + SSO.Domains.add(integration, "some-sso.example.com") + + {:ok, lv, _html} = live(conn, @cs_index) + + type_into_input(lv, "filter-text", "team:some-sso +sso") + html = render(lv) + + refute_search_result(html, "team", team1.id) + assert_search_result(html, "team", team2.id) + refute_search_result(html, "team", team3.id) + end + + test "search teams by identifier", %{conn: conn} do + team1 = new_user(team: [name: "Team One"]) |> team_of() + team2 = new_user(team: [name: "Team Two"]) |> team_of() + + {:ok, lv, _html} = live(conn, @cs_index) + + type_into_input(lv, "filter-text", "team:#{team2.identifier}") + html = render(lv) + + refute_search_result(html, "team", team1.id) + assert_search_result(html, "team", team2.id) + end + + test "search sites by domain_changed_from", %{conn: conn, user: user} do + {:ok, site} = + new_site(domain: "old.example.com", owner: user) + |> Plausible.Site.Domain.change("new.example.net") + + {:ok, lv, _html} = live(conn, @cs_index) + + type_into_input(lv, "filter-text", "site:old") + html = render(lv) + + assert_search_result(html, "site", site.id) + end + + test "search sites by tracker script ID", %{conn: conn} do + site = new_site() + + tracker_script_configuration = + PlausibleWeb.Tracker.get_or_create_tracker_script_configuration!(site) + + {:ok, lv, _html} = live(conn, @cs_index) + + type_into_input(lv, "filter-text", "site:#{tracker_script_configuration.id}") + + html = render(lv) + + assert_search_result(html, "site", site.id) + end + end + + defp assert_search_result(doc, type, id) do + assert link = find(doc, ~s|a[data-test-type="#{type}"][data-test-id="#{id}"]|) + + assert text_of_attr(link, "href") == + apply(Routes, :"customer_support_#{type}_path", [ + PlausibleWeb.Endpoint, + :show, + id + ]) + end + + defp refute_search_result(doc, type, id) do + refute element_exists?(doc, ~s|a[data-test-type="#{type}"][data-test-id="#{id}"]|) + end + + defp type_into_input(lv, id, text) do + lv + |> element("form#filter-form") + |> render_change(%{id => text}) + end + end +end diff --git a/test/plausible_web/live/funnel_settings/form_test.exs b/test/plausible_web/live/funnel_settings/form_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..38709ec4d45a4b0148ce4cf5565179d7983d26db --- /dev/null +++ b/test/plausible_web/live/funnel_settings/form_test.exs @@ -0,0 +1,117 @@ +defmodule PlausibleWeb.Live.FunnelSettings.FormTest do + use PlausibleWeb.ConnCase, async: true + @moduletag :ee_only + + import Phoenix.LiveViewTest + + describe "integration - live rendering" do + setup [:create_user, :log_in, :create_site] + + test "search reacts to the input, the user types in", %{conn: conn, site: site} do + setup_goals(site, ["Hello World", "Plausible", "Another World"]) + lv = get_liveview(conn, site) + + doc = type_into_combo(lv, 1, "hello") + + assert text_of_element(doc, "#dropdown-step-1-option-1") == "Hello World" + + doc = type_into_combo(lv, 1, "plausible") + + assert text_of_element(doc, "#dropdown-step-1-option-1") == "Plausible" + end + + test "selecting an option prefills input values", %{conn: conn, site: site} do + {:ok, [_, _, g3]} = setup_goals(site, ["Hello World", "Plausible", "Another World"]) + lv = get_liveview(conn, site) + + doc = type_into_combo(lv, 1, "another") + + refute element_exists?(doc, ~s/input[type="hidden"][value="#{g3.id}"]/) + refute element_exists?(doc, ~s/input[type="text"][value="Another World"]/) + + lv + |> element("li#dropdown-step-1-option-1 a") + |> render_click() + + assert lv + |> element("#submit-step-1") + |> render() + |> element_exists?(~s/input[type="hidden"][value="#{g3.id}"]/) + + assert lv + |> element("#step-1") + |> render() + |> element_exists?(~s/input[type="text"][value="Another World"]/) + end + + test "selecting one option reduces suggestions in the other", %{conn: conn, site: site} do + setup_goals(site, ["Hello World", "Plausible", "Another World"]) + lv = get_liveview(conn, site) + + type_into_combo(lv, 1, "another") + + lv + |> element("li#dropdown-step-1-option-1 a") + |> render_click() + + doc = type_into_combo(lv, 2, "another") + + refute text_of_element(doc, "ul#dropdown-step-1 li") =~ "Another World" + refute text_of_element(doc, "ul#dropdown-step-2 li") =~ "Another World" + end + + test "removing one option alters suggestions for other", %{conn: conn, site: site} do + setup_goals(site, ["Hello World", "Plausible", "Another World"]) + + lv = get_liveview(conn, site) + + lv |> element(~s/a[phx-click="add-step"]/) |> render_click() + + type_into_combo(lv, 2, "hello") + + lv + |> element("li#dropdown-step-2-option-1 a") + |> render_click() + + doc = type_into_combo(lv, 1, "hello") + + refute text_of_element(doc, "ul#dropdown-step-0 li") =~ "Hello World" + + lv |> element(~s/#remove-step-2/) |> render_click() + + doc = type_into_combo(lv, 1, "hello") + + assert text_of_element(doc, "ul#dropdown-step-1 li") =~ "Hello World" + end + end + + defp get_liveview(conn, site) do + conn = assign(conn, :live_module, PlausibleWeb.Live.FunnelSettings) + {:ok, lv, _html} = live(conn, "/#{site.domain}/settings/funnels") + lv |> element(~s/button[phx-click="add-funnel"]/) |> render_click() + assert form_view = find_live_child(lv, "funnels-form") + form_view |> element("form") |> render_change(%{funnel: %{name: "My test funnel"}}) + form_view + end + + defp setup_goals(site, goal_names) when is_list(goal_names) do + goals = + Enum.map(goal_names, fn goal_name -> + {:ok, g} = + Plausible.Goals.create(site, %{"event_name" => goal_name}, max_goals_per_site: 100) + + g + end) + + {:ok, goals} + end + + defp type_into_combo(lv, idx, text) do + lv + |> element("input#step-#{idx}") + |> render_change(%{ + "_target" => ["display-step-#{idx}"], + "display-step-#{idx}" => "#{text}" + }) + end +end diff --git a/test/plausible_web/live/funnel_settings_test.exs b/test/plausible_web/live/funnel_settings_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..5aec16db98e6cdcf8a597f864d49dbc11ff923ee --- /dev/null +++ b/test/plausible_web/live/funnel_settings_test.exs @@ -0,0 +1,589 @@ +defmodule PlausibleWeb.Live.FunnelSettingsTest do + use PlausibleWeb.ConnCase, async: false + @moduletag :ee_only + + on_ee do + import Phoenix.LiveViewTest + + describe "GET /:domain/settings/funnels" do + setup [:create_user, :log_in, :create_site] + + test "premium feature notice renders", %{conn: conn, site: site, user: user} do + user + |> team_of() + |> Plausible.Teams.Team.end_trial() + |> Plausible.Repo.update!() + + conn = get(conn, "/#{site.domain}/settings/funnels") + resp = conn |> html_response(200) + + assert text(resp) =~ "upgrade your subscription" + end + + test "guest editors should be able to access funnel settings", %{site: site, conn: conn} do + guest_user = new_user() + add_guest(site, user: guest_user, role: :editor) + + {:ok, conn: conn} = log_in(%{user: guest_user, conn: conn}) + + lock_notice = + conn + |> get("/#{site.domain}/settings/funnels") + |> html_response(200) + |> text_of_element("#lock-notice") + + refute lock_notice =~ "upgrade your subscription" + end + + test "lists funnels for the site and renders help link", %{conn: conn, site: site} do + {:ok, _} = setup_funnels(site) + conn = get(conn, "/#{site.domain}/settings/funnels") + + resp = html_response(conn, 200) + assert resp =~ "Compose goals into funnels" + assert resp =~ "From blog to signup" + assert resp =~ "From signup to blog" + refute resp =~ "Your account does not have access" + refute resp =~ "upgrade your subscription" + assert element_exists?(resp, "a[href=\"https://plausible.io/docs/funnel-analysis\"]") + end + + test "search funnels input is rendered", %{conn: conn, site: site} do + {:ok, _} = setup_funnels(site) + conn = get(conn, "/#{site.domain}/settings/funnels") + resp = html_response(conn, 200) + assert element_exists?(resp, ~s/input[type="text"]#filter-text/) + assert element_exists?(resp, ~s/form[phx-change="filter"]#filter-form/) + end + + test "lists funnels with delete actions", %{conn: conn, site: site} do + {:ok, [f1_id, f2_id]} = setup_funnels(site) + conn = get(conn, "/#{site.domain}/settings/funnels") + + resp = html_response(conn, 200) + + assert element_exists?( + resp, + ~s/button[phx-click="delete-funnel"][phx-value-funnel-id="#{f1_id}"]#delete-funnel-#{f1_id}/ + ) + + assert element_exists?( + resp, + ~s/button[phx-click="delete-funnel"][phx-value-funnel-id="#{f2_id}"]#delete-funnel-#{f2_id}/ + ) + end + + test "if goals are present, Add Funnel button is rendered", %{conn: conn, site: site} do + {:ok, _} = setup_funnels(site) + conn = get(conn, "/#{site.domain}/settings/funnels") + resp = conn |> html_response(200) + assert element_exists?(resp, ~S/button[phx-click="add-funnel"]/) + end + + test "if not enough goals are present, renders a hint to create goals + no search", %{ + conn: conn, + site: site + } do + {:ok, _} = Plausible.Goals.create(site, %{"page_path" => "/go/to/blog/**"}) + conn = get(conn, "/#{site.domain}/settings/funnels") + + doc = conn |> html_response(200) + assert text(doc) =~ "Set up a few goals" + + add_goals_path = Routes.site_path(conn, :settings_goals, site.domain) + assert element_exists?(doc, ~s/a[href="#{add_goals_path}"]/) + + refute element_exists?(doc, ~s/input[type="text"]#filter-text/) + refute element_exists?(doc, ~s/form[phx-change="filter"]#filter-form/) + end + end + + describe "FunnelSettings live view" do + setup [:create_user, :log_in, :create_site] + + test "allows dashboard toggle", %{conn: conn, site: site} do + lv = get_liveview(conn, site) + lv |> element("#feature-funnels-toggle button") |> render_click() + assert render(lv) =~ "Funnels are now hidden from your dashboard" + assert Plausible.Billing.Feature.Funnels.opted_out?(Plausible.Repo.reload!(site)) + lv |> element("#feature-funnels-toggle button") |> render_click() + assert render(lv) =~ "Funnels are now visible again on your dashboard" + refute Plausible.Billing.Feature.Funnels.opted_out?(Plausible.Repo.reload!(site)) + end + + test "allows list filtering / search", %{conn: conn, site: site} do + {:ok, _} = setup_funnels(site, ["Funnel One", "Search Me"]) + {lv, html} = get_liveview(conn, site, with_html?: true) + + assert html =~ "Funnel One" + assert html =~ "Search Me" + + html = type_into_search(lv, "search") + + refute html =~ "Funnel One" + assert html =~ "Search Me" + end + + test "allows resetting filter text via backspace icon", %{conn: conn, site: site} do + {:ok, _} = setup_funnels(site, ["Funnel One", "Another"]) + {lv, html} = get_liveview(conn, site, with_html?: true) + + refute element_exists?(html, ~s/svg[phx-click="reset-filter-text"]#reset-filter/) + + html = type_into_search(lv, "one") + refute html =~ "Another" + + assert element_exists?(html, ~s/svg[phx-click="reset-filter-text"]#reset-filter/) + + html = lv |> element(~s/svg#reset-filter/) |> render_click() + + assert html =~ "Funnel One" + assert html =~ "Another" + end + + test "allows resetting filter text via no match link", %{conn: conn, site: site} do + {:ok, _} = setup_funnels(site) + lv = get_liveview(conn, site) + html = type_into_search(lv, "Definitely this is not going to render any matches") + + assert html =~ "No funnels found for this site. Please refine or" + assert html =~ "reset your search" + + assert element_exists?(html, ~s/a[phx-click="reset-filter-text"]#reset-filter-hint/) + html = lv |> element(~s/a#reset-filter-hint/) |> render_click() + + refute html =~ "No funnels found for this site. Please refine or" + end + + test "allows to delete funnels", %{conn: conn, site: site} do + {:ok, [f1_id, _f2_id]} = setup_funnels(site) + + {lv, html} = get_liveview(conn, site, with_html?: true) + + assert html =~ "From blog to signup" + assert html =~ "From signup to blog" + + html = lv |> element(~s/button#delete-funnel-#{f1_id}/) |> render_click() + + refute html =~ "From blog to signup" + assert html =~ "From signup to blog" + + html = get(conn, "/#{site.domain}/settings/funnels") |> html_response(200) + + refute html =~ "From blog to signup" + assert html =~ "From signup to blog" + end + + test "renders the funnel form on clicking 'Add Funnel' button", %{conn: conn, site: site} do + setup_goals(site) + lv = get_liveview(conn, site) + doc = render_click(lv, "add-funnel") + + assert element_exists?( + doc, + ~s/form[phx-change="validate"][phx-submit="save"][phx-click-away="cancel-add-funnel"]/ + ) + + assert element_exists?(doc, ~s/form input[type="text"][name="funnel[name]"]/) + + assert element_exists?( + doc, + ~s/input[type="hidden"][name="funnel[steps][1][goal_id]"]#submit-step-1/ + ) + + step_setup_controls = [ + ~s/input[type="hidden"][name="funnel[steps][1][goal_id]"]#submit-step-1/, + ~s/input[type="hidden"][name="funnel[steps][2][goal_id]"]#submit-step-2/, + ~s/input[type="text"][name="display-step-1"]#step-1/, + ~s/input[type="text"][name="display-step-2"]#step-2/, + ~s/a[phx-click="add-step"]/ + ] + + Enum.each(step_setup_controls, &assert(element_exists?(doc, &1))) + end + + test "clicking 'Add another step' adds a pair of inputs and renders remove step buttons", %{ + conn: conn, + site: site + } do + setup_goals(site) + lv = get_liveview(conn, site) + + lv |> element(~s/button[phx-click="add-funnel"]/) |> render_click() + + assert lv = find_live_child(lv, "funnels-form") + lv |> element("form") |> render_change(%{funnel: %{name: "My test funnel"}}) + doc = lv |> element(~s/a[phx-click="add-step"]/) |> render_click() + + assert element_exists?( + doc, + ~s/input[type="hidden"][name="funnel[steps][3][goal_id]"]#submit-step-3/ + ) + + assert element_exists?(doc, ~s/input[type="text"][name="display-step-1"]#step-1/) + + assert element_exists?( + doc, + ~s/svg#remove-step-1[phx-click="remove-step"][phx-value-step-idx="1"]/ + ) + + assert element_exists?( + doc, + ~s/svg#remove-step-2[phx-click="remove-step"][phx-value-step-idx="2"]/ + ) + + assert element_exists?( + doc, + ~s/svg#remove-step-3[phx-click="remove-step"][phx-value-step-idx="3"]/ + ) + end + + test "clicking the 'remove step' button removes a step", %{site: site, conn: conn} do + setup_goals(site) + lv = get_liveview(conn, site) + + lv |> element(~s/button[phx-click="add-funnel"]/) |> render_click() + assert lv = find_live_child(lv, "funnels-form") + lv |> element("form") |> render_change(%{funnel: %{name: "My test funnel"}}) + lv |> element(~s/a[phx-click="add-step"]/) |> render_click() + + doc = lv |> element(~s/#remove-step-2/) |> render_click() + + assert element_exists?(doc, ~s/input#step-1/) + assert element_exists?(doc, ~s/input#step-3/) + refute element_exists?(doc, ~s/input#step-2/) + end + + test "save button becomes active once at least two steps are selected", %{ + conn: conn, + site: site + } do + setup_goals(site) + lv = get_liveview(conn, site) + lv |> element(~s/button[phx-click="add-funnel"]/) |> render_click() + + assert lv = find_live_child(lv, "funnels-form") + + lv + |> element("li#dropdown-step-1-option-1 a") + |> render_click() + + doc = + lv + |> element("li#dropdown-step-2-option-1 a") + |> render_click() + + assert element_exists?(doc, ~s/form button#save:disabled/) + + doc = + lv + |> element("form") + |> render_change(%{ + funnel: %{ + name: "My test funnel", + steps: [ + %{goal_id: 1}, + %{goal_id: 2} + ] + } + }) + + assert element_exists?(doc, ~s/form button#save/) + refute element_exists?(doc, ~s/form button#save:disabled/) + end + + test "save button saves a new funnel", %{ + conn: conn, + site: site + } do + setup_goals(site) + lv = get_liveview(conn, site) + lv |> element(~s/button[phx-click="add-funnel"]/) |> render_click() + + assert lv = find_live_child(lv, "funnels-form") + + lv + |> element("li#dropdown-step-1-option-1 a") + |> render_click() + + lv + |> element("li#dropdown-step-2-option-1 a") + |> render_click() + + lv + |> element("form") + |> render_change(%{ + funnel: %{ + name: "My test funnel", + steps: [ + %{goal_id: 1}, + %{goal_id: 2} + ] + } + }) + + lv + |> element(~s/form/) + |> render_submit() + + assert %Plausible.Funnel{steps: [_, _], strict_order: false} = + Plausible.Funnels.get(site, "My test funnel") + end + + test "save button saves strict-order setting", %{ + conn: conn, + site: site + } do + {:ok, [g1, g2]} = setup_goals(site) + + {:ok, %Plausible.Funnel{strict_order: false} = funnel} = + Plausible.Funnels.create( + site, + "Strict funnel", + [%{"goal_id" => g1.id}, %{"goal_id" => g2.id}], + strict_order?: false + ) + + lv = get_liveview(conn, site) + + lv + |> element(~s/button[phx-click="edit-funnel"][phx-value-funnel-id="#{funnel.id}"]/) + |> render_click() + + assert lv = find_live_child(lv, "funnels-form") + + assert element_exists?( + render(lv), + ~s/button#toggle-strict-order-switch[aria-checked="true"]/ + ) + + lv + |> element(~s/button#toggle-strict-order-switch[phx-click="toggle-strict-order"]/) + |> render_click() + + assert element_exists?( + render(lv), + ~s/button#toggle-strict-order-switch[aria-checked="false"]/ + ) + + lv + |> element(~s/form/) + |> render_submit() + + assert %Plausible.Funnel{strict_order: true} = + Plausible.Funnels.get(site, "Strict funnel") + end + + test "editing a funnel pre-renders it", %{ + conn: conn, + site: site + } do + {:ok, [f1_id, _]} = setup_funnels(site) + lv = get_liveview(conn, site) + + lv + |> element(~s/button[phx-click="edit-funnel"][phx-value-funnel-id="#{f1_id}"]/) + |> render_click() + + assert lv = find_live_child(lv, "funnels-form") + + assert lv |> element("#step-1") |> render() |> text_of_attr("value") == + "Visit /go/to/blog/**" + + assert lv |> element("#step-2") |> render() |> text_of_attr("value") == "Signup" + end + + test "funnel is re-evaluated when strict-order is changed", %{ + conn: conn, + site: site + } do + {:ok, [g1, g2]} = setup_goals(site) + + {:ok, funnel} = + Plausible.Funnels.create( + site, + "Strict-order switch", + [%{"goal_id" => g1.id}, %{"goal_id" => g2.id}] + ) + + ts = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) + + populate_stats(site, [ + build(:pageview, pathname: "/go/to/blog/foo", user_id: 123, timestamp: ts), + build(:pageview, + pathname: "/intervention", + user_id: 123, + timestamp: NaiveDateTime.add(ts, 1, :second) + ), + build(:event, + name: "Signup", + user_id: 123, + timestamp: NaiveDateTime.add(ts, 2, :second) + ) + ]) + + lv = get_liveview(conn, site) + + lv + |> element(~s/button[phx-click="edit-funnel"][phx-value-funnel-id="#{funnel.id}"]/) + |> render_click() + + assert lv = find_live_child(lv, "funnels-form") + + assert text_of_element(render(lv), ~s/#step-eval-1/) =~ "Dropoff: 0%" + assert text_of_element(render(lv), ~s/#funnel-eval/) =~ "Last month conversion rate: 100%" + + lv + |> element(~s/button#toggle-strict-order-switch[phx-click="toggle-strict-order"]/) + |> render_click() + + assert text_of_element(render(lv), ~s/#step-eval-1/) =~ "Dropoff: 100%" + assert text_of_element(render(lv), ~s/#funnel-eval/) =~ "Last month conversion rate: 0%" + end + + test "clicking save after editing the funnel, updates it", %{ + conn: conn, + site: site + } do + {:ok, [f1_id, _]} = setup_funnels(site) + {:ok, %{id: goal_id}} = Plausible.Goals.create(site, %{"page_path" => "/"}) + + lv = get_liveview(conn, site) + + lv + |> element(~s/button[phx-click="edit-funnel"][phx-value-funnel-id="#{f1_id}"]/) + |> render_click() + + assert lv = find_live_child(lv, "funnels-form") + + lv + |> element("li#dropdown-step-2-option-1 a") + |> render_click() + + lv + |> element("form") + |> render_change(%{ + funnel: %{ + name: "Updated funnel", + steps: [ + %{goal_id: 1}, + %{goal_id: goal_id} + ] + } + }) + + lv + |> element(~s/form/) + |> render_submit() + + assert %Plausible.Funnel{steps: [_, %Plausible.Funnel.Step{goal_id: ^goal_id}]} = + Plausible.Funnels.get(site, "Updated funnel") + end + + test "clicking save after editing the funnel, updates strict-order setting", %{ + conn: conn, + site: site + } do + {:ok, [g1, g2]} = setup_goals(site) + + {:ok, funnel} = + Plausible.Funnels.create( + site, + "Editable strict funnel", + [%{"goal_id" => g1.id}, %{"goal_id" => g2.id}], + strict_order?: false + ) + + lv = get_liveview(conn, site) + + lv + |> element(~s/button[phx-click="edit-funnel"][phx-value-funnel-id="#{funnel.id}"]/) + |> render_click() + + assert lv = find_live_child(lv, "funnels-form") + + lv + |> element(~s/button#toggle-strict-order-switch[phx-click="toggle-strict-order"]/) + |> render_click() + + lv + |> element(~s/form/) + |> render_submit() + + assert %Plausible.Funnel{strict_order: true} = + Plausible.Funnels.get(site, "Editable strict funnel") + end + + test "funnel gets evaluated on every select, assuming a second has passed between selections", + %{ + conn: conn, + site: site + } do + setup_goals(site) + lv = get_liveview(conn, site) + lv |> element(~s/button[phx-click="add-funnel"]/) |> render_click() + + assert lv = find_live_child(lv, "funnels-form") + + lv |> element("li#dropdown-step-1-option-1 a") |> render_click() + + lv |> element("li#dropdown-step-2-option-1 a") |> render_click() + + doc = lv |> element("#step-eval-0") |> render() + assert text_of_element(doc, ~s/#step-eval-0/) =~ "Visitors: 0" + + doc = lv |> element("#step-eval-1") |> render() + assert text_of_element(doc, ~s/#step-eval-1/) =~ "Dropoff: 0%" + + doc = lv |> element("#funnel-eval") |> render() + assert text_of_element(doc, ~s/#funnel-eval/) =~ "Last month conversion rate: 0%" + end + end + + defp setup_funnels(site, names \\ []) do + {:ok, [g1, g2]} = setup_goals(site) + + {:ok, f1} = + Plausible.Funnels.create( + site, + Enum.at(names, 0) || "From blog to signup", + [%{"goal_id" => g1.id}, %{"goal_id" => g2.id}] + ) + + {:ok, f2} = + Plausible.Funnels.create( + site, + Enum.at(names, 1) || "From signup to blog", + [%{"goal_id" => g2.id}, %{"goal_id" => g1.id}] + ) + + {:ok, [f1.id, f2.id]} + end + + defp setup_goals(site) do + {:ok, g1} = Plausible.Goals.create(site, %{"page_path" => "/go/to/blog/**"}) + {:ok, g2} = Plausible.Goals.create(site, %{"event_name" => "Signup"}) + {:ok, [g1, g2]} + end + + defp get_liveview(conn, site, opts \\ []) do + conn = assign(conn, :live_module, PlausibleWeb.Live.FunnelSettings) + {:ok, lv, html} = live(conn, "/#{site.domain}/settings/funnels") + + if Keyword.get(opts, :with_html?) do + {lv, html} + else + lv + end + end + + defp type_into_search(lv, text) do + lv + |> element("form#filter-form") + |> render_change(%{ + "_target" => ["filter-text"], + "filter-text" => "#{text}" + }) + end + end +end diff --git a/test/plausible_web/live/goal_settings/form_test.exs b/test/plausible_web/live/goal_settings/form_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..7d6774dbb4e7e338e3377daea969d409e396cf21 --- /dev/null +++ b/test/plausible_web/live/goal_settings/form_test.exs @@ -0,0 +1,784 @@ +defmodule PlausibleWeb.Live.GoalSettings.FormTest do + use PlausibleWeb.ConnCase, async: true + import Phoenix.LiveViewTest + + on_ee do + end + + @revenue_goal_settings ~s|div[data-test-id="revenue-goal-settings"]| + + describe "integration - live rendering" do + setup [:create_user, :log_in, :create_site] + + test "form renders with custom events when selected from dropdown", %{conn: conn, site: site} do + setup_goals(site) + lv = get_liveview(conn, site) |> open_modal_with_goal_type("custom_events") + + html = render(lv) + + assert html =~ "Add goal for #{site.domain}" + refute element_exists?(html, "#pageviews-form") + refute element_exists?(html, "#scroll-form") + assert element_exists?(html, "#custom-events-form") + + assert html =~ "Event name" + end + + test "form renders with pageview when selected from dropdown", %{conn: conn, site: site} do + lv = get_liveview(conn, site) |> open_modal_with_goal_type("pageviews") + + html = render(lv) + + assert html =~ "Add goal for #{site.domain}" + refute element_exists?(html, "#custom-events-form") + refute element_exists?(html, "#scroll-form") + assert element_exists?(html, "#pageviews-form") + assert html =~ "Page path" + end + + test "form renders with scroll when selected from dropdown", %{conn: conn, site: site} do + lv = get_liveview(conn, site) |> open_modal_with_goal_type("scroll") + + html = render(lv) + + assert html =~ "Add goal for #{site.domain}" + refute element_exists?(html, "#custom-events-form") + refute element_exists?(html, "#pageviews-form") + assert element_exists?(html, "#scroll-form") + assert html =~ "Scroll percentage threshold" + assert html =~ "Page path" + end + end + + describe "Goal submission" do + setup [:create_user, :log_in, :create_site] + + @tag :ee_only + test "renders form fields for custom events with currency", %{conn: conn, site: site} do + lv = get_liveview(conn, site) |> open_modal_with_goal_type("custom_events") + + html = render(lv) + + refute element_exists?(html, "#pageviews-form") + + input_names = html |> find("#custom-events-form input") |> Enum.map(&name_of/1) + + assert "goal[event_name]" in input_names + assert "goal[display_name]" in input_names + assert "display-currency_input_modalseq0" in input_names + + assert html =~ "Add custom property" + end + + test "renders form fields for pageview", %{conn: conn, site: site} do + lv = get_liveview(conn, site) |> open_modal_with_goal_type("pageviews") + + html = render(lv) + + refute element_exists?(html, "#custom-events-form") + + input_names = html |> find("#pageviews-form input") |> Enum.map(&name_of/1) + + assert "display-page_path_input_modalseq0" in input_names + assert "goal[page_path]" in input_names + assert "goal[display_name]" in input_names + + assert html =~ "Add custom property" + end + + @tag :ce_build_only + test "renders form fields for custom events (no currency)", %{conn: conn, site: site} do + lv = get_liveview(conn, site) |> open_modal_with_goal_type("custom_events") + + html = render(lv) + + refute element_exists?(html, "#pageviews-form") + + input_names = html |> find("#custom-events-form input") |> Enum.map(&name_of/1) + + assert "goal[event_name]" in input_names + assert "goal[display_name]" in input_names + refute "display-currency_input_modalseq0" in input_names + + assert html =~ "Add custom property" + end + + test "renders error on empty submission", %{conn: conn, site: site} do + lv = get_liveview(conn, site) |> open_modal_with_goal_type("custom_events") + + lv |> element("#goals-form-modalseq0 form") |> render_submit() + html = render(lv) + assert html =~ "this field is required and cannot be blank" + end + + test "renders error on empty pageview submission", %{conn: conn, site: site} do + lv = get_liveview(conn, site) |> open_modal_with_goal_type("pageviews") + + lv |> element("#goals-form-modalseq0 form") |> render_submit() + html = render(lv) + assert html =~ "this field is required and must start with a /" + end + + test "creates a custom event", %{conn: conn, site: site} do + lv = get_liveview(conn, site) |> open_modal_with_goal_type("custom_events") + refute render(lv) =~ "SampleCustomEvent" + + lv = open_modal_with_goal_type(lv, "custom_events") + + lv + |> element("#goals-form-modalseq0 form") + |> render_submit(%{goal: %{event_name: "SampleCustomEvent"}}) + + html = render(lv) + assert html =~ "SampleCustomEvent" + assert html =~ "Custom Event" + end + + on_ee do + test "creates a custom event for consolidated view (revenue switch not available)", %{ + conn: conn, + user: user + } do + {:ok, team} = Plausible.Teams.get_or_create(user) + new_site(team: team) + site = new_consolidated_view(team) + + lv = get_liveview(conn, site) |> open_modal_with_goal_type("custom_events") + + assert render(lv) =~ "Add goal for consolidated view" + refute element_exists?(render(lv), @revenue_goal_settings) + + lv + |> element("#goals-form-modalseq0 form") + |> render_submit(%{goal: %{event_name: "SampleCustomEvent"}}) + + html = render(lv) + assert html =~ "SampleCustomEvent" + assert html =~ "Custom Event" + end + end + + @tag :ee_only + test "creates a revenue goal", %{conn: conn, site: site} do + lv = get_liveview(conn, site) |> open_modal_with_goal_type("custom_events") + refute render(lv) =~ "SampleRevenueGoal" + + assert element_exists?(render(lv), @revenue_goal_settings) + + lv + |> element("#goals-form-modalseq0 form") + |> render_submit(%{ + goal: %{ + event_name: "SampleRevenueGoal", + currency: "EUR", + display_name: "Sample Display Name" + } + }) + + html = render(lv) + + assert html =~ "SampleRevenueGoal" + assert html =~ "Revenue Goal (EUR)" + assert html =~ "Sample Display Name" + end + + test "creates a pageview goal", %{conn: conn, site: site} do + lv = get_liveview(conn, site) |> open_modal_with_goal_type("pageviews") + refute render(lv) =~ "Visit /page/**" + + lv + |> element("#goals-form-modalseq0 form") + |> render_submit(%{goal: %{page_path: "/page/**"}}) + + html = render(lv) + assert html =~ "Visit /page/**" + assert html =~ "Pageview" + + assert Plausible.Goals.count(site) == 1 + end + + test "fails to create a goal above limit", %{conn: conn, site: site} do + for i <- 1..10, do: {:ok, _} = Plausible.Goals.create(site, %{"event_name" => "G#{i}"}) + + lv = get_liveview(conn, site) |> open_modal_with_goal_type("pageviews") + refute render(lv) =~ "Visit /page/**" + + lv + |> element("#goals-form-modalseq0 form") + |> render_submit(%{goal: %{page_path: "/page/**"}}) + + html = render(lv) + assert html =~ "Maximum number of goals reached" + + assert Plausible.Goals.count(site) == 10 + end + end + + describe "Editing goals" do + setup [:create_user, :log_in, :create_site] + + @tag :ee_only + test "tabless view is rendered with goal type change disabled", %{conn: conn, site: site} do + {:ok, [pageview, custom_event, revenue_goal]} = setup_goals(site) + lv = get_liveview(conn, site) + + # pageviews + lv |> element(~s/button#edit-goal-#{pageview.id}/) |> render_click() + html = render(lv) + + assert element_exists?(html, "#pageviews-form") + refute element_exists?(html, "#custom-events-form") + + refute element_exists?( + html, + ~s/button[role=switch][aria-labelledby=enable-revenue-tracking]/ + ) + + # custom events + lv |> element(~s/button#edit-goal-#{custom_event.id}/) |> render_click() + html = render(lv) + + refute element_exists?(html, "#pageviews-form") + assert element_exists?(html, "#custom-events-form") + refute String.downcase(text_of_element(html, "#custom-events-form")) =~ "currency" + + # revenue goals + lv |> element(~s/button#edit-goal-#{revenue_goal.id}/) |> render_click() + html = render(lv) + + refute element_exists?(html, "#pageviews-form") + assert element_exists?(html, "#custom-events-form") + assert element_exists?(html, ~s/[data-test-id=goal-currency-label]/) + end + + test "updates a custom event", %{conn: conn, site: site} do + {:ok, [_, g, _]} = setup_goals(site) + lv = get_liveview(conn, site) + + lv |> element(~s/button#edit-goal-#{g.id}/) |> render_click() + + html = render(lv) + assert element_exists?(html, "#event_name_input_modalseq0[value=Signup]") + + lv + |> element("#goals-form-modalseq0 form") + |> render_submit(%{goal: %{event_name: "Updated", display_name: "UPDATED"}}) + + _html = render(lv) + + updated = Plausible.Goals.get(site, g.id) + assert updated.event_name == "Updated" + assert updated.display_name == "UPDATED" + assert updated.id == g.id + end + + @tag :ee_only + test "updates a revenue goal event_name but not currency", %{conn: conn, site: site} do + {:ok, [_, _, g]} = setup_goals(site) + lv = get_liveview(conn, site) + + lv |> element(~s/button#edit-goal-#{g.id}/) |> render_click() + + html = render(lv) + assert element_exists?(html, "#event_name_input_modalseq0[value=Purchase]") + + lv + |> element("#goals-form-modalseq0 form") + |> render_submit(%{goal: %{event_name: "Updated"}}) + + _html = render(lv) + + updated = Plausible.Goals.get(site, g.id) + assert updated.event_name == "Updated" + assert updated.display_name == "Purchase" + assert updated.currency == :EUR + assert updated.id == g.id + end + + @tag :ee_only + test "currency field is displayed as disabled text input when editing revenue goal", %{ + conn: conn, + site: site + } do + {:ok, [_, _, revenue_goal]} = setup_goals(site) + lv = get_liveview(conn, site) + + lv |> element(~s/button#edit-goal-#{revenue_goal.id}/) |> render_click() + + html = render(lv) + + assert element_exists?(html, ~s/input[name="goal[currency]"][disabled]/) + refute element_exists?(html, ~s/#currency_input_modalseq0/) + end + + test "updates a pageview", %{conn: conn, site: site} do + {:ok, [g, _, _]} = setup_goals(site) + lv = get_liveview(conn, site) + + lv |> element(~s/button#edit-goal-#{g.id}/) |> render_click() + + html = render(lv) + assert element_exists?(html, ~s|#page_path_input_modalseq0[value="/go/to/blog/**"]|) + + lv + |> element("#goals-form-modalseq0 form") + |> render_submit(%{goal: %{page_path: "/updated", display_name: "Visit /updated"}}) + + _html = render(lv) + + updated = Plausible.Goals.get(site, g.id) + assert updated.page_path == "/updated" + assert updated.display_name == "Visit /updated" + assert updated.id == g.id + end + + test "renders error when trying to rename event_name of an automated goal", %{ + conn: conn, + site: site + } do + {:ok, goal} = Plausible.Goals.create(site, %{"event_name" => "File Download"}) + lv = get_liveview(conn, site) + + lv |> element(~s/button#edit-goal-#{goal.id}/) |> render_click() + + lv + |> element("#goals-form-modalseq0 form") + |> render_submit(%{goal: %{event_name: "My File Download"}}) + + assert render(lv) =~ "cannot be changed for an automated goal" + end + + test "renders error when trying to rename display_name of an automated goal", %{ + conn: conn, + site: site + } do + {:ok, goal} = Plausible.Goals.create(site, %{"event_name" => "File Download"}) + lv = get_liveview(conn, site) + + lv |> element(~s/button#edit-goal-#{goal.id}/) |> render_click() + + lv + |> element("#goals-form-modalseq0 form") + |> render_submit(%{goal: %{event_name: "File Download", display_name: "My Downloads"}}) + + assert render(lv) =~ "cannot be changed for an automated goal" + end + + test "renders error when goal is invalid", %{conn: conn, site: site} do + {:ok, [g, g2, _]} = setup_goals(site) + lv = get_liveview(conn, site) + + lv |> element(~s/button#edit-goal-#{g.id}/) |> render_click() + + html = render(lv) + assert element_exists?(html, ~s|#page_path_input_modalseq0[value="/go/to/blog/**"]|) + + lv + |> element("#goals-form-modalseq0 form") + |> render_submit(%{goal: %{page_path: "/updated", display_name: g2.display_name}}) + + html = render(lv) + + assert html =~ "has already been taken" + end + + test "hides 'Add custom property' toggle when editing goal with custom props", %{ + conn: conn, + site: site + } do + {:ok, goal} = + Plausible.Goals.create(site, %{ + "event_name" => "Purchase", + "custom_props" => %{"product" => "Shirt", "color" => "Blue"} + }) + + lv = get_liveview(conn, site) + lv |> element(~s/button#edit-goal-#{goal.id}/) |> render_click() + + html = render(lv) + + refute html =~ "Add custom property" + assert html =~ "Custom properties" + end + + test "shows 'Add custom property' toggle when editing goal without custom props", %{ + conn: conn, + site: site + } do + {:ok, goal} = Plausible.Goals.create(site, %{"event_name" => "Signup"}) + + lv = get_liveview(conn, site) + lv |> element(~s/button#edit-goal-#{goal.id}/) |> render_click() + + html = render(lv) + + assert html =~ "Add custom property" + refute html =~ "Custom properties" + end + + test "property pairs section is visible when editing goal with custom props", %{ + conn: conn, + site: site + } do + {:ok, goal} = + Plausible.Goals.create(site, %{ + "event_name" => "Purchase", + "custom_props" => %{"product" => "Shirt"} + }) + + lv = get_liveview(conn, site) + lv |> element(~s/button#edit-goal-#{goal.id}/) |> render_click() + + html = render(lv) + + assert html =~ "Custom properties" + assert element_exists?(html, ~s/[data-test-id="custom-property-pairs"]/) + end + + test "toggle can be clicked to show/hide custom props section", %{conn: conn, site: site} do + lv = get_liveview(conn, site) |> open_modal_with_goal_type("custom_events") + + html = render(lv) + assert html =~ "Add custom property" + refute element_exists?(html, ~s/[data-test-id="custom-property-pairs"]/) + + lv |> element(~s/button[phx-click="toggle-custom-props"]/) |> render_click() + html = render(lv) + + assert html =~ "Custom properties" + assert element_exists?(html, ~s/[data-test-id="custom-property-pairs"]/) + + lv |> element(~s/button[phx-click="toggle-custom-props"]/) |> render_click() + html = render(lv) + + assert html =~ "Add custom property" + refute element_exists?(html, ~s/[data-test-id="custom-property-pairs"]/) + end + + test "disabling custom props toggle clears custom props on save", %{conn: conn, site: site} do + {:ok, goal} = + Plausible.Goals.create(site, %{ + "event_name" => "Purchase", + "custom_props" => %{"product" => "Shirt", "color" => "Blue"} + }) + + assert Plausible.Goal.has_custom_props?(goal) + + lv = get_liveview(conn, site) + lv |> element(~s/button#edit-goal-#{goal.id}/) |> render_click() + + html = render(lv) + assert html =~ "Custom properties" + + lv |> element(~s/button[phx-click="toggle-custom-props"]/) |> render_click() + html = render(lv) + + assert html =~ "Add custom property" + refute element_exists?(html, ~s/[data-test-id="custom-property-pairs"]/) + + lv + |> element("#goals-form-modalseq0 form") + |> render_submit(%{goal: %{event_name: "Purchase"}}) + + # Helps avoid premature close of DB connection and logging error in test output + _ = render(lv) + + updated_goal = Plausible.Goals.get(site, goal.id) + refute Plausible.Goal.has_custom_props?(updated_goal) + assert updated_goal.custom_props == %{} + end + + @tag :ee_only + test "custom props toggle is disabled when Props feature is unavailable", %{ + conn: conn, + user: user, + site: site + } do + user + |> team_of() + |> Plausible.Teams.Team.end_trial() + |> Plausible.Repo.update!() + + lv = get_liveview(conn, site) |> open_modal_with_goal_type("custom_events") + + html = render(lv) + + assert element_exists?( + html, + ~s/button[role="switch"][disabled]#add-custom-property-modalseq0/ + ) + + assert html =~ "To get access to this feature" + end + + @tag :ee_only + test "custom props toggle is enabled when user has Business plan", %{ + conn: conn, + user: user, + site: site + } do + subscribe_to_business_plan(user) + + lv = get_liveview(conn, site) |> open_modal_with_goal_type("custom_events") + + html = render(lv) + + refute element_exists?( + html, + ~s/button[role="switch"][disabled]#add-custom-property-modalseq0/ + ) + + assert element_exists?(html, ~s/button[role="switch"]#add-custom-property-modalseq0/) + end + end + + describe "Combos integration" do + setup [:create_user, :log_in, :create_site] + + @tag :ee_only + test "currency combo works", %{conn: conn, site: site} do + lv = get_liveview(conn, site) |> open_modal_with_goal_type("custom_events") + + type_into_combo(lv, "currency_input_modalseq0", "Polish") + html = render(lv) + + assert element_exists?(html, ~s/a[phx-value-display-value="PLN - Polish Zloty"]/) + refute element_exists?(html, ~s/a[phx-value-display-value="EUR - Euro"]/) + + type_into_combo(lv, "currency_input_modalseq0", "Euro") + html = render(lv) + + refute element_exists?(html, ~s/a[phx-value-display-value="PLN - Polish Zloty"]/) + assert element_exists?(html, ~s/a[phx-value-display-value="EUR - Euro"]/) + end + + test "pageview combo works", %{conn: conn, site: site} do + lv = get_liveview(conn, site) |> open_modal_with_goal_type("pageviews") + + html = type_into_combo(lv, "page_path_input_modalseq0", "/hello") + + assert html =~ "Create "/hello"" + end + + test "pageview combo uses filter suggestions", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/go/to/page/1"), + build(:pageview, pathname: "/go/home") + ]) + + lv = get_liveview(conn, site) |> open_modal_with_goal_type("pageviews") + + type_into_combo(lv, "page_path_input_modalseq0", "/go/to/p") + + html = render(lv) + assert html =~ "Create "/go/to/p"" + assert html =~ "/go/to/page/1" + refute html =~ "/go/home" + + type_into_combo(lv, "page_path_input_modalseq0", "/go/h") + html = render(lv) + assert html =~ "/go/home" + refute html =~ "/go/to/page/1" + end + + test "pageview combo considers imported pageviews as well", %{conn: conn, site: site} do + site_import = insert(:site_import, site: site) + + populate_stats(site, site_import.id, [ + build(:imported_pages, page: "/go/to/page/1", pageviews: 2), + build(:imported_pages, page: "/go/home", pageviews: 1) + ]) + + lv = get_liveview(conn, site) |> open_modal_with_goal_type("pageviews") + + type_into_combo(lv, "page_path_input_modalseq0", "/go/to/p") + + html = render(lv) + assert html =~ "Create "/go/to/p"" + assert html =~ "/go/to/page/1" + refute html =~ "/go/home" + + type_into_combo(lv, "page_path_input_modalseq0", "/go/h") + html = render(lv) + assert html =~ "/go/home" + refute html =~ "/go/to/page/1" + end + + test "event name combo suggestions update on input", %{conn: conn, site: site} do + populate_stats(site, [ + build(:event, name: "EventOne"), + build(:event, name: "EventTwo"), + build(:event, name: "EventThree") + ]) + + lv = get_liveview(conn, site) |> open_modal_with_goal_type("custom_events") + + lv + |> element("button[phx-click='add-manually']") + |> render_click() + + type_into_combo(lv, "event_name_input_modalseq0", "One") + html = render(lv) + + assert text_of_element(html, "#goals-form-modalseq0") =~ "EventOne" + refute text_of_element(html, "#goals-form-modalseq0") =~ "EventTwo" + refute text_of_element(html, "#goals-form-modalseq0") =~ "EventThree" + end + + test "event name combo suggestions are up to date after deletion", %{conn: conn, site: site} do + insert(:goal, site: site, event_name: "EventOne") + insert(:goal, site: site, event_name: "EventTwo") + insert(:goal, site: site, event_name: "EventThree") + + populate_stats(site, [ + build(:event, name: "EventOne"), + build(:event, name: "EventTwo"), + build(:event, name: "EventThree") + ]) + + lv = get_liveview(conn, site) + + # Delete the goal + goal = Plausible.Repo.get_by(Plausible.Goal, site_id: site.id, event_name: "EventOne") + + lv |> element(~s/button#delete-goal-#{goal.id}/) |> render_click() + lv |> element("button[phx-click='add-manually']") |> render_click() + + html = render(lv) + + assert text_of_element(html, "#goals-form-modalseq0") =~ "EventOne" + refute text_of_element(html, "#goals-form-modalseq0") =~ "EventTwo" + refute text_of_element(html, "#goals-form-modalseq0") =~ "EventThree" + end + end + + describe "Autoconfigure goals from custom events modal" do + setup [:create_user, :log_in, :create_site] + + test "shows autoconfigure modal when opening custom events modal with available events", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, pathname: "/go/home"), + build(:event, name: "Signup"), + build(:event, name: "Newsletter Signup"), + build(:event, name: "Purchase") + ]) + + lv = get_liveview(conn, site) |> open_modal_with_goal_type("custom_events") + + html = render(lv) + assert html =~ "We detected 3 custom" + assert html =~ "These events have been sent from your site in the past 6 months" + end + + test "clicking 'Add manually' shows the regular form", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/go/home"), + build(:event, name: "Signup"), + build(:event, name: "Newsletter Signup"), + build(:event, name: "Purchase") + ]) + + lv = get_liveview(conn, site) |> open_modal_with_goal_type("custom_events") + + html = render(lv) + assert html =~ "We detected 3 custom" + + lv + |> element("button[phx-click='add-manually']") + |> render_click() + + html = render(lv) + refute html =~ "We detected" + assert html =~ "Add goal for" + end + + test "autoconfigure button adds all events", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/go/home"), + build(:event, name: "Signup"), + build(:event, name: "Newsletter Signup"), + build(:event, name: "Purchase") + ]) + + lv = get_liveview(conn, site) |> open_modal_with_goal_type("custom_events") + + lv + |> element("button[phx-click='autoconfigure']") + |> render_click() + + # Render again to process the async :autoconfigure message + _html = render(lv) + + goals = Plausible.Goals.for_site(site) + assert length(goals) == 3 + assert Enum.any?(goals, &(&1.event_name == "Signup")) + assert Enum.any?(goals, &(&1.event_name == "Newsletter Signup")) + assert Enum.any?(goals, &(&1.event_name == "Purchase")) + end + + test "autoconfigure modal does not show when all events are already goals", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, pathname: "/go/home"), + build(:event, name: "Signup"), + build(:event, name: "Newsletter Signup"), + build(:event, name: "Purchase") + ]) + + lv = get_liveview(conn, site) + html = render(lv) + + assert element_exists?(html, "[data-test-id='autoconfigure-modal']") + + lv + |> element("button[phx-click='autoconfigure']") + |> render_click() + + html = render(lv) + refute element_exists?(html, "[data-test-id='autoconfigure-modal']") + + lv = open_modal_with_goal_type(lv, "custom_events") + html = render(lv) + + refute element_exists?(html, "[data-test-id='autoconfigure-modal']") + refute html =~ "We detected" + refute html =~ "from the last 6 months" + end + end + + defp type_into_combo(lv, id, text) do + lv + |> element("input##{id}") + |> render_change(%{ + "_target" => ["display-#{id}"], + "display-#{id}" => "#{text}" + }) + end + + defp setup_goals(site) do + {:ok, g1} = Plausible.Goals.create(site, %{"page_path" => "/go/to/blog/**"}) + {:ok, g2} = Plausible.Goals.create(site, %{"event_name" => "Signup"}) + {:ok, g3} = Plausible.Goals.create(site, %{"event_name" => "Purchase", "currency" => "EUR"}) + {:ok, [g1, g2, g3]} + end + + defp get_liveview(conn, site) do + conn = assign(conn, :live_module, PlausibleWeb.Live.GoalSettings) + {:ok, lv, _html} = live(conn, "/#{site.domain}/settings/goals") + + lv + end + + defp open_modal_with_goal_type(lv, goal_type) do + lv + |> element(~s/[phx-click="add-goal"][phx-value-goal-type="#{goal_type}"]/) + |> render_click() + + lv + end +end diff --git a/test/plausible_web/live/goal_settings/property_pair_input_test.exs b/test/plausible_web/live/goal_settings/property_pair_input_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..603425fb4aab44b9536a1ed24de8152904b9aa70 --- /dev/null +++ b/test/plausible_web/live/goal_settings/property_pair_input_test.exs @@ -0,0 +1,192 @@ +defmodule PlausibleWeb.Live.GoalSettings.PropertyPairInputTest do + use PlausibleWeb.ConnCase, async: true + import Phoenix.LiveViewTest + + alias PlausibleWeb.Live.GoalSettings.PropertyPairInput + + describe "static rendering" do + setup [:create_user, :create_site] + + test "renders both property name and value comboboxes", %{site: site} do + doc = render_pair_component(site, "test-pair") + + assert element_exists?( + doc, + ~s/input#test-pair_key[name="display-test-pair_key"][phx-change="search"]/ + ) + + assert element_exists?( + doc, + ~s/input#test-pair_value[name="display-test-pair_value"][phx-change="search"]/ + ) + end + + test "property value combobox shows 'Select property first' when no property selected", %{ + site: site + } do + doc = render_pair_component(site, "test-pair") + + assert element_exists?( + doc, + ~s/input#test-pair_value[placeholder="Select property first"]/ + ) + end + + test "property value combobox shows 'Select value' when property is selected", %{site: site} do + doc = render_pair_component(site, "test-pair", selected_property: "product") + + assert element_exists?( + doc, + ~s/input#test-pair_value[placeholder="Select value"]/ + ) + end + + test "both comboboxes have correct submit names", %{site: site} do + doc = render_pair_component(site, "test-pair") + + assert element_exists?( + doc, + ~s/input[type="hidden"][name="goal[custom_props][keys][]"]/ + ) + + assert element_exists?( + doc, + ~s/input[type="hidden"][name="goal[custom_props][values][]"]/ + ) + end + + test "renders wrapper div with correct ID", %{site: site} do + doc = render_pair_component(site, "my-custom-id") + + assert element_exists?(doc, ~s/div#my-custom-id/) + end + + test "both comboboxes are marked as creatable", %{site: site} do + doc = render_pair_component(site, "test-pair") + + assert text_of_element(doc, "#dropdown-test-pair_key") =~ "Create an item by typing." + assert text_of_element(doc, "#dropdown-test-pair_value") =~ "Create an item by typing." + end + + test "pre-populates property key when initial_prop_key is provided", %{site: site} do + doc = render_pair_component(site, "test-pair", initial_prop_key: "product") + + assert element_exists?( + doc, + ~s/input#test-pair_key[name="display-test-pair_key"][value="product"]/ + ) + end + + test "pre-populates property value when initial_prop_value is provided", %{site: site} do + doc = render_pair_component(site, "test-pair", initial_prop_value: "Shirt") + + assert element_exists?( + doc, + ~s/input#test-pair_value[name="display-test-pair_value"][value="Shirt"]/ + ) + end + + test "pre-populates both property key and value when both are provided", %{site: site} do + doc = + render_pair_component(site, "test-pair", + initial_prop_key: "product", + initial_prop_value: "Shirt" + ) + + assert element_exists?( + doc, + ~s/input#test-pair_key[name="display-test-pair_key"][value="product"]/ + ) + + assert element_exists?( + doc, + ~s/input#test-pair_value[name="display-test-pair_value"][value="Shirt"]/ + ) + end + + test "value combobox shows 'Select value' when property is pre-populated", %{site: site} do + doc = render_pair_component(site, "test-pair", initial_prop_key: "product") + + assert element_exists?( + doc, + ~s/input#test-pair_value[placeholder="Select value"]/ + ) + end + end + + describe "integration with live view" do + setup [:create_user, :create_site] + + defmodule TestLiveView do + use Phoenix.LiveView + + def render(assigns) do + ~H""" +
+ <.live_component + id="test-property-pair" + module={PlausibleWeb.Live.GoalSettings.PropertyPairInput} + site={@site} + /> +
+ """ + end + + def mount(_params, %{"site" => site}, socket) do + {:ok, assign(socket, site: site)} + end + end + + test "selecting property value based on name selection", %{conn: conn, site: site} do + populate_stats(site, [ + build(:event, + name: "Purchase", + "meta.key": ["product"], + "meta.value": ["Shirt"] + ) + ]) + + {:ok, lv, html} = live_isolated(conn, TestLiveView, session: %{"site" => site}) + + assert html =~ "Select property first" + + html = type_into_combo(lv, "test-property-pair_key", "zzz") + refute element_exists?(html, "li#dropdown-test-property-pair_key-option-1 a") + + type_into_combo(lv, "test-property-pair_key", "duct") + + lv + |> element("li#dropdown-test-property-pair_key-option-1 a") + |> render_click() + + html = render(lv) + + refute html =~ "Select property first" + assert html =~ "Select value" + + html = type_into_combo(lv, "test-property-pair_value", "zzz") + refute html =~ "Shirt" + + html = type_into_combo(lv, "test-property-pair_value", "irt") + assert html =~ "Shirt" + + lv + |> element("li#dropdown-test-property-pair_value-option-1 a") + |> render_click() + end + end + + defp render_pair_component(site, id, extra_opts \\ []) do + opts = Keyword.merge([id: id, site: site], extra_opts) + render_component(PropertyPairInput, opts) + end + + defp type_into_combo(lv, id, text) do + lv + |> element("input##{id}") + |> render_change(%{ + "_target" => ["display-#{id}"], + "display-#{id}" => "#{text}" + }) + end +end diff --git a/test/plausible_web/live/goal_settings/property_pairs_test.exs b/test/plausible_web/live/goal_settings/property_pairs_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..16a2b8940f1f024831683d0133c210de2931d980 --- /dev/null +++ b/test/plausible_web/live/goal_settings/property_pairs_test.exs @@ -0,0 +1,169 @@ +defmodule PlausibleWeb.Live.GoalSettings.PropertyPairsTest do + use PlausibleWeb.ConnCase, async: true + import Phoenix.LiveViewTest + + alias PlausibleWeb.Live.GoalSettings.PropertyPairs + + describe "static rendering" do + setup [:create_user, :create_site] + + test "renders empty property pair by default", %{site: site} do + doc = render_pairs_component(site) + + assert element_exists?(doc, ~s/div[data-test-id="custom-property-pairs"]/) + assert element_exists?(doc, ~s/div[id^="property_pair_"]/) + assert element_exists?(doc, ~s/input[name="goal[custom_props][keys][]"]/) + assert element_exists?(doc, ~s/input[name="goal[custom_props][values][]"]/) + end + + test "renders 'Add another property' link when slots are below max", %{site: site} do + doc = render_pairs_component(site) + assert text_of_element(doc, ~s/a[phx-click="add-slot"]/) == "+ Add another property" + end + + test "does not render 'Add another property' link when slots are at max", %{site: site} do + {:ok, goal} = + Plausible.Goals.create(site, %{ + "event_name" => "TestEvent", + "custom_props" => %{"prop1" => "val1", "prop2" => "val2", "prop3" => "val3"} + }) + + doc = render_pairs_component(site, goal: goal) + + refute element_exists?(doc, ~s/a[phx-click="add-slot"]/) + refute doc =~ "+ Add another property" + end + + test "does not show remove button when only one slot exists", %{site: site} do + doc = render_pairs_component(site) + refute element_exists?(doc, ~s/div[data-test-id^="remove-property-"]/) + end + + test "shows remove button when multiple slots exist", %{site: site} do + {:ok, goal} = + Plausible.Goals.create(site, %{ + "event_name" => "TestEvent", + "custom_props" => %{"prop1" => "val1", "prop2" => "val2"} + }) + + doc = render_pairs_component(site, goal: goal) + + assert element_exists?(doc, ~s/div[data-test-id^="remove-property-"]/) + end + + test "renders property pairs for goal with existing custom props", %{site: site} do + {:ok, goal} = + Plausible.Goals.create(site, %{ + "event_name" => "Purchase", + "custom_props" => %{"product" => "Shirt", "color" => "Blue"} + }) + + doc = render_pairs_component(site, goal: goal) + + assert element_exists?(doc, ~s/input[value="product"]/) + assert element_exists?(doc, ~s/input[value="Shirt"]/) + assert element_exists?(doc, ~s/input[value="color"]/) + assert element_exists?(doc, ~s/input[value="Blue"]/) + end + + test "pre-populates property pairs when editing goal with custom props", %{site: site} do + {:ok, goal} = + Plausible.Goals.create(site, %{ + "event_name" => "Signup", + "custom_props" => %{"tier" => "Premium"} + }) + + doc = render_pairs_component(site, goal: goal) + + assert element_exists?(doc, ~s/input[value="tier"]/) + assert element_exists?(doc, ~s/input[value="Premium"]/) + end + end + + describe "integration with live view" do + setup [:create_user, :create_site] + + defmodule TestLiveView do + use Phoenix.LiveView + + def render(assigns) do + ~H""" +
+ <.live_component + id="test-property-pairs" + module={PlausibleWeb.Live.GoalSettings.PropertyPairs} + site={@site} + goal={@goal} + /> +
+ """ + end + + def mount(_params, session, socket) do + {:ok, assign(socket, site: session["site"], goal: session["goal"])} + end + end + + test "adds new slot when 'Add another property' is clicked", %{conn: conn, site: site} do + {:ok, lv, html} = + live_isolated(conn, TestLiveView, session: %{"site" => site, "goal" => nil}) + + assert elem_count(html, ~s/div[id^="property_pair_"]/) == 1 + + lv + |> element("a[phx-click='add-slot']") + |> render_click() + + html = render(lv) + + assert elem_count(html, ~s/div[id^="property_pair_"]/) == 2 + end + + test "removes slot when remove button is clicked", %{conn: conn, site: site} do + {:ok, goal} = + Plausible.Goals.create(site, %{ + "event_name" => "TestEvent", + "custom_props" => %{"prop1" => "val1", "prop2" => "val2"} + }) + + {:ok, lv, html} = + live_isolated(conn, TestLiveView, session: %{"site" => site, "goal" => goal}) + + assert elem_count(html, ~s/div[id^="property_pair_"]/) == 2 + + first_remove_button = + html + |> find(~s/div[data-test-id^="remove-property-"] [phx-click="remove-slot"]/) + |> Enum.at(0) + |> text_of_attr("id") + + lv + |> element("##{first_remove_button}") + |> render_click() + + html = render(lv) + + assert elem_count(html, ~s/div[id^="property_pair_"]/) == 1 + end + + test "does not add more slots when at max capacity", %{conn: conn, site: site} do + {:ok, goal} = + Plausible.Goals.create(site, %{ + "event_name" => "TestEvent", + "custom_props" => %{"prop1" => "val1", "prop2" => "val2", "prop3" => "val3"} + }) + + {:ok, _lv, html} = + live_isolated(conn, TestLiveView, session: %{"site" => site, "goal" => goal}) + + assert elem_count(html, ~s/div[id^="property_pair_"]/) == 3 + assert elem_count(html, ~s/[phx-click="remove-slot"]/) == 3 + refute element_exists?(html, ~s/a[phx-click="add-slot"]/) + end + end + + defp render_pairs_component(site, extra_opts \\ []) do + opts = Keyword.merge([id: "test-pairs", site: site], extra_opts) + render_component(PropertyPairs, opts) + end +end diff --git a/test/plausible_web/live/goal_settings_sync_test.exs b/test/plausible_web/live/goal_settings_sync_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..f22ffc84e730129226b80db56cc01e3495ed432f --- /dev/null +++ b/test/plausible_web/live/goal_settings_sync_test.exs @@ -0,0 +1,27 @@ +defmodule PlausibleWeb.Live.GoalSettingsSyncTest do + use PlausibleWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + + describe "GoalSettings live view" do + setup [:create_user, :log_in] + + @tag :ee_only + test "lists goals for super admin", %{conn: conn, user: user} do + patch_env(:super_admin_user_ids, [user.id]) + + site = new_site() + + {:ok, g1} = Plausible.Goals.create(site, %{"page_path" => "/go/to/blog/**"}) + {:ok, g2} = Plausible.Goals.create(site, %{"event_name" => "Register"}) + {:ok, g3} = Plausible.Goals.create(site, %{"event_name" => "Purchase", "currency" => "EUR"}) + + conn = assign(conn, :live_module, PlausibleWeb.Live.GoalSettings) + assert {:ok, _lv, html} = live(conn, "/#{site.domain}/settings/goals") + + assert html =~ to_string(g1) + assert html =~ to_string(g2) + assert html =~ to_string(g3) + end + end +end diff --git a/test/plausible_web/live/goal_settings_test.exs b/test/plausible_web/live/goal_settings_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..191ad702b3837507d13dc4286ceca962c233a541 --- /dev/null +++ b/test/plausible_web/live/goal_settings_test.exs @@ -0,0 +1,352 @@ +defmodule PlausibleWeb.Live.GoalSettingsTest do + use PlausibleWeb.ConnCase, async: true + import Phoenix.LiveViewTest + + @funnels_cta ~s|p[data-test-id="setup-funnels-cta"]| + + describe "GET /:domain/settings/goals" do + setup [:create_user, :log_in, :create_site] + + @tag :ee_only + test "lists goals for the site and renders links", %{conn: conn, site: site} do + {:ok, [g1, g2, g3]} = setup_goals(site) + conn = get(conn, "/#{site.domain}/settings/goals") + + resp = html_response(conn, 200) + assert resp =~ "Define actions that you want your users to take" + assert text_of_element(resp, @funnels_cta) =~ "compose goals into funnels" + + assert text_of_attr(resp, "#{@funnels_cta} a", "href") =~ + "/#{URI.encode_www_form(site.domain)}/settings/funnels" + + assert element_exists?(resp, ~s|a[href="https://plausible.io/docs/goal-conversions"]|) + + assert resp =~ to_string(g1) + assert resp =~ "Pageview" + assert resp =~ to_string(g2) + assert resp =~ "Custom Event" + assert resp =~ to_string(g3) + assert resp =~ "Revenue Goal (EUR)" + end + + @tag :ee_only + test "lists Revenue Goals with feature availability annotation if the plan does not cover them", + %{conn: conn, user: user, site: site} do + {:ok, [_, _, g3]} = setup_goals(site) + + user + |> team_of() + |> Plausible.Teams.Team.end_trial() + |> Plausible.Repo.update!() + + conn = get(conn, "/#{site.domain}/settings/goals") + + resp = html_response(conn, 200) + + assert g3.currency + assert resp =~ to_string(g3) + assert text_of_element(resp, ~s/[data-test-id="feature-unavailable-cta"]/) =~ "Upgrade" + + assert element_exists?( + resp, + ~s/button[data-test-id="edit-goal-button"][disabled]/ + ) + end + + @tag :ee_only + test "lists goals with custom props with feature availability annotation if the plan does not cover them", + %{conn: conn, user: user, site: site} do + {:ok, goal_with_props} = + Plausible.Goals.create(site, %{ + "event_name" => "Signup", + "custom_props" => %{"plan" => "premium"} + }) + + user + |> team_of() + |> Plausible.Teams.Team.end_trial() + |> Plausible.Repo.update!() + + conn = get(conn, "/#{site.domain}/settings/goals") + + resp = html_response(conn, 200) + + assert Plausible.Goal.has_custom_props?(goal_with_props) + assert resp =~ to_string(goal_with_props) + assert text_of_element(resp, ~s/[data-test-id="feature-unavailable-cta"]/) =~ "Upgrade" + + assert element_exists?( + resp, + ~s/button[data-test-id="edit-goal-button"][disabled]/ + ) + end + + test "lists goals with actions", %{conn: conn, site: site} do + {:ok, goals} = setup_goals(site) + conn = get(conn, "/#{site.domain}/settings/goals") + resp = html_response(conn, 200) + + for g <- goals do + assert element_exists?( + resp, + ~s/button[phx-click="delete-goal"][phx-value-goal-id="#{g.id}"]#delete-goal-#{g.id}/ + ) + + assert element_exists?( + resp, + ~s/button[data-test-id="edit-goal-button"][phx-click="edit-goal"][phx-value-goal-id="#{g.id}"]:not([disabled])#edit-goal-#{g.id}/ + ) + end + end + + test "if no goals are present, a proper info is displayed", %{conn: conn, site: site} do + conn = get(conn, "/#{site.domain}/settings/goals") + resp = html_response(conn, 200) + assert resp =~ "Create your first goal" + end + + test "if goals are present, no info about missing goals is displayed", %{ + conn: conn, + site: site + } do + {:ok, _goals} = setup_goals(site) + conn = get(conn, "/#{site.domain}/settings/goals") + resp = html_response(conn, 200) + refute resp =~ "Create your first goal" + end + + test "add goal dropdown is rendered in empty state", %{conn: conn, site: site} do + conn = get(conn, "/#{site.domain}/settings/goals") + resp = html_response(conn, 200) + assert element_exists?(resp, ~s/[id="add-goal-dropdown-empty"]/) + + assert element_exists?( + resp, + ~s/[phx-click="add-goal"][phx-value-goal-type="custom_events"]/ + ) + + assert element_exists?(resp, ~s/[phx-click="add-goal"][phx-value-goal-type="pageviews"]/) + assert element_exists?(resp, ~s/[phx-click="add-goal"][phx-value-goal-type="scroll"]/) + end + + test "add goal dropdown is rendered in non-empty state", %{conn: conn, site: site} do + {:ok, _goals} = setup_goals(site) + conn = get(conn, "/#{site.domain}/settings/goals") + resp = html_response(conn, 200) + assert element_exists?(resp, ~s/[id="add-goal-dropdown"]/) + + assert element_exists?( + resp, + ~s/[phx-click="add-goal"][phx-value-goal-type="custom_events"]/ + ) + + assert element_exists?(resp, ~s/[phx-click="add-goal"][phx-value-goal-type="pageviews"]/) + assert element_exists?(resp, ~s/[phx-click="add-goal"][phx-value-goal-type="scroll"]/) + end + + test "search goals input is rendered", %{conn: conn, site: site} do + {:ok, _goals} = setup_goals(site) + conn = get(conn, "/#{site.domain}/settings/goals") + resp = html_response(conn, 200) + assert element_exists?(resp, ~s/input[type="text"]#filter-text/) + assert element_exists?(resp, ~s/form[phx-change="filter"]#filter-form/) + end + end + + on_ee do + describe "GET /:domain/settings/goals - consolidated views" do + setup [:create_user, :create_team, :log_in] + + setup %{team: team} = context do + new_site(team: team) + new_site(team: team) + + {:ok, Map.put(context, :consolidated_view, new_consolidated_view(team))} + end + + test "no goals exist", %{conn: conn, consolidated_view: consolidated_view} do + conn = get(conn, "/#{consolidated_view.domain}/settings/goals") + + assert resp = html_response(conn, 200) + assert resp =~ "Define actions that you want your users to take" + assert resp =~ "Create your first goal" + assert element_exists?(resp, ~s|a[href="https://plausible.io/docs/goal-conversions"]|) + end + + test "lists goals", %{conn: conn, consolidated_view: consolidated_view} do + {:ok, g1} = Plausible.Goals.create(consolidated_view, %{"page_path" => "/go/to/blog/**"}) + {:ok, g2} = Plausible.Goals.create(consolidated_view, %{"event_name" => "Register"}) + + conn = get(conn, "/#{consolidated_view.domain}/settings/goals") + + assert resp = html_response(conn, 200) + assert resp =~ "Define actions that you want your users to take" + assert element_exists?(resp, ~s|a[href="https://plausible.io/docs/goal-conversions"]|) + + assert resp =~ to_string(g1) + assert resp =~ "Pageview" + assert resp =~ to_string(g2) + assert resp =~ "Custom Event" + end + + test "does not render funnels cta", %{conn: conn, consolidated_view: consolidated_view} do + conn = get(conn, "/#{consolidated_view.domain}/settings/goals") + + assert resp = html_response(conn, 200) + refute element_exists?(resp, @funnels_cta) + end + end + end + + describe "GoalSettings live view" do + setup [:create_user, :log_in, :create_site] + + test "allows dashboard toggle", %{conn: conn, site: site} do + lv = get_liveview(conn, site) + lv |> element("#feature-goals-toggle button") |> render_click() + assert render(lv) =~ "Goals are now hidden from your dashboard" + assert Plausible.Billing.Feature.Goals.opted_out?(Plausible.Repo.reload!(site)) + lv |> element("#feature-goals-toggle button") |> render_click() + assert render(lv) =~ "Goals are now visible again on your dashboard" + refute Plausible.Billing.Feature.Goals.opted_out?(Plausible.Repo.reload!(site)) + end + + test "allows goal deletion", %{conn: conn, site: site} do + {:ok, [g1, g2 | _]} = setup_goals(site) + {lv, html} = get_liveview(conn, site, with_html?: true) + + assert html =~ to_string(g1) + assert html =~ to_string(g2) + + html = lv |> element(~s/button#delete-goal-#{g1.id}/) |> render_click() + + refute html =~ to_string(g1) + assert html =~ to_string(g2) + + html = get(conn, "/#{site.domain}/settings/goals") |> html_response(200) + + refute html =~ to_string(g1) + assert html =~ to_string(g2) + end + + test "allows list filtering / search", %{conn: conn, site: site} do + {:ok, [g1, g2, g3]} = setup_goals(site) + {lv, html} = get_liveview(conn, site, with_html?: true) + + assert html =~ to_string(g1) + assert html =~ to_string(g2) + assert html =~ to_string(g3) + + html = type_into_search(lv, to_string(g3)) + + refute html =~ to_string(g1) + refute html =~ to_string(g2) + assert html =~ to_string(g3) + end + + test "allows resetting filter text via backspace icon", %{conn: conn, site: site} do + {:ok, [g1, g2, g3]} = setup_goals(site) + {lv, html} = get_liveview(conn, site, with_html?: true) + + refute element_exists?(html, ~s/svg[phx-click="reset-filter-text"]#reset-filter/) + + html = type_into_search(lv, to_string(g3)) + + assert element_exists?(html, ~s/svg[phx-click="reset-filter-text"]#reset-filter/) + + html = lv |> element(~s/svg#reset-filter/) |> render_click() + + assert html =~ to_string(g1) + assert html =~ to_string(g2) + assert html =~ to_string(g3) + end + + test "allows resetting filter text via no match link", %{conn: conn, site: site} do + {:ok, _goals} = setup_goals(site) + lv = get_liveview(conn, site) + html = type_into_search(lv, "Definitely this is not going to render any matches") + + assert html =~ "No goals found for this site. Please refine or" + assert html =~ "reset your search" + + assert element_exists?(html, ~s/a[phx-click="reset-filter-text"]#reset-filter-hint/) + html = lv |> element(~s/a#reset-filter-hint/) |> render_click() + + refute html =~ "No goals found for this site. Please refine or" + end + end + + on_ee do + describe "GoalSettings live view - consolidated views" do + setup [:create_user, :create_team, :log_in] + + setup %{team: team} = context do + new_site(team: team) + new_site(team: team) + + {:ok, Map.put(context, :consolidated_view, new_consolidated_view(team))} + end + + test "allows goal deletion", %{conn: conn, consolidated_view: consolidated_view} do + {:ok, g1} = Plausible.Goals.create(consolidated_view, %{"page_path" => "/go/to/blog/**"}) + {:ok, g2} = Plausible.Goals.create(consolidated_view, %{"event_name" => "Register"}) + + {lv, html} = get_liveview(conn, consolidated_view, with_html?: true) + + assert html =~ to_string(g1) + assert html =~ to_string(g2) + + html = lv |> element(~s/button#delete-goal-#{g1.id}/) |> render_click() + + refute html =~ to_string(g1) + assert html =~ to_string(g2) + + html = get(conn, "/#{consolidated_view.domain}/settings/goals") |> html_response(200) + + refute html =~ to_string(g1) + assert html =~ to_string(g2) + end + + test "allows list filtering / search", %{conn: conn, consolidated_view: consolidated_view} do + {:ok, g1} = Plausible.Goals.create(consolidated_view, %{"page_path" => "/go/to/blog/**"}) + {:ok, g2} = Plausible.Goals.create(consolidated_view, %{"event_name" => "Register"}) + {lv, html} = get_liveview(conn, consolidated_view, with_html?: true) + + assert html =~ to_string(g1) + assert html =~ to_string(g2) + + html = type_into_search(lv, to_string(g2)) + + refute html =~ to_string(g1) + assert html =~ to_string(g2) + end + end + end + + defp setup_goals(site) do + {:ok, g1} = Plausible.Goals.create(site, %{"page_path" => "/go/to/blog/**"}) + {:ok, g2} = Plausible.Goals.create(site, %{"event_name" => "Register"}) + {:ok, g3} = Plausible.Goals.create(site, %{"event_name" => "Purchase", "currency" => "EUR"}) + {:ok, [g1, g2, g3]} + end + + defp get_liveview(conn, site, opts \\ []) do + conn = assign(conn, :live_module, PlausibleWeb.Live.GoalSettings) + {:ok, lv, html} = live(conn, "/#{site.domain}/settings/goals") + + if Keyword.get(opts, :with_html?) do + {lv, html} + else + lv + end + end + + defp type_into_search(lv, text) do + lv + |> element("form#filter-form") + |> render_change(%{ + "_target" => ["filter-text"], + "filter-text" => "#{text}" + }) + end +end diff --git a/test/plausible_web/live/installation_test.exs b/test/plausible_web/live/installation_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..a3ac3aae5f060ed35933a145ea2ab49fc114de4d --- /dev/null +++ b/test/plausible_web/live/installation_test.exs @@ -0,0 +1,705 @@ +defmodule PlausibleWeb.Live.InstallationTest do + use PlausibleWeb.ConnCase + use Plausible.Test.Support.DNS + + import Phoenix.LiveViewTest + + alias Plausible.Site.TrackerScriptConfiguration + + @migration_guide_link "https://plausible.io/docs/script-update-guide" + + setup [:create_user, :log_in, :create_site] + + describe "GET /:domain/installation" do + @tag :ee_only + test "renders loading installation screen on EE", %{conn: conn, site: site} do + resp = get(conn, "/#{site.domain}/installation") |> html_response(200) + + assert resp =~ "animate-spin" + end + + @tag :ce_build_only + test "no loading spinner, no GTM tab on CE", %{conn: conn, site: site} do + resp = get(conn, "/#{site.domain}/installation") |> html_response(200) + + tabs_text = text_of_element(resp, "a[data-phx-link='patch']") + + assert length(String.split(tabs_text)) == 3 + + assert tabs_text =~ "Script" + assert tabs_text =~ "WordPress" + assert tabs_text =~ "NPM" + + refute resp =~ "animate-spin" + end + end + + describe "LiveView" do + @tag :ee_only + test "detects installation type when mounted", %{conn: conn, site: site} do + stub_lookup_a_records(site.domain) + stub_detection_wordpress() + + {lv, _} = get_lv(conn, site) + + html = render_async(lv, 500) + assert text(html) =~ "Verify WordPress installation" + end + + @tag :ee_only + test "When ?type=wordpress URL parameter is supplied, detected type is unused", %{ + conn: conn, + site: site + } do + stub_lookup_a_records(site.domain) + stub_detection_manual() + + {lv, _} = get_lv(conn, site, "?type=wordpress") + + html = render_async(lv, 500) + assert text(html) =~ "Verify WordPress installation" + end + + @tag :ee_only + test "When ?type=gtm URL parameter is supplied, detected type is unused", %{ + conn: conn, + site: site + } do + stub_lookup_a_records(site.domain) + stub_detection_wordpress() + + {lv, _} = get_lv(conn, site, "?type=gtm") + + html = render_async(lv, 500) + assert text(html) =~ "Verify Tag Manager installation" + end + + @tag :ee_only + test "When ?type=npm URL parameter is supplied, detected type is unused", %{ + conn: conn, + site: site + } do + stub_lookup_a_records(site.domain) + stub_detection_wordpress() + + {lv, _} = get_lv(conn, site, "?type=npm") + + html = render_async(lv, 500) + assert text(html) =~ "Verify NPM installation" + end + + @tag :ee_only + test "When ?type=manual URL parameter is supplied, detected type is unused", %{ + conn: conn, + site: site + } do + stub_lookup_a_records(site.domain) + stub_detection_wordpress() + + {lv, _} = get_lv(conn, site, "?type=manual") + + html = render_async(lv, 500) + assert text(html) =~ "Verify Script installation" + end + + @tag :ee_only + test "allows switching between installation tabs (EE)", %{conn: conn, site: site} do + stub_lookup_a_records(site.domain) + stub_detection_manual() + + {lv, _html} = get_lv(conn, site, "?type=manual") + + html = render_async(lv, 500) + assert html =~ "Verify Script installation" + + lv + |> element("a[href*=\"type=wordpress\"]") + |> render_click() + + html = render(lv) + assert html =~ "Verify WordPress installation" + + lv + |> element("a[href*=\"type=gtm\"]") + |> render_click() + + html = render(lv) + assert html =~ "Verify Tag Manager installation" + + lv + |> element("a[href*=\"type=npm\"]") + |> render_click() + + html = render(lv) + assert html =~ "Verify NPM installation" + end + + @tag :ce_build_only + test "allows switching between installation tabs (CE)", %{conn: conn, site: site} do + {lv, _html} = get_lv(conn, site) + + html = render_async(lv, 500) + assert html =~ "Verify Script installation" + + lv + |> element("a[href*=\"type=wordpress\"]") + |> render_click() + + html = render(lv) + assert html =~ "Verify WordPress installation" + end + + test "manual installations has script snippet with expected ID", %{conn: conn, site: site} do + on_ee do + stub_lookup_a_records(site.domain) + stub_detection_manual() + end + + {lv, _html} = get_lv(conn, site, "?type=manual&flow=review") + + assert eventually(fn -> + html = render(lv) + {html =~ "Verify Script installation", html} + end) + + html = render(lv) + config = Plausible.Repo.get_by!(TrackerScriptConfiguration, site_id: site.id) + assert html =~ "Privacy-friendly analytics by Plausible" + assert html =~ "/js/#{config.id}.js" + assert html =~ "async" + end + + test "manual installation shows optional measurements", %{conn: conn, site: site} do + on_ee do + stub_lookup_a_records(site.domain) + stub_detection_manual() + end + + {lv, _html} = get_lv(conn, site, "?type=manual&flow=review") + + html = render_async(lv, 500) + assert html =~ "Verify Script installation" + assert html =~ "Optional measurements" + assert html =~ "Outbound links" + assert html =~ "File downloads" + assert html =~ "Form submissions" + end + + test "manual installation shows advanced options in disclosure", %{conn: conn, site: site} do + on_ee do + stub_lookup_a_records(site.domain) + stub_detection_manual() + end + + {lv, _html} = get_lv(conn, site, "?type=manual&flow=review") + + html = render_async(lv, 500) + assert html =~ "Verify Script installation" + assert html =~ "Advanced options" + assert html =~ "Manual tagging" + assert html =~ "404 error pages" + assert html =~ "Hashed page paths" + assert html =~ "Custom properties" + assert html =~ "Ecommerce revenue" + end + + test "toggling optional measurements updates tracker configuration", %{ + conn: conn, + site: site + } do + on_ee do + stub_lookup_a_records(site.domain) + stub_detection_manual() + end + + {lv, _html} = get_lv(conn, site, "?type=manual&flow=review") + + html = render_async(lv, 500) + assert html =~ "Verify Script installation" + + config = TrackerScriptConfiguration |> Plausible.Repo.get_by!(site_id: site.id) + assert config.outbound_links == true + assert config.file_downloads == true + assert config.form_submissions == true + + lv + |> element("form[phx-submit='submit']") + |> render_submit(%{ + "tracker_script_configuration" => %{ + "installation_type" => "manual", + "outbound_links" => "false", + "file_downloads" => "true", + "form_submissions" => "true" + } + }) + + updated_config = TrackerScriptConfiguration |> Plausible.Repo.get_by!(site_id: site.id) + assert updated_config.outbound_links == false + assert updated_config.file_downloads == true + assert updated_config.form_submissions == true + end + + on_ee do + for {type, expected_text} <- [ + {"manual", "Verify Script installation"}, + {"wordpress", "Verify WordPress installation"}, + {"gtm", "Verify Tag Manager installation"}, + {"npm", "Verify NPM installation"} + ] do + test "submitting form with #{type} redirects to verification (EE)", %{ + conn: conn, + site: site + } do + stub_lookup_a_records(site.domain) + stub_detection_manual() + {lv, _html} = get_lv(conn, site, "?type=#{unquote(type)}") + + html = render_async(lv, 500) + assert html =~ unquote(expected_text) + + lv + |> element("form[phx-submit='submit']") + |> render_submit(%{ + "tracker_script_configuration" => %{ + "installation_type" => unquote(type), + "outbound_links" => "true", + "file_downloads" => "true", + "form_submissions" => "true" + } + }) + + assert_redirect( + lv, + Routes.site_path(conn, :verification, site.domain, + flow: "provisioning", + installation_type: unquote(type) + ) + ) + end + end + end + + @tag :ce_build_only + test "submitting the form redirects to verification (CE)", %{conn: conn, site: site} do + {lv, _html} = get_lv(conn, site) + + lv + |> element("form[phx-submit='submit']") + |> render_submit(%{ + "tracker_script_configuration" => %{ + "installation_type" => "manual", + "outbound_links" => "true", + "file_downloads" => "true", + "form_submissions" => "true" + } + }) + + assert_redirect( + lv, + Routes.site_path(conn, :verification, site.domain, + flow: "provisioning", + installation_type: "manual" + ) + ) + end + + test "404 goal gets created regardless of user options", %{conn: conn, site: site} do + on_ee do + stub_lookup_a_records(site.domain) + stub_detection_manual() + end + + {lv, _html} = get_lv(conn, site, "?type=manual") + + html = render_async(lv, 500) + assert html =~ "Verify Script installation" + + # Test with all options disabled + lv + |> element("form[phx-submit='submit']") + |> render_submit(%{ + "tracker_script_configuration" => %{ + "installation_type" => "manual", + "outbound_links" => "false", + "file_downloads" => "false", + "form_submissions" => "false" + } + }) + + # 404 goal should still be created + goals = Plausible.Goals.for_site(site) + assert Enum.any?(goals, &(&1.event_name == "404")) + end + + test "submitting form with review flow redirects to verification with flow param", %{ + conn: conn, + site: site + } do + on_ee do + stub_lookup_a_records(site.domain) + stub_detection_manual() + end + + {lv, _html} = get_lv(conn, site, "?type=manual&flow=review") + + html = render_async(lv, 500) + assert html =~ "Verify Script installation" + + lv + |> element("form[phx-submit='submit']") + |> render_submit(%{ + "tracker_script_configuration" => %{ + "installation_type" => "manual", + "outbound_links" => "true", + "file_downloads" => "true", + "form_submissions" => "true" + } + }) + + assert_redirect( + lv, + Routes.site_path(conn, :verification, site.domain, + flow: "review", + installation_type: "manual" + ) + ) + end + + @tag :ee_only + test "detected WordPress installation shows special message", %{conn: conn, site: site} do + stub_lookup_a_records(site.domain) + stub_detection_wordpress() + + {lv, _} = get_lv(conn, site) + + html = render_async(lv, 500) + assert text(html) =~ "We've detected your website is using WordPress" + end + + @tag :ee_only + test "if ratelimit for detection is exceeded, does not make detection request and falls back to recommending manual installation", + %{conn: conn, site: site} do + stub_lookup_a_records(site.domain) + + # exceed the rate limit for site detection + Plausible.RateLimit.check_rate( + Plausible.RateLimit, + "site_detection:#{site.domain}", + :timer.minutes(60), + 1, + 100 + ) + + # this won't be used: if it were used, the output would be different + stub_detection_wordpress() + + {lv, _} = get_lv(conn, site) + + html = render_async(lv, 500) + + refute text(html) =~ "We've detected your website is using WordPress" + assert text(html) =~ "Verify Script installation" + end + + @tag :ee_only + test "detected GTM installation shows special message", %{conn: conn, site: site} do + stub_lookup_a_records(site.domain) + stub_detection_gtm() + + {lv, _} = get_lv(conn, site) + + html = render_async(lv, 500) + assert html =~ "Verify Tag Manager installation" + + assert text(html) =~ "We've detected your website is using Google Tag Manager" + end + + @tag :ee_only + test "detected NPM installation shows npm tab", %{conn: conn, site: site} do + stub_lookup_a_records(site.domain) + + stub_detection_result(%{ + "v1Detected" => false, + "gtmLikely" => false, + "npm" => true, + "wordpressLikely" => false, + "wordpressPlugin" => false + }) + + {lv, _} = get_lv(conn, site) + + html = render_async(lv, 500) + assert html =~ "Verify NPM installation" + end + + @tag :ee_only + test "shows v1 detection warning and migration guide link for manual installation", %{ + conn: conn, + site: site + } do + stub_lookup_a_records(site.domain) + stub_detection_manual_with_v1() + + {lv, _} = get_lv(conn, site, "?type=manual") + + html = render_async(lv, 500) + assert text(html) =~ "Your website is running an outdated version of the tracking script" + assert element_exists?(html, "a[href='#{@migration_guide_link}']") + end + + @tag :ce_build_only + test "shows v1 migration guide link for manual instructions", %{conn: conn, site: site} do + {lv, _} = get_lv(conn, site) + + html = render_async(lv, 500) + assert text(html) =~ "Still using the legacy snippet" + assert element_exists?(html, "a[href='#{@migration_guide_link}']") + end + + test "does not render link to migrate guide on WordPress installation tab", %{ + conn: conn, + site: site + } do + on_ee do + stub_lookup_a_records(site.domain) + stub_detection_wordpress_with_v1() + end + + {lv, _} = get_lv(conn, site, "?type=wordpress") + + html = render_async(lv, 500) + assert html =~ "Verify WordPress installation" + refute element_exists?(html, "a[href='#{@migration_guide_link}']") + end + + @tag :ee_only + test "falls back to manual installation when detection fails at dns check level", %{ + conn: conn, + site: site + } do + stub_lookup_a_records(site.domain, []) + + ExUnit.CaptureLog.capture_log(fn -> + {lv, _} = get_lv(conn, site) + + assert eventually(fn -> + html = render(lv) + # Should default to manual installation when detection returns {:error, _} + {html =~ "Verify Script installation", html} + end) + end) + end + + @tag :ee_only + test "falls back to manual installation when dns succeeds but detection fails", %{ + conn: conn, + site: site + } do + stub_lookup_a_records(site.domain) + stub_detection_error() + + ExUnit.CaptureLog.capture_log(fn -> + {lv, _} = get_lv(conn, site) + + html = render_async(lv, 500) + # Should default to manual installation when detection returns {:error, _} + assert html =~ "Verify Script installation" + end) + end + end + + describe "Authorization" do + test "requires site access permissions", %{conn: conn} do + other_user = insert(:user) + other_site = new_site(owner: other_user) + + assert_raise Ecto.NoResultsError, fn -> + get_lv(conn, other_site) + end + end + + test "allows viewer access to installation page", %{conn: conn, user: user} do + site = new_site() + add_guest(site, user: user, role: :viewer) + + on_ee do + stub_lookup_a_records(site.domain) + stub_detection_manual() + end + + {lv, _} = get_lv(conn, site) + + html = render_async(lv, 500) + assert html =~ "Verify Script installation" + end + + test "allows editor access to installation page", %{conn: conn, user: user} do + site = new_site() + add_guest(site, user: user, role: :editor) + + on_ee do + stub_lookup_a_records(site.domain) + stub_detection_manual() + end + + {lv, _} = get_lv(conn, site) + + html = render_async(lv, 500) + assert html =~ "Verify Script installation" + end + end + + describe "URL Parameter Handling" do + test "falls back to manual installation when invalid installation type parameter supplied", + %{ + conn: conn, + site: site + } do + on_ee do + stub_lookup_a_records(site.domain) + stub_detection_manual() + end + + {lv, _} = get_lv(conn, site, "?type=invalid") + + html = render_async(lv, 500) + assert html =~ "Verify Script installation" + end + + test "falls back to provisioning flow when invalid flow parameter supplied", %{ + conn: conn, + site: site + } do + on_ee do + stub_lookup_a_records(site.domain) + stub_detection_manual() + end + + {lv, _} = get_lv(conn, site, "?flow=invalid") + + html = render_async(lv, 500) + assert html =~ "Verify Script installation" + end + end + + describe "Detection Result Combinations" do + @describetag :ee_only + + test "When GTM + Wordpress detected, GTM takes precedence", %{conn: conn, site: site} do + stub_lookup_a_records(site.domain) + + stub_detection_result(%{ + "v1Detected" => false, + "gtmLikely" => true, + "wordpressLikely" => true, + "wordpressPlugin" => false + }) + + {lv, _} = get_lv(conn, site) + + html = render_async(lv, 500) + assert html =~ "Verify Tag Manager installation" + end + end + + describe "Legacy Installations" do + @tag :ee_only + test "uses detected type in review flow when installation_type is nil", %{ + conn: conn, + site: site + } do + _config = + PlausibleWeb.Tracker.get_or_create_tracker_script_configuration!(site, %{ + installation_type: nil, + outbound_links: true, + file_downloads: false, + form_submissions: true + }) + + stub_lookup_a_records(site.domain) + stub_detection_wordpress() + + {lv, _} = get_lv(conn, site, "?flow=review") + + html = render_async(lv, 500) + assert html =~ "Verify WordPress installation" + end + end + + defp stub_detection_manual do + stub_detection_result(%{ + "v1Detected" => false, + "gtmLikely" => false, + "npm" => false, + "wordpressLikely" => false, + "wordpressPlugin" => false + }) + end + + defp stub_detection_wordpress do + stub_detection_result(%{ + "v1Detected" => false, + "gtmLikely" => false, + "npm" => false, + "wordpressLikely" => true, + "wordpressPlugin" => false + }) + end + + defp stub_detection_gtm do + stub_detection_result(%{ + "v1Detected" => false, + "gtmLikely" => true, + "npm" => false, + "wordpressLikely" => false, + "wordpressPlugin" => false + }) + end + + defp stub_detection_manual_with_v1 do + stub_detection_result(%{ + "v1Detected" => true, + "gtmLikely" => false, + "npm" => false, + "wordpressLikely" => false, + "wordpressPlugin" => false + }) + end + + on_ee do + defp stub_detection_wordpress_with_v1 do + stub_detection_result(%{ + "v1Detected" => true, + "gtmLikely" => false, + "npm" => false, + "wordpressLikely" => true, + "wordpressPlugin" => false + }) + end + end + + defp stub_detection_result(js_data) do + Req.Test.stub(Plausible.InstallationSupport.Checks.Detection, fn conn -> + conn + |> put_resp_content_type("application/json") + |> send_resp(200, Jason.encode!(%{"data" => Map.put(js_data, "completed", true)})) + end) + end + + defp stub_detection_error do + Req.Test.stub(Plausible.InstallationSupport.Checks.Detection, fn conn -> + conn + |> put_resp_content_type("application/json") + |> send_resp( + 200, + Jason.encode!(%{"data" => %{"error" => %{"message" => "Simulated browser error"}}}) + ) + end) + end + + defp get_lv(conn, site, qs \\ nil) do + {:ok, lv, html} = live(conn, "/#{site.domain}/installation#{qs}") + + {lv, html} + end +end diff --git a/test/plausible_web/live/plugins_api_tokens_test.exs b/test/plausible_web/live/plugins_api_tokens_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..4738df7f4fe694db10032816490149511dcbeb63 --- /dev/null +++ b/test/plausible_web/live/plugins_api_tokens_test.exs @@ -0,0 +1,149 @@ +defmodule PlausibleWeb.Live.PluginsAPISettingsTest do + use PlausibleWeb.ConnCase, async: true + import Phoenix.LiveViewTest + + alias Plausible.Plugins.API.Tokens + + describe "GET /:domain/settings/integrations" do + setup [:create_user, :log_in, :create_site] + + test "does not display the Plugins API section by default", %{conn: conn, site: site} do + conn = get(conn, "/#{site.domain}/settings/integrations") + resp = html_response(conn, 200) + + refute resp =~ "Plugin Tokens" + end + + test "does display the Plugins API section on ?new_token=....", %{ + conn: conn, + site: site + } do + conn = get(conn, "/#{site.domain}/settings/integrations?new_token=test") + resp = html_response(conn, 200) + + assert resp =~ "Plugin tokens" + end + + test "does display the Plugins API section when there are tokens already created", %{ + conn: conn, + site: site + } do + {:ok, _, _} = Tokens.create(site, "test") + conn = get(conn, "/#{site.domain}/settings/integrations") + resp = html_response(conn, 200) + + assert resp =~ "Plugin tokens" + end + + test "lists tokens with revoke actions", %{conn: conn, site: site} do + {:ok, t1, _} = Tokens.create(site, "test-token-1") + {:ok, t2, _} = Tokens.create(site, "test-token-2") + {:ok, _, _} = Tokens.create(build(:site), "test-token-3") + + conn = get(conn, "/#{site.domain}/settings/integrations") + resp = html_response(conn, 200) + + assert resp =~ "test-token-1" + assert resp =~ "test-token-2" + + assert resp =~ "Last used" + assert resp =~ "Not yet" + + assert resp =~ "**********" <> t1.hint + assert resp =~ "**********" <> t2.hint + refute resp =~ "test-token-3" + + assert element_exists?( + resp, + ~s/button[phx-click="revoke-token"][phx-value-token-id="#{t1.id}"]#revoke-token-#{t1.id}/ + ) + + assert element_exists?( + resp, + ~s/button[phx-click="revoke-token"][phx-value-token-id="#{t2.id}"]#revoke-token-#{t2.id}/ + ) + end + + test "create token button is rendered", %{conn: conn, site: site} do + conn = get(conn, "/#{site.domain}/settings/integrations?new_token=WordPress") + resp = html_response(conn, 200) + + assert element_exists?(resp, ~s/button[phx-click="create-token"]/) + end + end + + describe "Plugins.API.Settings live view" do + setup [:create_user, :log_in, :create_site] + + test "create token when invoked via URL", %{conn: conn, site: site} do + {lv, html} = + get_liveview(conn, site, with_html?: true, query_params: "?new_token=WordPress") + + assert element_exists?(html, "#token-form") + assert text_of_element(html, "label[for=token_description]") == "Description" + assert element_exists?(html, "input[value=WordPress]#token_description") + + assert element_exists?( + html, + ~s/div#token-form form[phx-submit="generate-token"]/ + ) + + html = + lv + |> find_live_child("token-form") + |> element("form") + |> render_submit() + + assert text_of_element(html, "label[for=token-clipboard]") == "Plugin Token" + assert element_exists?(html, "input#token-clipboard") + assert element_exists?(html, ~s/button[phx-click="close-token-modal"]/) + + assert Tokens.any?(site) + end + + test "adds token and shows it", %{conn: conn, site: site} do + refute Tokens.any?(site) + + lv = get_liveview(conn, site, query_params: "?new_token=WordPress") + + lv + |> find_live_child("token-form") + |> element("form") + |> render_submit() + + assert Tokens.any?(site) + + html = render(lv) + assert text_of_element(html, "label[for=token-clipboard]") == "Plugin Token" + assert element_exists?(html, "input#token-clipboard") + assert text_of_element(html, "span.token-description") == "WordPress" + end + + test "fails to add token with no description", %{conn: conn, site: site} do + {:ok, _, _} = Tokens.create(site, "test") + + lv = get_liveview(conn, site) + + lv |> render_click("create-token") + + lv + |> find_live_child("token-form") + |> element("form") + |> render_submit() + + assert [_] = Tokens.list(site) + end + end + + defp get_liveview(conn, site, opts \\ []) do + query_params = Keyword.get(opts, :query_params, "") + conn = assign(conn, :live_module, PlausibleWeb.Live.Plugins.API.Settings) + {:ok, lv, html} = live(conn, "/#{site.domain}/settings/integrations#{query_params}") + + if Keyword.get(opts, :with_html?) do + {lv, html} + else + lv + end + end +end diff --git a/test/plausible_web/live/props_settings/form_test.exs b/test/plausible_web/live/props_settings/form_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..f7983748b1f53334e33815bd69562765175fa80a --- /dev/null +++ b/test/plausible_web/live/props_settings/form_test.exs @@ -0,0 +1,227 @@ +defmodule PlausibleWeb.Live.PropsSettings.FormTest do + use PlausibleWeb.ConnCase, async: true + import Phoenix.LiveViewTest + + on_ee do + end + + describe "Props submission" do + setup [:create_user, :log_in, :create_site] + + test "renders form fields", %{conn: conn, site: site} do + lv = get_liveview(conn, site) + html = render(lv) + + assert element_exists?(html, "form input[type=text][name=display-prop_input]") + assert element_exists?(html, "form input[type=hidden][name=prop]") + end + + test "renders error on empty submission", %{conn: conn, site: site} do + lv = get_liveview(conn, site) + html = lv |> element("form") |> render_submit() + assert html =~ "must be between 1 and 300 characters" + end + + test "renders error on whitespace submission", %{conn: conn, site: site} do + lv = get_liveview(conn, site) + html = lv |> element("form") |> render_submit(%{prop: " "}) + assert html =~ "must be between 1 and 300 characters" + end + + test "renders 'Create' suggestion", %{conn: conn, site: site} do + lv = get_liveview(conn, site) + type_into_combo(lv, "#prop_input", "Hello world") + html = render(lv) + assert text_of_element(html, "#dropdown-prop_input-option-0 a") == ~s/Create "Hello world"/ + end + + test "clicking suggestion fills out input", %{conn: conn, site: site} = context do + seed_props(context) + lv = get_liveview(conn, site) + type_into_combo(lv, "#prop_input", "amo") + + doc = + lv + |> element(~s/ul#dropdown-prop_input li#dropdown-prop_input-option-1 a/) + |> render_click() + + assert element_exists?(doc, ~s/input[type="hidden"][value="amount"]/) + end + + test "allowing a single property", %{conn: conn, site: site} do + {parent, lv} = get_liveview(conn, site, with_parent?: true) + refute render(parent) =~ "foobarbaz" + lv |> element("form") |> render_submit(%{prop: "foobarbaz"}) + parent_html = render(parent) + assert text_of_element(parent_html, "#prop-0") == "foobarbaz" + + site = Plausible.Repo.reload!(site) + assert site.allowed_event_props == ["foobarbaz"] + end + + test "allowing existing properties", %{conn: conn, site: site} = context do + seed_props(context) + {parent, lv} = get_liveview(conn, site, with_parent?: true) + parent_html = render(parent) + refute element_exists?(parent_html, "#prop-0") + refute element_exists?(parent_html, "#prop-1") + refute element_exists?(parent_html, "#prop-2") + + lv + |> element(~s/button[phx-click="allow-existing-props"]/) + |> render_click() + + parent_html = render(parent) + assert text_of_element(parent_html, "#prop-0") == "amount" + assert text_of_element(parent_html, "#prop-1") == "logged_in" + assert text_of_element(parent_html, "#prop-2") == "is_customer" + + site = Plausible.Repo.reload!(site) + assert site.allowed_event_props == ["amount", "logged_in", "is_customer"] + end + + test "does not show allow existing props button when there are no events with props", %{ + conn: conn, + site: site + } do + lv = get_liveview(conn, site) + refute element_exists?(render(lv), ~s/button[phx-click="allow-existing-props"]/) + end + + test "does not show allow existing props button after adding all suggestions", + %{ + conn: conn, + site: site + } = context do + seed_props(context) + + conn + |> get_liveview(site) + |> element(~s/button[phx-click="allow-existing-props"]/) + |> render_click() + + site = Plausible.Repo.reload!(site) + assert site.allowed_event_props == ["amount", "logged_in", "is_customer"] + + html = + conn + |> get_liveview(site) + |> render() + + refute element_exists?(html, ~s/button[phx-click="allow-existing-props"]/) + end + end + + on_ee do + describe "Props submission for consolidated views" do + setup [:create_user, :create_team, :log_in] + + setup %{team: team} = context do + new_site(team: team) + new_site(team: team) + + {:ok, Map.put(context, :consolidated_view, new_consolidated_view(team))} + end + + test "renders form fields", %{conn: conn, consolidated_view: consolidated_view} do + lv = get_liveview(conn, consolidated_view) + html = render(lv) + + assert html =~ "Add property for consolidated view" + assert element_exists?(html, "form input[type=text][name=display-prop_input]") + assert element_exists?(html, "form input[type=hidden][name=prop]") + end + + test "allowing a single property", %{conn: conn, consolidated_view: consolidated_view} do + {parent, lv} = get_liveview(conn, consolidated_view, with_parent?: true) + refute render(parent) =~ "foobarbaz" + lv |> element("form") |> render_submit(%{prop: "foobarbaz"}) + parent_html = render(parent) + assert text_of_element(parent_html, "#prop-0") == "foobarbaz" + + consolidated_view = Plausible.Repo.reload!(consolidated_view) + assert consolidated_view.allowed_event_props == ["foobarbaz"] + end + + test "allowing existing properties", %{conn: conn, consolidated_view: consolidated_view} do + {:ok, site_ids} = Plausible.ConsolidatedView.site_ids(consolidated_view.domain) + [site1, site2] = Enum.map(site_ids, &Plausible.Repo.get(Plausible.Site, &1)) + + populate_stats(site1, [ + build(:pageview, "meta.key": ["a"], "meta.value": ["a"]), + build(:pageview, "meta.key": ["b"], "meta.value": ["b"]) + ]) + + populate_stats(site2, [ + build(:pageview, "meta.key": ["c"], "meta.value": ["c"]) + ]) + + list_item_selectors = ["#prop-0", "#prop-1", "#prop-2"] + + {parent, lv} = get_liveview(conn, consolidated_view, with_parent?: true) + parent_html = render(parent) + + Enum.each(list_item_selectors, fn list_item -> + refute element_exists?(parent_html, list_item) + end) + + lv + |> element(~s/button[phx-click="allow-existing-props"]/) + |> render_click() + + parent_html = render(parent) + + allowed_props = Enum.map(list_item_selectors, &text_of_element(parent_html, &1)) + + assert Enum.sort(allowed_props) == ["a", "b", "c"] + + consolidated_view = Plausible.Repo.reload!(consolidated_view) + assert Enum.sort(consolidated_view.allowed_event_props) == ["a", "b", "c"] + end + end + end + + defp seed_props(%{site: site}) do + populate_stats(site, [ + build(:event, + name: "Payment", + "meta.key": ["amount"], + "meta.value": ["500"] + ), + build(:event, + name: "Payment", + "meta.key": ["amount", "logged_in"], + "meta.value": ["100", "false"] + ), + build(:event, + name: "Payment", + "meta.key": ["amount", "is_customer"], + "meta.value": ["100", "false"] + ) + ]) + + :ok + end + + defp get_liveview(conn, site, opts \\ []) do + conn = assign(conn, :live_module, PlausibleWeb.Live.PropsSettings) + {:ok, lv, _html} = live(conn, "/#{site.domain}/settings/properties") + lv |> element(~s/button[phx-click="add-prop"]/) |> render_click() + assert form_view = find_live_child(lv, "props-form") + + if opts[:with_parent?] do + {lv, form_view} + else + form_view + end + end + + defp type_into_combo(lv, id, text) do + lv + |> element("input#{id}") + |> render_change(%{ + "_target" => ["display-#{id}"], + "display-#{id}" => "#{text}" + }) + end +end diff --git a/test/plausible_web/live/props_settings_test.exs b/test/plausible_web/live/props_settings_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..1e36e4c6321c900cbb04572e852a722eab47c876 --- /dev/null +++ b/test/plausible_web/live/props_settings_test.exs @@ -0,0 +1,288 @@ +defmodule PlausibleWeb.Live.PropsSettingsTest do + use PlausibleWeb.ConnCase, async: true + import Phoenix.LiveViewTest + + describe "GET /:domain/settings/properties" do + setup [:create_user, :log_in, :create_site] + + @tag :ee_only + test "renders with locked content when subscription is insufficient", %{ + conn: conn, + site: site, + user: user + } do + subscribe_to_growth_plan(user) + + lock_notice = + conn + |> get("/#{site.domain}/settings/properties") + |> html_response(200) + |> text_of_element("#lock-notice") + + assert lock_notice =~ "upgrade your subscription" + end + + @tag :ee_only + test "does not lock content when subscription is sufficient", %{ + conn: conn, + site: site, + user: user + } do + subscribe_to_business_plan(user) + + lock_notice = + conn + |> get("/#{site.domain}/settings/properties") + |> html_response(200) + |> text_of_element("#lock-notice") + + refute lock_notice =~ "upgrade your subscription" + end + + @tag :ee_only + test "guest editors should be able to access prop settings", %{site: site, conn: conn} do + guest_user = new_user() + add_guest(site, user: guest_user, role: :editor) + + {:ok, conn: conn} = log_in(%{user: guest_user, conn: conn}) + + lock_notice = + conn + |> get("/#{site.domain}/settings/properties") + |> html_response(200) + |> text_of_element("#lock-notice") + + refute lock_notice =~ "upgrade your subscription" + end + + test "lists props for the site and renders links", %{conn: conn, site: site} do + {:ok, site} = Plausible.Props.allow(site, ["amount", "logged_in", "is_customer"]) + conn = get(conn, "/#{site.domain}/settings/properties") + + resp = html_response(conn, 200) + assert resp =~ "Attach custom properties" + + assert element_exists?( + resp, + ~s|a[href="https://plausible.io/docs/custom-props/introduction"]| + ) + + assert resp =~ "amount" + assert resp =~ "logged_in" + assert resp =~ "is_customer" + refute resp =~ "upgrade your subscription" + end + + test "lists props with disallow actions", %{conn: conn, site: site} do + {:ok, site} = Plausible.Props.allow(site, ["amount", "logged_in", "is_customer"]) + conn = get(conn, "/#{site.domain}/settings/properties") + resp = html_response(conn, 200) + + for p <- site.allowed_event_props do + assert element_exists?( + resp, + ~s/button[phx-click="disallow-prop"][phx-value-prop=#{p}]#disallow-prop-#{p}/ + ) + end + end + + test "if no props are allowed, a proper info is displayed", %{conn: conn, site: site} do + conn = get(conn, "/#{site.domain}/settings/properties") + resp = html_response(conn, 200) + assert resp =~ "Create a custom property" + end + + test "if props are enabled, no info about missing props is displayed", %{ + conn: conn, + site: site + } do + {:ok, site} = Plausible.Props.allow(site, ["amount", "logged_in", "is_customer"]) + conn = get(conn, "/#{site.domain}/settings/properties") + resp = html_response(conn, 200) + refute resp =~ "Create a custom property" + end + + test "add property button is rendered", %{conn: conn, site: site} do + conn = get(conn, "/#{site.domain}/settings/properties") + resp = html_response(conn, 200) + assert element_exists?(resp, ~s/button[phx-click="add-prop"]/) + end + + test "search props input is rendered", %{conn: conn, site: site} do + {:ok, site} = Plausible.Props.allow(site, ["amount", "logged_in", "is_customer"]) + conn = get(conn, "/#{site.domain}/settings/properties") + resp = html_response(conn, 200) + assert element_exists?(resp, ~s/input[type="text"]#filter-text/) + assert element_exists?(resp, ~s/form[phx-change="filter"]#filter-form/) + end + end + + on_ee do + describe "GET /:domain/settings/properties - consolidated views" do + setup [:create_user, :create_team, :log_in] + + setup %{team: team} = context do + new_site(team: team) + new_site(team: team) + + {:ok, Map.put(context, :consolidated_view, new_consolidated_view(team))} + end + + test "lists existing props and renders links", %{ + conn: conn, + consolidated_view: consolidated_view + } do + {:ok, consolidated_view} = + Plausible.Props.allow(consolidated_view, ["amount", "logged_in", "is_customer"]) + + conn = get(conn, "/#{consolidated_view.domain}/settings/properties") + + resp = html_response(conn, 200) + assert resp =~ "Attach custom properties" + + assert element_exists?( + resp, + ~s|a[href="https://plausible.io/docs/custom-props/introduction"]| + ) + + assert resp =~ "amount" + assert resp =~ "logged_in" + assert resp =~ "is_customer" + refute resp =~ "upgrade your subscription" + end + + test "if no props are allowed, a proper info is displayed", %{ + conn: conn, + consolidated_view: consolidated_view + } do + conn = get(conn, "/#{consolidated_view.domain}/settings/properties") + resp = html_response(conn, 200) + assert resp =~ "Create a custom property" + end + + test "add property button and search input are rendered", %{ + conn: conn, + consolidated_view: consolidated_view + } do + {:ok, consolidated_view} = + Plausible.Props.allow(consolidated_view, ["amount", "logged_in", "is_customer"]) + + conn = get(conn, "/#{consolidated_view.domain}/settings/properties") + resp = html_response(conn, 200) + assert element_exists?(resp, ~s/button[phx-click="add-prop"]/) + assert element_exists?(resp, ~s/input[type="text"]#filter-text/) + assert element_exists?(resp, ~s/form[phx-change="filter"]#filter-form/) + end + end + end + + describe "PropsSettings live view" do + setup [:create_user, :log_in, :create_site] + + test "allows dashboard toggle", %{conn: conn, site: site} do + lv = get_liveview(conn, site) + lv |> element("#feature-props-toggle button") |> render_click() + assert render(lv) =~ "Custom Properties are now hidden from your dashboard" + assert Plausible.Billing.Feature.Props.opted_out?(Plausible.Repo.reload!(site)) + lv |> element("#feature-props-toggle button") |> render_click() + assert render(lv) =~ "Custom Properties are now visible again on your dashboard" + refute Plausible.Billing.Feature.Props.opted_out?(Plausible.Repo.reload!(site)) + end + + test "allows prop removal", %{conn: conn, site: site} do + {:ok, site} = Plausible.Props.allow(site, ["amount", "logged_in"]) + {lv, html} = get_liveview(conn, site, with_html?: true) + + assert html =~ "amount" + assert html =~ "logged_in" + + html = lv |> element(~s/button#disallow-prop-amount/) |> render_click() + + refute html =~ "amount" + assert html =~ "logged_in" + + html = get(conn, "/#{site.domain}/settings/properties") |> html_response(200) + + refute html =~ "amount" + assert html =~ "logged_in" + end + + test "allows props filtering / search", %{conn: conn, site: site} do + {:ok, site} = Plausible.Props.allow(site, ["amount", "logged_in", "is_customer"]) + {lv, html} = get_liveview(conn, site, with_html?: true) + + assert html =~ to_string("amount") + assert html =~ to_string("logged_in") + assert html =~ to_string("is_customer") + + html = type_into_search(lv, "is_customer") + + refute html =~ to_string("amount") + refute html =~ to_string("logged_in") + assert html =~ to_string("is_customer") + end + + test "allows resetting filter text via backspace icon", %{conn: conn, site: site} do + {:ok, site} = Plausible.Props.allow(site, ["amount", "logged_in", "is_customer"]) + {lv, html} = get_liveview(conn, site, with_html?: true) + + refute element_exists?(html, ~s/svg[phx-click="reset-filter-text"]#reset-filter/) + + html = type_into_search(lv, to_string("is_customer")) + + assert element_exists?(html, ~s/svg[phx-click="reset-filter-text"]#reset-filter/) + + html = lv |> element(~s/svg#reset-filter/) |> render_click() + + assert html =~ to_string("is_customer") + assert html =~ to_string("amount") + assert html =~ to_string("logged_in") + end + + test "allows resetting filter text via no match link", %{conn: conn, site: site} do + {:ok, site} = Plausible.Props.allow(site, ["amount", "logged_in", "is_customer"]) + lv = get_liveview(conn, site) + html = type_into_search(lv, "Definitely this is not going to render any matches") + + assert html =~ "No properties found for this site. Please refine or" + assert html =~ "reset your search" + + assert element_exists?(html, ~s/a[phx-click="reset-filter-text"]#reset-filter-hint/) + html = lv |> element(~s/a#reset-filter-hint/) |> render_click() + + refute html =~ "No properties found for this site. Please refine or" + end + + test "clicking Add Property button renders the form view", %{conn: conn, site: site} do + lv = get_liveview(conn, site) + html = lv |> element(~s/button[phx-click="add-prop"]/) |> render_click() + + assert html =~ "Add property for #{site.domain}" + + assert element_exists?( + html, + ~s/div#props-form form[phx-submit="allow-prop"][phx-click-away="cancel-allow-prop"]/ + ) + end + end + + defp get_liveview(conn, site, opts \\ []) do + conn = assign(conn, :live_module, PlausibleWeb.Live.PropsSettings) + {:ok, lv, html} = live(conn, "/#{site.domain}/settings/properties") + + if Keyword.get(opts, :with_html?) do + {lv, html} + else + lv + end + end + + defp type_into_search(lv, text) do + lv + |> element("form#filter-form") + |> render_change(%{ + "_target" => ["filter-text"], + "filter-text" => "#{text}" + }) + end +end diff --git a/test/plausible_web/live/register_form_test.exs b/test/plausible_web/live/register_form_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..3aee16fdef2a16edf3650d5ae5bd9dfeb0bc445e --- /dev/null +++ b/test/plausible_web/live/register_form_test.exs @@ -0,0 +1,320 @@ +defmodule PlausibleWeb.Live.RegisterFormTest do + use PlausibleWeb.ConnCase, async: true + + import Mox + import Phoenix.LiveViewTest + + alias Plausible.Auth.User + alias Plausible.Repo + + setup :verify_on_exit! + + describe "/register" do + test "renders registration form (LV)", %{conn: conn} do + lv = get_liveview(conn, "/register") + html = render(lv) + + assert element_exists?(html, ~s|form[phx-change="validate"][phx-submit="register"]|) + assert element_exists?(html, ~s|input[type="hidden"][name="_csrf_token"]|) + assert element_exists?(html, ~s|input#register-form_name[type="text"][name="user[name]"]|) + + assert element_exists?( + html, + ~s|input#register-form_email[type="email"][name="user[email]"]| + ) + + assert element_exists?( + html, + ~s|input#register-form_password[type="password"][name="user[password]"]| + ) + + assert element_exists?(html, ~s|button[type="submit"]|) + end + + test "renders validation errors depending on input", %{conn: conn} do + lv = get_liveview(conn, "/register") + + type_into_input(lv, "user[password]", "too-short") + html = render(lv) + + assert html =~ "Password is too weak" + + type_into_input(lv, "user[password]", "very-long-and-very-secret-123") + html = render(lv) + + refute html =~ "Password is too weak" + end + + test "creates user entry on valid input", %{conn: conn} do + mock_captcha_success() + + lv = get_liveview(conn, "/register") + + type_into_input(lv, "user[name]", "Mary Sue") + type_into_input(lv, "user[email]", "mary.sue@plausible.test") + type_into_input(lv, "user[password]", "very-long-and-very-secret-123") + + html = lv |> element("form") |> render_submit() + + on_ee do + assert_push_event(lv, "send-metrics", %{event_name: "Signup"}) + end + + assert [ + csrf_input, + action_input, + name_input, + email_input, + password_input | _ + ] = find(html, "input") |> Enum.into([]) + + assert String.length(text_of_attr(csrf_input, "value")) > 0 + assert text_of_attr(action_input, "value") == "register_form" + assert text_of_attr(name_input, "value") == "Mary Sue" + assert text_of_attr(email_input, "value") == "mary.sue@plausible.test" + assert text_of_attr(password_input, "value") == "very-long-and-very-secret-123" + + assert %{ + name: "Mary Sue", + email: "mary.sue@plausible.test", + password_hash: password_hash + } = Repo.one(User) + + assert String.length(password_hash) > 0 + end + + test "renders error on failed captcha", %{conn: conn} do + mock_captcha_failure() + + lv = get_liveview(conn, "/register") + + type_into_input(lv, "user[name]", "Mary Sue") + type_into_input(lv, "user[email]", "mary.sue@plausible.test") + type_into_input(lv, "user[password]", "very-long-and-very-secret-123") + + html = lv |> element("form") |> render_submit() + + assert html =~ "Please complete the captcha to register" + + refute Repo.one(User) + end + + test "pushing send-metrics-after event submits the form", %{conn: conn} do + lv = get_liveview(conn, "/register") + + refute render(lv) =~ ~s|phx-trigger-action=""| + + render_hook(lv, "send-metrics-after", %{event_name: "Signup", params: %{}}) + + assert render(lv) =~ ~s|phx-trigger-action=""| + end + end + + describe "/register/invitation/:invitation_id" do + setup do + inviter = new_user() + site = new_site(owner: inviter) + + guest_invitation = + invite_guest(site, "user@email.co", role: :editor, inviter: inviter) + + {:ok, %{site: site, guest_invitation: guest_invitation, inviter: inviter}} + end + + test "registers user from guest invitation", %{conn: conn, guest_invitation: guest_invitation} do + mock_captcha_success() + + lv = get_liveview(conn, "/register/invitation/#{guest_invitation.invitation_id}") + + type_into_input(lv, "user[name]", "Mary Sue") + type_into_input(lv, "user[password]", "very-long-and-very-secret-123") + + html = lv |> element("form") |> render_submit() + + on_ee do + assert_push_event(lv, "send-metrics", %{event_name: "Signup via invitation"}) + end + + assert [ + csrf_input, + action_input, + email_input, + name_input, + password_input | _ + ] = find(html, "input") |> Enum.into([]) + + assert String.length(text_of_attr(csrf_input, "value")) > 0 + assert text_of_attr(action_input, "value") == "register_from_invitation_form" + assert text_of_attr(name_input, "value") == "Mary Sue" + assert text_of_attr(email_input, "value") == "user@email.co" + assert text_of_attr(password_input, "value") == "very-long-and-very-secret-123" + + assert user = + %{ + name: "Mary Sue", + email: "user@email.co", + password_hash: password_hash + } = Repo.get_by(User, email: "user@email.co") + + assert team_of(user) == nil + + assert String.length(password_hash) > 0 + end + + test "registers user from team invitation", %{conn: conn, inviter: inviter} do + mock_captcha_success() + + team = team_of(inviter) + + team_invitation = + invite_member(team, "team-user@email.co", role: :editor, inviter: inviter) + + lv = get_liveview(conn, "/register/invitation/#{team_invitation.invitation_id}") + + type_into_input(lv, "user[name]", "Mary Sue") + type_into_input(lv, "user[password]", "very-long-and-very-secret-123") + + html = lv |> element("form") |> render_submit() + + on_ee do + assert_push_event(lv, "send-metrics", %{event_name: "Signup via invitation"}) + end + + assert [ + csrf_input, + action_input, + team_input, + email_input, + name_input, + password_input | _ + ] = find(html, "input") |> Enum.into([]) + + assert String.length(text_of_attr(csrf_input, "value")) > 0 + assert text_of_attr(action_input, "value") == "register_from_invitation_form" + assert text_of_attr(team_input, "value") == team.identifier + assert text_of_attr(name_input, "value") == "Mary Sue" + assert text_of_attr(email_input, "value") == "team-user@email.co" + assert text_of_attr(password_input, "value") == "very-long-and-very-secret-123" + end + + test "preserves trial_expiry_date when invitation role is :owner", %{ + conn: conn, + site: site, + inviter: inviter + } do + mock_captcha_success() + + site_transfer = invite_transfer(site, "owner_user@email.co", inviter: inviter) + + lv = get_liveview(conn, "/register/invitation/#{site_transfer.transfer_id}") + + type_into_input(lv, "user[name]", "Mary Sue") + type_into_input(lv, "user[password]", "very-long-and-very-secret-123") + + _html = lv |> element("form") |> render_submit() + + assert user = Repo.get_by(User, email: "owner_user@email.co") + + assert team_of(user).trial_expiry_date != nil + end + + test "always uses original email from the invitation", %{ + conn: conn, + guest_invitation: guest_invitation + } do + mock_captcha_success() + + lv = get_liveview(conn, "/register/invitation/#{guest_invitation.invitation_id}") + + type_into_input(lv, "user[name]", "Mary Sue") + type_into_input(lv, "user[email]", "mary.sue@plausible.test") + type_into_input(lv, "user[password]", "very-long-and-very-secret-123") + + html = lv |> element("form") |> render_submit() + + assert [ + _csrf_input, + _action_input, + email_input | _ + ] = find(html, "input") |> Enum.into([]) + + # attempt at tampering with form + assert text_of_attr(email_input, "value") == "mary.sue@plausible.test" + + assert Repo.get_by(User, email: "user@email.co") + refute Repo.get_by(User, email: "mary.sue@plausible.test") + end + + test "renders expired invitation notice on on-existent invitation ID", %{conn: conn} do + lv = get_liveview(conn, "/register/invitation/doesnotexist") + + html = render(lv) + + assert html =~ "This invitation has expired or was revoked" + end + + test "renders error on failed captcha", %{conn: conn, guest_invitation: guest_invitation} do + mock_captcha_failure() + + lv = get_liveview(conn, "/register/invitation/#{guest_invitation.invitation_id}") + + type_into_input(lv, "user[name]", "Mary Sue") + type_into_input(lv, "user[password]", "very-long-and-very-secret-123") + + html = lv |> element("form") |> render_submit() + + assert html =~ "Please complete the captcha to register" + + refute Repo.get_by(User, email: "user@email.co") + end + + test "pushing send-metrics-after event submits the form", %{ + conn: conn, + guest_invitation: guest_invitation + } do + lv = get_liveview(conn, "/register/invitation/#{guest_invitation.invitation_id}") + + refute render(lv) =~ ~s|phx-trigger-action=""| + + render_hook(lv, "send-metrics-after", %{event_name: "Signup via invitation", params: %{}}) + + assert render(lv) =~ ~s|phx-trigger-action=""| + end + end + + defp get_liveview(conn, url) do + conn = assign(conn, :live_module, PlausibleWeb.Live.RegisterForm) + {:ok, lv, _html} = live(conn, url) + + lv + end + + defp type_into_input(lv, id, text) do + lv + |> element("form") + |> render_change(%{id => text}) + end + + defp mock_captcha_success() do + mock_captcha(true) + end + + defp mock_captcha_failure() do + mock_captcha(false) + end + + defp mock_captcha(success) do + expect( + Plausible.HTTPClient.Mock, + :post, + fn _, _, _ -> + {:ok, + %Finch.Response{ + status: 200, + headers: [{"content-type", "application/json"}], + body: %{"success" => success} + }} + end + ) + end +end diff --git a/test/plausible_web/live/reset_password_form_test.exs b/test/plausible_web/live/reset_password_form_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..ab53e92d04a781f2a0bb6b66e10ee1118ca218ac --- /dev/null +++ b/test/plausible_web/live/reset_password_form_test.exs @@ -0,0 +1,102 @@ +defmodule PlausibleWeb.Live.ResetPasswordFormTest do + use PlausibleWeb.ConnCase, async: true + + import Phoenix.ChannelTest + import Phoenix.LiveViewTest + + alias Plausible.Auth.User + alias Plausible.Auth.Token + alias Plausible.Auth.TOTP + alias Plausible.Repo + + describe "/password/reset" do + test "sets new password with valid token", %{conn: conn} do + user = insert(:user) + token = Token.sign_password_reset(user.email) + + lv = get_liveview(conn, "/password/reset?token=#{token}") + + type_into_passowrd(lv, "very-secret-and-very-long-123") + html = lv |> element("form") |> render_submit() + + csrf_input = find(html, ~s|input[name="_csrf_token"]|) + password_input = find(html, ~s|input[name="user[password]"]|) + assert String.length(text_of_attr(csrf_input, "value")) > 0 + assert text_of_attr(password_input, "value") == "very-secret-and-very-long-123" + assert %{password_hash: new_hash} = Repo.one(User) + assert new_hash != user.password_hash + end + + test "reset's user's TOTP token when present", %{conn: conn} do + user = insert(:user) + {:ok, user, _} = TOTP.initiate(user) + {:ok, user, _} = TOTP.enable(user, :skip_verify) + + token = Token.sign_password_reset(user.email) + + lv = get_liveview(conn, "/password/reset?token=#{token}") + + type_into_passowrd(lv, "very-secret-and-very-long-123") + lv |> element("form") |> render_submit() + + updated_user = Repo.reload!(user) + + assert byte_size(updated_user.totp_token) > 0 + assert updated_user.totp_token != user.totp_token + end + + test "renders error when new password fails validation", %{conn: conn} do + user = insert(:user) + token = Token.sign_password_reset(user.email) + + lv = get_liveview(conn, "/password/reset?token=#{token}") + + type_into_passowrd(lv, "too-short") + html = lv |> element("form") |> render_submit() + + assert html =~ "Password is too weak" + + assert %{password_hash: hash} = Repo.one(User) + assert hash == user.password_hash + end + end + + describe "/password/reset with logged in user" do + setup [:create_user, :log_in] + + test "revokes all active user sessions when present", %{conn: conn, user: user} do + assert [active_session] = Repo.preload(user, :sessions).sessions + live_socket_id = "user_sessions:" <> Base.url_encode64(active_session.token) + Phoenix.PubSub.subscribe(Plausible.PubSub, live_socket_id) + + another_session = + user + |> Plausible.Auth.UserSession.new_session("Some Device") + |> Repo.insert!() + + token = Token.sign_password_reset(user.email) + + lv = get_liveview(conn, "/password/reset?token=#{token}") + + type_into_passowrd(lv, "very-secret-and-very-long-123") + lv |> element("form") |> render_submit() + + assert [] = Repo.preload(user, :sessions).sessions + assert_broadcast "disconnect", %{} + refute Repo.reload(another_session) + end + end + + defp get_liveview(conn, url) do + conn = assign(conn, :live_module, PlausibleWeb.Live.ResetPasswordForm) + {:ok, lv, _html} = live(conn, url) + + lv + end + + defp type_into_passowrd(lv, text) do + lv + |> element("form") + |> render_change(%{"user[password]" => text}) + end +end diff --git a/test/plausible_web/live/sentry_context_test.exs b/test/plausible_web/live/sentry_context_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..190425e12afb89a59e9448c11e6a1ea72348f7bf --- /dev/null +++ b/test/plausible_web/live/sentry_context_test.exs @@ -0,0 +1,82 @@ +defmodule PlausibleWeb.Live.SentryContextTest do + use PlausibleWeb.ConnCase, async: true + import Phoenix.LiveViewTest + + defmodule SampleLV do + use PlausibleWeb, :live_view + + def mount(_params, %{"test" => test_pid}, socket) do + socket = assign(socket, test_pid: test_pid) + {:ok, socket} + end + + def render(assigns) do + ~H""" + ok computer + """ + end + + def handle_event("get_sentry_context", _params, socket) do + context = Sentry.Context.get_all() + send(socket.assigns.test_pid, {:context, context}) + {:noreply, socket} + end + end + + describe "sentry context" do + test "basic shape", %{conn: conn} do + context_hook(conn) + assert_receive {:context, context} + + assert %{ + extra: %{}, + request: %{ + env: %{ + "REMOTE_ADDR" => "127.0.0.1", + "REMOTE_PORT" => port, + "SEVER_NAME" => "www.example.com" + }, + url: :not_mounted_at_router + }, + user: %{}, + tags: %{}, + breadcrumbs: [] + } = context + + assert is_integer(port) + end + + test "user-agent is included", %{conn: conn} do + conn + |> put_req_header("user-agent", "Firefox") + |> context_hook() + + assert_receive {:context, context} + assert context.request.headers["User-Agent"] == "Firefox" + end + end + + describe "sentry context with logged in user" do + setup [:create_user, :log_in] + + test "user_id is included", %{conn: conn, user: user} do + context_hook(conn) + + assert_receive {:context, context} + assert context.user.id == user.id + end + end + + defp context_hook(conn, extra_session \\ %{}) do + lv = get_liveview(conn, extra_session) + assert render(lv) =~ "ok computer" + render_hook(lv, :get_sentry_context, %{}) + end + + defp get_liveview(conn, extra_session) do + {:ok, lv, _html} = + live_isolated(conn, SampleLV, session: Map.merge(%{"test" => self()}, extra_session)) + + lv + end +end diff --git a/test/plausible_web/live/shared_link_settings/form_test.exs b/test/plausible_web/live/shared_link_settings/form_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..c78d7cf5469e7bbe25eda102306570649f998e81 --- /dev/null +++ b/test/plausible_web/live/shared_link_settings/form_test.exs @@ -0,0 +1,468 @@ +defmodule PlausibleWeb.Live.SharedLinkSettings.FormTest do + use PlausibleWeb.ConnCase, async: true + import Phoenix.LiveViewTest + + describe "Shared link form" do + setup [:create_user, :log_in, :create_site] + + setup %{user: user, site: site} do + subscribe_to_growth_plan(user) + {:ok, session: %{"site_id" => site.id, "domain" => site.domain}} + end + + test "renders form fields for creating a shared link", %{ + conn: conn, + session: session + } do + lv = get_liveview(conn, session) + + lv |> element("button#add-shared-link-button") |> render_click() + html = render(lv) + + assert html =~ "New shared link" + assert html =~ "Password protect" + assert html =~ "Limit to segment" + assert element_exists?(html, ~s|button[role="switch"]#password-protect-|) + assert element_exists?(html, ~s|button[role="switch"]#limit-view-|) + assert element_exists?(html, ~s|input[name="shared_link[name]"]|) + assert element_exists?(html, ~s|input[name="shared_link[password]"][type="password"]|) + assert element_exists?(html, ~s|input[name="shared_link[segment_id]"]|) + assert element_exists?(html, ~s|button[type="submit"]|) + + assert element_exists?( + html, + ~s|a[href="https://plausible.io/docs/filters-segments#how-to-save-a-segment"]| + ) + end + + test "renders error on empty submission", %{conn: conn, session: session} do + lv = get_liveview(conn, session) + + lv |> element("button#add-shared-link-button") |> render_click() + + html = lv |> find_form() |> render_submit() + + assert html =~ "can't be blank" + end + + test "creates a shared link without password", %{conn: conn, site: site, session: session} do + lv = get_liveview(conn, session) + refute render(lv) =~ "My Shared Link" + + lv |> element("button#add-shared-link-button") |> render_click() + + lv + |> find_form() + |> render_submit(%{shared_link: %{name: "My Shared Link"}}) + + html = render(lv) + assert html =~ "My Shared Link" + assert html =~ "Shared link saved" + + shared_link = + Plausible.Repo.get_by(Plausible.Site.SharedLink, name: "My Shared Link", site_id: site.id) + + assert shared_link + refute shared_link.password_hash + end + + test "creates a shared link limited to segment", %{conn: conn, site: site, session: session} do + segment = insert(:segment, type: :site, site: site, name: "Scandinavia") + lv = get_liveview(conn, session) + + lv |> element("button#add-shared-link-button") |> render_click() + + lv + |> find_form() + |> render_submit(%{shared_link: %{name: "Limited to Scandinavia", segment_id: segment.id}}) + + html = render(lv) + assert html =~ "Limited to Scandinavia" + assert html =~ "Shared link saved" + + shared_link = + Plausible.Repo.get_by(Plausible.Site.SharedLink, + name: "Limited to Scandinavia", + site_id: site.id, + segment_id: segment.id + ) + + assert shared_link + refute shared_link.password_hash + end + + test "creates a shared link with password", %{conn: conn, site: site, session: session} do + lv = get_liveview(conn, session) + refute render(lv) =~ "Protected Link" + + lv |> element("button#add-shared-link-button") |> render_click() + + lv + |> find_form() + |> render_submit(%{shared_link: %{name: "Protected Link", password: "secret123"}}) + + html = render(lv) + assert html =~ "Protected Link" + assert html =~ "Shared link saved" + + shared_link = + Plausible.Repo.get_by(Plausible.Site.SharedLink, name: "Protected Link", site_id: site.id) + + assert shared_link + assert shared_link.password_hash + end + + test "renders form fields for editing a shared link (not limited to segment, not protected)", + %{ + conn: conn, + site: site, + session: session + } do + shared_link = insert(:shared_link, site: site, name: "Existing Link") + + lv = get_liveview(conn, session) + + lv + |> element(~s/button[phx-click="edit-shared-link"][phx-value-slug="#{shared_link.slug}"]/) + |> render_click() + + html = render(lv) + assert html =~ "Edit shared link" + assert element_exists?(html, ~s|input[name="shared_link[name]"]|) + assert html =~ ~s|value="#{shared_link.name}"| + refute html =~ "Password protect" + assert html =~ "Limit to segment" + refute element_exists?(html, ~s|button#password-protect-[role="switch"]|) + refute element_exists?(html, ~s|input[name="shared_link[password]"][type="password"]|) + assert element_exists?(html, ~s|input[name="shared_link[segment_id]"][value=""]|) + + assert element_exists?( + html, + ~s|a[href="https://plausible.io/docs/filters-segments#how-to-save-a-segment"]| + ) + end + + test "renders form fields for editing a shared link (limited to segment, protected)", %{ + conn: conn, + site: site, + session: session + } do + segment = insert(:segment, type: :site, site: site, name: "Scandinavia") + + shared_link = + insert(:shared_link, + site: site, + name: "Existing Link", + segment: segment, + password: "secret123" + ) + + lv = get_liveview(conn, session) + + lv + |> element(~s/button[phx-click="edit-shared-link"][phx-value-slug="#{shared_link.slug}"]/) + |> render_click() + + html = render(lv) + assert html =~ "Edit shared link" + assert element_exists?(html, ~s|input[name="shared_link[name]"]|) + assert html =~ ~s|value="#{shared_link.name}"| + refute html =~ "Password protect" + assert html =~ "Limit to segment" + assert element_exists?(html, ~s|input[name="shared_link[segment_id]"]|) + refute element_exists?(html, ~s|input[name="shared_link[password]"][type="password"]|) + + assert element_exists?( + html, + ~s|input[name="shared_link[segment_id]"][value="#{segment.id}"]| + ) + + assert html =~ ~s|value="#{segment.name}"| + end + + test "updates a shared link name", %{conn: conn, site: site, session: session} do + shared_link = insert(:shared_link, site: site, name: "Original Name") + + lv = get_liveview(conn, session) + + lv + |> element(~s/button[phx-click="edit-shared-link"][phx-value-slug="#{shared_link.slug}"]/) + |> render_click() + + lv + |> find_form() + |> render_submit(%{shared_link: %{name: "Updated Name"}}) + + html = render(lv) + assert html =~ "Updated Name" + assert html =~ "Shared link saved" + refute html =~ "Original Name" + + updated = Plausible.Repo.get!(Plausible.Site.SharedLink, shared_link.id) + assert updated.name == "Updated Name" + assert updated.id == shared_link.id + end + + test "updates a shared link password", %{conn: conn, site: site, session: session} do + shared_link = insert(:shared_link, site: site, name: "Test Link", password: "old_password") + + lv = get_liveview(conn, session) + + lv + |> element(~s/button[phx-click="edit-shared-link"][phx-value-slug="#{shared_link.slug}"]/) + |> render_click() + + old_hash = shared_link.password_hash + + lv + |> find_form() + |> render_submit(%{shared_link: %{name: "Test Link", password: "new_password123"}}) + + _html = render(lv) + + updated = Plausible.Repo.get!(Plausible.Site.SharedLink, shared_link.id) + assert updated.password_hash != old_hash + assert updated.password_hash + end + + test "removes password when password field is left empty on update", %{ + conn: conn, + site: site, + session: session + } do + shared_link = insert(:shared_link, site: site, name: "Test Link", password: "old_password") + + lv = get_liveview(conn, session) + + lv + |> element(~s/button[phx-click="edit-shared-link"][phx-value-slug="#{shared_link.slug}"]/) + |> render_click() + + lv + |> find_form() + |> render_submit(%{shared_link: %{name: "Test Link", password: ""}}) + + _html = render(lv) + + updated = Plausible.Repo.get!(Plausible.Site.SharedLink, shared_link.id) + refute updated.password_hash + end + + test "renders error when shared link name is invalid", %{ + conn: conn, + session: session + } do + lv = get_liveview(conn, session) + + lv |> element("button#add-shared-link-button") |> render_click() + + html = lv |> find_form() |> render_submit(%{shared_link: %{name: ""}}) + + assert html =~ "can't be blank" + end + + test "attaches a valid same-site segment when editing a shared link", %{ + conn: conn, + site: site, + session: session + } do + segment = insert(:segment, type: :site, site: site, name: "Scandinavia") + shared_link = insert(:shared_link, site: site, name: "My Link") + + lv = get_liveview(conn, session) + + lv + |> element(~s/button[phx-click="edit-shared-link"][phx-value-slug="#{shared_link.slug}"]/) + |> render_click() + + lv + |> find_form() + |> render_submit(%{shared_link: %{name: "My Link", segment_id: segment.id}}) + + html = render(lv) + assert html =~ "Shared link saved" + + updated = Plausible.Repo.reload!(shared_link) + assert updated.segment_id == segment.id + end + + test "clears a segment when editing a shared link with segment_id empty", %{ + conn: conn, + site: site, + session: session + } do + segment = insert(:segment, type: :site, site: site, name: "Scandinavia") + shared_link = insert(:shared_link, site: site, name: "My Link", segment: segment) + + lv = get_liveview(conn, session) + + lv + |> element(~s/button[phx-click="edit-shared-link"][phx-value-slug="#{shared_link.slug}"]/) + |> render_click() + + # Toggle off → ComboBox submits segment_id: "" + lv + |> find_form() + |> render_submit(%{shared_link: %{name: "My Link", segment_id: ""}}) + + html = render(lv) + assert html =~ "Shared link saved" + + updated = Plausible.Repo.reload!(shared_link) + assert is_nil(updated.segment_id) + end + + test "does not associate a segment from another site when creating a shared link", %{ + conn: conn, + site: site, + session: session + } do + victim_site = insert(:site) + victim_segment = insert(:segment, type: :site, site: victim_site, name: "Victim Segment") + + lv = get_liveview(conn, session) + + lv |> element("button#add-shared-link-button") |> render_click() + + lv + |> find_form() + |> render_submit(%{ + shared_link: %{name: "Attacker Link", segment_id: victim_segment.id} + }) + + html = render(lv) + assert html =~ "Attacker Link" + assert html =~ "Shared link saved" + + # The shared link must NOT reference the foreign segment + shared_link = + Plausible.Repo.get_by(Plausible.Site.SharedLink, + name: "Attacker Link", + site_id: site.id + ) + + assert shared_link + assert is_nil(shared_link.segment_id) + end + + test "does not associate a segment from another site when editing a shared link", %{ + conn: conn, + site: site, + session: session + } do + own_segment = insert(:segment, type: :site, site: site, name: "Own Segment") + shared_link = insert(:shared_link, site: site, name: "My Link", segment: own_segment) + + victim_site = insert(:site) + victim_segment = insert(:segment, type: :site, site: victim_site, name: "Victim Segment") + + lv = get_liveview(conn, session) + + lv + |> element(~s/button[phx-click="edit-shared-link"][phx-value-slug="#{shared_link.slug}"]/) + |> render_click() + + lv + |> find_form() + |> render_submit(%{ + shared_link: %{name: "My Link", segment_id: victim_segment.id} + }) + + html = render(lv) + assert html =~ "Shared link saved" + + # The shared link must NOT be updated to reference the foreign segment + updated = Plausible.Repo.reload!(shared_link) + assert is_nil(updated.segment_id) + end + + test "keeps the segment when editing a link whose segment was downgraded to personal", %{ + conn: conn, + site: site, + session: session + } do + # A site segment is attached, then later downgraded to personal. Editing the + # link (e.g. renaming) must NOT detach the now-personal segment. + segment = insert(:segment, type: :site, site: site, name: "Scandinavia") + shared_link = insert(:shared_link, site: site, name: "My Link", segment: segment) + + Plausible.Repo.update!(Ecto.Changeset.change(segment, type: :personal)) + + lv = get_liveview(conn, session) + + lv + |> element(~s/button[phx-click="edit-shared-link"][phx-value-slug="#{shared_link.slug}"]/) + |> render_click() + + lv + |> find_form() + |> render_submit(%{shared_link: %{name: "Renamed Link", segment_id: segment.id}}) + + html = render(lv) + assert html =~ "Shared link saved" + + updated = Plausible.Repo.reload!(shared_link) + assert updated.name == "Renamed Link" + + assert updated.segment_id == segment.id, + "Downgraded segment must stay attached to the shared link" + end + + test "cannot overwrite the shared link slug via form params", %{ + conn: conn, + site: site, + session: session + } do + shared_link = insert(:shared_link, site: site, name: "Original Link") + original_slug = shared_link.slug + + lv = get_liveview(conn, session) + + lv + |> element(~s/button[phx-click="edit-shared-link"][phx-value-slug="#{original_slug}"]/) + |> render_click() + + # Attempt to inject a custom slug through the form params + lv + |> find_form() + |> render_submit(%{shared_link: %{name: "Original Link", slug: "evil-custom-slug"}}) + + html = render(lv) + assert html =~ "Shared link saved" + + updated = Plausible.Repo.reload!(shared_link) + assert updated.slug == original_slug, "Slug must remain unchanged after update" + refute updated.slug == "evil-custom-slug" + end + + test "shows upgrade required error when subscription doesn't include Shared Links", %{ + conn: conn, + site: site, + session: session + } do + site.team |> Plausible.Teams.Team.end_trial() |> Plausible.Repo.update!() + insert(:starter_subscription, team: site.team) + + lv = get_liveview(conn, session) + + lv |> element("button#add-shared-link-button") |> render_click() + + html = + lv + |> find_form() + |> render_submit(%{shared_link: %{name: "Test Link"}}) + + assert html =~ "New shared link" or + html =~ "Your current subscription plan does not include Shared Links" + end + end + + defp get_liveview(conn, session) do + {:ok, lv, _html} = live_isolated(conn, PlausibleWeb.Live.SharedLinkSettings, session: session) + lv + end + + defp find_form(lv) do + lv |> element("#shared-links-form-modalseq0 form") + end +end diff --git a/test/plausible_web/live/shared_link_settings_test.exs b/test/plausible_web/live/shared_link_settings_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..28d226bcd566ea67f0058945df54d7b586eaf9be --- /dev/null +++ b/test/plausible_web/live/shared_link_settings_test.exs @@ -0,0 +1,147 @@ +defmodule PlausibleWeb.Live.SharedLinkSettingsTest do + use PlausibleWeb.ConnCase, async: true + import Phoenix.LiveViewTest + + describe "SharedLinkSettings LiveView" do + setup [:create_user, :log_in, :create_site] + + setup %{user: user, site: site} do + subscribe_to_growth_plan(user) + {:ok, session: %{"site_id" => site.id, "domain" => site.domain}} + end + + test "guest editors should be able to access shared link settings", %{ + site: site, + conn: conn, + session: session + } do + insert(:shared_link, site: site, name: "Link 1") + guest_user = new_user() + add_guest(site, user: guest_user, role: :editor) + + {:ok, conn: conn} = log_in(%{user: guest_user, conn: conn}) + lv = get_liveview(conn, session) + lock_notice = render(lv) |> text_of_element("#lock-notice") + + refute lock_notice =~ "upgrade your subscription" + end + + test "allows shared link deletion", %{conn: conn, site: site, session: session} do + link1 = insert(:shared_link, site: site, name: "Link 1") + link2 = insert(:shared_link, site: site, name: "Link 2") + + lv = get_liveview(conn, session) + html = render(lv) + + assert html =~ "Link 1" + assert html =~ "Link 2" + + html = + lv + |> element(~s/button[phx-click="delete-shared-link"][phx-value-slug="#{link1.slug}"]/) + |> render_click() + + refute html =~ "Link 1" + assert html =~ "Link 2" + assert html =~ "Shared link deleted" + + # Verify it's actually deleted from database + refute Plausible.Repo.get_by(Plausible.Site.SharedLink, slug: link1.slug) + assert Plausible.Repo.get_by(Plausible.Site.SharedLink, slug: link2.slug) + end + + test "shows success message when link is deleted", %{conn: conn, site: site, session: session} do + link = insert(:shared_link, site: site, name: "Test Link") + + lv = get_liveview(conn, session) + + html = + lv + |> element(~s/button[phx-click="delete-shared-link"][phx-value-slug="#{link.slug}"]/) + |> render_click() + + assert html =~ "Shared link deleted" + refute html =~ "Test Link" + end + + test "shows error message when trying to delete non-existent link (race condition)", %{ + conn: conn, + site: site, + session: session + } do + # Simulate race condition: link exists when page loads, but is deleted + # by another user/process before we click delete + link = insert(:shared_link, site: site, name: "Test Link") + + lv = get_liveview(conn, session) + + html = render(lv) + assert html =~ "Test Link" + + Plausible.Repo.delete!(link) + + html = + lv + |> element(~s/button[phx-click="delete-shared-link"][phx-value-slug="#{link.slug}"]/) + |> render_click() + + assert html =~ "Could not find Shared Link" + end + + test "displays shared links with password indicators", %{ + conn: conn, + site: site, + session: session + } do + link_with_password = + insert(:shared_link, site: site, name: "Protected", password: "secret") + + link_without_password = insert(:shared_link, site: site, name: "Public") + + lv = get_liveview(conn, session) + html = render(lv) + + assert html =~ "Protected" + assert html =~ "value=\"#{Plausible.Sites.shared_link_url(site, link_with_password)}\"" + + assert html =~ "Public" + assert html =~ "value=\"#{Plausible.Sites.shared_link_url(site, link_without_password)}\"" + + # Check for lock icons + assert element_exists?(html, ~s|svg|) + end + + test "displays empty state when no shared links", %{conn: conn, session: session} do + lv = get_liveview(conn, session) + html = render(lv) + + assert html =~ "Create your first shared link" + end + + test "cannot edit shared link from another site by passing its slug as a param", %{ + conn: conn, + site: site, + session: session + } do + _own_link = insert(:shared_link, site: site, name: "Own Link") + victim_site = insert(:site) + victim_link = insert(:shared_link, site: victim_site, name: "Victim Link") + + lv = get_liveview(conn, session) + + html = render_click(lv, "edit-shared-link", %{"slug" => victim_link.slug}) + + refute html =~ "Edit shared link" + assert html =~ "Could not find Shared Link" + + # Verify the victim's shared link is unchanged in the database + reloaded = Plausible.Repo.reload!(victim_link) + assert reloaded.name == "Victim Link" + end + end + + defp get_liveview(conn, session) do + {:ok, lv, _html} = live_isolated(conn, PlausibleWeb.Live.SharedLinkSettings, session: session) + lv + end +end diff --git a/test/plausible_web/live/shields/countries_test.exs b/test/plausible_web/live/shields/countries_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..68cd90225dfb3420b0f31d631f342f4bd1d46bf2 --- /dev/null +++ b/test/plausible_web/live/shields/countries_test.exs @@ -0,0 +1,159 @@ +defmodule PlausibleWeb.Live.Shields.CountriesTest do + use PlausibleWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + + alias Plausible.Shields + + setup [:create_user, :create_site, :log_in] + + describe "Country Rules - static" do + test "renders country rules page with empty list", %{conn: conn, site: site} do + conn = get(conn, "/#{site.domain}/settings/shields/countries") + resp = html_response(conn, 200) + + assert resp =~ "Block a country" + assert resp =~ "Country block list" + end + + test "lists country rules with remove actions", %{conn: conn, site: site} do + {:ok, r1} = + Shields.add_country_rule(site, %{"country_code" => "PL"}) + + {:ok, r2} = + Shields.add_country_rule(site, %{"country_code" => "EE"}) + + conn = get(conn, "/#{site.domain}/settings/shields/countries") + resp = html_response(conn, 200) + + assert resp =~ "Poland" + assert resp =~ "Estonia" + + assert remove_button_1 = find(resp, "#remove-country-rule-#{r1.id}") + assert remove_button_2 = find(resp, "#remove-country-rule-#{r2.id}") + + assert text_of_attr(remove_button_1, "phx-click") == "remove-country-rule" + assert text_of_attr(remove_button_1, "phx-value-rule-id") == r1.id + assert text_of_attr(remove_button_2, "phx-click") == "remove-country-rule" + assert text_of_attr(remove_button_2, "phx-value-rule-id") == r2.id + end + + test "lists Unknown country rule", %{conn: conn, site: site} do + {:ok, rule} = + Shields.add_country_rule(site, %{"country_code" => "ZZ"}) + + conn = get(conn, "/#{site.domain}/settings/shields/countries") + resp = html_response(conn, 200) + + assert resp =~ "Unknown" + assert find(resp, "#remove-country-rule-#{rule.id}") + end + + test "add rule button is rendered", %{conn: conn, site: site} do + conn = get(conn, "/#{site.domain}/settings/shields/countries") + resp = html_response(conn, 200) + + assert element_exists?(resp, ~s/button#add-country-rule[x-data]/) + attr = text_of_attr(resp, ~s/button#add-country-rule/, "x-on:click") + + assert attr =~ "open-modal" + assert attr =~ "country-rule-form-modal" + end + + test "add rule button is not rendered when maximum reached", %{conn: conn, site: site} do + country_codes = + Location.Country.all() + |> Enum.take(Shields.maximum_country_rules()) + |> Enum.map(& &1.alpha_2) + + for cc <- country_codes do + assert {:ok, _} = + Shields.add_country_rule(site, %{"country_code" => "#{cc}"}) + end + + conn = get(conn, "/#{site.domain}/settings/shields/countries") + resp = html_response(conn, 200) + + refute element_exists?(resp, ~s/button#add-country-rule[x-data]/) + assert resp =~ "Maximum number of countries reached" + assert resp =~ "You've reached the maximum number of countries you can block (30)" + end + end + + describe "Country Rules - LiveView" do + test "modal contains form", %{site: site, conn: conn} do + lv = get_liveview(conn, site) + html = render(lv) + + assert element_exists?( + html, + ~s/form[phx-submit="save-country-rule"] input[name="country_rule\[country_code\]"]/ + ) + + assert submit_button(html, ~s/form[phx-submit="save-country-rule"]/) + end + + test "submitting a valid country saves it", %{conn: conn, site: site, user: user} do + lv = get_liveview(conn, site) + + lv + |> element("form") + |> render_submit(%{ + "country_rule[country_code]" => "EE" + }) + + html = render(lv) + + assert html =~ "Estonia" + + added_by = "#{user.name} <#{user.email}>" + + assert [%{id: id, country_code: "EE", added_by: ^added_by}] = + Shields.list_country_rules(site) + + tooltip = text_of_attr(html, "#country-#{id}", "title") + assert tooltip =~ "Added at #{Date.utc_today()}" + assert tooltip =~ "by #{added_by}" + end + + test "submitting Unknown (ZZ) country saves it", %{conn: conn, site: site} do + lv = get_liveview(conn, site) + + lv + |> element("form") + |> render_submit(%{ + "country_rule[country_code]" => "ZZ" + }) + + html = render(lv) + + assert html =~ "Unknown" + + assert [%{country_code: "ZZ"}] = Shields.list_country_rules(site) + end + + test "clicking Remove deletes the rule", %{conn: conn, site: site} do + {:ok, _} = + Shields.add_country_rule(site, %{"country_code" => "EE"}) + + lv = get_liveview(conn, site) + + html = render(lv) + assert text_of_element(html, "table tbody td") =~ "Estonia" + + lv |> element(~s/button[phx-click="remove-country-rule"]/) |> render_click() + + html = render(lv) + refute text_of_element(html, "table tbody td") =~ "Estonia" + + assert Shields.count_country_rules(site) == 0 + end + + defp get_liveview(conn, site) do + conn = assign(conn, :live_module, PlausibleWeb.Live.Shields) + {:ok, lv, _html} = live(conn, "/#{site.domain}/settings/shields/countries") + + lv + end + end +end diff --git a/test/plausible_web/live/shields/hostnames_test.exs b/test/plausible_web/live/shields/hostnames_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..063b5dee3449d2aca2b48e9860143883a5e667a2 --- /dev/null +++ b/test/plausible_web/live/shields/hostnames_test.exs @@ -0,0 +1,197 @@ +defmodule PlausibleWeb.Live.Shields.HostnamesTest do + use PlausibleWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + + alias Plausible.Shields + + setup [:create_user, :create_site, :log_in] + + describe "Hostname Rules - static" do + test "renders hostname rules hostname with empty list", %{conn: conn, site: site} do + conn = get(conn, "/#{site.domain}/settings/shields/hostnames") + resp = html_response(conn, 200) + + assert resp =~ "Allow a hostname" + assert resp =~ "Hostnames allow list" + assert resp =~ "Traffic from all hostnames is recorded until you add your first rule" + end + + test "lists hostname rules with remove actions", %{conn: conn, site: site} do + {:ok, r1} = + Shields.add_hostname_rule(site, %{"hostname" => "example.com"}) + + {:ok, r2} = + Shields.add_hostname_rule(site, %{"hostname" => "example.org"}) + + conn = get(conn, "/#{site.domain}/settings/shields/hostnames") + resp = html_response(conn, 200) + + assert resp =~ "example.com" + assert resp =~ "example.org" + + assert remove_button_1 = find(resp, "#remove-hostname-rule-#{r1.id}") + assert remove_button_2 = find(resp, "#remove-hostname-rule-#{r2.id}") + + assert text_of_attr(remove_button_1, "phx-click") == "remove-hostname-rule" + assert text_of_attr(remove_button_1, "phx-value-rule-id") == r1.id + assert text_of_attr(remove_button_2, "phx-click") == "remove-hostname-rule" + assert text_of_attr(remove_button_2, "phx-value-rule-id") == r2.id + end + + test "add rule button is rendered", %{conn: conn, site: site} do + conn = get(conn, "/#{site.domain}/settings/shields/hostnames") + resp = html_response(conn, 200) + + assert element_exists?(resp, ~s/button#add-hostname-rule[x-data]/) + attr = text_of_attr(resp, ~s/button#add-hostname-rule/, "x-on:click") + + assert attr =~ "open-modal" + assert attr =~ "hostname-rule-form-modal" + end + + test "add rule button is not rendered when maximum reached", %{conn: conn, site: site} do + for i <- 1..Shields.maximum_hostname_rules() do + assert {:ok, _} = + Shields.add_hostname_rule(site, %{"hostname" => "#{i}.example.com"}) + end + + conn = get(conn, "/#{site.domain}/settings/shields/hostnames") + resp = html_response(conn, 200) + + refute element_exists?(resp, ~s/button#add-hostname-rule[x-data]/) + assert resp =~ "Maximum number of hostnames reached" + assert resp =~ "You've reached the maximum number of hostnames you can block (10)" + end + end + + describe "Hostname Rules - LiveView" do + test "modal contains form", %{site: site, conn: conn} do + lv = get_liveview(conn, site) + html = render(lv) + + assert element_exists?( + html, + ~s/form[phx-submit="save-hostname-rule"] input[name="hostname_rule\[hostname\]"]/ + ) + + assert submit_button(html, ~s/form[phx-submit="save-hostname-rule"]/) + end + + test "if no rules are added yet, form displays hint", %{site: site, conn: conn} do + lv = get_liveview(conn, site) + html = render(lv) + + assert text(html) =~ + "NB: Once added, we will start rejecting traffic from non-matching hostnames within a few minutes." + + refute text(html) =~ "we will start accepting" + end + + test "if rules are added, form changes the hint", %{site: site, conn: conn} do + {:ok, _} = + Shields.add_hostname_rule(site, %{"hostname" => "*.example.com"}) + + lv = get_liveview(conn, site) + html = render(lv) + + refute text(html) =~ "we will start rejecting traffic" + + assert text(html) =~ + "Once added, we will start accepting traffic from this hostname within a few minutes." + end + + test "submitting a valid Hostname saves it", %{conn: conn, site: site} do + lv = get_liveview(conn, site) + + lv + |> element("form") + |> render_submit(%{ + "hostname_rule[hostname]" => "*.example.com" + }) + + html = render(lv) + + assert html =~ "*.example.com" + + assert [%{hostname: "*.example.com"}] = Shields.list_hostname_rules(site) + end + + test "submitting invalid Hostname renders error", %{conn: conn, site: site} do + lv = get_liveview(conn, site) + + lv + |> element("form") + |> render_submit(%{ + "hostname_rule[hostname]" => :binary.copy("a", 256) + }) + + html = render(lv) + assert html =~ "should be at most 250 character(s)" + end + + test "clicking Remove deletes the rule", %{conn: conn, site: site} do + {:ok, _} = + Shields.add_hostname_rule(site, %{"hostname" => "*.example.com"}) + + lv = get_liveview(conn, site) + + html = render(lv) + assert html =~ "*.example.com" + + lv |> element(~s/button[phx-click="remove-hostname-rule"]/) |> render_click() + + html = render(lv) + refute html =~ "*.example.com" + + assert Shields.count_hostname_rules(site) == 0 + end + + test "conclicting rules are annotated with a warning", %{conn: conn, site: site} do + lv = get_liveview(conn, site) + + lv + |> element("form") + |> render_submit(%{ + "hostname_rule[hostname]" => "*example.com" + }) + + html = render(lv) + refute html =~ "This rule might be redundant" + + lv + |> element("form") + |> render_submit(%{ + "hostname_rule[hostname]" => "subdomain.example.com" + }) + + html = render(lv) + + assert html =~ "*example.com" + assert html =~ "subdomain.example.com" + + assert html =~ + "This rule might be redundant because the following rules may match first:\n\n*example.com" + + broader_rule_id = + site + |> Shields.list_hostname_rules() + |> Enum.find(&(&1.hostname == "*example.com")) + |> Map.fetch!(:id) + + lv |> element(~s/button#remove-hostname-rule-#{broader_rule_id}/) |> render_click() + html = render(lv) + + assert html =~ "subdomain.example.com" + refute html =~ "*example.com" + refute html =~ "This rule might be redundant" + end + + defp get_liveview(conn, site) do + conn = assign(conn, :live_module, PlausibleWeb.Live.Shields) + {:ok, lv, _html} = live(conn, "/#{site.domain}/settings/shields/hostnames") + + lv + end + end +end diff --git a/test/plausible_web/live/shields/ip_addresses_test.exs b/test/plausible_web/live/shields/ip_addresses_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..0885d767cda7a901e177ffeb289868d20e182b0e --- /dev/null +++ b/test/plausible_web/live/shields/ip_addresses_test.exs @@ -0,0 +1,202 @@ +defmodule PlausibleWeb.Live.Shields.IPAddressesTest do + use PlausibleWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + + alias Plausible.Shields + + setup [:create_user, :create_site, :log_in] + + describe "IP Rules - static" do + test "renders ip rules page with empty list", %{conn: conn, site: site} do + conn = get(conn, "/#{site.domain}/settings/shields/ip_addresses") + resp = html_response(conn, 200) + + assert resp =~ "Block an IP address" + assert resp =~ "IP block list" + end + + test "lists ip rules with remove actions", %{conn: conn, site: site} do + {:ok, r1} = + Shields.add_ip_rule(site, %{"inet" => "127.0.0.1", "description" => "Alice"}) + + {:ok, r2} = + Shields.add_ip_rule(site, %{"inet" => "127.0.0.2", "description" => "Bob"}) + + conn = get(conn, "/#{site.domain}/settings/shields/ip_addresses") + resp = html_response(conn, 200) + + assert resp =~ "127.0.0.1" + assert resp =~ "Alice" + + assert resp =~ "127.0.0.2" + assert resp =~ "Bob" + + assert remove_button_1 = find(resp, "#remove-ip-rule-#{r1.id}") + assert remove_button_2 = find(resp, "#remove-ip-rule-#{r2.id}") + + assert text_of_attr(remove_button_1, "phx-click") == "remove-ip-rule" + assert text_of_attr(remove_button_1, "phx-value-rule-id") == r1.id + assert text_of_attr(remove_button_2, "phx-click") == "remove-ip-rule" + assert text_of_attr(remove_button_2, "phx-value-rule-id") == r2.id + end + + test "add rule button is rendered", %{conn: conn, site: site} do + conn = get(conn, "/#{site.domain}/settings/shields/ip_addresses") + resp = html_response(conn, 200) + + assert element_exists?(resp, ~s/button#add-ip-rule/) + end + + test "add rule button is not rendered when maximum reached", %{conn: conn, site: site} do + for i <- 1..Shields.maximum_ip_rules() do + assert {:ok, _} = + Shields.add_ip_rule(site, %{"inet" => "1.1.1.#{i}"}) + end + + conn = get(conn, "/#{site.domain}/settings/shields/ip_addresses") + resp = html_response(conn, 200) + + refute element_exists?(resp, ~s/button#add-ip-rule[x-data]/) + assert resp =~ "Maximum number of addresses reached" + assert resp =~ "You've reached the maximum number of IP addresses you can block (30)" + end + end + + describe "IP Rules - LiveView" do + test "modal contains form", %{site: site, conn: conn} do + lv = get_liveview(conn, site) + html = render(lv) + + assert element_exists?( + html, + ~s/form[phx-submit="save-ip-rule"] input[name="ip_rule\[inet\]"]/ + ) + + assert element_exists?( + html, + ~s/form[phx-submit="save-ip-rule"] input[name="ip_rule\[description\]"]/ + ) + + assert submit_button(html, ~s/form[phx-submit="save-ip-rule"]/) + end + + test "form modal contains link to add own IP", %{site: site, conn: conn} do + ip = PlausibleWeb.RemoteIP.get(conn) + lv = get_liveview(conn, site) + html = render(lv) + + assert text(html) =~ "Your current IP address is: #{ip}" + assert element_exists?(html, ~s/a[phx-click="prefill-own-ip-rule"]/) + end + + test "form modal does not contain link to add own IP if already added", %{ + site: site, + conn: conn + } do + ip = PlausibleWeb.RemoteIP.get(conn) + + {:ok, _} = + Shields.add_ip_rule(site, %{ + "inet" => ip, + "description" => "Alice" + }) + + lv = get_liveview(conn, site) + html = render(lv) + + refute text(html) =~ "Your current IP address is: #{ip}" + refute element_exists?(html, ~s/a[phx-click="prefill-own-ip-rule"]/) + end + + test "clicking the link prefills own IP", %{conn: conn, site: site, user: user} do + lv = get_liveview(conn, site) + lv |> element(~s/a[phx-click="prefill-own-ip-rule"]/) |> render_click() + + html = render(lv) + + assert text_of_attr(html, "input[name=\"ip_rule[inet]\"]", "value") == + PlausibleWeb.RemoteIP.get(conn) + + assert text_of_attr(html, "input[name=\"ip_rule[description]\"]", "value") == user.name + end + + test "submitting own IP saves it", %{conn: conn, site: site, user: user} do + ip = PlausibleWeb.RemoteIP.get(conn) + assert [] = Shields.list_ip_rules(site) + + lv = get_liveview(conn, site) + lv |> element(~s/a[phx-click="prefill-own-ip-rule"]/) |> render_click() + lv |> element(~s/form/) |> render_submit() + + html = render(lv) + + assert html =~ ip + assert html =~ user.name + + assert [%{id: id}] = Shields.list_ip_rules(site) + + tooltip = text_of_attr(html, "#inet-#{id}", "title") + assert tooltip =~ "Added at #{Date.utc_today()}" + assert tooltip =~ "by #{user.name} <#{user.email}>" + + assert [_] = Shields.list_ip_rules(site) + end + + test "submitting a valid IP saves it", %{conn: conn, site: site} do + lv = get_liveview(conn, site) + + lv + |> element("form") + |> render_submit(%{ + "ip_rule[inet]" => "1.1.1.1", + "ip_rule[description]" => "A happy song" + }) + + html = render(lv) + + assert html =~ "1.1.1.1" + assert html =~ "A happy song" + + assert [%{inet: ip, description: "A happy song"}] = Shields.list_ip_rules(site) + assert to_string(ip) == "1.1.1.1" + end + + test "submitting invalid IP renders error", %{conn: conn, site: site} do + lv = get_liveview(conn, site) + + lv + |> element("form") + |> render_submit(%{ + "ip_rule[inet]" => "WRONG" + }) + + html = render(lv) + assert html =~ "is invalid" + end + + test "clicking Remove deletes the rule", %{conn: conn, site: site} do + {:ok, _} = + Shields.add_ip_rule(site, %{"inet" => "2.2.2.2", "description" => "Alice"}) + + lv = get_liveview(conn, site) + + html = render(lv) + assert html =~ "2.2.2.2" + + lv |> element(~s/button[phx-click="remove-ip-rule"]/) |> render_click() + + html = render(lv) + refute html =~ "2.2.2.2" + + assert Shields.count_ip_rules(site) == 0 + end + + defp get_liveview(conn, site) do + conn = assign(conn, :live_module, PlausibleWeb.Live.Shields) + {:ok, lv, _html} = live(conn, "/#{site.domain}/settings/shields/ip_addresses") + + lv + end + end +end diff --git a/test/plausible_web/live/shields/pages_test.exs b/test/plausible_web/live/shields/pages_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..79c57f23b55450bfaad4486d2bdf209fc5f7a612 --- /dev/null +++ b/test/plausible_web/live/shields/pages_test.exs @@ -0,0 +1,226 @@ +defmodule PlausibleWeb.Live.Shields.PagesTest do + use PlausibleWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + + alias Plausible.Shields + + setup [:create_user, :create_site, :log_in] + + describe "Page Rules - static" do + test "renders page rules page with empty list", %{conn: conn, site: site} do + conn = get(conn, "/#{site.domain}/settings/shields/pages") + resp = html_response(conn, 200) + + assert resp =~ "Block a page" + assert resp =~ "Pages block list" + end + + test "lists page rules with remove actions", %{conn: conn, site: site} do + {:ok, r1} = + Shields.add_page_rule(site, %{"page_path" => "/test/1"}) + + {:ok, r2} = + Shields.add_page_rule(site, %{"page_path" => "/test/2"}) + + conn = get(conn, "/#{site.domain}/settings/shields/pages") + resp = html_response(conn, 200) + + assert resp =~ "/test/1" + assert resp =~ "/test/2" + + assert remove_button_1 = find(resp, "#remove-page-rule-#{r1.id}") + assert remove_button_2 = find(resp, "#remove-page-rule-#{r2.id}") + + assert text_of_attr(remove_button_1, "phx-click") == "remove-page-rule" + assert text_of_attr(remove_button_1, "phx-value-rule-id") == r1.id + assert text_of_attr(remove_button_2, "phx-click") == "remove-page-rule" + assert text_of_attr(remove_button_2, "phx-value-rule-id") == r2.id + end + + test "add rule button is rendered", %{conn: conn, site: site} do + conn = get(conn, "/#{site.domain}/settings/shields/pages") + resp = html_response(conn, 200) + + assert element_exists?(resp, ~s/button#add-page-rule[x-data]/) + attr = text_of_attr(resp, ~s/button#add-page-rule/, "x-on:click") + + assert attr =~ "open-modal" + assert attr =~ "page-rule-form-modal" + end + + test "add rule button is not rendered when maximum reached", %{conn: conn, site: site} do + for i <- 1..Shields.maximum_page_rules() do + assert {:ok, _} = + Shields.add_page_rule(site, %{"page_path" => "/test/#{i}"}) + end + + conn = get(conn, "/#{site.domain}/settings/shields/pages") + resp = html_response(conn, 200) + + refute element_exists?(resp, ~s/button#add-page-rule[x-data]/) + assert resp =~ "Maximum number of pages reached" + assert resp =~ "You've reached the maximum number of pages you can block (30)" + end + end + + describe "Page Rules - LiveView" do + test "modal contains form", %{site: site, conn: conn} do + lv = get_liveview(conn, site) + html = render(lv) + + assert element_exists?( + html, + ~s/form[phx-submit="save-page-rule"] input[name="page_rule\[page_path\]"]/ + ) + + assert submit_button(html, ~s/form[phx-submit="save-page-rule"]/) + end + + test "submitting a valid Page saves it", %{conn: conn, site: site} do + lv = get_liveview(conn, site) + + lv + |> element("form") + |> render_submit(%{ + "page_rule[page_path]" => "/test/**" + }) + + html = render(lv) + + assert html =~ "/test/**" + + assert [%{page_path: "/test/**"}] = Shields.list_page_rules(site) + end + + test "submitting invalid Page renders error", %{conn: conn, site: site} do + lv = get_liveview(conn, site) + + lv + |> element("form") + |> render_submit(%{ + "page_rule[page_path]" => "WRONG" + }) + + html = render(lv) + assert html =~ "must start with /" + end + + test "clicking Remove deletes the rule", %{conn: conn, site: site} do + {:ok, _} = + Shields.add_page_rule(site, %{"page_path" => "/test/*/page"}) + + lv = get_liveview(conn, site) + + html = render(lv) + assert html =~ "/test/*/page" + + lv |> element(~s/button[phx-click="remove-page-rule"]/) |> render_click() + + html = render(lv) + refute html =~ "/test/*/page" + + assert Shields.count_page_rules(site) == 0 + end + + test "conclicting rules are annotated with a warning", %{conn: conn, site: site} do + lv = get_liveview(conn, site) + + lv + |> element("form") + |> render_submit(%{ + "page_rule[page_path]" => "/test/*" + }) + + html = render(lv) + refute html =~ "This rule might be redundant" + + lv + |> element("form") + |> render_submit(%{ + "page_rule[page_path]" => "/test/another" + }) + + html = render(lv) + + assert html =~ "/test/*" + assert html =~ "/test/another" + + assert html =~ + "This rule might be redundant because the following rules may match first:\n\n/test/*" + + broader_rule_id = + site + |> Shields.list_page_rules() + |> Enum.find(&(&1.page_path == "/test/*")) + |> Map.fetch!(:id) + + lv |> element(~s/button#remove-page-rule-#{broader_rule_id}/) |> render_click() + html = render(lv) + + assert html =~ "/test/another" + refute html =~ "/test/*" + + refute html =~ + "This rule might be redundant because the following rules may match first:\n\n/test/*" + end + + test "debug - check combo box behavior", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/blog/post1", timestamp: Date.add(Date.utc_today(), -3)), + build(:pageview, pathname: "/blog/post2", timestamp: Date.add(Date.utc_today(), -3)) + ]) + + opt1_selector = "li#dropdown-page_rule_page_path-modalseq0-option-1" + opt2_selector = "li#dropdown-page_rule_page_path-modalseq0-option-2" + + lv = get_liveview(conn, site) + + type_into_combo(lv, "page_rule_page_path-modalseq0", "blog") + html = lv |> render() + + assert text_of_element(html, opt1_selector) == "/blog/post1" + assert text_of_element(html, opt2_selector) == "/blog/post2" + + lv + |> element("form") + |> render_submit(%{ + "page_rule[page_path]" => "/blog/post2" + }) + + type_into_combo(lv, "page_rule_page_path-modalseq0", "blog") + html = lv |> render() + + assert text_of_element(html, opt1_selector) == "/blog/post1" + refute element_exists?(html, opt2_selector) + + lv + |> element("form") + |> render_submit(%{ + "page_rule[page_path]" => "/blog/post1" + }) + + type_into_combo(lv, "page_rule_page_path-modalseq0", "blog") + html = lv |> render() + + refute element_exists?(html, opt1_selector) + refute element_exists?(html, opt2_selector) + end + + defp type_into_combo(lv, id, text) do + lv + |> element("input##{id}") + |> render_change(%{ + "_target" => ["display-#{id}"], + "display-#{id}" => "#{text}" + }) + end + + defp get_liveview(conn, site) do + conn = assign(conn, :live_module, PlausibleWeb.Live.Shields) + {:ok, lv, _html} = live(conn, "/#{site.domain}/settings/shields/pages") + + lv + end + end +end diff --git a/test/plausible_web/live/site_transfer_settings_test.exs b/test/plausible_web/live/site_transfer_settings_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..36c60f63b99e914bce0ee1723f81e8b2ad3c6d69 --- /dev/null +++ b/test/plausible_web/live/site_transfer_settings_test.exs @@ -0,0 +1,540 @@ +defmodule PlausibleWeb.Live.SiteTransferSettingsTest do + use PlausibleWeb.ConnCase, async: false + use Plausible.Repo + use Bamboo.Test, shared: true + + import Phoenix.LiveViewTest + + @subject_prefix if ee?(), do: "[Plausible Analytics] ", else: "[Plausible CE] " + + setup [:create_user, :log_in, :create_site] + + describe "mount" do + test "renders the Team radio as disabled when user has no other team", %{ + conn: conn, + site: site + } do + {:ok, _lv, html} = get_liveview(conn, site) + + assert html =~ "Transfer site" + assert html =~ "Move this site to another team or Plausible account" + assert html =~ "Another Plausible account" + + assert element_exists?(html, ~s|input[name="form[destination]"][value="account"]|) + + assert element_exists?( + html, + ~s|input[name="form[destination]"][value="team"][disabled]| + ) + + assert text_of_element(html, "#site-transfer-form") =~ + "You aren't a member of any other teams" + + assert element_exists?( + html, + ~s|input[name="form[destination]"][value="my_team"][disabled]| + ) + + assert text_of_element(html, "#site-transfer-form") =~ + "The site is already in your personal sites" + end + + test "renders team destinations enabled when the user has another team", %{ + conn: conn, + user: user, + site: site + } do + _team2 = join_2nd_team(user) + + {:ok, _lv, html} = get_liveview(conn, site) + + assert element_exists?(html, ~s|input[name="form[destination]"][value="team"]|) + assert element_exists?(html, ~s|input[name="form[destination]"][value="account"]|) + + refute element_exists?( + html, + ~s|input[name="form[destination]"][value="team"][disabled]| + ) + + assert element_exists?( + html, + ~s|input[name="form[destination]"][value="my_team"][disabled]| + ) + + assert html =~ "The site will immediately move to the selected team" + refute html =~ "joe@example.com" + + refute text_of_element(html, "#site-transfer-form") =~ + "You aren't a member of any other teams" + end + + test "renders personal team destination enabled when the user transfers from another team", %{ + conn: conn, + user: user + } do + _team2 = join_2nd_team(user) + + owner = new_user() + site = new_site(owner: owner) + add_member(site.team, user: user, role: :admin) + + {:ok, _lv, html} = get_liveview(conn, site) + + assert element_exists?(html, ~s|input[name="form[destination]"][value="team"]|) + assert element_exists?(html, ~s|input[name="form[destination]"][value="account"]|) + assert element_exists?(html, ~s|input[name="form[destination]"][value="my_team"]|) + + refute element_exists?( + html, + ~s|input[name="form[destination]"][value="team"][disabled]| + ) + + refute element_exists?( + html, + ~s|input[name="form[destination]"][value="my_team"][disabled]| + ) + + assert html =~ "The site will immediately move to the selected team" + refute html =~ "example@email.com" + + refute text_of_element(html, "#site-transfer-form") =~ + "You aren't a member of any other teams" + + refute text_of_element(html, "#site-transfer-form") =~ + "The site is already in your personal sites" + end + + test "renders personal team destination disabled if user does not have one", %{ + conn: conn, + user: user, + site: site + } do + _team2 = join_2nd_team(user) + + Repo.delete!(site.team) + + owner = new_user() + site = new_site(owner: owner) + add_member(site.team, user: user, role: :admin) + + {:ok, _lv, html} = get_liveview(conn, site) + + assert element_exists?(html, ~s|input[name="form[destination]"][value="team"]|) + assert element_exists?(html, ~s|input[name="form[destination]"][value="account"]|) + assert element_exists?(html, ~s|input[name="form[destination]"][value="my_team"]|) + + refute element_exists?( + html, + ~s|input[name="form[destination]"][value="team"][disabled]| + ) + + assert element_exists?( + html, + ~s|input[name="form[destination]"][value="my_team"][disabled]| + ) + + assert text_of_element(html, "#site-transfer-form") =~ + "You don't have an active subscription" + end + + test "Team destination is preselected when available", %{ + conn: conn, + user: user, + site: site + } do + _team2 = join_2nd_team(user) + + {:ok, _lv, html} = get_liveview(conn, site) + + assert element_exists?( + html, + ~s|input[name="form[destination]"][value="team"][checked]| + ) + + refute element_exists?( + html, + ~s|input[name="form[destination]"][value="account"][checked]| + ) + + refute element_exists?( + html, + ~s|input[name="form[destination]"][value="my_team"][checked]| + ) + end + + test "Personal team destination is preselected if available and team destination is unavailable", + %{ + conn: conn, + user: user + } do + owner = new_user() + site = new_site(owner: owner) + add_member(site.team, user: user, role: :admin) + + {:ok, _lv, html} = get_liveview(conn, site) + + refute element_exists?( + html, + ~s|input[name="form[destination]"][value="team"][checked]| + ) + + refute element_exists?( + html, + ~s|input[name="form[destination]"][value="account"][checked]| + ) + + assert element_exists?( + html, + ~s|input[name="form[destination]"][value="my_team"][checked]| + ) + end + end + + describe "switching destination" do + test "changing to Account hides team picker and shows email input", %{ + conn: conn, + user: user, + site: site + } do + _team2 = join_2nd_team(user) + + {:ok, lv, _html} = get_liveview(conn, site) + + html = + lv + |> element("#site-transfer-form") + |> render_change(%{"form" => %{"destination" => "account"}}) + + assert html =~ "Email address" + assert html =~ "example@email.com" + refute html =~ "The site will immediately move to the selected team" + + assert text_of_attr(html, ~s|button[type=submit]|, "phx-disable-with") == + "Transferring..." + + assert text_of_element(html, ~s|button[type=submit]|) =~ "Send transfer request" + end + + test "changing to Team hides email and shows team picker", %{ + conn: conn, + user: user, + site: site + } do + _team2 = join_2nd_team(user) + + {:ok, lv, _html} = get_liveview(conn, site) + + lv + |> element("#site-transfer-form") + |> render_change(%{"form" => %{"destination" => "account"}}) + + html = + lv + |> element("#site-transfer-form") + |> render_change(%{"form" => %{"destination" => "team"}}) + + assert html =~ "Select a team" + refute html =~ "Email address" + assert text_of_element(html, ~s|button[type=submit]|) =~ "Move site" + end + + test "changing to My personal sites hides everything else", %{ + conn: conn, + user: user + } do + _team2 = join_2nd_team(user) + + site = new_site() + add_member(site.team, user: user, role: :admin) + + {:ok, lv, _html} = get_liveview(conn, site) + + lv + |> element("#site-transfer-form") + |> render_change(%{"form" => %{"destination" => "account"}}) + + html = + lv + |> element("#site-transfer-form") + |> render_change(%{"form" => %{"destination" => "my_team"}}) + + refute html =~ "Select a team" + refute html =~ "Email address" + + assert text_of_element(html, ~s|button[type=submit]|) =~ "Move site" + end + end + + describe "submitting (account destination)" do + test "creates a site transfer and redirects to settings/people", %{ + conn: conn, + site: site + } do + {:ok, lv, _html} = get_liveview(conn, site) + + lv + |> element("#site-transfer-form") + |> render_submit(%{ + "form" => %{"destination" => "account", "email" => "john.doe@example.com"} + }) + + assert_redirect(lv, "/#{URI.encode_www_form(site.domain)}/settings/people") + + assert_email_delivered_with( + to: [nil: "john.doe@example.com"], + subject: @subject_prefix <> "Request to transfer ownership of #{site.domain}" + ) + end + + test "renders an inline error when the user has already been invited", %{ + conn: conn, + user: user, + site: site + } do + invited = "john.doe@example.com" + + {:ok, _invitation} = + Plausible.Teams.Invitations.InviteToSite.invite(site, user, invited, :editor) + + {:ok, lv, _html} = get_liveview(conn, site) + + html = + lv + |> element("#site-transfer-form") + |> render_submit(%{ + "form" => %{"destination" => "account", "email" => invited} + }) + + assert html =~ "Invitation has already been sent" + end + + test "validates that an email is required", %{conn: conn, site: site} do + {:ok, lv, _html} = get_liveview(conn, site) + + html = + lv + |> element("#site-transfer-form") + |> render_submit(%{"form" => %{"destination" => "account", "email" => ""}}) + + assert html =~ "Please enter an email address" + end + end + + describe "submitting (team destination)" do + test "successfully changes the site's team and redirects to sites listing", %{ + conn: conn, + user: user, + site: site + } do + team2 = join_2nd_team(user, subscribe?: true) + + {:ok, lv, _html} = get_liveview(conn, site) + + lv + |> element("#site-transfer-form") + |> render_submit(%{ + "form" => %{"destination" => "team", "team_identifier" => team2.identifier} + }) + + assert_redirect(lv, "/sites?__team=#{team2.identifier}") + + assert Plausible.Repo.reload!(site).team_id == team2.id + end + + @tag :ee_only + test "renders an inline error when the destination team has no subscription", %{ + conn: conn, + user: user, + site: site + } do + team2 = join_2nd_team(user) + + {:ok, lv, _html} = get_liveview(conn, site) + + html = + lv + |> element("#site-transfer-form") + |> render_submit(%{ + "form" => %{"destination" => "team", "team_identifier" => team2.identifier} + }) + + assert text_of_element(html, "#site-transfer-form") =~ + "This team doesn't have a subscription" + end + + @tag :ee_only + test "renders an inline error when usage exceeds destination team's limits", %{ + conn: conn, + user: user, + site: site + } do + team2 = join_2nd_team(user, subscribe?: true) + + generate_usage_for(site, 11_000, NaiveDateTime.utc_now() |> NaiveDateTime.shift(day: -5)) + generate_usage_for(site, 11_000, NaiveDateTime.utc_now() |> NaiveDateTime.shift(day: -35)) + + {:ok, lv, _html} = get_liveview(conn, site) + + html = + lv + |> element("#site-transfer-form") + |> render_submit(%{ + "form" => %{"destination" => "team", "team_identifier" => team2.identifier} + }) + + assert text_of_element(html, "#site-transfer-form") =~ + "This site's usage exceeds the destination team's subscription limits" + end + + test "validates that a team must be selected", %{conn: conn, user: user, site: site} do + _team2 = join_2nd_team(user) + + {:ok, lv, _html} = get_liveview(conn, site) + + html = + lv + |> element("#site-transfer-form") + |> render_submit(%{ + "form" => %{"destination" => "team", "team_identifier" => ""} + }) + + assert html =~ "Please select a team" + end + + test "rejects an unknown team identifier", %{conn: conn, user: user, site: site} do + _team2 = join_2nd_team(user) + + {:ok, lv, _html} = get_liveview(conn, site) + + html = + lv + |> element("#site-transfer-form") + |> render_submit(%{ + "form" => %{ + "destination" => "team", + "team_identifier" => Ecto.UUID.generate() + } + }) + + assert html =~ "Please select a team" + end + + test "rejects a team identifier the user has no transfer permissions for", %{ + conn: conn, + user: user, + site: site + } do + _team2 = join_2nd_team(user) + team3 = join_2nd_team(user, role: :viewer) + + {:ok, lv, _html} = get_liveview(conn, site) + + html = + lv + |> element("#site-transfer-form") + |> render_submit(%{ + "form" => %{ + "destination" => "team", + "team_identifier" => team3.identifier + } + }) + + assert html =~ "Please select a team" + end + end + + describe "submitting (my_team destination)" do + test "successfully changes the site's team to personal team and redirects to sites listing", + %{ + conn: conn, + user: user + } do + owner = new_user() + site = new_site(owner: owner) + add_member(site.team, user: user, role: :admin) + + subscribe_to_growth_plan(user) + my_team = team_of(user) + + {:ok, lv, _html} = get_liveview(conn, site) + + lv + |> element("#site-transfer-form") + |> render_submit(%{ + "form" => %{"destination" => "my_team", "my_team_available" => "true"} + }) + + assert_redirect(lv, "/sites?__team=#{my_team.identifier}") + + assert Plausible.Repo.reload!(site).team_id == my_team.id + end + + @tag :ee_only + test "renders an inline error when personal team has no subscription", %{ + conn: conn, + user: user + } do + owner = new_user() + site = new_site(owner: owner) + add_member(site.team, user: user, role: :admin) + + {:ok, lv, _html} = get_liveview(conn, site) + + html = + lv + |> element("#site-transfer-form") + |> render_submit(%{ + "form" => %{"destination" => "my_team", "my_team_available" => "true"} + }) + + assert text_of_element(html, "#site-transfer-form") =~ "You don't have a subscription" + end + + @tag :ee_only + test "renders an inline error when usage exceeds destination personal team's limits", %{ + conn: conn, + user: user + } do + owner = new_user() + site = new_site(owner: owner) + add_member(site.team, user: user, role: :admin) + + subscribe_to_growth_plan(user) + + generate_usage_for(site, 11_000, NaiveDateTime.utc_now() |> NaiveDateTime.shift(day: -5)) + generate_usage_for(site, 11_000, NaiveDateTime.utc_now() |> NaiveDateTime.shift(day: -35)) + + {:ok, lv, _html} = get_liveview(conn, site) + + html = + lv + |> element("#site-transfer-form") + |> render_submit(%{ + "form" => %{"destination" => "my_team", "my_team_available" => "true"} + }) + + assert text_of_element(html, "#site-transfer-form") =~ + "This site's usage exceeds your subscription limits" + end + end + + defp get_liveview(conn, site) do + conn = assign(conn, :live_module, PlausibleWeb.Live.SiteTransferSettings) + live(conn, "/#{URI.encode_www_form(site.domain)}/settings/danger-zone") + end + + defp join_2nd_team(user, opts \\ []) do + role = Keyword.get(opts, :role, :admin) + another = new_user() + + if opts[:subscribe?] do + subscribe_to_growth_plan(another) + end + + new_site(owner: another) + team2 = another |> team_of() |> Plausible.Teams.complete_setup() + add_member(team2, user: user, role: role) + + team2 + end +end diff --git a/test/plausible_web/live/sites_test.exs b/test/plausible_web/live/sites_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..add5590f84431d7a837b139426c9c53940e74197 --- /dev/null +++ b/test/plausible_web/live/sites_test.exs @@ -0,0 +1,999 @@ +defmodule PlausibleWeb.Live.SitesTest do + use PlausibleWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + + alias Plausible.Repo + + setup [:create_user, :log_in] + + describe "/sites" do + test "renders empty sites page", %{conn: conn} do + {:ok, _lv, html} = live(conn, "/sites") + + text = text(html) + + assert text =~ "My personal sites" + assert text =~ "Add your first personal site" + refute text =~ "Go to team sites" + end + + test "renders team switcher link, if on personal sites with other teams available", %{ + conn: conn, + user: user + } do + team2 = new_site().team |> Plausible.Teams.complete_setup() + + add_member(team2, user: user, role: :admin) + + {:ok, _lv, html} = live(conn, "/sites") + text = text(html) + + assert text =~ "My personal sites" + refute text =~ "You don't have any sites yet" + assert text =~ "Add your first personal site" + assert text =~ "Go to team sites" + + assert element_exists?( + html, + ~s|a[href="/sites?__team=#{team2.identifier}"]| + ) + end + + test "renders settings link when current team is set", %{user: user, conn: conn} do + {:ok, _lv, html} = live(conn, "/sites") + + refute element_exists?(html, ~s|a[data-test-id="team-settings-link"]|) + + new_site(owner: user) + team = team_of(user) + team = Plausible.Teams.complete_setup(team) + + conn = set_current_team(conn, team) + + {:ok, _lv, html} = live(conn, "/sites") + + assert element_exists?(html, ~s|a[data-test-id="team-settings-link"]|) + end + + @tag :ee_only + test "renders upgrade nag when current team has a site and trial expired", %{ + conn: conn, + user: user + } do + team = new_site(owner: user).team + + team + |> Ecto.Changeset.change(trial_expiry_date: Date.add(Date.utc_today(), -1)) + |> Repo.update!() + + {:ok, _lv, html} = live(conn, "/sites") + + assert html =~ "Payment required" + end + + @tag :ee_only + test "does not render upgrade nag when there's no current team", %{conn: conn, user: user} do + team = new_site().team |> Plausible.Teams.complete_setup() + add_member(team, user: user, role: :owner) + + {:ok, _lv, html} = live(conn, "/sites") + + refute html =~ "Payment required" + end + + @tag :ee_only + test "does not render upgrade nag if current team does not have any sites yet", + %{conn: conn, user: user} do + {:ok, personal_team} = Plausible.Teams.get_or_create(user) + + personal_team + |> Ecto.Changeset.change(trial_expiry_date: Date.add(Date.utc_today(), -1)) + |> Repo.update!() + + team = new_site().team |> Plausible.Teams.complete_setup() + add_member(team, user: user, role: :owner) + + {:ok, _lv, html} = live(conn, "/sites") + + refute html =~ "Payment required" + end + + test "renders 24h visitors correctly", %{conn: conn, user: user} do + site = new_site(owner: user) + + populate_stats(site, [build(:pageview), build(:pageview), build(:pageview)]) + + {:ok, lv, _html} = live(conn, "/sites") + + html = render(lv) + site_card = text_of_element(html, "li[data-domain=\"#{site.domain}\"]") + assert site_card =~ "3 visitors in last 24h" + assert site_card =~ site.domain + end + + test "filters by domain", %{conn: conn, user: user} do + _site1 = new_site(domain: "first.example.com", owner: user) + _site2 = new_site(domain: "second.example.com", owner: user) + _site3 = new_site(domain: "first-another.example.com", owner: user) + + {:ok, lv, _html} = live(conn, "/sites") + + type_into_input(lv, "filter-text", "firs") + html = render(lv) + + assert html =~ "first.example.com" + assert html =~ "first-another.example.com" + refute html =~ "second.example.com" + end + + test "filtering plays well with pagination", %{conn: conn, user: user} do + _site1 = new_site(domain: "first.another.example.com", owner: user) + _site2 = new_site(domain: "second.example.com", owner: user) + _site3 = new_site(domain: "third.another.example.com", owner: user) + + {:ok, lv, html} = live(conn, "/sites?sort_by=alnum&sort_direction=asc&page_size=2") + + assert html =~ "first.another.example.com" + assert html =~ "second.example.com" + refute html =~ "third.another.example.com" + assert html =~ "page=2" + refute html =~ "page=1" + + type_into_input(lv, "filter-text", "anot") + html = render(lv) + + assert html =~ "first.another.example.com" + refute html =~ "second.example.com" + assert html =~ "third.another.example.com" + refute html =~ "page=1" + refute html =~ "page=2" + end + end + + on_ee do + describe "consolidated views appearance" do + test "consolidated view shows up", %{conn: conn, user: user} do + new_site(owner: user) + new_site(owner: user) + team = user |> team_of() + + conn = set_current_team(conn, team) + + {:ok, _lv, html} = live(conn, "/sites") + + refute element_exists?(html, ~s|[data-test-id="consolidated-view-card"]|) + + team = Plausible.Teams.complete_setup(team) + conn = set_current_team(conn, team) + + {:ok, _lv, html} = live(conn, "/sites") + + assert element_exists?(html, ~s|[data-test-id="consolidated-view-card"]|) + assert element_exists?(html, ~s|[data-test-id="consolidated-view-stats-loaded"]|) + assert element_exists?(html, ~s|[data-test-id="consolidated-view-chart-loaded"]|) + end + + test "consolidated view presents consolidated stats", %{conn: conn, user: user} do + site1 = new_site(owner: user) + site2 = new_site(owner: user) + + populate_stats(site1, [ + build(:pageview, user_id: 1), + build(:pageview, user_id: 1), + build(:pageview) + ]) + + populate_stats(site2, [ + build(:pageview, user_id: 3) + ]) + + team = user |> team_of() |> Plausible.Teams.complete_setup() + + conn = set_current_team(conn, team) + + {:ok, _lv, html} = live(conn, "/sites") + + stats = text_of_element(html, ~s|[data-test-id="consolidated-view-stats-loaded"]|) + assert stats =~ "Unique visitors 3" + assert stats =~ "Total visits 3" + assert stats =~ "Total pageviews 4" + assert stats =~ "Views per visit 1.33" + end + + test "consolidated view disappears when trial ends - CTA is shown instead", %{ + conn: conn, + user: user + } do + new_site(owner: user) + new_site(owner: user) + team = user |> team_of() |> Plausible.Teams.complete_setup() + + conn = set_current_team(conn, team) + + {:ok, _lv, html} = live(conn, "/sites") + + refute element_exists?(html, ~s|[data-test-id="consolidated-view-card-cta"]|) + assert element_exists?(html, ~s|[data-test-id="consolidated-view-card"]|) + + team |> Plausible.Teams.Team.end_trial() |> Plausible.Repo.update!() + + {:ok, _lv, html} = live(conn, "/sites") + + refute element_exists?(html, ~s|[data-test-id="consolidated-view-card"]|) + assert element_exists?(html, ~s|[data-test-id="consolidated-view-card-cta"]|) + + assert text_of_element(html, ~s|[data-test-id="consolidated-view-card-cta"]|) =~ + "Upgrade to the Business plan to enable consolidated view." + + assert element_exists?( + html, + ~s|[data-test-id="consolidated-view-card-cta"] a[href$="/billing/choose-plan"]| + ) + end + + test "CTA for insufficient custom plans", %{conn: conn, user: user} do + user + |> subscribe_to_enterprise_plan(features: [Plausible.Billing.Feature.Goals]) + |> team_of() + + new_site(owner: user) + new_site(owner: user) + + {:ok, _lv, html} = live(conn, "/sites") + + assert element_exists?(html, ~s|[data-test-id="consolidated-view-card-cta"]|) + + assert text_of_element(html, ~s|[data-test-id="consolidated-view-card-cta"]|) =~ + "Your plan does not include consolidated view. Contact us to discuss an upgrade." + end + + test "a team that hasn't been set up shows different CTA", %{ + conn: conn, + user: user + } do + new_site(owner: user) + new_site(owner: user) + + {:ok, _lv, html} = live(conn, "/sites") + + assert element_exists?(html, ~s|[data-test-id="consolidated-view-card-cta"]|) + + assert text_of_element(html, ~s|[data-test-id="consolidated-view-card-cta"]|) =~ + "To create a consolidated view, you'll need to set up a team." + end + + test "single site won't show neither CTA or view - team not setup", %{ + conn: conn, + user: user + } do + new_site(owner: user) + + {:ok, _lv, html} = live(conn, "/sites") + + refute element_exists?(html, ~s|[data-test-id="consolidated-view-card-cta"]|) + refute element_exists?(html, ~s|[data-test-id="consolidated-view-card"]|) + end + + test "single site won't show neither CTA or view - team setup", %{ + conn: conn, + user: user + } do + new_site(owner: user) + + user |> team_of() |> Plausible.Teams.complete_setup() + + {:ok, _lv, html} = live(conn, "/sites") + + refute element_exists?(html, ~s|[data-test-id="consolidated-view-card-cta"]|) + refute element_exists?(html, ~s|[data-test-id="consolidated-view-card"]|) + end + + test "CTA advertises contacting team owner to viewers", %{ + conn: conn, + user: user + } do + new_site(owner: user) + new_site(owner: user) + + subscribe_to_growth_plan(user) + + team = user |> team_of() |> Plausible.Teams.complete_setup() + + viewer = add_member(team, role: :viewer) + + {:ok, conn: conn} = log_in(%{user: viewer, conn: conn}) + + {:ok, _lv, html} = live(conn, "/sites?__team=#{team.identifier}") + + assert element_exists?(html, ~s|[data-test-id="consolidated-view-card-cta"]|) + + assert text_of_element(html, ~s|[data-test-id="consolidated-view-card-cta"]|) =~ + "Available on Business plans. Contact your team owner to create it." + + refute element_exists?( + html, + ~s|[data-test-id="consolidated-view-card-cta"] a[href="/billing/choose-plan"]| + ) + end + + test "CTA can be permanently dismissed, in which case dropdown option to restore it shows up", + %{conn: conn, user: user} do + new_site(owner: user) + new_site(owner: user) + + dismiss_selector = ~s|[phx-click="consolidated-view-cta-dismiss"]| + cta_selector = ~s|[data-test-id="consolidated-view-card-cta"]| + restore_selector = ~s|[phx-click="consolidated-view-cta-restore"]| + + subscribe_to_growth_plan(user) + + {:ok, lv, html} = live(conn, "/sites") + + assert element_exists?(html, cta_selector) + refute element_exists?(html, restore_selector) + + lv + |> element(dismiss_selector) + |> render_click() + + html = render(lv) + + refute element_exists?(html, cta_selector) + assert element_exists?(html, restore_selector) + + {:ok, _lv, html} = live(conn, "/sites") + refute element_exists?(html, cta_selector) + + lv + |> element(restore_selector) + |> render_click() + + html = render(lv) + assert element_exists?(html, cta_selector) + end + + test "consolidated view card disappears when searching", %{conn: conn, user: user} do + new_site(owner: user) + new_site(owner: user) + + team = user |> team_of() |> Plausible.Teams.complete_setup() + conn = set_current_team(conn, team) + + {:ok, lv, html} = live(conn, "/sites") + + assert element_exists?(html, ~s|[data-test-id="consolidated-view-card"]|) + + type_into_input(lv, "filter-text", "a") + + html = render(lv) + + refute element_exists?(html, ~s|[data-test-id="consolidated-view-card"]|) + end + + test "CTA card disappears when searching", %{conn: conn, user: user} do + new_site(owner: user) + new_site(owner: user) + + {:ok, lv, html} = live(conn, "/sites") + + assert element_exists?(html, ~s|[data-test-id="consolidated-view-card-cta"]|) + + type_into_input(lv, "filter-text", "a") + + html = render(lv) + + refute element_exists?(html, ~s|[data-test-id="consolidated-view-card-cta"]|) + end + end + end + + describe "invitation notices" do + test "renders team invitation notice", %{conn: conn, user: user} do + inviter = new_user(name: "Jane Doe") + _site = new_site(owner: inviter) + team = team_of(inviter) + + inv = invite_member(team, user, inviter: inviter, role: :admin) + + {:ok, _lv, html} = live(conn, "/sites") + + notice = text_of_element(html, "#invitation-#{inv.invitation_id}") + assert notice =~ "Jane Doe" + assert notice =~ team.name + assert notice =~ "admin" + + assert element_exists?( + html, + ~s|#invitation-#{inv.invitation_id} a[href$="#{inv.invitation_id}/accept"]| + ) + + assert element_exists?( + html, + ~s|#invitation-#{inv.invitation_id} a[href$="#{inv.invitation_id}/reject"]| + ) + end + + test "renders multiple team invitation notices", %{conn: conn, user: user} do + inviter1 = new_user(name: "Alice") + inviter2 = new_user(name: "Bob") + _site1 = new_site(owner: inviter1) + _site2 = new_site(owner: inviter2) + team1 = team_of(inviter1) + team2 = team_of(inviter2) + + inv1 = invite_member(team1, user, inviter: inviter1, role: :viewer) + inv2 = invite_member(team2, user, inviter: inviter2, role: :editor) + + {:ok, _lv, html} = live(conn, "/sites") + + assert element_exists?(html, "#invitation-#{inv1.invitation_id}") + assert element_exists?(html, "#invitation-#{inv2.invitation_id}") + end + + test "renders guest invitation notice", %{conn: conn, user: user} do + inviter = new_user(name: "John Smith") + site = new_site(owner: inviter) + + inv = invite_guest(site, user, inviter: inviter, role: :viewer) + + {:ok, _lv, html} = live(conn, "/sites") + + notice = text_of_element(html, "#site-invitation-#{inv.invitation_id}") + assert notice =~ "John Smith" + assert notice =~ site.domain + assert notice =~ "viewer" + + assert element_exists?( + html, + ~s|#site-invitation-#{inv.invitation_id} a[href$="#{inv.invitation_id}/accept"]| + ) + + assert element_exists?( + html, + ~s|#site-invitation-#{inv.invitation_id} a[href$="#{inv.invitation_id}/reject"]| + ) + end + + @tag :ee_only + test "renders site transfer invitation notice", %{conn: conn, user: user} do + inviter = new_user(name: "Carol White") + site = new_site(owner: inviter) + + subscribe_to_growth_plan(user) + transfer = invite_transfer(site, user, inviter: inviter) + + {:ok, _lv, html} = live(conn, "/sites") + + notice = text_of_element(html, "#site-ownership-invitation-#{transfer.transfer_id}") + assert notice =~ "Carol White" + assert notice =~ site.domain + + assert element_exists?( + html, + ~s|#site-ownership-invitation-#{transfer.transfer_id} a[href$="#{transfer.transfer_id}/accept"]| + ) + + assert element_exists?( + html, + ~s|#site-ownership-invitation-#{transfer.transfer_id} a[href$="#{transfer.transfer_id}/reject"]| + ) + end + + @tag :ee_only + test "renders upgrade CTA instead of accept for site transfer when no plan", %{ + conn: conn, + user: user + } do + inviter = new_user() + site = new_site(owner: inviter) + + transfer = invite_transfer(site, user, inviter: inviter) + + {:ok, _lv, html} = live(conn, "/sites") + + notice = text_of_element(html, "#site-ownership-invitation-#{transfer.transfer_id}") + assert notice =~ "You don't have an active subscription" + + assert element_exists?( + html, + ~s|#site-ownership-invitation-#{transfer.transfer_id} a[href$="/billing/choose-plan"]| + ) + + refute element_exists?( + html, + ~s|#site-ownership-invitation-#{transfer.transfer_id} a[href$="#{transfer.transfer_id}/accept"]| + ) + end + + @tag :ee_only + test "renders upgrade CTA instead of accept for site transfer when plan limits exceeded", %{ + conn: conn, + user: user + } do + inviter = new_user() + site = new_site(owner: inviter) + + transfer = invite_transfer(site, user, inviter: inviter) + + subscribe_to_growth_plan(user) + for _ <- 1..10, do: new_site(owner: user) + + {:ok, _lv, html} = live(conn, "/sites") + + notice = text_of_element(html, "#site-ownership-invitation-#{transfer.transfer_id}") + assert notice =~ "site limit" + + assert element_exists?( + html, + ~s|#site-ownership-invitation-#{transfer.transfer_id} a[href$="/billing/choose-plan"]| + ) + + refute element_exists?( + html, + ~s|#site-ownership-invitation-#{transfer.transfer_id} a[href$="#{transfer.transfer_id}/accept"]| + ) + end + + test "shows invitations on page 1 (explicit) and with no page parameter, hides on page 2+", + %{conn: conn, user: user} do + for _ <- 1..3, do: new_site(owner: user) + + inviter = new_user() + _site = new_site(owner: inviter) + inv = invite_member(team_of(inviter), user, inviter: inviter, role: :admin) + + {:ok, _lv, html} = live(conn, "/sites?page_size=2") + assert element_exists?(html, "#invitation-#{inv.invitation_id}") + + {:ok, _lv, html} = live(conn, "/sites?page_size=2&page=1") + assert element_exists?(html, "#invitation-#{inv.invitation_id}") + + {:ok, _lv, html} = live(conn, "/sites?page_size=2&page=2") + refute element_exists?(html, "#invitation-#{inv.invitation_id}") + end + + test "does not render notices for other users' invitations", %{conn: conn} do + other_user = new_user() + + team_inviter = new_user() + _site = new_site(owner: team_inviter) + inv = invite_member(team_of(team_inviter), other_user, inviter: team_inviter, role: :admin) + + guest_inviter = new_user() + guest_site = new_site(owner: guest_inviter) + gi = invite_guest(guest_site, other_user, inviter: guest_inviter, role: :viewer) + + transfer_inviter = new_user() + transfer_site = new_site(owner: transfer_inviter) + transfer = invite_transfer(transfer_site, other_user, inviter: transfer_inviter) + + {:ok, _lv, html} = live(conn, "/sites") + + refute element_exists?(html, "#invitation-#{inv.invitation_id}") + refute element_exists?(html, "#site-invitation-#{gi.invitation_id}") + refute element_exists?(html, "#site-ownership-invitation-#{transfer.transfer_id}") + end + end + + describe "pinning" do + test "renders pin site option when site not pinned", %{conn: conn, user: user} do + site = new_site(owner: user) + + {:ok, _lv, html} = live(conn, "/sites") + + assert text_of_element( + html, + ~s/li[data-domain="#{site.domain}"] button[phx-value-domain]/ + ) == "Pin site" + end + + test "site state changes when pin toggled", %{conn: conn, user: user} do + site = new_site(owner: user) + + {:ok, lv, _html} = live(conn, "/sites") + + button_selector = + ~s/button[phx-value-domain="#{site.domain}"][data-test-id="ellipsis-menu-pin-item"]/ + + html = + lv + |> element(button_selector) + |> render_click() + + assert html =~ "Site pinned" + + assert text_of_element(html, button_selector) == "Unpin site" + + html = + lv + |> element(button_selector) + |> render_click() + + assert html =~ "Site unpinned" + + assert text_of_element(html, button_selector) == "Pin site" + end + + test "shows error when pins limit hit", %{conn: conn, user: user} do + for _ <- 1..9 do + site = new_site(owner: user) + assert {:ok, _} = Plausible.Sites.toggle_pin(user, site) + end + + site = new_site(owner: user) + + {:ok, lv, _html} = live(conn, "/sites") + + button_selector = ~s/li[data-domain="#{site.domain}"] button[phx-value-domain]/ + + html = + lv + |> element(button_selector) + |> render_click() + + assert html =~ + LazyHTML.html_escape("Looks like you've hit the pinned sites limit!") + end + + test "does not allow pinning site user doesn't have access to", %{conn: conn, user: user} do + site = new_site() + + {:ok, lv, _html} = live(conn, "/sites") + + render_click(lv, "pin-toggle", %{"domain" => site.domain}) + + refute Repo.get_by(Plausible.Site.UserPreference, user_id: user.id, site_id: site.id) + end + + test "pin icon is shown on site card when site is pinned", %{conn: conn, user: user} do + site = new_site(owner: user) + assert {:ok, _} = Plausible.Sites.toggle_pin(user, site) + + {:ok, _lv, html} = live(conn, "/sites") + + assert element_exists?( + html, + ~s/li[data-domain="#{site.domain}"] [data-test-id="site-card-pin-icon"]/ + ) + end + + test "pin icon is not shown on site card when site is not pinned", %{conn: conn, user: user} do + site = new_site(owner: user) + + {:ok, _lv, html} = live(conn, "/sites") + + refute element_exists?( + html, + ~s/li[data-domain="#{site.domain}"] [data-test-id="site-card-pin-icon"]/ + ) + end + + test "pin icon appears on site card after pinning", %{conn: conn, user: user} do + site = new_site(owner: user) + + {:ok, lv, _html} = live(conn, "/sites") + + button_selector = + ~s/button[phx-value-domain="#{site.domain}"][data-test-id="ellipsis-menu-pin-item"]/ + + lv |> element(button_selector) |> render_click() + + html = render(lv) + + assert element_exists?( + html, + ~s/li[data-domain="#{site.domain}"] [data-test-id="site-card-pin-icon"]/ + ) + end + + test "pin icon disappears from site card after unpinning", %{conn: conn, user: user} do + site = new_site(owner: user) + assert {:ok, _} = Plausible.Sites.toggle_pin(user, site) + + {:ok, lv, _html} = live(conn, "/sites") + + button_selector = + ~s/[phx-value-domain="#{site.domain}"][data-test-id="site-card-pin-icon"]/ + + lv |> element(button_selector) |> render_click() + + html = render(lv) + + refute element_exists?( + html, + button_selector + ) + end + + test "billing role member can pin a site via the ellipsis menu", %{conn: conn, user: user} do + site = new_site(owner: user) + team = site.team |> Plausible.Teams.complete_setup() + billing_user = add_member(team, role: :billing) + + {:ok, conn: conn} = log_in(%{user: billing_user, conn: conn}) + + {:ok, lv, _html} = live(conn, "/sites?__team=#{team.identifier}") + + button_selector = + ~s/button[phx-value-domain="#{site.domain}"][data-test-id="ellipsis-menu-pin-item"]/ + + html = lv |> element(button_selector) |> render_click() + + assert html =~ "Site pinned" + end + + test "billing role member does not see Settings in the ellipsis menu", %{ + conn: conn, + user: user + } do + site = new_site(owner: user) + team = site.team |> Plausible.Teams.complete_setup() + billing_user = add_member(team, role: :billing) + + {:ok, conn: conn} = log_in(%{user: billing_user, conn: conn}) + + {:ok, _lv, html} = live(conn, "/sites?__team=#{team.identifier}") + + settings_selector = + ~s|li[data-domain="#{site.domain}"] a[href$="/settings/general"]| + + refute element_exists?(html, settings_selector) + end + end + + describe "ellipsis menu settings visibility" do + @allowed_to_see_settings [:owner, :editor, :admin] + for role <- @allowed_to_see_settings do + test "#{role} can see settings", %{conn: conn, user: user} do + site = new_site(owner: user) + team = site.team |> Plausible.Teams.complete_setup() + member = add_member(team, role: unquote(role)) + + {:ok, conn: conn} = log_in(%{user: member, conn: conn}) + + {:ok, _lv, html} = live(conn, "/sites?__team=#{team.identifier}") + + assert element_exists?( + html, + ~s|li[data-domain="#{site.domain}"] a[href$="/settings/general"]| + ) + end + end + + for role <- Plausible.Teams.Membership.roles() -- @allowed_to_see_settings do + test "#{role} can't see settings", %{conn: conn, user: user} do + site = new_site(owner: user) + team = site.team |> Plausible.Teams.complete_setup() + member = add_member(team, role: :viewer) + + {:ok, conn: conn} = log_in(%{user: member, conn: conn}) + + {:ok, _lv, html} = live(conn, "/sites?__team=#{team.identifier}") + + refute element_exists?( + html, + ~s|li[data-domain="#{site.domain}"] a[href$="/settings/general"]| + ) + end + end + end + + describe "sort widget" do + test "renders sort widget with default 'Most visitors' label", %{conn: conn, user: user} do + new_site(owner: user) + + {:ok, _lv, html} = live(conn, "/sites") + + assert text(find(html, "#sort-dropdown-trigger")) =~ "Most visitors" + end + + test "sort widget shows all four options", %{conn: conn, user: user} do + new_site(owner: user) + + {:ok, _lv, html} = live(conn, "/sites") + + assert html =~ "Most visitors" + assert html =~ "Fewest visitors" + assert html =~ "Name A-Z" + assert html =~ "Name Z-A" + + assert find(html, "#sort-dropdown-item-traffic-desc") + assert find(html, "#sort-dropdown-item-traffic-asc") + assert find(html, "#sort-dropdown-item-alnum-desc") + assert find(html, "#sort-dropdown-item-alnum-asc") + end + + test "clicking a sort option updates the active label", %{conn: conn, user: user} do + new_site(owner: user) + + {:ok, lv, _html} = live(conn, "/sites") + + lv + |> element(~s|[id="sort-dropdown-item-alnum-asc"]|) + |> render_click() + + html = render(lv) + + assert text(find(html, "#sort-dropdown-trigger")) =~ "Name A-Z" + end + + test "invalid sort_by param is sanitized to traffic", %{conn: conn, user: user} do + new_site(owner: user) + + {:ok, _lv, html} = live(conn, "/sites?sort_by=invalid&sort_direction=desc") + + assert text(find(html, "#sort-dropdown-trigger")) =~ "Most visitors" + end + + test "invalid sort_direction param is sanitized to desc", %{conn: conn, user: user} do + new_site(owner: user) + + {:ok, _lv, html} = live(conn, "/sites?sort_by=alnum&sort_direction=invalid") + + assert text(find(html, "#sort-dropdown-trigger")) =~ "Name Z-A" + end + + test "Name A-Z sort orders sites alphabetically ascending", %{conn: conn, user: user} do + new_site(domain: "zebra.example.com", owner: user) + new_site(domain: "apple.example.com", owner: user) + new_site(domain: "mango.example.com", owner: user) + + {:ok, _lv, html} = live(conn, "/sites?sort_by=alnum&sort_direction=asc") + + domains = find(html, "li[data-domain] h3") |> text() + + assert domains == "apple.example.com mango.example.com zebra.example.com" + end + + test "Name Z-A sort orders sites alphabetically descending", %{conn: conn, user: user} do + new_site(domain: "zebra.example.com", owner: user) + new_site(domain: "apple.example.com", owner: user) + new_site(domain: "mango.example.com", owner: user) + + {:ok, _lv, html} = live(conn, "/sites?sort_by=alnum&sort_direction=desc") + + domains = find(html, "li[data-domain] h3") |> text() + + assert domains == "zebra.example.com mango.example.com apple.example.com" + end + + test "Most visitors sort orders sites by traffic", %{ + conn: conn, + user: user + } do + high = new_site(domain: "high.example.com", owner: user) + mid = new_site(domain: "mid.example.com", owner: user) + low = new_site(domain: "low.example.com", owner: user) + + populate_stats(high, [build(:pageview), build(:pageview), build(:pageview)]) + populate_stats(mid, [build(:pageview)]) + + {:ok, _lv, html} = live(conn, "/sites?sort_by=traffic&sort_direction=desc") + + domains = find(html, "li[data-domain] h3") |> text() + + assert domains == "#{high.domain} #{mid.domain} #{low.domain}" + end + + test "Fewest visitors sort orders by traffic", %{ + conn: conn, + user: user + } do + low = new_site(domain: "low.example.com", owner: user) + mid = new_site(domain: "mid.example.com", owner: user) + high = new_site(domain: "high.example.com", owner: user) + + populate_stats(high, [build(:pageview), build(:pageview), build(:pageview)]) + populate_stats(mid, [build(:pageview)]) + + {:ok, _lv, html} = live(conn, "/sites?sort_by=traffic&sort_direction=asc") + + domains = find(html, "li[data-domain] h3") |> text() + + assert domains == "#{low.domain} #{mid.domain} #{high.domain}" + end + + test "switching sort resets to page 1", %{conn: conn, user: user} do + for _ <- 1..3, do: new_site(owner: user) + + {:ok, lv, html} = live(conn, "/sites?page_size=2&page=2&sort_by=alnum&sort_direction=asc") + + assert html =~ "page=1" + + lv + |> element(~s|[id="sort-dropdown-item-alnum-desc"]|) + |> render_click() + + html = render(lv) + + refute html =~ "page=1" + end + + test "selecting a sort option persists the preference to the database", %{ + conn: conn, + user: user + } do + new_site(owner: user) + + {:ok, lv, _html} = live(conn, "/sites") + + lv + |> element(~s|[id="sort-dropdown-item-alnum-asc"]|) + |> render_click() + + {:ok, team} = Plausible.Teams.get_by_owner(user) + {:ok, membership} = Plausible.Teams.Memberships.get_team_membership(team, user) + + assert Plausible.Teams.Memberships.get_preference(membership, :sort_index_options) == + %Plausible.Sites.Index.UserPreference{ + sort_by: :alnum, + sort_direction: :asc + } + end + + test "selecting a sort option for a guest won't persist the preference", %{ + conn: conn, + user: user + } do + site = new_site(owner: user) + viewer_guest = add_guest(site, role: :viewer, user: new_user()) + + {:ok, conn: conn} = log_in(%{user: viewer_guest, conn: conn}) + + {:ok, lv, html} = live(conn, "/sites") + + assert html =~ site.domain + + lv + |> element(~s|[id="sort-dropdown-item-alnum-asc"]|) + |> render_click() + + {:ok, membership} = + Plausible.Teams.Memberships.get_team_membership(team_of(user), viewer_guest) + + assert Plausible.Teams.Memberships.get_preference(membership, :sort_index_options) == nil + end + + test "saved sort preference is applied on next visit when no URL params", %{ + conn: conn, + user: user + } do + new_site(owner: user) + + {:ok, team} = Plausible.Teams.get_by_owner(user) + {:ok, membership} = Plausible.Teams.Memberships.get_team_membership(team, user) + + Plausible.Teams.Memberships.set_preference(membership, :sort_index_options, %{ + sort_by: :alnum, + sort_direction: :desc + }) + + {:ok, _lv, html} = live(conn, "/sites") + + assert text(find(html, "#sort-dropdown-trigger")) =~ "Name Z-A" + end + + test "URL sort params take precedence over saved preference", %{conn: conn, user: user} do + new_site(owner: user) + + {:ok, team} = Plausible.Teams.get_by_owner(user) + {:ok, membership} = Plausible.Teams.Memberships.get_team_membership(team, user) + + Plausible.Teams.Memberships.set_preference(membership, :sort_index_options, %{ + "sort_by" => "alnum", + "sort_direction" => "asc" + }) + + {:ok, _lv, html} = live(conn, "/sites?sort_by=traffic&sort_direction=desc") + + assert text(find(html, "#sort-dropdown-trigger")) =~ "Most visitors" + end + end + + defp type_into_input(lv, id, text) do + lv + |> element("form") + |> render_change(%{id => text}) + end +end diff --git a/test/plausible_web/live/sso_management_test.exs b/test/plausible_web/live/sso_management_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..f803984dbee29ca9450034e8371275ac8d006a4b --- /dev/null +++ b/test/plausible_web/live/sso_management_test.exs @@ -0,0 +1,317 @@ +defmodule PlausibleWeb.Live.SSOMangementTest do + use PlausibleWeb.ConnCase, async: false + use Oban.Testing, repo: Plausible.Repo + + @moduletag :ee_only + + on_ee do + use Bamboo.Test, shared: true + + import Phoenix.LiveViewTest + + alias Plausible.Auth + alias Plausible.Auth.SSO + + @cert_pem """ + -----BEGIN CERTIFICATE----- + MIIFdTCCA12gAwIBAgIUNcATm3CidmlEMMsZa9KBZpWYCVcwDQYJKoZIhvcNAQEL + BQAwYzELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM + GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDEcMBoGA1UEAwwTc29tZWlkcC5leGFt + cGxlLmNvbTAeFw0yNTA1MjExMjI5MzVaFw0yNjA1MjExMjI5MzVaMGMxCzAJBgNV + BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX + aWRnaXRzIFB0eSBMdGQxHDAaBgNVBAMME3NvbWVpZHAuZXhhbXBsZS5jb20wggIi + MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC1N6Drbjed+lFXpOYvE6Efgndy + W7kYiO8LqQTr4UwVrp9ArxgYuK4TrcNRh2rhS08xAzNTo+NqnJOm95baG97ADYk1 + TqVIKxzaFurv+L/Na0wVXyeNUtxIVKF59uElsg2YLm5eQhL9fmN8jVINCvwDPzxc + Ihm6mQOaL/i/0DGINOqwHG9MGMZ11AeOM0wKMuXJ2+aKjHOCedhMYVuOaHZgLkcX + Zzgiv7itm3+JpCjL474MMfibiqKHR0e3QRNcsEC13f/LD8BAGOwsKLznFC8Uctms + 48EDNbxxLG01jVbnJSxRrcDN3RUDjtCdHyaTCCFJAgmldHKKua3VQEynOwJIkFMC + fpL1LpLvATzIt0cT1ESb1RHIlgacmESVn/TW2QjO5tp4FAu7GJK+5xY7jPvI6saG + oUHsk0zo9obLK8WYneF19ln+Ea5ZCl9PcTi559AKGpYzpL/9uxoPT1zxxTn6c2lt + 4xkxkuHtYqi/ENHGdo4CLBL93GDZEilSVmZjD/9N9990yWbPXXQ0eNoFckYSZuls + HaWz8W5c046/ob8mASI6wzAUCkO9Zz4WbIj9A+mNZB32hMZbMA02gU//ffvNkFjL + DGlNbROCg2DX64rvGs/RuqhuDVCnVfid9B36Cgs76GWI8dCInEfyZMtiqUb7E8Oe + BPVwtTscz1StlF/0cQIDAQABoyEwHzAdBgNVHQ4EFgQU9lvXH4X04v99rrwKNzsw + pNQP/dUwDQYJKoZIhvcNAQELBQADggIBAJD0MD+OK58vlP2HEoKLQYAKYM/4NsBz + vSK1PtZsEj0fqiuu66ceH0wlKlGquRad4Z+LXMptu1DzMNmJsf0zSQfleGFks3xI + 86hgkQ7f0qjs+YJzjJUxF9H8zX4jJk5poOqOJwStHBCDLsUmIxnT7/il3jlT0Nj4 + cVs4946pCg7rP1kR9jojFD5yvzKoRBJG3/qvFnzAi8cDv9CRjSgoDTZyzZmwdCgu + NioW7YeFCtvYxvY7HDXinwq/w8Gn3n8zdISoAqSpYrt5Y5ygJGiEYVDWdA50a6PC + gq5xt8RCizz1L7a5BUJFMCQ0pyAUuODTndPUGLT8i7jFgzhamFPD72zFMk2+IabE + Dutyt2GFeTQ75wL8QvfsKm29Vd5EjAsdfmup3hCpLGqF3g8Sh0aXDrj8KPqIecuS + gkL69M9iXfnwZhTo23zUuFjBNoAIPXkNKXiJS7p9IEpYRVnlPYLToSEnnzptoPPQ + zMBb8x/UMMtNYkyehSLhuIPrRLvv3eth7Hq3hA7tOCRyyf78tReVm+VoRx6AK68v + 5ufxMKBFRTNoLIN3sD+DmSUNY+CaHxRMDhSESy0Ac/95J2yKi+Y1Kml2GV53pSlT + 6FPm8B0R9YXM7lHhTLyL7DYqnvklkLh2bUqCLyBowynPyGqdYV4DbFSiST14fGXR + mNEYF78kg0IA + -----END CERTIFICATE----- + """ + + describe "/settings/sso/general" do + setup [:create_user, :log_in, :create_team, :add_plan, :setup_team] + + test "renders", %{conn: conn} do + resp = + conn + |> get(Routes.sso_path(conn, :sso_settings)) + |> html_response(200) + |> text() + + assert resp =~ "Start Configuring SSO" + end + end + + describe "live" do + setup [:create_user, :log_in, :create_team, :add_plan, :setup_team] + + test "init setup - basic walk through", %{conn: conn} do + {lv, _html} = get_lv(conn) + lv |> element("form#sso-init") |> render_submit() + html = render(lv) + + assert element_exists?(html, "form#sso-sp-config") + + lv |> element("form#sso-idp-form") |> render_submit() + lv |> element("form#sso-idp-config") |> render_submit() + + text = text(render(lv)) + + assert text =~ "SSO URL / Sign-on URL / Login URL can't be blank" + assert text =~ "Entity ID / Issuer / Identifier can't be blank" + assert text =~ "Certificate in PEM format can't be blank" + + lv + |> element("form#sso-idp-config") + |> render_submit(%{ + saml_config: %{ + idp_signin_url: "http://signin.example.com", + idp_entity_id: "abc123", + idp_cert_pem: @cert_pem + } + }) + + lv + |> element("form#sso-add-domain") + |> render_submit() + + text = text(render(lv)) + assert text =~ "Domain can't be blank" + + lv + |> element("form#sso-add-domain") + |> render_submit(%{ + domain: %{ + domain: "example.com" + } + }) + + text = render(lv) |> text() + assert text =~ "Verifying domain" + + lv |> element("form#verify-domain-submit") |> render_submit() + + assert_enqueued( + worker: SSO.Domain.Verification.Worker, + args: %{domain: "example.com"} + ) + + html = render(lv) + text = text(html) + + assert text =~ "example.com" + assert text =~ "-BEGIN CERTIFICATE-" + + sp_entity_id = text_of_attr(html, "#sp-entity-id", "value") + integration_identifier = sp_entity_id |> Path.split() |> List.last() + {:ok, integration} = SSO.get_integration(integration_identifier) + + assert integration.config.idp_signin_url == "http://signin.example.com" + assert integration.config.idp_entity_id == "abc123" + assert [%{domain: "example.com"}] = integration.sso_domains + end + + test "renders integration summary", %{conn: conn, team: team} do + setup_integration(team, "example.com") + + {_lv, html} = get_lv(conn) + + assert element_exists?(html, "h2 a#sso-manage-config") + assert element_exists?(html, "h2 a#sso-domains-config") + assert element_exists?(html, "h2 a#sso-policy-config") + end + + test "edit idp config", %{conn: conn, team: team} do + setup_integration(team, "example.com") + + {lv, _html} = get_lv(conn) + + lv |> element("form#show-idp-form") |> render_submit() + + html = render(lv) + + assert element_exists?(html, "input#sso-idp-config_idp_signin_url") + assert element_exists?(html, "input#sso-idp-config_idp_entity_id") + assert element_exists?(html, "textarea#sso-idp-config_idp_cert_pem") + + lv + |> element("form#sso-idp-config") + |> render_submit(%{ + saml_config: %{ + idp_signin_url: "http://updated.example.com", + idp_entity_id: "zxc345", + idp_cert_pem: @cert_pem + } + }) + + html = render(lv) + assert element_exists?(html, "h2 a#sso-manage-config") + + assert text_of_attr(html, "input#sso-idp-config_idp_signin_url", "value") == + "http://updated.example.com" + + assert text_of_attr(html, "input#sso-idp-config_idp_entity_id", "value") == "zxc345" + end + + test "add another domain", %{conn: conn, team: team} do + setup_integration(team, "org.example.com") + + {lv, _html} = get_lv(conn) + + lv |> element("form#show-domain-setup") |> render_submit() + + lv + |> element("form#sso-add-domain") + |> render_submit(%{ + domain: %{ + domain: "new.example.com" + } + }) + + html = render(lv) + assert text(html) =~ "Verifying domain new.example.com" + + lv |> element("form#verify-domain-submit") |> render_submit() + + assert_enqueued( + worker: SSO.Domain.Verification.Worker, + args: %{domain: "new.example.com"} + ) + + text = text(render(lv)) + assert text =~ "org.example.com" + assert text =~ "new.example.com" + end + + test "policy update", %{conn: conn, team: team} do + setup_integration(team, "org.example.com") + {lv, html} = get_lv(conn) + + assert text_of_attr( + html, + ~s|#sso-policy_sso_default_role option[selected]|, + "value" + ) == "viewer" + + assert text_of_attr(html, ~s|#sso-policy_sso_session_timeout_minutes|, "value") == "360" + + lv + |> element("form#sso-policy") + |> render_submit(%{ + policy: %{ + sso_default_role: "admin", + sso_session_timeout_minutes: "710" + } + }) + + html = render(lv) + + assert text_of_attr( + html, + ~s|#sso-policy_sso_default_role option[selected]|, + "value" + ) == "admin" + + assert text_of_attr(html, ~s|#sso-policy_sso_session_timeout_minutes|, "value") == "710" + end + + test "force SSO toggle disabled when operator is standard user", %{ + conn: conn, + team: team, + user: user + } do + integration = setup_integration(team, "org.example.com") + {_lv, html} = get_lv(conn) + + assert element_exists?(html, "button#enable-force-sso-toggle[disabled]") + + {:ok, user, _} = Auth.TOTP.initiate(user) + {:ok, _user, _} = Auth.TOTP.enable(user, :skip_verify) + + identity = new_identity("Lance Wurst", "lance@org.example.com", integration) + {:ok, _, _, _sso_user} = SSO.provision_user(identity) + + {_lv, html} = get_lv(conn) + + assert element_exists?(html, "button#enable-force-sso-toggle[disabled]") + assert html =~ "you must be logged in via SSO" + end + + test "force SSO toggle works when operator is SSO user", %{ + conn: conn, + team: team, + user: user + } do + integration = setup_integration(team, "example.com") + {_lv, html} = get_lv(conn) + + assert element_exists?(html, "button#enable-force-sso-toggle[disabled]") + + {:ok, user, _} = Auth.TOTP.initiate(user) + {:ok, _user, _} = Auth.TOTP.enable(user, :skip_verify) + + identity = new_identity(user.name, user.email, integration) + {:ok, _, _, user} = SSO.provision_user(identity) + + {:ok, conn: conn} = log_in(%{conn: conn, user: user}) + conn = set_current_team(conn, team) + + {lv, html} = get_lv(conn) + + refute element_exists?(html, "button#enable-force-sso-toggle[disabled]") + + lv + |> element("#enable-force-sso-toggle") + |> render_click() + + assert Plausible.Repo.reload(team).policy.force_sso == :all_but_owners + end + + defp setup_integration(team, domain) do + integration = SSO.initiate_saml_integration(team) + {:ok, sso_domain} = SSO.Domains.add(integration, domain) + SSO.Domains.verify(sso_domain, skip_checks?: true) + + {:ok, integration} = + SSO.update_integration(integration, %{ + idp_signin_url: "https://#{domain}", + idp_entity_id: "some-entity", + idp_cert_pem: @cert_pem + }) + + integration + end + + defp get_lv(conn) do + conn = assign(conn, :live_module, PlausibleWeb.Live.SSOManagement) + {:ok, lv, html} = live(conn, Routes.sso_path(conn, :sso_settings)) + {lv, html} + end + + defp add_plan(%{user: user}) do + subscribe_to_enterprise_plan(user, features: [Plausible.Billing.Feature.SSO]) + + :ok + end + end + end +end diff --git a/test/plausible_web/live/team_management_test.exs b/test/plausible_web/live/team_management_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..5b220d285769365885e9af3bb40ebe15d628dcd1 --- /dev/null +++ b/test/plausible_web/live/team_management_test.exs @@ -0,0 +1,537 @@ +defmodule PlausibleWeb.Live.TeamMangementTest do + use PlausibleWeb.ConnCase, async: false + use Bamboo.Test, shared: true + + import Phoenix.LiveViewTest + + def team_general_path(), do: Routes.settings_path(PlausibleWeb.Endpoint, :team_general) + @subject_prefix if ee?(), do: "[Plausible Analytics] ", else: "[Plausible CE] " + + describe "/settings/team/general" do + setup [:create_user, :log_in, :create_team, :setup_team] + + test "renders team management section", %{conn: conn} do + resp = + conn + |> get(team_general_path()) + |> html_response(200) + |> text() + + assert resp =~ "Add or remove team members and adjust their roles" + + refute element_exists?(resp, ~s|button[phx-click="save-team-layout"]|) + end + + test "renders existing guests under Guest divider", %{conn: conn, team: team, user: user} do + site = new_site(team: team) + add_guest(site, role: :viewer, user: new_user(name: "Mr Guest", email: "guest@example.com")) + + resp = + conn + |> get(team_general_path()) + |> html_response(200) + + assert element_exists?(resp, "#guests-hr") + + assert find(resp, "#{member_el()}:first-of-type") |> text() =~ "#{user.email}" + assert find(resp, "#{guest_el()}:first-of-type") |> text() =~ "guest@example.com" + end + + test "does not render Guest divider when no guests found", %{conn: conn} do + resp = + conn + |> get(team_general_path()) + |> html_response(200) + + refute element_exists?(resp, "#guests-hr") + refute element_exists?(resp, "#guest-list") + end + end + + on_ee do + describe "live - SSO user" do + setup [ + :create_user, + :create_team, + :setup_sso, + :provision_sso_user, + :log_in, + :setup_team + ] + + test "fails to save layout with SSO user updated to owner with Force SSO but without 2FA", + %{ + conn: conn, + team: team, + user: user, + sso_integration: integration + } do + {:ok, user, _} = Plausible.Auth.TOTP.initiate(user) + {:ok, _user, _} = Plausible.Auth.TOTP.enable(user, :skip_verify) + {:ok, team} = Plausible.Auth.SSO.set_force_sso(team, :all_but_owners) + member = add_member(team, role: :viewer) + + {:ok, _, _, _member} = + new_identity(member.name, member.email, integration) + |> Plausible.Auth.SSO.provision_user() + + {:ok, _, _, user} = + new_identity(user.name, user.email, integration) + |> Plausible.Auth.SSO.provision_user() + + {:ok, conn: conn} = log_in(%{conn: conn, user: user}) + conn = set_current_team(conn, team) + + lv = get_liveview(conn) + + html = render(lv) + + assert text_of_element( + html, + "#{member_el()}:nth-of-type(1) button" + ) == "Owner" + + assert text_of_element(html, "#{member_el()}:nth-of-type(1)") =~ "You (SSO)" + + assert text_of_element(html, "#{member_el()}:nth-of-type(2) button") == "Viewer" + assert text_of_element(html, "#{member_el()}:nth-of-type(2)") =~ "SSO" + + change_role(lv, 2, "owner") + html = render(lv) + + assert html =~ "User must have 2FA enabled to become an owner" + end + end + end + + describe "live" do + setup [:create_user, :log_in, :create_team, :setup_team] + + test "renders member, immediately delivers invitation", %{conn: conn, user: user, team: team} do + {lv, html} = get_liveview(conn, with_html?: true) + member_row1 = find(html, "#{member_el()}:nth-of-type(1)") |> text() + assert member_row1 =~ "#{user.name}" + assert member_row1 =~ "#{user.email}" + assert member_row1 =~ "You" + + add_invite(lv, "new@example.com", "admin") + + html = render(lv) + + member_row1 = find(html, "#{member_el()}:nth-of-type(1)") |> text() + assert member_row1 =~ "new@example.com" + assert member_row1 =~ "Invited User" + assert member_row1 =~ "Invitation sent" + + member_row2 = find(html, "#{member_el()}:nth-of-type(2)") |> text() + assert member_row2 =~ "#{user.name}" + assert member_row2 =~ "#{user.email}" + + assert_email_delivered_with( + to: [nil: "new@example.com"], + subject: @subject_prefix <> "You've been invited to \"#{team.name}\" team" + ) + end + + test "allows updating membership role in place", %{conn: conn, team: team} do + member2 = add_member(team, role: :admin) + lv = get_liveview(conn) + + html = render(lv) + + assert text_of_element( + html, + "#{member_el()}:nth-of-type(1) button" + ) == "Owner" + + assert text_of_element(html, "#{member_el()}:nth-of-type(2) button") == "Admin" + + change_role(lv, 2, "viewer") + html = render(lv) + + assert text_of_element(html, "#{member_el()}:nth-of-type(2) button") == "Viewer" + + assert_no_emails_delivered() + + assert_team_membership(member2, team, :viewer) + end + + test "allows updating guest membership so it moves sections", %{conn: conn, team: team} do + site = new_site(team: team) + add_guest(site, role: :viewer, user: new_user(name: "Mr Guest", email: "guest@example.com")) + + lv = get_liveview(conn) + + html = render(lv) + + assert elem_count(html, member_el()) == 1 + + assert text_of_element(html, "#{guest_el()}:first-of-type button") == "Guest" + + change_role(lv, 1, "viewer", guest_el()) + html = render(lv) + + assert elem_count(html, member_el()) == 2 + refute element_exists?(html, "#guest-list") + end + + @tag :ee_only + test "fails to save layout with limits breached", %{conn: conn, team: team} do + insert(:growth_subscription, team: team) + + lv = get_liveview(conn) + add_invite(lv, "new1@example.com", "admin") + add_invite(lv, "new2@example.com", "admin") + add_invite(lv, "new3@example.com", "admin") + add_invite(lv, "new4@example.com", "admin") + + assert lv |> render() |> text() =~ "Your account is limited to 3 team members" + assert Enum.count(Plausible.Teams.Invitations.pending_team_invitations_for(team)) == 3 + end + + test "fails to accept invitation to already existing e-mail", %{ + conn: conn, + user: user + } do + lv = get_liveview(conn) + add_invite(lv, user.email, "admin") + + assert lv |> render() |> text() =~ + "Error! Make sure the e-mail is valid and is not taken already" + end + + @tag :ee_only + test "allows removing any type of entry (EE)", %{ + conn: conn, + user: user, + team: team + } do + insert(:growth_subscription, team: team) + + member2 = add_member(team, role: :admin) + _invitation = invite_member(team, "sent@example.com", inviter: user, role: :viewer) + + site = new_site(team: team) + + guest = + add_guest(site, + role: :viewer, + user: new_user(name: "Mr Guest", email: "guest@example.com") + ) + + lv = get_liveview(conn) + add_invite(lv, "pending@example.com", "admin") + + html = render(lv) + + assert elem_count(html, member_el()) == 4 + assert elem_count(html, guest_el()) == 1 + + pending = find(html, "#{member_el()}:nth-of-type(1)") |> text() + sent = find(html, "#{member_el()}:nth-of-type(2)") |> text() + owner = find(html, "#{member_el()}:nth-of-type(3)") |> text() + admin = find(html, "#{member_el()}:nth-of-type(4)") |> text() + + guest_member = find(html, "#{guest_el()}:first-of-type") |> text() + + assert pending =~ "Invitation pending" + assert sent =~ "Invitation sent" + assert owner =~ "Owner" + assert admin != "" + assert guest_member =~ "Guest" + + remove_member(lv, 1) + # next becomes first + remove_member(lv, 1) + # last becomes second + remove_member(lv, 2) + + # remove guest + remove_member(lv, 1, guest_el()) + + html = render(lv) |> text() + + refute html =~ "Invitation pending" + refute html =~ "Invitation sent" + refute html =~ "Guest" + + html = render(lv) + + assert elem_count(html, member_el()) == 1 + refute element_exists?(html, "#guest-list") + + assert_email_delivered_with( + to: [nil: member2.email], + subject: @subject_prefix <> "Your access to \"#{team.name}\" team has been revoked" + ) + + assert_email_delivered_with( + to: [nil: guest.email], + subject: @subject_prefix <> "Your access to \"#{team.name}\" team has been revoked" + ) + + assert_no_emails_delivered() + end + + @tag :ce_build_only + test "allows removing any type of entry (CE)", %{ + conn: conn, + user: user, + team: team + } do + member2 = add_member(team, role: :admin) + _invitation = invite_member(team, "sent@example.com", inviter: user, role: :viewer) + + site = new_site(team: team) + + guest = + add_guest(site, + role: :viewer, + user: new_user(name: "Mr Guest", email: "guest@example.com") + ) + + lv = get_liveview(conn) + + html = render(lv) + + assert elem_count(html, member_el()) == 3 + assert elem_count(html, guest_el()) == 1 + + sent = find(html, "#{member_el()}:nth-of-type(1)") |> text() + owner = find(html, "#{member_el()}:nth-of-type(2)") |> text() + admin = find(html, "#{member_el()}:nth-of-type(3)") |> text() + + guest_member = find(html, "#{guest_el()}:first-of-type") |> text() + + assert sent =~ "Invitation sent" + assert owner =~ "Owner" + assert admin != "" + assert guest_member =~ "Guest" + + remove_member(lv, 1) + # last becomes second + remove_member(lv, 2) + + # remove guest + remove_member(lv, 1, guest_el()) + + html = render(lv) |> text() + + refute html =~ "Invitation sent" + refute html =~ "Guest" + + html = render(lv) + + assert elem_count(html, member_el()) == 1 + refute element_exists?(html, "#guest-list") + + assert_email_delivered_with( + to: [nil: member2.email], + subject: @subject_prefix <> "Your access to \"#{team.name}\" team has been revoked" + ) + + assert_email_delivered_with( + to: [nil: guest.email], + subject: @subject_prefix <> "Your access to \"#{team.name}\" team has been revoked" + ) + + assert_no_emails_delivered() + end + + test "guest->owner promotion", + %{ + conn: conn, + user: user, + team: team + } do + site = new_site(team: team) + + member2 = + add_guest(site, + role: :viewer, + user: new_user(name: "Mr Guest", email: "guest@example.com") + ) + + lv = get_liveview(conn) + + change_role(lv, 1, "owner", guest_el()) + html = render(lv) + + refute html =~ "Error!" + + assert_team_membership(user, team, :owner) + assert_team_membership(member2, team, :owner) + + assert_email_delivered_with( + to: [nil: member2.email], + subject: @subject_prefix <> "Welcome to \"#{team.name}\" team" + ) + end + + test "multiple-owners", + %{ + conn: conn, + team: team, + user: user + } do + member2 = add_member(team, role: :admin) + + lv = get_liveview(conn) + + change_role(lv, 2, "owner") + html = render(lv) + + refute html =~ "Error!" + + assert_team_membership(user, team, :owner) + assert_team_membership(member2, team, :owner) + end + + test "remove member items carry data-confirm for both self and others", %{ + conn: conn, + team: team, + user: user + } do + member2 = add_member(team, role: :owner) + + lv = get_liveview(conn) + html = render(lv) + + assert attr_defined?(html, "##{:erlang.phash2(user.email)}-remove", "data-confirm") + assert attr_defined?(html, "##{:erlang.phash2(member2.email)}-remove", "data-confirm") + end + + test "self-demotion role items carry data-confirm, others do not", %{ + conn: conn, + team: team, + user: user + } do + # Second owner is required so the current user's own dropdown is not disabled + member2 = add_member(team, role: :owner) + + lv = get_liveview(conn) + html = render(lv) + + my_hash = :erlang.phash2(user.email) + other_hash = :erlang.phash2(member2.email) + + for role <- ~w(editor billing viewer) do + assert attr_defined?(html, "#option-#{my_hash}-#{role}", "data-confirm"), + "expected data-confirm on self #{role} item" + + refute attr_defined?(html, "#option-#{other_hash}-#{role}", "data-confirm"), + "expected no data-confirm on other member #{role} item" + end + + for role <- ~w(owner admin) do + refute attr_defined?(html, "#option-#{my_hash}-#{role}", "data-confirm"), + "expected no data-confirm on self #{role} item" + end + end + + test "removes self, redirecting away from team", %{conn: conn, team: team} do + _owner2 = add_member(team, role: :owner) + + lv = get_liveview(conn) + + remove_member(lv, 1) + + assert_redirect(lv, "/sites?__team=none") + end + + test "demotes self to billing, redirecting to team general", %{ + conn: conn, + team: team + } do + _owner2 = add_member(team, role: :owner) + + lv = get_liveview(conn) + + change_role(lv, 1, "billing") + + assert_redirect(lv, "/settings/team/general?__team=#{team.identifier}") + end + + test "demotes self to viewer, redirecting to team general", %{ + conn: conn, + team: team + } do + _owner2 = add_member(team, role: :owner) + + lv = get_liveview(conn) + + change_role(lv, 1, "viewer") + + assert_redirect(lv, "/settings/team/general?__team=#{team.identifier}") + end + + test "billing role is supported", + %{ + conn: conn, + team: team + } do + member2 = add_member(team, role: :admin) + + lv = get_liveview(conn) + + change_role(lv, 2, "billing") + assert_team_membership(member2, team, :billing) + end + + test "demotes self to editor, redirecting to team general", %{ + conn: conn, + team: team + } do + _owner2 = add_member(team, role: :owner) + + lv = get_liveview(conn) + + change_role(lv, 1, "editor") + + assert_redirect(lv, "/settings/team/general?__team=#{team.identifier}") + end + end + + defp change_role(lv, index, role, main_selector \\ member_el()) do + lv + |> element(~s|#{main_selector}:nth-of-type(#{index}) a[phx-value-role="#{role}"]|) + |> render_click() + end + + defp remove_member(lv, index, main_selector \\ member_el()) do + lv + |> element(~s|#{main_selector}:nth-of-type(#{index}) a[phx-click="remove-member"]|) + |> render_click() + end + + defp add_invite(lv, email, role) do + lv + |> element(~s|#input-role-picker a[phx-value-role="#{role}"]|) + |> render_click() + + lv + |> element("#team-layout-form") + |> render_submit(%{ + "input-email" => email + }) + end + + defp get_liveview(conn, opts \\ []) do + conn = assign(conn, :live_module, PlausibleWeb.Live.TeamManagement) + {:ok, lv, html} = live(conn, team_general_path()) + + if Keyword.get(opts, :with_html?) do + {lv, html} + else + lv + end + end + + defp member_el() do + ~s|#member-list div[data-test-kind="member"]| + end + + defp guest_el() do + ~s|#guest-list div[data-test-kind="guest"]| + end +end diff --git a/test/plausible_web/live/team_setup_test.exs b/test/plausible_web/live/team_setup_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..1c9ac36195767b5445da16695f05ea9e709beae8 --- /dev/null +++ b/test/plausible_web/live/team_setup_test.exs @@ -0,0 +1,414 @@ +defmodule PlausibleWeb.Live.TeamSetupTest do + use PlausibleWeb.ConnCase, async: false + use Bamboo.Test, shared: true + + import Phoenix.LiveViewTest + + alias Plausible.Teams + alias Plausible.Repo + + @url "/team/setup" + @subject_prefix if ee?(), do: "[Plausible Analytics] ", else: "[Plausible CE] " + + describe "/team/setup - edge cases" do + setup [:create_user, :log_in] + + test "redirects if there's no implicit team created", %{conn: conn} do + assert {:error, {:redirect, %{to: "/sites"}}} = live(conn, @url) + end + + test "redirects to /team/general if team is already set up", %{conn: conn, user: user} do + {:ok, team} = Teams.get_or_create(user) + Teams.complete_setup(team) + conn = set_current_team(conn, team) + assert {:error, {:redirect, %{to: "/settings/team/general"}}} = live(conn, @url) + end + end + + describe "/team/setup - main differences from team management" do + setup [:create_user, :log_in, :create_team] + + test "renames the team on first render", %{conn: conn, team: team} do + assert team.name == "My personal sites" + {:ok, _lv, html} = live(conn, @url) + + assert text_of_attr(html, ~s|input#update-team-form_name[name="team[name]"]|, "value") == + "Jane Smith's team" + + assert Repo.reload!(team).name == "Jane Smith's team" + end + + test "renames even if team already has non-default name", %{conn: conn, team: team} do + assert team.name == "My personal sites" + Repo.update!(Teams.Team.name_changeset(team, %{name: "Foo"})) + {:ok, _lv, html} = live(conn, @url) + + assert text_of_attr(html, ~s|input#update-team-form_name[name="team[name]"]|, "value") == + "Jane Smith's team" + + assert Repo.reload!(team).name == "Jane Smith's team" + end + + test "renders form", %{conn: conn} do + {:ok, lv, html} = live(conn, @url) + assert element_exists?(html, ~s|input#update-team-form_name[name="team[name]"]|) + assert element_exists?(html, ~s|button[phx-click="save-team-layout"]|) + + _ = render(lv) + end + + test "changing team name, updates team name in db", %{conn: conn, team: team} do + {:ok, lv, _html} = live(conn, @url) + type_into_input(lv, "team[name]", "New Team Name") + assert Repo.reload!(team).name == "New Team Name" + + _ = render(lv) + end + + test "setting team name to 'My personal sites' is reserved", %{ + conn: conn, + team: team, + user: user + } do + {:ok, lv, html} = live(conn, @url) + + assert text_of_attr(html, ~s|input#update-team-form_name[name="team[name]"]|, "value") == + "#{user.name}'s team" + + type_into_input(lv, "team[name]", "Team Name 1") + _ = render(lv) + type_into_input(lv, "team[name]", "My personal sites") + _ = render(lv) + assert Repo.reload!(team).name == "Team Name 1" + end + + @tag :ee_only + test "blurs UI with an upgrade CTA if the subscription team member limit is 0", %{ + conn: conn, + user: user + } do + subscribe_to_starter_plan(user) + + {:ok, _lv, html} = live(conn, @url) + + assert element_exists?(html, "#feature-gate-inner-block-container") + assert element_exists?(html, "#feature-gate-overlay") + assert text_of_element(html, "#feature-gate-overlay") =~ "Upgrade to unlock" + end + end + + describe "/team/setup - full integration" do + setup [:create_user, :log_in, :create_team] + + test "renders member, enqueues invitation, delivers it", %{conn: conn, user: user, team: team} do + {lv, html} = get_child_lv(conn, with_html?: true) + member_row1 = find(html, "#{member_el()}:nth-of-type(1)") |> text() + assert member_row1 =~ "#{user.name}" + assert member_row1 =~ "#{user.email}" + assert member_row1 =~ "You" + + add_invite(lv, "new@example.com", "admin") + + html = render(lv) + + member_row1 = find(html, "#{member_el()}:nth-of-type(1)") |> text() + assert member_row1 =~ "new@example.com" + assert member_row1 =~ "Invited User" + assert member_row1 =~ "Invitation pending" + + member_row2 = find(html, "#{member_el()}:nth-of-type(2)") |> text() + assert member_row2 =~ "#{user.name}" + assert member_row2 =~ "#{user.email}" + + save_layout(lv) + + assert_redirect(lv, "/settings/team/general?__team=" <> team.identifier) + + team = Repo.reload!(team) + + assert_email_delivered_with( + to: [nil: "new@example.com"], + subject: @subject_prefix <> "You've been invited to \"#{team.name}\" team" + ) + end + + test "allows updating pending invitation role in place", %{conn: conn, team: team} do + lv = get_child_lv(conn) + add_invite(lv, "new@example.com", "admin") + + html = render(lv) + + assert text_of_element(html, "#{member_el()}:nth-of-type(1) button") == "Admin" + assert text_of_element(html, "#{member_el()}:nth-of-type(2) button") == "Owner" + + change_role(lv, 1, "viewer") + html = render(lv) + + assert text_of_element(html, "#{member_el()}:nth-of-type(1) button") == "Viewer" + + save_layout(lv) + + team = Repo.reload!(team) + + assert_email_delivered_with( + to: [nil: "new@example.com"], + subject: @subject_prefix <> "You've been invited to \"#{team.name}\" team" + ) + end + + test "allows updating membership role in place", %{conn: conn, team: team} do + member2 = add_member(team, role: :admin) + {lv, html} = get_child_lv(conn, with_html?: true) + + assert text_of_element(html, "#{member_el()}:nth-of-type(1) button") == "Owner" + assert text_of_element(html, "#{member_el()}:nth-of-type(2) button") == "Admin" + + change_role(lv, 2, "viewer") + html = render(lv) + + assert text_of_element(html, "#{member_el()}:nth-of-type(2) button") == "Viewer" + + save_layout(lv) + + assert_no_emails_delivered() + + assert_team_membership(member2, team, :viewer) + end + + test "allows updating guest membership so it moves sections and sends out promotion e-mail", + %{ + conn: conn, + user: user, + team: team + } do + site = new_site(owner: user) + add_guest(site, role: :viewer, user: new_user(name: "Mr Guest", email: "guest@example.com")) + + {:ok, main_lv, _html} = live(conn, @url) + lv = get_child_lv(conn) + + type_into_input(main_lv, "team[name]", "A-Team!") + + assert Repo.reload!(team).name == "A-Team!" + + html = render(lv) + + assert elem_count(html, member_el()) == 1 + + assert text_of_element(html, "#{guest_el()}:first-of-type button") == "Guest" + + change_role(lv, 1, "viewer", guest_el()) + html = render(lv) + + assert elem_count(html, member_el()) == 2 + refute element_exists?(html, "#guest-list") + + save_layout(lv) + + assert_email_delivered_with( + to: [nil: "guest@example.com"], + subject: @subject_prefix <> "Welcome to \"A-Team!\" team" + ) + end + + @tag :ee_only + test "fails to save layout with limits breached", %{conn: conn, team: team} do + insert(:growth_subscription, team: team) + + lv = get_child_lv(conn) + html = render(lv) + refute attr_defined?(html, ~s|#team-layout-form input[name="input-email"]|, "readonly") + refute attr_defined?(html, ~s|#invite-member|, "disabled") + + add_invite(lv, "new1@example.com", "admin") + add_invite(lv, "new2@example.com", "admin") + add_invite(lv, "new3@example.com", "admin") + add_invite(lv, "new4@example.com", "admin") + + html = render(lv) + + assert attr_defined?(html, ~s|#team-layout-form input[name="input-email"]|, "readonly") + assert attr_defined?(html, ~s|#invite-member|, "disabled") + + assert text_of_element(html, ~s/[data-test="limit-exceeded-notice"]/) =~ + "This account is limited to 3 members" + end + + test "all options are disabled for the sole owner", %{conn: conn} do + lv = get_child_lv(conn) + + options = + lv + |> render() + |> find("#{member_el()} a") + + assert Enum.empty?(options) + end + + test "in case of >1 owner, the one owner limit is still enforced", %{conn: conn, team: team} do + _other_owner = add_member(team, role: :owner) + lv = get_child_lv(conn) + + options = + lv + |> render() + |> find("#{member_el()} a") + + refute Enum.empty?(options) + + change_role(lv, 1, "viewer") + + html = lv |> render() + + assert element_exists?(html, "#{member_el()}:nth-of-type(1) a") + refute element_exists?(html, "#{member_el()}:nth-of-type(2) a") + end + + test "allows removing any type of entry", %{ + conn: conn, + user: user, + team: team + } do + member2 = add_member(team, role: :admin) + _invitation = invite_member(team, "sent@example.com", inviter: user, role: :viewer) + + site = new_site(owner: user) + + guest = + add_guest(site, + role: :viewer, + user: new_user(name: "Mr Guest", email: "guest@example.com") + ) + + lv = get_child_lv(conn) + add_invite(lv, "pending@example.com", "admin") + + html = render(lv) + + assert elem_count(html, member_el()) == 4 + assert elem_count(html, guest_el()) == 1 + + pending = find(html, "#{member_el()}:nth-of-type(1)") |> text() + sent = find(html, "#{member_el()}:nth-of-type(2)") |> text() + owner = find(html, "#{member_el()}:nth-of-type(3)") |> text() + admin = find(html, "#{member_el()}:nth-of-type(4)") |> text() + + guest_member = find(html, "#{guest_el()}:first-of-type") |> text() + + assert pending =~ "Invitation pending" + assert sent =~ "Invitation sent" + assert owner =~ "You" + assert admin != "" + assert guest_member =~ "Guest" + + remove_member(lv, 1) + # next becomes first + remove_member(lv, 1) + # last becomes second + remove_member(lv, 2) + + # remove guest + remove_member(lv, 1, guest_el()) + + html = render(lv) |> text() + + refute html =~ "Invitation pending" + refute html =~ "Invitation sent" + refute html =~ "Team member" + refute html =~ "Guest" + + save_layout(lv) + + team = Repo.reload!(team) + + assert_email_delivered_with( + to: [nil: guest.email], + subject: @subject_prefix <> "Your access to \"#{team.name}\" team has been revoked" + ) + + assert_email_delivered_with( + to: [nil: member2.email], + subject: @subject_prefix <> "Your access to \"#{team.name}\" team has been revoked" + ) + + assert_no_emails_delivered() + end + + test "respawns membersip enqueued for deletion", %{ + conn: conn, + team: team + } do + member2 = add_member(team, role: :editor, user: new_user(email: "another@example.com")) + + lv = get_child_lv(conn) + + remove_member(lv, 1) + + add_invite(lv, "another@example.com", "viewer") + + html = render(lv) + + assert find(html, "#{member_el()}:nth-of-type(2)") |> text() =~ "You" + + save_layout(lv) + + assert_no_emails_delivered() + assert_team_membership(member2, team, :viewer) + end + end + + defp type_into_input(lv, id, text) do + lv + |> element("form#update-team-form") + |> render_change(%{id => text}) + end + + defp add_invite(lv, email, role) do + lv + |> element(~s|#input-role-picker a[phx-value-role="#{role}"]|) + |> render_click() + + lv + |> element("#team-layout-form") + |> render_submit(%{ + "input-email" => email + }) + end + + defp save_layout(lv) do + lv + |> element("button#save-layout") + |> render_click() + end + + defp change_role(lv, index, role, main_selector \\ member_el()) do + lv + |> element(~s|#{main_selector}:nth-of-type(#{index}) a[phx-value-role="#{role}"]|) + |> render_click() + end + + defp get_child_lv(conn, opts \\ []) do + {:ok, lv, _} = live(conn, @url) + assert lv = find_live_child(lv, "team-management-setup") + + if Keyword.get(opts, :with_html?) do + {lv, render(lv)} + else + lv + end + end + + defp remove_member(lv, index, main_selector \\ member_el()) do + lv + |> element(~s|#{main_selector}:nth-of-type(#{index}) a[phx-click="remove-member"]|) + |> render_click() + end + + defp member_el() do + ~s|#member-list div[data-test-kind="member"]| + end + + defp guest_el() do + ~s|#guest-list div[data-test-kind="guest"]| + end +end diff --git a/test/plausible_web/live/verification_test.exs b/test/plausible_web/live/verification_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..4580e932e96b5cb79846596b96cab0645b55ad73 --- /dev/null +++ b/test/plausible_web/live/verification_test.exs @@ -0,0 +1,314 @@ +defmodule PlausibleWeb.Live.VerificationTest do + use PlausibleWeb.ConnCase + + use Plausible.Test.Support.DNS + + import Phoenix.LiveViewTest + + @moduletag :capture_log + + setup [:create_user, :log_in, :create_site] + + # @verify_button ~s|button#launch-verification-button[phx-click="launch-verification"]| + @retry_button ~s|a[phx-click="retry"]| + # @go_to_dashboard_button ~s|a[href$="?skip_to_dashboard=true"]| + @progress ~s|#verification-ui p#progress| + @awaiting ~s|#verification-ui span#awaiting| + @heading ~s|#verification-ui h2| + + describe "GET /:domain" do + @tag :ee_only + test "static verification screen renders", %{conn: conn, site: site} do + resp = + get(conn, conn |> no_slowdown() |> get("/#{site.domain}") |> redirected_to) + |> html_response(200) + + assert text_of_element(resp, @progress) =~ + "We're visiting your site to ensure that everything is working" + + assert resp =~ "Verifying your installation" + end + + @tag :ce_build_only + test "static verification screen renders (ce)", %{conn: conn, site: site} do + resp = + get(conn, conn |> no_slowdown() |> get("/#{site.domain}") |> redirected_to) + |> html_response(200) + + assert resp =~ "Awaiting your first pageview …" + end + end + + describe "LiveView" do + @tag :ee_only + test "LiveView mounts", %{conn: conn, site: site} do + stub_lookup_a_records(site.domain) + + stub_verification_result(%{ + "completed" => false, + "error" => %{"message" => "Error"} + }) + + {_, html} = get_lv(conn, site) + + assert html =~ "Verifying your installation" + + assert text_of_element(html, @progress) =~ + "We're visiting your site to ensure that everything is working" + end + + @tag :ce_build_only + test "LiveView mounts (ce)", %{conn: conn, site: site} do + {_, html} = get_lv(conn, site) + assert html =~ "Awaiting your first pageview …" + end + + @tag :ee_only + test "from custom URL input form to verification", %{conn: conn, site: site} do + stub_lookup_a_records(site.domain) + + stub_verification_result(%{ + "completed" => false, + "error" => %{"message" => "Error"} + }) + + # Get liveview with ?custom_url=true query param + {:ok, lv, html} = + conn |> no_slowdown() |> live("/#{site.domain}/verification?custom_url=true") + + verifying_installation_text = "Verifying your installation" + + # Assert form is rendered instead of kicking off verification automatically + assert html =~ "Enter Your Custom URL" + assert html =~ ~s[value="https://#{site.domain}"] + assert html =~ ~s[placeholder="https://#{site.domain}"] + refute html =~ verifying_installation_text + + # Submit custom URL form + html = lv |> element("form") |> render_submit(%{"custom_url" => "https://abc.de"}) + + # Should now show verification progress and hide custom URL form + assert html =~ verifying_installation_text + refute html =~ "Enter Your Custom URL" + end + + @tag :ee_only + test "eventually verifies installation", %{conn: conn, site: site} do + stub_lookup_a_records(site.domain) + + stub_verification_result(%{ + "completed" => true, + "trackerIsInHtml" => true, + "plausibleIsOnWindow" => true, + "plausibleIsInitialized" => true, + "testEvent" => %{ + "normalizedBody" => %{ + "domain" => site.domain + }, + "responseStatus" => 200 + } + }) + + {:ok, lv} = kick_off_live_verification(conn, site) + + assert eventually(fn -> + html = render(lv) + + { + text_of_element(html, @awaiting) =~ + "Awaiting your first pageview", + html + } + end) + + html = render(lv) + assert html =~ "Success!" + assert html =~ "Awaiting your first pageview" + end + + @tag :ee_only + test "won't await first pageview if site has pageviews", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview) + ]) + + stub_lookup_a_records(site.domain) + + stub_verification_result(%{ + "completed" => true, + "trackerIsInHtml" => true, + "plausibleIsOnWindow" => true, + "plausibleIsInitialized" => true, + "testEvent" => %{ + "normalizedBody" => %{ + "domain" => site.domain + }, + "responseStatus" => 200 + } + }) + + {:ok, lv} = kick_off_live_verification(conn, site) + + assert eventually(fn -> + html = render(lv) + + { + text(html) =~ "Success", + html + } + end) + + html = render(lv) + + refute text_of_element(html, @awaiting) =~ "Awaiting your first pageview" + refute_redirected(lv, "/#{URI.encode_www_form(site.domain)}/") + end + + test "will redirect when first pageview arrives", %{conn: conn, site: site} do + stub_lookup_a_records(site.domain) + + stub_verification_result(%{ + "completed" => true, + "trackerIsInHtml" => true, + "plausibleIsOnWindow" => true, + "plausibleIsInitialized" => true, + "testEvent" => %{ + "normalizedBody" => %{ + "domain" => site.domain + }, + "responseStatus" => 200 + } + }) + + {:ok, lv} = kick_off_live_verification(conn, site) + + assert eventually(fn -> + html = render(lv) + + { + text(html) =~ "Awaiting", + html + } + end) + + populate_stats(site, [ + build(:pageview) + ]) + + assert_redirect(lv, Routes.stats_path(PlausibleWeb.Endpoint, :stats, site.domain)) + end + + @tag :ce_build_only + test "will redirect when first pageview arrives (ce)", %{conn: conn, site: site} do + {:ok, lv} = kick_off_live_verification(conn, site) + + html = render(lv) + assert text(html) =~ "Awaiting your first pageview …" + + populate_stats(site, [build(:pageview)]) + + assert_redirect(lv, Routes.stats_path(PlausibleWeb.Endpoint, :stats, site.domain)) + end + + for {installation_type_param, expected_text, saved_installation_type} <- [ + {"manual", + "Please make sure you've copied the snippet to the head of your site, or verify your installation manually.", + nil}, + {"npm", + "Please make sure you've initialized Plausible on your site, or verify your installation manually.", + nil}, + {"gtm", + "Please make sure you've configured the GTM template correctly, or verify your installation manually.", + nil}, + {"wordpress", + "Please make sure you've enabled the plugin, or verify your installation manually.", + nil}, + # trusts param over saved installation type + {"wordpress", + "Please make sure you've enabled the plugin, or verify your installation manually.", + "npm"}, + # falls back to saved installation type if no param + {"", + "Please make sure you've initialized Plausible on your site, or verify your installation manually.", + "npm"}, + # falls back to manual if no param and no saved installation type + {"", + "Please make sure you've copied the snippet to the head of your site, or verify your installation manually.", + nil} + ] do + @tag :ee_only + test "eventually fails to verify installation (?installation_type=#{installation_type_param}) if saved installation type is #{inspect(saved_installation_type)}", + %{ + conn: conn, + site: site + } do + stub_lookup_a_records(site.domain) + + stub_verification_result(%{ + "completed" => true, + "trackerIsInHtml" => false, + "plausibleIsOnWindow" => false, + "plausibleIsInitialized" => false + }) + + if unquote(saved_installation_type) do + PlausibleWeb.Tracker.get_or_create_tracker_script_configuration!(site, %{ + "installation_type" => unquote(saved_installation_type) + }) + end + + {:ok, lv} = + kick_off_live_verification( + conn, + site, + "?installation_type=#{unquote(installation_type_param)}" + ) + + assert html = + eventually(fn -> + html = render(lv) + {html =~ "", html} + + { + text_of_element(html, @heading) =~ + "We couldn't detect Plausible on your site", + html + } + end) + + assert element_exists?(html, @retry_button) + + assert html =~ htmlize_quotes(unquote(expected_text)) + refute element_exists?(html, "#super-admin-report") + end + end + end + + defp get_lv(conn, site, qs \\ nil) do + {:ok, lv, html} = conn |> no_slowdown() |> live("/#{site.domain}/verification#{qs}") + {lv, html} + end + + defp kick_off_live_verification(conn, site, qs \\ nil) do + {:ok, lv, _html} = + conn |> no_slowdown() |> no_delay() |> live("/#{site.domain}/verification#{qs}") + + {:ok, lv} + end + + defp no_slowdown(conn) do + Plug.Conn.put_private(conn, :slowdown, 0) + end + + defp no_delay(conn) do + Plug.Conn.put_private(conn, :delay, 0) + end + + defp stub_verification_result(js_data) do + Req.Test.stub(Plausible.InstallationSupport.Checks.VerifyInstallation, fn conn -> + conn + |> put_resp_content_type("application/json") + |> send_resp(200, Jason.encode!(%{"data" => js_data})) + end) + end +end diff --git a/test/plausible_web/plugins/api/controllers/capabilities_test.exs b/test/plausible_web/plugins/api/controllers/capabilities_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..75faa09a591011d34840508e03fc9bdf824e5d49 --- /dev/null +++ b/test/plausible_web/plugins/api/controllers/capabilities_test.exs @@ -0,0 +1,177 @@ +defmodule PlausibleWeb.Plugins.API.Controllers.CapabilitiesTest do + use PlausibleWeb.PluginsAPICase, async: true + alias PlausibleWeb.Plugins.API.Schemas + + describe "examples" do + test "Capabilities" do + assert_schema( + Schemas.Capabilities.schema().example, + "Capabilities", + spec() + ) + end + end + + describe "unauthorized" do + test "no token", %{conn: conn} do + resp = + conn + |> get(Routes.plugins_api_capabilities_url(PlausibleWeb.Endpoint, :index)) + |> json_response(200) + + assert resp == + %{ + "authorized" => false, + "data_domain" => nil, + "features" => %{ + "Funnels" => false, + "Goals" => false, + "Props" => false, + "RevenueGoals" => false, + "StatsAPI" => false, + "SitesAPI" => false, + "SiteSegments" => false, + "SiteAnnotations" => false, + "SharedLinks" => false, + "SSO" => false, + "ConsolidatedView" => false + } + } + + assert_schema(resp, "Capabilities", spec()) + end + + test "bad token", %{conn: conn} do + resp = + conn + |> put_req_header("content-type", "application/json") + |> authenticate("foo", "bad token") + |> get(Routes.plugins_api_capabilities_url(PlausibleWeb.Endpoint, :index)) + |> json_response(200) + + assert resp == + %{ + "authorized" => false, + "data_domain" => nil, + "features" => %{ + "Funnels" => false, + "Goals" => false, + "Props" => false, + "RevenueGoals" => false, + "StatsAPI" => false, + "SitesAPI" => false, + "SiteSegments" => false, + "SiteAnnotations" => false, + "SharedLinks" => false, + "SSO" => false, + "ConsolidatedView" => false + } + } + + assert_schema(resp, "Capabilities", spec()) + end + end + + describe "authorized" do + test "trial", %{conn: conn, site: site, token: token} do + resp = + conn + |> put_req_header("content-type", "application/json") + |> authenticate(site.domain, token) + |> get(Routes.plugins_api_capabilities_url(PlausibleWeb.Endpoint, :index)) + |> json_response(200) + + assert resp == + %{ + "authorized" => true, + "data_domain" => site.domain, + "features" => %{ + "Funnels" => true, + "Goals" => true, + "Props" => true, + "RevenueGoals" => true, + "StatsAPI" => true, + "SitesAPI" => false, + "SiteSegments" => true, + "SiteAnnotations" => true, + "SharedLinks" => true, + "SSO" => false, + "ConsolidatedView" => true + } + } + + assert_schema(resp, "Capabilities", spec()) + end + + @tag :ee_only + test "growth", %{conn: conn, site: site, token: token} do + [owner | _] = Plausible.Repo.preload(site, :owners).owners + subscribe_to_growth_plan(owner) + + resp = + conn + |> put_req_header("content-type", "application/json") + |> authenticate(site.domain, token) + |> get(Routes.plugins_api_capabilities_url(PlausibleWeb.Endpoint, :index)) + |> json_response(200) + + assert resp == + %{ + "authorized" => true, + "data_domain" => site.domain, + "features" => %{ + "Funnels" => false, + "Goals" => true, + "Props" => false, + "RevenueGoals" => false, + "StatsAPI" => false, + "SitesAPI" => false, + "SiteSegments" => false, + "SiteAnnotations" => false, + "SharedLinks" => true, + "SSO" => false, + "ConsolidatedView" => false + } + } + + assert_schema(resp, "Capabilities", spec()) + end + + @tag :ee_only + test "enterprise with SitesAPI", %{conn: conn, site: site, token: token} do + [owner | _] = Plausible.Repo.preload(site, :owners).owners + + subscribe_to_enterprise_plan(owner, + features: [Plausible.Billing.Feature.StatsAPI, Plausible.Billing.Feature.SitesAPI] + ) + + resp = + conn + |> put_req_header("content-type", "application/json") + |> authenticate(site.domain, token) + |> get(Routes.plugins_api_capabilities_url(PlausibleWeb.Endpoint, :index)) + |> json_response(200) + + assert resp == + %{ + "authorized" => true, + "data_domain" => site.domain, + "features" => %{ + "Funnels" => false, + "Goals" => true, + "Props" => false, + "RevenueGoals" => false, + "StatsAPI" => true, + "SitesAPI" => true, + "SiteSegments" => false, + "SiteAnnotations" => false, + "SharedLinks" => false, + "SSO" => false, + "ConsolidatedView" => false + } + } + + assert_schema(resp, "Capabilities", spec()) + end + end +end diff --git a/test/plausible_web/plugins/api/controllers/custom_props_test.exs b/test/plausible_web/plugins/api/controllers/custom_props_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..16143d24c0718c42ac7543d89dbb2a6534d0fd39 --- /dev/null +++ b/test/plausible_web/plugins/api/controllers/custom_props_test.exs @@ -0,0 +1,304 @@ +defmodule PlausibleWeb.Plugins.API.Controllers.CustomPropsTest do + use PlausibleWeb.PluginsAPICase, async: true + alias PlausibleWeb.Plugins.API.Schemas + + describe "examples" do + test "CustomProp" do + assert_schema( + Schemas.CustomProp.schema().example, + "CustomProp", + spec() + ) + end + + test "CustomProp.CreateRequest" do + assert_schema( + Schemas.CustomProp.EnableRequest.schema().example, + "CustomProp.EnableRequest", + spec() + ) + end + end + + describe "unauthorized calls" do + for {method, url} <- [ + {:put, Routes.plugins_api_custom_props_url(PlausibleWeb.Endpoint, :enable)}, + {:delete, Routes.plugins_api_custom_props_url(PlausibleWeb.Endpoint, :disable)} + ] do + test "unauthorized call: #{method} #{url}", %{conn: conn} do + conn + |> unquote(method)(unquote(url)) + |> json_response(401) + |> assert_schema("UnauthorizedError", spec()) + end + end + end + + describe "business tier" do + @describetag :ee_only + + test "allows prop enable for special key", %{ + site: site, + token: token, + conn: conn + } do + [owner | _] = Plausible.Repo.preload(site, :owners).owners + subscribe_to_growth_plan(owner) + + url = Routes.plugins_api_custom_props_url(PlausibleWeb.Endpoint, :enable) + + payload = %{ + custom_prop: %{key: "search_query"} + } + + assert_request_schema(payload, "CustomProp.EnableRequest", spec()) + + conn + |> authenticate(site.domain, token) + |> put_req_header("content-type", "application/json") + |> put(url, payload) + |> json_response(201) + |> assert_schema("CustomProp.ListResponse", spec()) + end + + test "allows bulk prop enable for special keys", %{ + site: site, + token: token, + conn: conn + } do + [owner | _] = Plausible.Repo.preload(site, :owners).owners + subscribe_to_growth_plan(owner) + + url = Routes.plugins_api_custom_props_url(PlausibleWeb.Endpoint, :enable) + + payload = %{ + custom_props: [ + %{ + custom_prop: %{key: "search_query"} + }, + %{ + custom_prop: %{key: "url"} + } + ] + } + + conn + |> authenticate(site.domain, token) + |> put_req_header("content-type", "application/json") + |> put(url, payload) + |> json_response(201) + |> assert_schema("CustomProp.ListResponse", spec()) + end + + test "fails on custom prop enable attempt with insufficient plan", %{ + site: site, + token: token, + conn: conn + } do + [owner | _] = Plausible.Repo.preload(site, :owners).owners + subscribe_to_growth_plan(owner) + + url = Routes.plugins_api_custom_props_url(PlausibleWeb.Endpoint, :enable) + + payload = %{ + custom_prop: %{key: "author"} + } + + assert_request_schema(payload, "CustomProp.EnableRequest", spec()) + + conn + |> authenticate(site.domain, token) + |> put_req_header("content-type", "application/json") + |> put(url, payload) + |> json_response(402) + |> assert_schema("PaymentRequiredError", spec()) + end + + test "fails on bulk prop enable attempt with insufficient plan", %{ + site: site, + token: token, + conn: conn + } do + [owner | _] = Plausible.Repo.preload(site, :owners).owners + subscribe_to_growth_plan(owner) + + url = Routes.plugins_api_custom_props_url(PlausibleWeb.Endpoint, :enable) + + payload = %{ + custom_props: [ + %{ + custom_prop: %{key: "author"} + }, + %{ + custom_prop: %{key: "search_query"} + }, + %{ + custom_prop: %{key: "category"} + } + ] + } + + conn + |> authenticate(site.domain, token) + |> put_req_header("content-type", "application/json") + |> put(url, payload) + |> json_response(402) + |> assert_schema("PaymentRequiredError", spec()) + end + end + + describe "put /custom_prop - enable single prop" do + test "validates input according to the schema", %{conn: conn, token: token, site: site} do + url = Routes.plugins_api_custom_props_url(PlausibleWeb.Endpoint, :enable) + + conn + |> authenticate(site.domain, token) + |> put_req_header("content-type", "application/json") + |> put(url, %{custom_prop: %{typo: "author"}}) + |> json_response(422) + |> assert_schema("UnprocessableEntityError", spec()) + end + + test "enables single custom prop", %{conn: conn, token: token, site: site} do + url = Routes.plugins_api_custom_props_url(PlausibleWeb.Endpoint, :enable) + + payload = %{ + custom_prop: %{key: "author"} + } + + assert_request_schema(payload, "CustomProp.EnableRequest", spec()) + + conn = + conn + |> authenticate(site.domain, token) + |> put_req_header("content-type", "application/json") + |> put(url, payload) + + resp = + conn + |> json_response(201) + |> assert_schema("CustomProp.ListResponse", spec()) + + resp.custom_props + |> List.first() + |> assert_schema("CustomProp", spec()) + + assert "author" in Plausible.Repo.reload!(site).allowed_event_props + end + + test "is idempotent", %{conn: conn, token: token, site: site} do + url = Routes.plugins_api_custom_props_url(PlausibleWeb.Endpoint, :enable) + + initial_conn = + conn + |> authenticate(site.domain, token) + |> put_req_header("content-type", "application/json") + + resp1 = + initial_conn + |> put( + url, + %{ + custom_prop: %{key: "author"} + } + ) + |> json_response(201) + |> assert_schema("CustomProp.ListResponse", spec()) + + resp1.custom_props + |> List.first() + |> assert_schema("CustomProp", spec()) + + assert initial_conn + |> put( + url, + %{ + custom_prop: %{key: "author"} + } + ) + |> json_response(201) + |> assert_schema("CustomProp.ListResponse", spec()) == resp1 + end + end + + describe "put /custom_props - bulk creation" do + test "creates many custom props", %{conn: conn, token: token, site: site} do + url = Routes.plugins_api_custom_props_url(PlausibleWeb.Endpoint, :enable) + + payload = %{ + custom_props: [ + %{ + custom_prop: %{key: "author"} + }, + %{ + custom_prop: %{key: "rating"} + }, + %{ + custom_prop: %{key: "category"} + } + ] + } + + assert_request_schema(payload, "CustomProp.EnableRequest.BulkEnable", spec()) + + conn = + conn + |> authenticate(site.domain, token) + |> put_req_header("content-type", "application/json") + |> put(url, payload) + + resp = + conn + |> json_response(201) + |> assert_schema("CustomProp.ListResponse", spec()) + + assert Enum.count(resp.custom_props) == 3 + + assert [ + "author", + "rating", + "category" + ] = Plausible.Repo.reload!(site).allowed_event_props + end + end + + describe "delete /custom_props" do + test "disable one prop", %{conn: conn, site: site, token: token} do + {:ok, ["author"]} = Plausible.Plugins.API.CustomProps.enable(site, "author") + + url = Routes.plugins_api_custom_props_url(PlausibleWeb.Endpoint, :enable) + + payload = %{custom_prop: %{key: "author"}} + + conn + |> authenticate(site.domain, token) + |> put_req_header("content-type", "application/json") + |> delete(url, payload) + |> response(204) + + assert Plausible.Repo.reload!(site).allowed_event_props == [] + end + + test "disable many props", %{conn: conn, site: site, token: token} do + {:ok, [_, _, _]} = + Plausible.Plugins.API.CustomProps.enable(site, ["author", "category", "third"]) + + url = Routes.plugins_api_custom_props_url(PlausibleWeb.Endpoint, :enable) + + payload = %{ + custom_props: [ + %{custom_prop: %{key: "author"}}, + %{custom_prop: %{key: "category"}} + ] + } + + conn + |> authenticate(site.domain, token) + |> put_req_header("content-type", "application/json") + |> delete(url, payload) + |> response(204) + + assert Plausible.Repo.reload!(site).allowed_event_props == ["third"] + end + end +end diff --git a/test/plausible_web/plugins/api/controllers/funnels_test.exs b/test/plausible_web/plugins/api/controllers/funnels_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..1467ddc4b44c66dcce0832ecfb709b756851ff99 --- /dev/null +++ b/test/plausible_web/plugins/api/controllers/funnels_test.exs @@ -0,0 +1,406 @@ +defmodule PlausibleWeb.Plugins.API.Controllers.FunnelsTest do + use PlausibleWeb.PluginsAPICase, async: true + + @moduletag :ee_only + + on_ee do + alias PlausibleWeb.Plugins.API.Schemas + + describe "examples" do + test "Funnel" do + assert_schema( + Schemas.Funnel.schema().example, + "Funnel", + spec() + ) + end + + test "Funnel.CreateRequest" do + assert_schema( + Schemas.Funnel.CreateRequest.schema().example, + "Funnel.CreateRequest", + spec() + ) + end + end + + describe "unauthorized calls" do + for {method, url} <- [ + {:get, Routes.plugins_api_funnels_url(PlausibleWeb.Endpoint, :index)}, + {:get, Routes.plugins_api_funnels_url(PlausibleWeb.Endpoint, :get, 1)}, + {:put, Routes.plugins_api_funnels_url(PlausibleWeb.Endpoint, :create, %{})} + ] do + test "unauthorized call: #{method} #{url}", %{conn: conn} do + conn + |> unquote(method)(unquote(url)) + |> json_response(401) + |> assert_schema("UnauthorizedError", spec()) + end + end + end + + describe "get /funnels/:id" do + test "validates input out of the box", %{conn: conn, token: token, site: site} do + url = Routes.plugins_api_funnels_url(PlausibleWeb.Endpoint, :get, "hello") + + resp = + conn + |> authenticate(site.domain, token) + |> get(url) + |> json_response(422) + |> assert_schema("UnprocessableEntityError", spec()) + + assert %{errors: [%{detail: "Invalid integer. Got: string"}]} = resp + end + + test "retrieves no funnel on non-existing ID", %{conn: conn, token: token, site: site} do + url = Routes.plugins_api_funnels_url(PlausibleWeb.Endpoint, :get, 9999) + + resp = + conn + |> authenticate(site.domain, token) + |> get(url) + |> json_response(404) + |> assert_schema("NotFoundError", spec()) + + assert %{errors: [%{detail: "Plugins API: resource not found"}]} = resp + end + + test "retrieves funnel by ID", %{conn: conn, site: site, token: token} do + {:ok, g1} = Plausible.Goals.create(site, %{"page_path" => "/product/123"}) + + {:ok, g2} = + Plausible.Goals.create(site, %{"event_name" => "Purchase", "currency" => "EUR"}) + + {:ok, g3} = Plausible.Goals.create(site, %{"event_name" => "FiveStarReview"}) + + {:ok, funnel} = + Plausible.Funnels.create( + site, + "Peek & buy", + [g1, g2, g3] + ) + + url = Routes.plugins_api_funnels_url(PlausibleWeb.Endpoint, :get, funnel.id) + + resp = + conn + |> authenticate(site.domain, token) + |> get(url) + |> json_response(200) + + schema = assert_schema(resp, "Funnel", spec()) + + assert schema.funnel.id == funnel.id + assert schema.funnel.name == "Peek & buy" + [_, _, _] = schema.funnel.steps + [s1, s2, s3] = resp |> Map.fetch!("funnel") |> Map.fetch!("steps") + + assert_schema(s1, "Goal.Pageview", spec()) + assert_schema(s2, "Goal.Revenue", spec()) + assert_schema(s3, "Goal.CustomEvent", spec()) + end + end + + describe "get /funnels" do + test "returns an empty funnels list if there's none", %{ + conn: conn, + token: token, + site: site + } do + url = Routes.plugins_api_funnels_url(PlausibleWeb.Endpoint, :index) + + resp = + conn + |> authenticate(site.domain, token) + |> get(url) + |> json_response(200) + |> assert_schema("Funnel.ListResponse", spec()) + + assert resp.funnels == [] + assert resp.meta.pagination.has_next_page == false + assert resp.meta.pagination.has_prev_page == false + assert resp.meta.pagination.links == %{} + end + + test "retrieves all funnels", %{conn: conn, site: site, token: token} do + {:ok, g1} = Plausible.Goals.create(site, %{"page_path" => "/product/123"}) + + {:ok, g2} = + Plausible.Goals.create(site, %{"event_name" => "Purchase", "currency" => "EUR"}) + + {:ok, g3} = Plausible.Goals.create(site, %{"event_name" => "FiveStarReview"}) + + for i <- 1..3 do + {:ok, _} = + Plausible.Funnels.create( + site, + "Funnel #{i}", + [g1, g2, g3] + ) + end + + url = Routes.plugins_api_funnels_url(PlausibleWeb.Endpoint, :index) + + resp = + conn + |> authenticate(site.domain, token) + |> get(url) + |> json_response(200) + |> assert_schema("Funnel.ListResponse", spec()) + + assert Enum.count(resp.funnels) == 3 + end + + test "retrieves funnels with pagination", %{conn: conn, site: site, token: token} do + {:ok, g1} = Plausible.Goals.create(site, %{"page_path" => "/product/123"}) + + {:ok, g2} = + Plausible.Goals.create(site, %{"event_name" => "Purchase", "currency" => "EUR"}) + + {:ok, g3} = Plausible.Goals.create(site, %{"event_name" => "FiveStarReview"}) + + initial_order = Enum.shuffle([g1, g2, g3]) + + for i <- 1..3 do + {:ok, _} = + Plausible.Funnels.create( + site, + "Funnel #{i}", + initial_order + ) + end + + url = Routes.plugins_api_funnels_url(PlausibleWeb.Endpoint, :index, limit: 2) + initial_conn = authenticate(conn, site.domain, token) + + page1 = + initial_conn + |> get(url) + |> json_response(200) + |> assert_schema("Funnel.ListResponse", spec()) + + assert Enum.count(page1.funnels) == 2 + assert page1.meta.pagination.has_next_page == true + assert page1.meta.pagination.has_prev_page == false + + assert [%{funnel: %{steps: steps}}, %{funnel: %{steps: steps}}] = page1.funnels + assert Enum.map(steps, & &1.goal.id) == Enum.map(initial_order, & &1.id) + + page2 = + initial_conn + |> get(page1.meta.pagination.links.next.url) + |> json_response(200) + |> assert_schema("Funnel.ListResponse", spec()) + + assert Enum.count(page2.funnels) == 1 + assert page2.meta.pagination.has_next_page == false + assert page2.meta.pagination.has_prev_page == true + end + end + + describe "put /funnels - funnel creation" do + test "creates a funnel including its goals", %{conn: conn, token: token, site: site} do + url = Routes.plugins_api_funnels_url(PlausibleWeb.Endpoint, :create) + + payload = %{ + funnel: %{ + name: "My Test Funnel", + steps: [ + %{ + goal_type: "Goal.CustomEvent", + goal: %{event_name: "Signup"} + }, + %{ + goal_type: "Goal.Pageview", + goal: %{path: "/checkout"} + }, + %{ + goal_type: "Goal.Revenue", + goal: %{event_name: "Purchase", currency: "EUR"} + } + ] + } + } + + assert_request_schema(payload, "Funnel.CreateRequest", spec()) + + conn = + conn + |> authenticate(site.domain, token) + |> put_req_header("content-type", "application/json") + |> put(url, payload) + + resp = + conn + |> json_response(201) + |> assert_schema("Funnel", spec()) + + [location] = get_resp_header(conn, "location") + + assert location == + Routes.plugins_api_funnels_url(PlausibleWeb.Endpoint, :get, resp.funnel.id) + + funnel = Plausible.Funnels.get(site, resp.funnel.id) + + assert funnel.name == resp.funnel.name + assert funnel.site_id == site.id + assert Enum.count(funnel.steps) == 3 + end + + test "fails for insufficient plan", %{conn: conn, token: token, site: site} do + [owner | _] = Plausible.Repo.preload(site, :owners).owners + subscribe_to_growth_plan(owner) + + url = Routes.plugins_api_funnels_url(PlausibleWeb.Endpoint, :create) + + payload = %{ + funnel: %{ + name: "My Test Funnel", + steps: [ + %{ + goal_type: "Goal.CustomEvent", + goal: %{event_name: "Signup"} + }, + %{ + goal_type: "Goal.Pageview", + goal: %{path: "/checkout"} + } + ] + } + } + + assert_request_schema(payload, "Funnel.CreateRequest", spec()) + + conn + |> authenticate(site.domain, token) + |> put_req_header("content-type", "application/json") + |> put(url, payload) + |> json_response(402) + |> assert_schema("PaymentRequiredError", spec()) + end + + test "fails with only one step - guarded by the schema", %{ + conn: conn, + token: token, + site: site + } do + url = Routes.plugins_api_funnels_url(PlausibleWeb.Endpoint, :create) + + payload = %{ + funnel: %{ + name: "My Test Funnel", + steps: [ + %{ + goal_type: "Goal.CustomEvent", + goal: %{event_name: "Signup"} + } + ] + } + } + + resp = + conn + |> authenticate(site.domain, token) + |> put_req_header("content-type", "application/json") + |> put(url, payload) + |> json_response(422) + |> assert_schema("UnprocessableEntityError", spec()) + + assert %{errors: [%{detail: "Array length 1 is smaller than minItems: 2"}]} = resp + end + + test "is idempotent on full creation", %{conn: conn, token: token, site: site} do + url = Routes.plugins_api_funnels_url(PlausibleWeb.Endpoint, :create) + + {:ok, _g1} = + Plausible.Goals.create(site, %{"event_name" => "Purchase", "currency" => "EUR"}) + + payload = %{ + funnel: %{ + name: "My Test Funnel", + steps: [ + %{ + goal_type: "Goal.CustomEvent", + goal: %{event_name: "Signup"} + }, + %{ + goal_type: "Goal.Pageview", + goal: %{path: "/checkout"} + }, + %{ + goal_type: "Goal.Revenue", + goal: %{event_name: "Purchase", currency: "EUR"} + } + ] + } + } + + assert_request_schema(payload, "Funnel.CreateRequest", spec()) + + initial_conn = + conn + |> authenticate(site.domain, token) + |> put_req_header("content-type", "application/json") + + resp1 = + initial_conn + |> put(url, payload) + |> json_response(201) + |> assert_schema("Funnel", spec()) + + resp2 = + initial_conn + |> put(url, payload) + |> json_response(201) + |> assert_schema("Funnel", spec()) + + assert resp1.funnel == resp2.funnel + end + + test "edge case - different currency goal already exists", %{ + conn: conn, + token: token, + site: site + } do + url = Routes.plugins_api_funnels_url(PlausibleWeb.Endpoint, :create) + + {:ok, _g1} = + Plausible.Goals.create(site, %{"event_name" => "Purchase", "currency" => "USD"}) + + payload = %{ + funnel: %{ + name: "My Test Funnel", + steps: [ + %{ + goal_type: "Goal.CustomEvent", + goal: %{event_name: "Signup"} + }, + %{ + goal_type: "Goal.Pageview", + goal: %{path: "/checkout"} + }, + %{ + goal_type: "Goal.Revenue", + goal: %{event_name: "Purchase", currency: "EUR"} + } + ] + } + } + + assert_request_schema(payload, "Funnel.CreateRequest", spec()) + + resp = + conn + |> authenticate(site.domain, token) + |> put_req_header("content-type", "application/json") + |> put(url, payload) + |> json_response(422) + |> assert_schema("UnprocessableEntityError", spec()) + + assert [%{detail: "event_name: 'Purchase' (with currency: USD) has already been taken"}] = + resp.errors + end + end + end +end diff --git a/test/plausible_web/plugins/api/controllers/goals_test.exs b/test/plausible_web/plugins/api/controllers/goals_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..df2e7c089ae7111e91efe93ee370746a6c3e2b4a --- /dev/null +++ b/test/plausible_web/plugins/api/controllers/goals_test.exs @@ -0,0 +1,907 @@ +defmodule PlausibleWeb.Plugins.API.Controllers.GoalsTest do + use PlausibleWeb.PluginsAPICase, async: true + alias PlausibleWeb.Plugins.API.Schemas + + describe "examples" do + test "Goal.CreateRequest.Revenue" do + assert_schema( + Schemas.Goal.CreateRequest.Revenue.schema().example, + "Goal.CreateRequest.Revenue", + spec() + ) + end + + test "Goal.CreateRequest.Pageview" do + assert_schema( + Schemas.Goal.CreateRequest.Pageview.schema().example, + "Goal.CreateRequest.Pageview", + spec() + ) + end + + test "Goal.CreateRequest.CustomEvent" do + assert_schema( + Schemas.Goal.CreateRequest.CustomEvent.schema().example, + "Goal.CreateRequest.CustomEvent", + spec() + ) + end + end + + describe "unauthorized calls" do + for {method, url} <- [ + {:get, Routes.plugins_api_goals_url(PlausibleWeb.Endpoint, :index)}, + {:get, Routes.plugins_api_goals_url(PlausibleWeb.Endpoint, :get, 1)}, + {:put, Routes.plugins_api_goals_url(PlausibleWeb.Endpoint, :create, %{})}, + {:delete, Routes.plugins_api_goals_url(PlausibleWeb.Endpoint, :delete, 1)}, + {:delete, Routes.plugins_api_goals_url(PlausibleWeb.Endpoint, :delete_bulk, %{})} + ] do + test "unauthorized call: #{method} #{url}", %{conn: conn} do + conn + |> unquote(method)(unquote(url)) + |> json_response(401) + |> assert_schema("UnauthorizedError", spec()) + end + end + end + + describe "business tier" do + @tag :ee_only + test "fails on revenue goal creation attempt with insufficient plan", %{ + site: site, + token: token, + conn: conn + } do + subscribe_to_growth_plan(site.team) + + url = Routes.plugins_api_goals_url(PlausibleWeb.Endpoint, :create) + + payload = %{ + goal_type: "Goal.Revenue", + goal: %{event_name: "Purchase", currency: "EUR"} + } + + assert_request_schema(payload, "Goal.CreateRequest.Revenue", spec()) + + conn + |> authenticate(site.domain, token) + |> put_req_header("content-type", "application/json") + |> put(url, payload) + |> json_response(402) + |> assert_schema("PaymentRequiredError", spec()) + end + + @tag :ee_only + test "fails on bulk revenue goal creation attempt with insufficient plan", %{ + site: site, + token: token, + conn: conn + } do + subscribe_to_growth_plan(site.team) + + url = Routes.plugins_api_goals_url(PlausibleWeb.Endpoint, :create) + + payload = %{ + goals: [ + %{ + goal_type: "Goal.CustomEvent", + goal: %{event_name: "Signup"} + }, + %{ + goal_type: "Goal.Revenue", + goal: %{event_name: "Purchase", currency: "EUR"} + }, + %{ + goal_type: "Goal.Pageview", + goal: %{path: "/checkout"} + } + ] + } + + conn + |> authenticate(site.domain, token) + |> put_req_header("content-type", "application/json") + |> put(url, payload) + |> json_response(402) + |> assert_schema("PaymentRequiredError", spec()) + end + end + + describe "put /goals - create a single goal" do + test "creates a custom event goal with custom props", %{conn: conn, token: token, site: site} do + subscribe_to_business_plan(site.team) + + url = Routes.plugins_api_goals_url(PlausibleWeb.Endpoint, :create) + + payload = %{ + goal_type: "Goal.CustomEvent", + goal: %{event_name: "Signup", custom_props: %{"tier" => "premium", "source" => "landing"}} + } + + assert_request_schema(payload, "Goal.CreateRequest.CustomEvent", spec()) + + conn = + conn + |> authenticate(site.domain, token) + |> put_req_header("content-type", "application/json") + |> put(url, payload) + + resp = json_response(conn, 201) + + schema = assert_schema(resp, "Goal.ListResponse", spec()) + + resp + |> Map.fetch!("goals") + |> List.first() + |> assert_schema("Goal.CustomEvent", spec()) + + [location] = get_resp_header(conn, "location") + + assert location == + Routes.plugins_api_goals_url( + PlausibleWeb.Endpoint, + :get, + List.first(schema.goals).goal.id + ) + + [goal] = Plausible.Goals.for_site(site) + assert goal.event_name == "Signup" + assert goal.custom_props == %{"tier" => "premium", "source" => "landing"} + + first_goal = List.first(resp["goals"]) + assert first_goal["goal"]["custom_props"] == %{"tier" => "premium", "source" => "landing"} + end + + test "fails to create goal with custom prop having invalid value", %{ + conn: conn, + token: token, + site: site + } do + subscribe_to_business_plan(site.team) + + url = Routes.plugins_api_goals_url(PlausibleWeb.Endpoint, :create) + + payload = %{ + goal_type: "Goal.CustomEvent", + goal: %{event_name: "Signup", custom_props: %{"count" => 5}} + } + + resp = + conn + |> authenticate(site.domain, token) + |> put_req_header("content-type", "application/json") + |> put(url, payload) + |> json_response(422) + |> assert_schema("UnprocessableEntityError", spec()) + + assert Enum.any?(resp.errors, &(&1.detail == "Invalid string. Got: integer")) + end + + test "fails to create goal with more than 3 custom props", %{ + conn: conn, + token: token, + site: site + } do + subscribe_to_business_plan(site.team) + + url = Routes.plugins_api_goals_url(PlausibleWeb.Endpoint, :create) + + payload = %{ + goal_type: "Goal.CustomEvent", + goal: %{ + event_name: "Signup", + custom_props: %{"a" => "1", "b" => "2", "c" => "3", "d" => "4"} + } + } + + resp = + conn + |> authenticate(site.domain, token) + |> put_req_header("content-type", "application/json") + |> put(url, payload) + |> json_response(422) + |> assert_schema("UnprocessableEntityError", spec()) + + assert Enum.any?( + resp.errors, + &(&1.detail == "Object property count 4 is greater than maxProperties: 3") + ) + end + + test "fails to create goal with null custom props values", %{ + conn: conn, + token: token, + site: site + } do + subscribe_to_business_plan(site.team) + + url = Routes.plugins_api_goals_url(PlausibleWeb.Endpoint, :create) + + payload = %{ + goal_type: "Goal.CustomEvent", + goal: %{event_name: "Signup", custom_props: %{"tier" => nil}} + } + + resp = + conn + |> authenticate(site.domain, token) + |> put_req_header("content-type", "application/json") + |> put(url, payload) + |> json_response(422) + |> assert_schema("UnprocessableEntityError", spec()) + + assert Enum.any?(resp.errors, &(&1.detail == "null value where string expected")) + end + + @tag :ee_only + test "fails without Props feature when custom props is non-empty", %{ + conn: conn, + token: token, + site: site + } do + site = Plausible.Repo.preload(site, :team) + # End trial to ensure team doesn't have access to Props feature + site.team + |> Ecto.Changeset.change(%{trial_expiry_date: Date.add(Date.utc_today(), -1)}) + |> Plausible.Repo.update!() + + url = Routes.plugins_api_goals_url(PlausibleWeb.Endpoint, :create) + + payload = %{ + goal_type: "Goal.CustomEvent", + goal: %{event_name: "Signup", custom_props: %{"tier" => "premium"}} + } + + conn + |> authenticate(site.domain, token) + |> put_req_header("content-type", "application/json") + |> put(url, payload) + |> json_response(402) + |> assert_schema("PaymentRequiredError", spec()) + end + + test "validates input according to the schema", %{conn: conn, token: token, site: site} do + url = Routes.plugins_api_goals_url(PlausibleWeb.Endpoint, :create) + + conn + |> authenticate(site.domain, token) + |> put_req_header("content-type", "application/json") + |> put(url, %{goal_type: "Goal.SomeTypo", goal: %{event_name: "Signup"}}) + |> json_response(422) + |> assert_schema("UnprocessableEntityError", spec()) + end + + test "creates a custom event goal", %{conn: conn, token: token, site: site} do + url = Routes.plugins_api_goals_url(PlausibleWeb.Endpoint, :create) + + payload = %{goal_type: "Goal.CustomEvent", goal: %{event_name: "Signup"}} + + assert_request_schema(payload, "Goal.CreateRequest.CustomEvent", spec()) + + conn = + conn + |> authenticate(site.domain, token) + |> put_req_header("content-type", "application/json") + |> put(url, payload) + + resp = json_response(conn, 201) + + schema = assert_schema(resp, "Goal.ListResponse", spec()) + + resp + |> Map.fetch!("goals") + |> List.first() + |> assert_schema("Goal.CustomEvent", spec()) + + [location] = get_resp_header(conn, "location") + + assert location == + Routes.plugins_api_goals_url( + PlausibleWeb.Endpoint, + :get, + List.first(schema.goals).goal.id + ) + + assert [%{event_name: "Signup"}] = Plausible.Goals.for_site(site) + end + + @tag :ee_only + test "creates a revenue goal", %{conn: conn, token: token, site: site} do + url = Routes.plugins_api_goals_url(PlausibleWeb.Endpoint, :create) + + payload = %{ + goal_type: "Goal.Revenue", + goal: %{event_name: "Purchase", currency: "EUR"} + } + + assert_request_schema(payload, "Goal.CreateRequest.Revenue", spec()) + + conn = + conn + |> authenticate(site.domain, token) + |> put_req_header("content-type", "application/json") + |> put(url, payload) + + resp = json_response(conn, 201) + + schema = assert_schema(resp, "Goal.ListResponse", spec()) + + resp + |> Map.fetch!("goals") + |> List.first() + |> assert_schema("Goal.Revenue", spec()) + + [location] = get_resp_header(conn, "location") + + assert location == + Routes.plugins_api_goals_url( + PlausibleWeb.Endpoint, + :get, + List.first(schema.goals).goal.id + ) + + assert [%{event_name: "Purchase", currency: :EUR}] = Plausible.Goals.for_site(site) + end + + @tag :ee_only + test "fails to create a revenue goal with unknown currency", %{ + conn: conn, + token: token, + site: site + } do + url = Routes.plugins_api_goals_url(PlausibleWeb.Endpoint, :create) + + payload = %{ + goal_type: "Goal.Revenue", + goal: %{event_name: "Purchase", currency: "DFJKHJESFHYU"} + } + + assert_request_schema(payload, "Goal.CreateRequest.Revenue", spec()) + + conn = + conn + |> authenticate(site.domain, token) + |> put_req_header("content-type", "application/json") + |> put(url, payload) + + resp = + conn + |> json_response(422) + |> assert_schema("UnprocessableEntityError", spec()) + + assert [%{detail: "currency: is invalid"}] = resp.errors + end + + @tag :ee_only + test "edge case - revenue goal exists under the same name and different currency", %{ + conn: conn, + token: token, + site: site + } do + url = Routes.plugins_api_goals_url(PlausibleWeb.Endpoint, :create) + + {:ok, _} = Plausible.Goals.create(site, %{"event_name" => "Purchase", "currency" => "USD"}) + + conn = + conn + |> authenticate(site.domain, token) + |> put_req_header("content-type", "application/json") + |> put(url, %{goal_type: "Goal.Revenue", goal: %{event_name: "Purchase", currency: "EUR"}}) + + resp = + conn + |> json_response(422) + |> assert_schema("UnprocessableEntityError", spec()) + + assert [%{detail: "event_name: 'Purchase' (with currency: USD) has already been taken"}] = + resp.errors + end + + test "creates a pageview goal", %{conn: conn, token: token, site: site} do + url = Routes.plugins_api_goals_url(PlausibleWeb.Endpoint, :create) + + payload = %{goal_type: "Goal.Pageview", goal: %{path: "/checkout"}} + assert_request_schema(payload, "Goal.CreateRequest.Pageview", spec()) + + conn = + conn + |> authenticate(site.domain, token) + |> put_req_header("content-type", "application/json") + |> put(url, payload) + + resp = json_response(conn, 201) + schema = assert_schema(resp, "Goal.ListResponse", spec()) + + resp + |> Map.fetch!("goals") + |> List.first() + |> assert_schema("Goal.Pageview", spec()) + + [location] = get_resp_header(conn, "location") + + assert location == + Routes.plugins_api_goals_url( + PlausibleWeb.Endpoint, + :get, + List.first(schema.goals).goal.id + ) + + assert [%{page_path: "/checkout"}] = Plausible.Goals.for_site(site) + end + + test "fails to create goal when max goals per site limit reached", %{ + conn: conn, + token: token, + site: site + } do + for i <- 1..10, do: {:ok, _} = Plausible.Goals.create(site, %{"event_name" => "G#{i}"}) + + url = Routes.plugins_api_goals_url(PlausibleWeb.Endpoint, :create) + + payload = %{goal_type: "Goal.Pageview", goal: %{path: "/checkout"}} + + resp = + conn + |> authenticate(site.domain, token) + |> put_req_header("content-type", "application/json") + |> put(url, payload) + |> json_response(422) + |> assert_schema("UnprocessableEntityError", spec()) + + assert %Schemas.Error{ + detail: "event_name: Maximum number of goals reached" + } in resp.errors + + assert Plausible.Goals.count(site) == 10 + end + + test "is idempotent", %{conn: conn, token: token, site: site} do + url = Routes.plugins_api_goals_url(PlausibleWeb.Endpoint, :create) + + initial_conn = + conn + |> authenticate(site.domain, token) + |> put_req_header("content-type", "application/json") + + resp1 = + initial_conn + |> put(url, %{goal_type: "Goal.Pageview", goal: %{path: "/checkout"}}) + |> json_response(201) + + schema = assert_schema(resp1, "Goal.ListResponse", spec()) + + resp1 + |> Map.fetch!("goals") + |> List.first() + |> assert_schema("Goal.Pageview", spec()) + + assert initial_conn + |> put(url, %{goal_type: "Goal.Pageview", goal: %{path: "/checkout"}}) + |> json_response(201) + |> assert_schema("Goal.ListResponse", spec()) == schema + end + end + + describe "put /goals - bulk creation" do + @tag :ee_only + test "creates a goal of each type", %{conn: conn, token: token, site: site} do + url = Routes.plugins_api_goals_url(PlausibleWeb.Endpoint, :create) + + payload = %{ + goals: [ + %{ + goal_type: "Goal.CustomEvent", + goal: %{event_name: "Signup"} + }, + %{ + goal_type: "Goal.Revenue", + goal: %{event_name: "Purchase", currency: "EUR"} + }, + %{ + goal_type: "Goal.Pageview", + goal: %{path: "/checkout"} + } + ] + } + + assert_request_schema(payload, "Goal.CreateRequest.BulkGetOrCreate", spec()) + + conn = + conn + |> authenticate(site.domain, token) + |> put_req_header("content-type", "application/json") + |> put(url, payload) + + resp = + conn + |> json_response(201) + |> assert_schema("Goal.ListResponse", spec()) + + [l1, l2, l3] = get_resp_header(conn, "location") + + assert l1 == + Routes.plugins_api_goals_url( + PlausibleWeb.Endpoint, + :get, + Enum.at(resp.goals, 0).goal.id + ) + + assert l2 == + Routes.plugins_api_goals_url( + PlausibleWeb.Endpoint, + :get, + Enum.at(resp.goals, 1).goal.id + ) + + assert l3 == + Routes.plugins_api_goals_url( + PlausibleWeb.Endpoint, + :get, + Enum.at(resp.goals, 2).goal.id + ) + + assert Enum.count(resp.goals) == 3 + + assert [ + %{page_path: "/checkout"}, + %{event_name: "Purchase", currency: :EUR}, + %{event_name: "Signup"} + ] = Plausible.Goals.for_site(site) + end + + test "no more than 8 goals can be created in bulk", %{conn: conn, token: token, site: site} do + # if this test fails due to implementation change, consider what to do with the pagination meta + # object returned in the response and also revise how funnels are created based on a list of goals + # - the funnels creation endpoint will likely reuse this schema's constraints + url = Routes.plugins_api_goals_url(PlausibleWeb.Endpoint, :create) + + payload = + Enum.map(1..9, fn i -> + %{goal_type: "Goal.CustomEvent", goal: %{event_name: "Bulk Goal ##{i}"}} + end) + + resp = + conn + |> authenticate(site.domain, token) + |> put_req_header("content-type", "application/json") + |> put(url, %{ + goals: payload + }) + |> json_response(422) + |> assert_schema("UnprocessableEntityError", spec()) + + assert %Schemas.Error{ + detail: "Array length 9 is larger than maxItems: 8" + } in resp.errors + end + + @tag :ee_only + test "is idempotent", %{conn: conn, token: token, site: site} do + url = Routes.plugins_api_goals_url(PlausibleWeb.Endpoint, :create) + + initial_conn = + conn + |> authenticate(site.domain, token) + |> put_req_header("content-type", "application/json") + + payload = [ + %{ + goal_type: "Goal.CustomEvent", + goal: %{event_name: "Signup"} + }, + %{ + goal_type: "Goal.Revenue", + goal: %{event_name: "Purchase", currency: "EUR"} + }, + %{ + goal_type: "Goal.Pageview", + goal: %{path: "/checkout"} + } + ] + + initial_conn + |> put(url, %{goals: payload}) + |> json_response(201) + |> assert_schema("Goal.ListResponse", spec()) + + initial_conn + |> put(url, %{goals: payload}) + |> json_response(201) + |> assert_schema("Goal.ListResponse", spec()) + end + + @tag :ee_only + test "edge case - revenue goals exist under the same name and different currency", %{ + conn: conn, + token: token, + site: site + } do + url = Routes.plugins_api_goals_url(PlausibleWeb.Endpoint, :create) + + initial_conn = + conn + |> authenticate(site.domain, token) + |> put_req_header("content-type", "application/json") + + payload1 = [%{goal_type: "Goal.Revenue", goal: %{event_name: "Purchase", currency: "EUR"}}] + payload2 = [%{goal_type: "Goal.Revenue", goal: %{event_name: "Purchase", currency: "USD"}}] + + initial_conn + |> put(url, %{goals: payload1}) + |> json_response(201) + |> assert_schema("Goal.ListResponse", spec()) + + resp = + initial_conn + |> put(url, %{goals: payload2}) + |> json_response(422) + |> assert_schema("UnprocessableEntityError", spec()) + + assert [%{detail: "event_name: 'Purchase' (with currency: EUR) has already been taken"}] = + resp.errors + end + end + + describe "get /goals/:id" do + test "validates input out of the box", %{conn: conn, token: token, site: site} do + url = Routes.plugins_api_goals_url(PlausibleWeb.Endpoint, :get, "hello") + + resp = + conn + |> authenticate(site.domain, token) + |> get(url) + |> json_response(422) + |> assert_schema("UnprocessableEntityError", spec()) + + assert %{errors: [%{detail: "Invalid integer. Got: string"}]} = resp + end + + @tag :ee_only + test "retrieves revenue goal by ID", %{conn: conn, site: site, token: token} do + {:ok, goal} = + Plausible.Goals.create(site, %{"event_name" => "Purchase", "currency" => "EUR"}) + + url = Routes.plugins_api_goals_url(PlausibleWeb.Endpoint, :get, goal.id) + + resp = + conn + |> authenticate(site.domain, token) + |> get(url) + |> json_response(200) + + assert_schema(resp, "Goal", spec()) + schema = assert_schema(resp, "Goal.Revenue", spec()) + + assert schema.goal.id == goal.id + assert schema.goal_type == "Goal.Revenue" + assert schema.goal.display_name == "Purchase" + end + + test "retrieves pageview goal by ID", %{conn: conn, site: site, token: token} do + {:ok, goal} = Plausible.Goals.create(site, %{"page_path" => "/checkout"}) + + url = Routes.plugins_api_goals_url(PlausibleWeb.Endpoint, :get, goal.id) + + resp = + conn + |> authenticate(site.domain, token) + |> get(url) + |> json_response(200) + + assert_schema(resp, "Goal", spec()) + schema = assert_schema(resp, "Goal.Pageview", spec()) + + assert schema.goal.id == goal.id + assert schema.goal_type == "Goal.Pageview" + assert schema.goal.display_name == "Visit /checkout" + end + + test "retrieves custom event goal by ID", %{conn: conn, site: site, token: token} do + {:ok, goal} = + Plausible.Goals.create(site, %{ + "event_name" => "Signup", + "custom_props" => %{"tier" => "premium"} + }) + + url = Routes.plugins_api_goals_url(PlausibleWeb.Endpoint, :get, goal.id) + + resp = + conn + |> authenticate(site.domain, token) + |> get(url) + |> json_response(200) + + assert_schema(resp, "Goal", spec()) + schema = assert_schema(resp, "Goal.CustomEvent", spec()) + + assert schema.goal.id == goal.id + assert schema.goal_type == "Goal.CustomEvent" + assert schema.goal.display_name == "Signup" + assert schema.goal.custom_props == %{"tier" => "premium"} + end + end + + describe "get /goals" do + test "returns goals with custom props in response", %{conn: conn, site: site, token: token} do + {:ok, _g1} = + Plausible.Goals.create(site, %{ + "event_name" => "Signup", + "custom_props" => %{"tier" => "premium"} + }) + + {:ok, _g2} = + Plausible.Goals.create(site, %{"page_path" => "/checkout", "custom_props" => %{}}) + + url = Routes.plugins_api_goals_url(PlausibleWeb.Endpoint, :index) + + resp = + conn + |> authenticate(site.domain, token) + |> get(url) + |> json_response(200) + |> assert_schema("Goal.ListResponse", spec()) + + [checkout, signup] = resp.goals + + assert signup.goal.event_name == "Signup" + assert signup.goal.custom_props == %{"tier" => "premium"} + + assert checkout.goal.path == "/checkout" + assert checkout.goal.custom_props == %{} + end + + test "returns an empty goals list if there's none", %{ + conn: conn, + token: token, + site: site + } do + url = Routes.plugins_api_goals_url(PlausibleWeb.Endpoint, :index) + + resp = + conn + |> authenticate(site.domain, token) + |> get(url) + |> json_response(200) + |> assert_schema("Goal.ListResponse", spec()) + + assert resp.goals == [] + assert resp.meta.pagination.has_next_page == false + assert resp.meta.pagination.has_prev_page == false + assert resp.meta.pagination.links == %{} + end + + @tag :ee_only + test "returns a list of goals of each possible goal type", %{ + conn: conn, + site: site, + token: token + } do + {:ok, g1} = Plausible.Goals.create(site, %{"event_name" => "Signup"}) + {:ok, g2} = Plausible.Goals.create(site, %{"event_name" => "Purchase", "currency" => "EUR"}) + {:ok, g3} = Plausible.Goals.create(site, %{"page_path" => "/checkout"}) + + url = Routes.plugins_api_goals_url(PlausibleWeb.Endpoint, :index) + + resp = + conn + |> authenticate(site.domain, token) + |> get(url) + |> json_response(200) + |> assert_schema("Goal.ListResponse", spec()) + + assert [checkout, purchase, signup] = resp.goals + assert checkout.goal.display_name == "Visit /checkout" + assert checkout.goal.path == "/checkout" + assert checkout.goal.id == g3.id + assert checkout.goal_type == "Goal.Pageview" + + assert purchase.goal.display_name == "Purchase" + assert purchase.goal.currency == "EUR" + assert purchase.goal.event_name == "Purchase" + assert purchase.goal.id == g2.id + assert purchase.goal_type == "Goal.Revenue" + + assert signup.goal.display_name == "Signup" + assert signup.goal.event_name == "Signup" + assert signup.goal.id == g1.id + assert signup.goal_type == "Goal.CustomEvent" + end + + test "returns a list of goals with pagination", %{conn: conn, site: site, token: token} do + for i <- 1..5 do + insert(:goal, site: site, event_name: "Goal #{i}") + end + + url = Routes.plugins_api_goals_url(PlausibleWeb.Endpoint, :index, limit: 2) + + initial_conn = authenticate(conn, site.domain, token) + + page1 = + initial_conn + |> get(url) + |> json_response(200) + |> assert_schema("Goal.ListResponse", spec()) + + assert [%{goal: %{event_name: "Goal 5"}}, %{goal: %{event_name: "Goal 4"}}] = page1.goals + assert page1.meta.pagination.has_next_page == true + assert page1.meta.pagination.has_prev_page == false + assert page1.meta.pagination.links.next + refute page1.meta.pagination.links[:prev] + + page2 = + initial_conn + |> get(page1.meta.pagination.links.next.url) + |> json_response(200) + |> assert_schema("Goal.ListResponse", spec()) + + assert [%{goal: %{event_name: "Goal 3"}}, %{goal: %{event_name: "Goal 2"}}] = page2.goals + + assert page2.meta.pagination.has_next_page == true + assert page2.meta.pagination.has_prev_page == true + assert page2.meta.pagination.links.next + assert page2.meta.pagination.links.prev + + assert ^page1 = + initial_conn + |> get(page2.meta.pagination.links.prev.url) + |> json_response(200) + |> assert_schema("Goal.ListResponse", spec()) + end + end + + describe "delete /goals/:id" do + test "deletes goal by id", %{conn: conn, site: site, token: token} do + {:ok, %{id: goal_id}} = + Plausible.Goals.create(site, %{"event_name" => "Purchase", "currency" => "USD"}) + + url = Routes.plugins_api_goals_url(PlausibleWeb.Endpoint, :delete, goal_id) + + conn + |> authenticate(site.domain, token) + |> delete(url) + |> response(204) + + refute Plausible.Repo.exists?(Plausible.Goal) + end + + test "is idempotent", %{conn: conn, site: site, token: token} do + url = Routes.plugins_api_goals_url(PlausibleWeb.Endpoint, :delete, 666) + + conn + |> authenticate(site.domain, token) + |> delete(url) + |> response(204) + end + end + + describe "delete - bulk" do + test "delete multiple goals", %{conn: conn, site: site, token: token} do + {:ok, g1} = + Plausible.Goals.create(site, %{"event_name" => "Purchase", "currency" => "USD"}) + + {:ok, g2} = + Plausible.Goals.create(site, %{"event_name" => "Signup"}) + + {:ok, g3} = + Plausible.Goals.create(site, %{"page_path" => "/home"}) + + url = Routes.plugins_api_goals_url(PlausibleWeb.Endpoint, :delete_bulk) + + payload = %{ + goal_ids: [ + g1.id, + g2.id, + g3.id + ] + } + + conn + |> authenticate(site.domain, token) + |> put_req_header("content-type", "application/json") + |> delete(url, payload) + |> response(204) + + refute Plausible.Repo.exists?(Plausible.Goal) + end + end +end diff --git a/test/plausible_web/plugins/api/controllers/shared_links_test.exs b/test/plausible_web/plugins/api/controllers/shared_links_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..af1c86c47f686c54dffbfc11c7180574bc301799 --- /dev/null +++ b/test/plausible_web/plugins/api/controllers/shared_links_test.exs @@ -0,0 +1,304 @@ +defmodule PlausibleWeb.Plugins.API.Controllers.SharedLinksTest do + use PlausibleWeb.PluginsAPICase, async: true + + describe "examples" do + test "SharedLink" do + assert_schema( + Schemas.SharedLink.schema().example, + "SharedLink", + spec() + ) + end + end + + describe "unauthorized calls" do + for {method, url} <- [ + {:get, Routes.plugins_api_shared_links_url(PlausibleWeb.Endpoint, :get, 1)}, + {:put, Routes.plugins_api_shared_links_url(PlausibleWeb.Endpoint, :create)}, + {:get, Routes.plugins_api_shared_links_url(PlausibleWeb.Endpoint, :index)} + ] do + test "unauthorized call: #{method} #{url}", %{conn: conn} do + conn + |> unquote(method)(unquote(url)) + |> json_response(401) + |> assert_schema("UnauthorizedError", spec()) + end + end + end + + describe "get /shared_links/:id" do + test "validates input out of the box", %{conn: conn, token: token, site: site} do + url = Routes.plugins_api_shared_links_url(PlausibleWeb.Endpoint, :get, "hello") + + resp = + conn + |> authenticate(site.domain, token) + |> get(url) + |> json_response(422) + |> assert_schema("UnprocessableEntityError", spec()) + + assert %{errors: [%{detail: "Invalid integer. Got: string"}]} = resp + end + + test "retrieve shared link by ID", %{conn: conn, site: site, token: token} do + shared_link = insert(:shared_link, name: "Some Link Name", site: site) + + url = Routes.plugins_api_shared_links_url(PlausibleWeb.Endpoint, :get, shared_link.id) + + resp = + conn + |> authenticate(site.domain, token) + |> get(url) + |> json_response(200) + |> assert_schema("SharedLink", spec()) + + assert resp.shared_link.href == + "http://localhost:8000/share/#{URI.encode_www_form(site.domain)}?auth=#{shared_link.slug}" + + assert resp.shared_link.id == shared_link.id + assert resp.shared_link.password_protected == false + assert resp.shared_link.name == "Some Link Name" + end + + test "fails to retrieve non-existing link", %{conn: conn, site: site, token: token} do + url = Routes.plugins_api_shared_links_url(PlausibleWeb.Endpoint, :get, 666) + + conn + |> authenticate(site.domain, token) + |> get(url) + |> json_response(404) + |> assert_schema("NotFoundError", spec()) + end + + test "fails to retrieve link from another site", %{conn: conn, site: site, token: token} do + shared_link = insert(:shared_link, name: "Some Link Name", site: build(:site)) + url = Routes.plugins_api_shared_links_url(PlausibleWeb.Endpoint, :get, shared_link.id) + + conn + |> authenticate(site.domain, token) + |> get(url) + |> json_response(404) + |> assert_schema("NotFoundError", spec()) + end + end + + describe "put /shared_links" do + test "successfully creates a shared link with the location header", %{ + conn: conn, + site: site, + token: token + } do + url = Routes.plugins_api_shared_links_url(PlausibleWeb.Endpoint, :create) + + initial_conn = authenticate(conn, site.domain, token) + + conn = + initial_conn + |> put_req_header("content-type", "application/json") + |> put(url, %{ + shared_link: %{ + name: "My Shared Link" + } + }) + + resp = + conn + |> json_response(201) + |> assert_schema("SharedLink", spec()) + + assert resp.shared_link.name == "My Shared Link" + + assert resp.shared_link.href =~ + "http://localhost:8000/share/#{URI.encode_www_form(site.domain)}?auth=" + + [location] = get_resp_header(conn, "location") + + assert location == + Routes.plugins_api_shared_links_url( + PlausibleWeb.Endpoint, + :get, + resp.shared_link.id + ) + + assert ^resp = + initial_conn + |> get(location) + |> json_response(200) + |> assert_schema("SharedLink", spec()) + end + + test "create is idempotent", %{ + conn: conn, + site: site, + token: token + } do + url = Routes.plugins_api_shared_links_url(PlausibleWeb.Endpoint, :create) + + initial_conn = authenticate(conn, site.domain, token) + + create = fn -> + initial_conn + |> put_req_header("content-type", "application/json") + |> put(url, %{ + shared_link: %{ + name: "My Shared Link" + } + }) + end + + conn = create.() + + resp = + conn + |> json_response(201) + |> assert_schema("SharedLink", spec()) + + id = resp.shared_link.id + + conn = create.() + + assert ^id = + conn + |> json_response(201) + |> Map.fetch!("shared_link") + |> Map.fetch!("id") + end + + test "validates input out of the box", %{conn: conn, token: token, site: site} do + url = Routes.plugins_api_shared_links_url(PlausibleWeb.Endpoint, :create) + + resp = + conn + |> authenticate(site.domain, token) + |> put_req_header("content-type", "application/json") + |> put(url, %{}) + |> json_response(422) + |> assert_schema("UnprocessableEntityError", spec()) + + assert %{errors: [%{detail: "Missing field: shared_link"}]} = resp + end + + test "skips shared links feature access check", %{ + conn: conn, + site: site, + token: token + } do + insert(:starter_subscription, team: site.team) + + url = Routes.plugins_api_shared_links_url(PlausibleWeb.Endpoint, :create) + + resp = + authenticate(conn, site.domain, token) + |> put_req_header("content-type", "application/json") + |> put(url, %{ + shared_link: %{ + name: "My Shared Link" + } + }) + |> json_response(201) + |> assert_schema("SharedLink", spec()) + + assert resp.shared_link.name == "My Shared Link" + + assert resp.shared_link.href =~ + "http://localhost:8000/share/#{URI.encode_www_form(site.domain)}?auth=" + end + + for special_name <- Plausible.Sites.shared_link_special_names() do + test "can create shared link with the reserved '#{special_name}' name", %{ + conn: conn, + site: site, + token: token + } do + url = Routes.plugins_api_shared_links_url(PlausibleWeb.Endpoint, :create) + + resp = + authenticate(conn, site.domain, token) + |> put_req_header("content-type", "application/json") + |> put(url, %{ + shared_link: %{ + name: unquote(special_name) + } + }) + |> json_response(201) + |> assert_schema("SharedLink", spec()) + + assert resp.shared_link.name == unquote(special_name) + + assert resp.shared_link.href =~ + "http://localhost:8000/share/#{URI.encode_www_form(site.domain)}?auth=" + end + end + end + + describe "get /shared_links" do + test "returns an empty shared link list if there's none", %{ + conn: conn, + token: token, + site: site + } do + url = Routes.plugins_api_shared_links_url(PlausibleWeb.Endpoint, :index) + + resp = + conn + |> authenticate(site.domain, token) + |> get(url) + |> json_response(200) + |> assert_schema("SharedLink.ListResponse", spec()) + + assert resp.shared_links == [] + assert resp.meta.pagination.has_next_page == false + assert resp.meta.pagination.has_prev_page == false + assert resp.meta.pagination.links == %{} + end + + test "returns a shared links list with pagination", %{ + conn: conn, + token: token, + site: site + } do + for i <- 1..5 do + insert(:shared_link, site: site, name: "Shared Link #{i}") + end + + url = + Routes.plugins_api_shared_links_url(PlausibleWeb.Endpoint, :index, limit: 2) + + initial_conn = authenticate(conn, site.domain, token) + + page1 = + initial_conn + |> get(url) + |> json_response(200) + |> assert_schema("SharedLink.ListResponse", spec()) + + assert [%{shared_link: %{name: "Shared Link 5"}}, %{shared_link: %{name: "Shared Link 4"}}] = + page1.shared_links + + assert page1.meta.pagination.has_next_page == true + assert page1.meta.pagination.has_prev_page == false + assert page1.meta.pagination.links.next + refute page1.meta.pagination.links[:prev] + + page2 = + initial_conn + |> get(page1.meta.pagination.links.next.url) + |> json_response(200) + |> assert_schema("SharedLink.ListResponse", spec()) + + assert [%{shared_link: %{name: "Shared Link 3"}}, %{shared_link: %{name: "Shared Link 2"}}] = + page2.shared_links + + assert page2.meta.pagination.has_next_page == true + assert page2.meta.pagination.has_prev_page == true + assert page2.meta.pagination.links.next + assert page2.meta.pagination.links.prev + + assert ^page1 = + initial_conn + |> get(page2.meta.pagination.links.prev.url) + |> json_response(200) + |> assert_schema("SharedLink.ListResponse", spec()) + end + end +end diff --git a/test/plausible_web/plugins/api/controllers/tracker_script_configuration_test.exs b/test/plausible_web/plugins/api/controllers/tracker_script_configuration_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..9e1415e52f46ccf12533d53376d4a13dd9adaba6 --- /dev/null +++ b/test/plausible_web/plugins/api/controllers/tracker_script_configuration_test.exs @@ -0,0 +1,216 @@ +defmodule PlausibleWeb.Plugins.API.Controllers.TrackerScriptConfigurationTest do + use PlausibleWeb.PluginsAPICase, async: true + import Plausible.AssertMatches + alias PlausibleWeb.Plugins.API.Schemas + + describe "examples" do + test "TrackerScriptConfiguration" do + assert_schema( + Schemas.TrackerScriptConfiguration.schema().example, + "TrackerScriptConfiguration", + spec() + ) + end + + test "TrackerScriptConfiguration.UpdateRequest" do + assert_schema( + Schemas.TrackerScriptConfiguration.UpdateRequest.schema().example, + "TrackerScriptConfiguration.UpdateRequest", + spec() + ) + end + end + + describe "unauthorized calls" do + for {method, url} <- [ + {:get, + Routes.plugins_api_tracker_script_configuration_url(PlausibleWeb.Endpoint, :get)}, + {:put, + Routes.plugins_api_tracker_script_configuration_url( + PlausibleWeb.Endpoint, + :update, + %{} + )} + ] do + test "unauthorized call: #{method} #{url}", %{conn: conn} do + conn + |> unquote(method)(unquote(url)) + |> json_response(401) + |> assert_schema("UnauthorizedError", spec()) + end + end + end + + describe "get/put /tracker_script_configuration" do + test "inserts a new tracker script configuration if one doesn't exist and returns it consistently", + %{conn: conn, token: token, site: site} do + initial_resp = get_tracker_script_configuration(conn, site, token) + + id = initial_resp.tracker_script_configuration.id + + assert_matches ^strict_map(%{ + id: ^any(:string), + installation_type: "manual", + hash_based_routing: false, + outbound_links: false, + file_downloads: false, + form_submissions: false + }) = initial_resp.tracker_script_configuration + + resp = get_tracker_script_configuration(conn, site, token) + + assert_matches ^strict_map(%{ + id: ^id, + installation_type: "manual", + hash_based_routing: false, + outbound_links: false, + file_downloads: false, + form_submissions: false + }) = resp.tracker_script_configuration + end + + test "both return updated tracker script configuration", %{ + conn: conn, + token: token, + site: site + } do + update_response = + update_tracker_script_configuration(conn, site, token, %{ + tracker_script_configuration: %{ + installation_type: "manual", + file_downloads: true + } + }) + + get_response = get_tracker_script_configuration(conn, site, token) + + assert get_response == update_response + + assert_matches ^strict_map(%{ + id: ^update_response.tracker_script_configuration.id, + installation_type: "manual", + hash_based_routing: false, + outbound_links: false, + file_downloads: true, + form_submissions: false + }) = get_response.tracker_script_configuration + end + + test "multiple updates only overwrite the specified fields", %{ + conn: conn, + token: token, + site: site + } do + update_tracker_script_configuration(conn, site, token, %{ + tracker_script_configuration: %{ + installation_type: "manual", + hash_based_routing: true, + outbound_links: true, + file_downloads: true, + form_submissions: true + } + }) + + update_response = + update_tracker_script_configuration(conn, site, token, %{ + tracker_script_configuration: %{ + installation_type: "wordpress", + hash_based_routing: false + } + }) + + assert_matches ^strict_map(%{ + id: ^any(:string), + installation_type: "wordpress", + hash_based_routing: false, + outbound_links: true, + file_downloads: true, + form_submissions: true + }) = update_response.tracker_script_configuration + end + + test "ignores unknown fields", %{conn: conn, token: token, site: site} do + response = + update_tracker_script_configuration(conn, site, token, %{ + tracker_script_configuration: %{ + installation_type: "wordpress", + hash_based_routing: true, + unknown_field: "unknown_value" + } + }) + + assert_matches ^strict_map(%{ + id: ^any(:string), + installation_type: "wordpress", + hash_based_routing: true, + outbound_links: false, + file_downloads: false, + form_submissions: false + }) = response.tracker_script_configuration + end + + test "installation_type parameter is required", %{conn: conn, token: token, site: site} do + url = Routes.plugins_api_tracker_script_configuration_url(PlausibleWeb.Endpoint, :update) + + payload = %{tracker_script_configuration: %{hash_based_routing: true}} + + response = + conn + |> authenticate(site.domain, token) + |> put_req_header("content-type", "application/json") + |> put(url, payload) + |> json_response(422) + |> assert_schema("UnprocessableEntityError", spec()) + + assert %{errors: [%{detail: "Missing field: installation_type"}]} = response + end + + test "installation_type and boolean parameters are validated", %{ + conn: conn, + token: token, + site: site + } do + url = Routes.plugins_api_tracker_script_configuration_url(PlausibleWeb.Endpoint, :update) + + payload = %{ + tracker_script_configuration: %{installation_type: "unknown", hash_based_routing: "1234"} + } + + response = + conn + |> authenticate(site.domain, token) + |> put_req_header("content-type", "application/json") + |> put(url, payload) + |> json_response(422) + |> assert_schema("UnprocessableEntityError", spec()) + + assert %{ + errors: [ + %{detail: "Invalid value for enum"}, + %{detail: "Invalid boolean. Got: string"} + ] + } = response + end + end + + defp get_tracker_script_configuration(conn, site, token) do + url = Routes.plugins_api_tracker_script_configuration_url(PlausibleWeb.Endpoint, :get) + + conn + |> authenticate(site.domain, token) + |> get(url) + |> json_response(200) + |> assert_schema("TrackerScriptConfiguration", spec()) + end + + defp update_tracker_script_configuration(conn, site, token, payload) do + url = Routes.plugins_api_tracker_script_configuration_url(PlausibleWeb.Endpoint, :update) + + conn + |> authenticate(site.domain, token) + |> put_req_header("content-type", "application/json") + |> put(url, payload) + |> json_response(200) + |> assert_schema("TrackerScriptConfiguration", spec()) + end +end diff --git a/test/plausible_web/plugins/api/errors_test.exs b/test/plausible_web/plugins/api/errors_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..b6a93434b55483a66bc1fdedb289f39315e4eb54 --- /dev/null +++ b/test/plausible_web/plugins/api/errors_test.exs @@ -0,0 +1,75 @@ +defmodule PlausibleWeb.Plugins.API.ErrorsTest do + use ExUnit.Case, async: true + + import Phoenix.ConnTest, only: [json_response: 2] + + alias PlausibleWeb.Plugins.API.Errors + + describe "unauthorized/1" do + test "sends an 401 response with the `www-authenticate` header set" do + conn = + Plug.Test.conn(:get, "/") + |> Errors.unauthorized() + + assert conn.halted + + assert json_response(conn, 401) == %{ + "errors" => [%{"detail" => "Plugins API: unauthorized"}] + } + + assert Plug.Conn.get_resp_header(conn, "www-authenticate") == [ + ~s[Basic realm="Plugins API Access"] + ] + end + end + + describe "error/3" do + test "formats the given error message" do + message = "Some message" + + conn = + Plug.Test.conn(:get, "/") + |> Errors.error(:forbidden, message) + + assert conn.halted + + assert json_response(conn, 403) == %{ + "errors" => [%{"detail" => "Some message"}] + } + end + + defmodule Example do + use Ecto.Schema + import Ecto.Changeset + + schema "" do + field(:name) + field(:email) + field(:age, :integer) + end + + def changeset(example, params \\ %{}) do + example + |> cast(params, [:name, :email, :age]) + |> validate_required([:name, :email]) + |> validate_format(:email, ~r/@/) + |> validate_inclusion(:age, 18..100) + end + end + + test "formats changeset errors" do + changeset = Example.changeset(%Example{}, %{email: "foo", age: 101}) + + errors = + Plug.Test.conn(:get, "/") + |> Errors.error(:bad_request, changeset) + |> json_response(400) + |> Map.fetch!("errors") + + assert Enum.count(errors) == 3 + assert %{"detail" => "age: is invalid"} in errors + assert %{"detail" => "email: has invalid format"} in errors + assert %{"detail" => "name: can't be blank"} in errors + end + end +end diff --git a/test/plausible_web/plugs/auth_plug_test.exs b/test/plausible_web/plugs/auth_plug_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..f6de7f8d7f32bed70bf62f0689d2eb51b5e4ed3a --- /dev/null +++ b/test/plausible_web/plugs/auth_plug_test.exs @@ -0,0 +1,178 @@ +defmodule PlausibleWeb.AuthPlugTest do + use PlausibleWeb.ConnCase, async: true + + alias PlausibleWeb.AuthPlug + + setup [:create_user, :log_in] + + test "does nothing if user is not logged in" do + conn = + build_conn(:get, "/") + |> init_test_session(%{}) + |> AuthPlug.call(%{}) + + assert is_nil(conn.assigns[:current_user]) + end + + test "looks up current user if they are logged in", %{conn: conn, user: user} do + subscribe_to_plan(user, "123", inserted_at: NaiveDateTime.utc_now()) + + conn = + conn + |> Plug.Adapters.Test.Conn.conn(:get, "/", %{}) + |> AuthPlug.call(%{}) + + assert conn.assigns[:current_user].id == user.id + assert conn.assigns[:my_team].subscription.paddle_plan_id == "123" + assert conn.assigns[:current_team_role] == :owner + end + + test "looks up the latest subscription", %{conn: conn, user: user} do + # old subscription + subscribe_to_plan( + user, + "123", + inserted_at: NaiveDateTime.shift(NaiveDateTime.utc_now(), day: -1) + ) + + subscribe_to_plan( + user, + "456", + inserted_at: NaiveDateTime.utc_now() + ) + + conn = + conn + |> Plug.Adapters.Test.Conn.conn(:get, "/", %{}) + |> AuthPlug.call(%{}) + + assert conn.assigns[:current_user].id == user.id + assert conn.assigns[:my_team].subscription.paddle_plan_id == "456" + end + + test "switches current team when `team` parameter provided", %{conn: conn, user: user} do + subscribe_to_plan(user, "123", inserted_at: NaiveDateTime.utc_now()) + team = team_of(user) + + conn = + conn + |> Plug.Adapters.Test.Conn.conn(:get, "/", %{__team: team.identifier}) + |> AuthPlug.call(%{}) + + assert conn.assigns[:current_team].id == team.id + assert conn.assigns[:current_team_role] == :owner + assert get_session(conn, "current_team_id") == team.identifier + end + + test "does not switch to team provided via `team` the user doesn't belong to", %{conn: conn} do + other_user = new_user() + subscribe_to_plan(other_user, "123", inserted_at: NaiveDateTime.utc_now()) + team = team_of(other_user) + + conn = + conn + |> Plug.Adapters.Test.Conn.conn(:get, "/", %{team: team.identifier}) + |> AuthPlug.call(%{}) + + refute conn.assigns[:current_team] + refute get_session(conn, "current_team_id") + end + + test "does not switch to a team from session when the user doesn't belong to it", %{conn: conn} do + other_user = new_user() + subscribe_to_plan(other_user, "123", inserted_at: NaiveDateTime.utc_now()) + team = team_of(other_user) + + conn = + conn + |> put_session("current_team_id", team.identifier) + |> Plug.Adapters.Test.Conn.conn(:get, "/", %{}) + |> AuthPlug.call(%{}) + + refute conn.assigns[:current_team] + refute get_session(conn, "current_team_id") + end + + test "falls back to my_team when there's no current team picked", %{conn: conn, user: user} do + subscribe_to_plan(user, "123", inserted_at: NaiveDateTime.utc_now()) + team = team_of(user) + + conn = + conn + |> Plug.Adapters.Test.Conn.conn(:get, "/", %{}) + |> AuthPlug.call(%{}) + + assert conn.assigns[:current_team].id == team.id + refute get_session(conn, "current_team_id") + end + + test "tracks current team role", %{conn: conn, user: user} do + other_user = new_user() + subscribe_to_plan(other_user, "123", inserted_at: NaiveDateTime.utc_now()) + team = team_of(other_user) + + add_member(team, user: user, role: :editor) + conn = set_current_team(conn, team) + + conn = + conn + |> Plug.Adapters.Test.Conn.conn(:get, "/", %{}) + |> AuthPlug.call(%{}) + + assert conn.assigns[:current_team].id == team.id + assert conn.assigns[:current_team_role] == :editor + end + + test "stores team identifier when team changes", %{conn: conn, user: user} do + subscribe_to_plan(user, "123", inserted_at: NaiveDateTime.utc_now()) + team = team_of(user) + + assert is_nil(user.last_team_identifier) + + conn = + conn + |> Plug.Adapters.Test.Conn.conn(:get, "/", %{__team: team.identifier}) + |> AuthPlug.call(%{}) + + updated_user = Plausible.Repo.reload!(user) + assert updated_user.last_team_identifier == team.identifier + assert get_session(conn, "current_team_id") == team.identifier + end + + test "clears team identifier when recently stored team identifier doesn't exist", %{ + conn: conn, + user: user + } do + subscribe_to_plan(user, "123", inserted_at: NaiveDateTime.utc_now()) + + stale_team_id = Ecto.UUID.generate() + :ok = Plausible.Users.remember_last_team(user, stale_team_id) + assert Plausible.Repo.reload!(user).last_team_identifier + + conn = + conn + |> put_session("current_team_id", stale_team_id) + |> Plug.Adapters.Test.Conn.conn(:get, "/", %{}) + |> AuthPlug.call(%{}) + + updated_user = Plausible.Repo.reload!(user) + assert is_nil(updated_user.last_team_identifier) + refute get_session(conn, "current_team_id") + end + + test "assigns expired session for further subsequent processing", %{conn: conn} do + now = NaiveDateTime.utc_now(:second) + in_the_past = NaiveDateTime.add(now, -1, :hour) + {:ok, user_session} = PlausibleWeb.UserAuth.get_user_session(conn) + user_session |> Ecto.Changeset.change(timeout_at: in_the_past) |> Plausible.Repo.update!() + + conn = + conn + |> Plug.Adapters.Test.Conn.conn(:get, "/", %{}) + |> AuthPlug.call(%{}) + + refute conn.assigns[:current_user] + refute conn.assigns[:current_team] + assert conn.assigns[:expired_session].id == user_session.id + end +end diff --git a/test/plausible_web/plugs/authorize_plugins_api_test.exs b/test/plausible_web/plugs/authorize_plugins_api_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..7630316d1c8cfa96dfacc2dc78a40ef22464c532 --- /dev/null +++ b/test/plausible_web/plugs/authorize_plugins_api_test.exs @@ -0,0 +1,98 @@ +defmodule PlausibleWeb.Plugs.AuthorizePluginsAPITest do + use PlausibleWeb.ConnCase, async: true + + alias Plausible.Plugins.API.{Token, Tokens} + alias PlausibleWeb.Plugs.AuthorizePluginsAPI + alias Plausible.Repo + + import Plug.Conn + + test "plug passes when a token is found" do + %{id: site_id} = site = insert(:site, domain: "pass.example.com") + {:ok, _, raw} = Tokens.create(site, "Some token") + + credentials = "Basic " <> Base.encode64("#{site.domain}:#{raw}") + + conn = + build_conn() + |> put_req_header("authorization", credentials) + |> AuthorizePluginsAPI.call() + + refute conn.halted + assert %Plausible.Site{id: ^site_id} = conn.assigns.authorized_site + end + + test "plug passes when a token is found, no domain provided" do + %{id: site_id} = site = insert(:site, domain: "pass.example.com") + {:ok, _, raw} = Tokens.create(site, "Some token") + + credentials = "Basic " <> Base.encode64(raw) + + conn = + build_conn() + |> put_req_header("authorization", credentials) + |> AuthorizePluginsAPI.call() + + refute conn.halted + assert %Plausible.Site{id: ^site_id} = conn.assigns.authorized_site + end + + test "plug halts when a token is not found" do + site = insert(:site, domain: "pass.example.com") + + credentials = "Basic " <> Base.encode64("#{site.domain}:invalid-token") + + conn = + build_conn() + |> put_req_header("authorization", credentials) + |> AuthorizePluginsAPI.call() + + assert conn.halted + + assert json_response(conn, 401) == %{ + "errors" => [ + %{"detail" => "Plugins API: unauthorized"} + ] + } + end + + test "plug halts when no authorization header is passed" do + conn = + build_conn() + |> AuthorizePluginsAPI.call() + + assert conn.halted + + assert json_response(conn, 401) == %{ + "errors" => [ + %{"detail" => "Plugins API: unauthorized"} + ] + } + end + + test "plug optionally doesn't halt when no authorization header is passed" do + conn = + build_conn() + |> AuthorizePluginsAPI.call(send_error?: false) + + refute conn.halted + end + + test "plug updates last seen timestamp" do + site = insert(:site, domain: "pass.example.com") + {:ok, token, raw} = Tokens.create(site, "Some token") + + refute token.last_used_at + assert Token.last_used_humanize(token) == "Not yet" + + credentials = "Basic " <> Base.encode64(raw) + + build_conn() + |> put_req_header("authorization", credentials) + |> AuthorizePluginsAPI.call() + + token = Repo.reload!(token) + assert token.last_used_at + assert Token.last_used_humanize(token) == "Just recently" + end +end diff --git a/test/plausible_web/plugs/authorize_public_api_test.exs b/test/plausible_web/plugs/authorize_public_api_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..a7aa215d2544ebca83d992afcc4c4714d55bf451 --- /dev/null +++ b/test/plausible_web/plugs/authorize_public_api_test.exs @@ -0,0 +1,618 @@ +defmodule PlausibleWeb.Plugs.AuthorizePublicAPITest do + use PlausibleWeb.ConnCase, async: false + + import ExUnit.CaptureLog + + alias PlausibleWeb.Plugs.AuthorizePublicAPI + alias Plausible.Repo + + setup %{conn: conn} do + {:ok, conn: prepare_conn_for_auth(conn)} + end + + test "halts with error when bearer token is missing", %{conn: conn} do + conn = + conn + |> get("/") + |> assign(:api_scope, "stats:read:*") + |> AuthorizePublicAPI.call(nil) + + assert conn.halted + assert json_response(conn, 401)["error"] =~ "Missing API key." + end + + test "halts with error when bearer token is invalid against read-only Stats API", %{conn: conn} do + conn = + conn + |> put_req_header("authorization", "Bearer invalid") + |> get("/") + |> assign(:api_scope, "stats:read:*") + |> AuthorizePublicAPI.call(nil) + + assert conn.halted + assert json_response(conn, 401)["error"] =~ "Invalid API key or site ID." + end + + test "halts with error when bearer token is invalid", %{conn: conn} do + conn = + conn + |> put_req_header("authorization", "Bearer invalid") + |> get("/") + |> assign(:api_scope, "sites:provision:*") + |> AuthorizePublicAPI.call(nil) + + assert conn.halted + assert json_response(conn, 401)["error"] =~ "Invalid API key." + end + + for query_string <- ["?foo=bar", "?site_id", "?site_id="] do + test "halts with error when requesting site context API without site_id parameter (query string: #{query_string})", + %{conn: conn} do + # `site_id` param is checked for nil or empty + # for `api_context: :site` APIs before the key is verified, + # therefore the key here doesn't matter + key = "123" + + conn = + conn + |> put_req_header("authorization", "Bearer #{key}") + |> get("/#{unquote(query_string)}") + |> assign(:api_context, :site) + |> assign(:api_scope, "sites:read:*") + |> AuthorizePublicAPI.call(nil) + + assert conn.halted + assert json_response(conn, 400)["error"] =~ "Missing site ID." + end + end + + @tag :ee_only + test "401 error has priority over 402 error", %{conn: conn} do + user = new_user() + _site = new_site(owner: user) + api_key = insert_api_key(:team_scope_api_key, user: user) + + another_owner = + new_user() |> subscribe_to_enterprise_plan(paddle_plan_id: "123321", features: []) + + another_site = new_site(owner: another_owner) + + conn = + conn + |> authorize(api_key, site: another_site, api_context: :site, api_scope: "stats:read:*") + + assert conn.halted + + assert json_response(conn, 401)["error"] =~ + "Invalid API key or site ID. Please make sure you're using a valid API key with access to the site you've requested." + end + + for key_type <- [:legacy_api_key, :team_scope_api_key] do + describe "#{key_type} ::" do + test "halts with error on missing site ID when request made for stats:read:* scope API, even without :site API context set", + %{conn: conn} do + api_key = insert_api_key(unquote(key_type), user: new_user()) + + conn = + conn + |> authorize(api_key, api_scope: "stats:read:*") + + assert conn.halted + assert json_response(conn, 400)["error"] =~ "Missing site ID." + end + + @tag :ee_only + test "halts with error when site's team lacks feature access", %{conn: conn} do + user = new_user() |> subscribe_to_enterprise_plan(paddle_plan_id: "123321", features: []) + site = new_site(owner: user) + api_key = insert_api_key(unquote(key_type), user: user) + + conn = + conn + |> authorize(api_key, api_scope: "stats:read:*", site: site) + + assert conn.halted + + assert json_response(conn, 402)["error"] =~ + "The account that owns this API key does not have access" + end + + on_ee do + test "rejects access to a site that is a consolidated view (unless instructed otherwise)", + %{ + conn: conn + } do + user = new_user() + api_key = insert_api_key(unquote(key_type), user: user, scopes: ["sites:provision:*"]) + {:ok, team} = new_user() |> Plausible.Teams.get_or_create() + new_site(team: team) + new_site(team: team) + consolidated_view = new_consolidated_view(team) + + conn = + conn + |> authorize(api_key, site: consolidated_view, api_scope: "sites:provision:*") + + assert conn.halted + + assert json_response(conn, 400)["error"] =~ + "This operation is unavailable for a consolidated view" + end + end + + @tag :ee_only + test "halts with error when upgrade is required", %{conn: conn} do + user = new_user() |> subscribe_to_enterprise_plan(paddle_plan_id: "123321", features: []) + site = new_site(owner: user) + api_key = insert_api_key(unquote(key_type), user: user) + + conn = + conn |> authorize(api_key, site: site, api_scope: "stats:read:*") + + assert conn.halted + + assert json_response(conn, 402)["error"] =~ + "The account that owns this API key does not have access" + end + + test "halts with error when site is locked", %{conn: conn} do + user = new_user() + site = new_site(owner: user) + site.team |> Ecto.Changeset.change(locked: true) |> Repo.update!() + api_key = insert_api_key(unquote(key_type), user: user) + + conn = + conn + |> authorize(api_key, site: site, api_scope: "stats:read:*") + + assert conn.halted + assert json_response(conn, 402)["error"] =~ "This Plausible site is locked" + end + + test "halts with error when site ID is invalid", %{conn: conn} do + user = new_user(trial_expiry_date: Date.utc_today()) + _site = new_site(owner: user) + api_key = insert_api_key(unquote(key_type), user: user) + + conn = + conn + |> authorize(api_key, site: %{domain: "invalid.domain"}, api_scope: "stats:read:*") + + assert conn.halted + assert json_response(conn, 401)["error"] =~ "Invalid API key or site ID." + end + + test "halts with error when API key owner does not have access to the requested site", %{ + conn: conn + } do + user = new_user(trial_expiry_date: Date.utc_today()) + site = new_site() + api_key = insert_api_key(unquote(key_type), user: user) + + conn = + conn + |> authorize(api_key, site: site, api_scope: "stats:read:*") + + assert conn.halted + assert json_response(conn, 401)["error"] =~ "Invalid API key or site ID." + end + + test "halts with error when API lacks required scope", %{conn: conn} do + user = new_user() + api_key = insert_api_key(unquote(key_type), user: user) + + conn = + conn |> authorize(api_key, api_scope: "sites:provision:*") + + assert conn.halted + assert json_response(conn, 401)["error"] =~ "Invalid API key." + end + + test "halts with error when over burst request limit" do + user = new_user() + api_key = insert_api_key(unquote(key_type), user: user, scopes: ["sites:read:*"]) + + limit = Plausible.Auth.ApiKey.burst_request_limit() + + # The limiter uses a fixed time window, so a client straddling a window + # boundary can legitimately get up to `2 * limit` requests through in + # quick succession. Only the `2 * limit + 1`th request is guaranteed to + # be throttled, so that's what the test asserts (rather than assuming + # the `limit + 1`th trips, which flakes near a boundary). + conns = + for _ <- 1..(2 * limit + 1) do + get_fresh_conn() |> authorize(api_key, api_scope: "sites:read:*") + end + + {allowed, rest} = Enum.split(conns, limit) + + # The first `limit` requests always fit within a single window and pass. + assert Enum.all?(allowed, &(not &1.halted)) + assert Enum.all?(allowed, &(&1.assigns.current_user.id == user.id)) + + # At least one of the remaining requests exceeds the per-window limit. + assert throttled = Enum.find(rest, & &1.halted) + + assert json_response(throttled, 429)["error"] == + "Too many API requests in a short period of time. The limit is 60 per 10 seconds. Please throttle your requests." + end + + test "halts with error when over hourly request limit" do + # the lower value is ignored in the test + {legacy_hourly_limit, team_hourly_limit} = + case unquote(key_type) do + :legacy_api_key -> {100, 1} + :team_scope_api_key -> {1, 100} + end + + patch_env(Plausible.Auth.ApiKey, + legacy_per_user_hourly_request_limit: legacy_hourly_limit, + # relax burst request limit to check hourly request limit + burst_request_limit: 1_000, + burst_period_seconds: 10 + ) + + user = new_user(team: [hourly_api_request_limit: team_hourly_limit]) + api_key = insert_api_key(unquote(key_type), user: user) + + limit = 100 + + # See the burst test above: with a fixed window, only the + # `2 * limit + 1`th request is guaranteed to be throttled. + conns = + for _ <- 1..(2 * limit + 1) do + get_fresh_conn() |> authorize(api_key, api_scope: "sites:read:*") + end + + {allowed, rest} = Enum.split(conns, limit) + + # The first `limit` requests pass, which also confirms the higher of the + # two configured limits (not the ignored lower one) is the one applied. + assert Enum.all?(allowed, &(not &1.halted)) + assert Enum.all?(allowed, &(&1.assigns.current_user.id == user.id)) + + assert throttled = Enum.find(rest, & &1.halted) + + assert json_response(throttled, 429)["error"] == + "Too many API requests. The limit is 100 per hour. Please contact us to request more capacity." + end + + test "passes and sets current user when valid API key with required scope provided", %{ + conn: conn + } do + user = new_user() + api_key = insert_api_key(unquote(key_type), user: user, scopes: ["sites:provision:*"]) + + conn = + conn + |> authorize(api_key, api_scope: "sites:provision:*") + + refute conn.halted + assert conn.assigns.current_user.id == user.id + end + + test "passes and sets current user and site when valid API key and site ID provided", %{ + conn: conn + } do + user = new_user() + site = new_site(owner: user) + api_key = insert_api_key(unquote(key_type), user: user) + + conn = + conn + |> authorize(api_key, site: site, api_scope: "stats:read:*") + + refute conn.halted + assert conn.assigns.current_user.id == user.id + assert conn.assigns.site.id == site.id + end + + @tag :ee_only + test "passes for super admin user even if not a member of the requested site", %{conn: conn} do + user = new_user() + patch_env(:super_admin_user_ids, [user.id]) + site = new_site() + site.team |> Ecto.Changeset.change(locked: true) |> Repo.update!() + api_key = insert_api_key(unquote(key_type), user: user) + + conn = + conn |> authorize(api_key, site: site, api_scope: "stats:read:*") + + refute conn.halted + assert conn.assigns.current_user.id == user.id + assert conn.assigns.site.id == site.id + end + + test "passes for subscope match", %{conn: conn} do + user = new_user() + api_key = insert_api_key(unquote(key_type), user: user, scopes: ["funnels:*"]) + + conn = + conn + |> authorize(api_key, api_scope: "funnels:read:*") + + refute conn.halted + assert conn.assigns.current_user.id == user.id + end + end + end + + describe "no context API ::" do + test "legacy API key request passes validation", %{ + conn: conn + } do + legacy_api_key_user = new_user() + legacy_api_key = insert_api_key(:legacy_api_key, user: legacy_api_key_user) + + conn = + conn |> authorize(legacy_api_key, api_scope: "sites:read:*") + + refute conn.halted + assert conn.assigns.current_user.id == legacy_api_key_user.id + end + + test "legacy API key requests are rate limited to configured requests per hour _per user_, but their team's team-scoped keys are on an independent rate limit" do + patch_env(Plausible.Auth.ApiKey, + legacy_per_user_hourly_request_limit: 100, + # relax burst request limit to check hourly request limit + burst_request_limit: 200, + burst_period_seconds: 30 + ) + + legacy_api_key_user = new_user() + _site = new_site(owner: legacy_api_key_user) + legacy_api_key = insert_api_key(:legacy_api_key, user: legacy_api_key_user) + + 1..Plausible.Auth.ApiKey.legacy_hourly_request_limit() + |> Enum.map(fn _ -> + conn = + get_fresh_conn() + |> authorize(legacy_api_key, api_scope: "sites:read:*") + + refute conn.halted + assert conn.assigns.current_user.id == legacy_api_key_user.id + end) + + # the hardcoded limit applies per user, not per api key + [legacy_api_key, insert_api_key(:legacy_api_key, user: legacy_api_key_user)] + |> Enum.map(fn api_key -> + conn = + get_fresh_conn() + |> authorize(api_key, api_scope: "sites:read:*") + + assert conn.halted + + assert json_response(conn, 429)["error"] == + "Too many API requests. The limit is 100 per hour. Please contact us to request more capacity." + end) + + # no context API requests made with legacy API keys don't count towards team limits + team_scope_api_key_user = legacy_api_key_user |> team_of() |> add_member(role: :editor) + + conn = + get_fresh_conn() + |> authorize( + insert_api_key(:team_scope_api_key, + user: team_scope_api_key_user + ), + api_scope: "sites:read:*" + ) + + refute conn.halted + end + end + + describe("site context API ::") do + @tag :capture_log + test "legacy API key requests pass validation for sites of all their teams", + %{ + conn: conn + } do + legacy_api_key_user = new_user() + legacy_api_key = insert_api_key(:legacy_api_key, user: legacy_api_key_user) + site_from_first_team = new_site(owner: legacy_api_key_user) + site_from_second_team = new_site() + add_member(site_from_second_team.team, user: legacy_api_key_user, role: :editor) + + [site_from_first_team, site_from_second_team] + |> Enum.map(fn site -> + conn = + conn + |> authorize(legacy_api_key, + api_context: :site, + api_scope: "sites:read:*", + site: site + ) + + refute conn.halted + assert conn.assigns.current_user.id == legacy_api_key_user.id + end) + end + + for guest_role <- [:viewer, :editor] do + @tag :capture_log + test "legacy API key requests pass validation for sites where they are a guest #{guest_role} at", + %{ + conn: conn + } do + legacy_api_key_user = new_user() + legacy_api_key = insert_api_key(:legacy_api_key, user: legacy_api_key_user) + site = new_site() + add_guest(site, user: legacy_api_key_user, role: unquote(guest_role)) + + conn = + conn + |> authorize(legacy_api_key, + api_context: :site, + api_scope: "sites:read:*", + site: site + ) + + refute conn.halted + assert conn.assigns.current_user.id == legacy_api_key_user.id + end + end + + for {key_type, expected_halted} <- [{:legacy_api_key, false}, {:team_scope_api_key, true}] do + test "logs a warning on using #{key_type} against site with guest access", %{conn: conn} do + user = new_user(team: [hourly_api_request_limit: 1]) + site = new_site() + add_guest(site, user: user, role: :editor) + + api_key = insert_api_key(unquote(key_type), user: user) + + capture_log(fn -> + conn = + conn |> authorize(api_key, site: site, api_context: :site, api_scope: "sites:read:*") + + assert conn.halted == unquote(expected_halted) + end) =~ "API key #{api_key.id} user accessing #{site.domain} as a guest" + end + end + + for {key_type, expected_halted} <- [{:legacy_api_key, true}, {:team_scope_api_key, true}] do + test "logs a warning on using #{key_type} against site with no access", %{conn: conn} do + user = new_user(team: [hourly_api_request_limit: 1]) + site = new_site() + + api_key = insert_api_key(unquote(key_type), user: user) + + capture_log(fn -> + conn = + conn |> authorize(api_key, site: site, api_context: :site, api_scope: "stats:read:*") + + assert conn.halted == unquote(expected_halted) + end) =~ "API key #{api_key.id} user trying to access #{site.domain} as a non-member" + end + end + + @tag :capture_log + test "legacy API key request doesn't pass validation for sites where they are not a guest at", + %{ + conn: conn + } do + legacy_api_key_user = new_user() + legacy_api_key = insert_api_key(:legacy_api_key, user: legacy_api_key_user) + site_with_no_guests = new_site() + + conn = + conn + |> authorize(legacy_api_key, + api_context: :site, + api_scope: "sites:read:*", + site: site_with_no_guests + ) + + assert conn.halted + assert json_response(conn, 401)["error"] =~ "Invalid API key." + end + + for guest_role <- [:viewer, :editor] do + test "team-scope API key request doesn't pass validation for sites where they are a guest #{guest_role} at", + %{ + conn: conn + } do + user = new_user() + _site = new_site(owner: user) + site = new_site() + add_guest(site, user: user, role: unquote(guest_role)) + team_scope_api_key = insert_api_key(:team_scope_api_key, user: user) + + conn = + conn + |> authorize( + team_scope_api_key, + api_context: :site, + api_scope: "sites:read:*", + site: site + ) + + assert conn.halted + assert json_response(conn, 401)["error"] =~ "Invalid API key." + end + end + + @tag :capture_log + test "legacy API key requests count towards the rate limit of the team of the site" do + user = new_user() + legacy_api_key = insert_api_key(:legacy_api_key, user: user) + + team_hourly_request_limit = 5 + user_of_other_team = new_user(team: [hourly_api_request_limit: team_hourly_request_limit]) + + team_scope_api_key_of_other_team = + insert_api_key(:team_scope_api_key, user: user_of_other_team) + + site = new_site(owner: user_of_other_team) + + add_guest(site, user: user, role: :viewer) + + 1..team_hourly_request_limit + |> Enum.map(fn _ -> + conn = + get_fresh_conn() + |> authorize(legacy_api_key, + api_context: :site, + api_scope: "sites:read:*", + site: site + ) + + refute conn.halted + assert conn.assigns.current_user.id == user.id + end) + + [ + legacy_api_key, + team_scope_api_key_of_other_team + ] + |> Enum.map(fn api_key -> + conn = + get_fresh_conn() + |> authorize(api_key, + api_context: :site, + api_scope: "sites:read:*", + site: site + ) + + assert json_response(conn, 429)["error"] =~ "Too many API requests." + assert conn.halted + end) + end + end + + defp prepare_conn_for_auth(conn) do + conn + |> put_private(PlausibleWeb.FirstLaunchPlug, :skip) + |> bypass_through(PlausibleWeb.Router) + end + + defp authorize(conn, api_key, opts) do + context = opts |> Keyword.get(:api_context) + scope = opts |> Keyword.fetch!(:api_scope) + site = opts |> Keyword.get(:site) + + conn = + conn + |> put_req_header("authorization", "Bearer #{api_key.key}") + |> get("/", if(site, do: %{"site_id" => site.domain}, else: %{})) + |> assign(:api_scope, scope) + + conn = if context, do: assign(conn, :api_context, context), else: conn + AuthorizePublicAPI.call(conn, nil) + end + + defp insert_api_key(:legacy_api_key, opts) do + insert(:api_key, opts |> Keyword.drop([:team, :team_id])) + end + + defp insert_api_key(:team_scope_api_key, opts) do + team = opts |> Keyword.fetch!(:user) |> team_of() + insert(:api_key, opts |> Keyword.put(:team, team)) + end + + defp get_fresh_conn(), + do: + build_conn() + |> prepare_conn() + |> prepare_conn_for_auth() +end diff --git a/test/plausible_web/plugs/authorize_site_access_test.exs b/test/plausible_web/plugs/authorize_site_access_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..cceaa5a7b0e1d5f993f513e1ee928d4d1eb16dc3 --- /dev/null +++ b/test/plausible_web/plugs/authorize_site_access_test.exs @@ -0,0 +1,393 @@ +defmodule PlausibleWeb.Plugs.AuthorizeSiteAccessTest do + use PlausibleWeb.ConnCase, async: false + alias PlausibleWeb.Plugs.AuthorizeSiteAccess + + setup [:create_user, :log_in, :create_site] + + test "init rejects invalid role names" do + assert_raise ArgumentError, fn -> + AuthorizeSiteAccess.init(_allowed_roles = [:admin, :invalid]) + end + end + + for init_argument <- [ + [], + :all_roles, + {:all_roles, nil}, + {[:public, :viewer, :admin, :editor, :super_admin, :owner, :billing], nil} + ] do + test "init resolves to expected options with argument #{inspect(init_argument)}" do + assert {[:public, :viewer, :admin, :editor, :super_admin, :owner, :billing], nil} == + AuthorizeSiteAccess.init(unquote(init_argument)) + end + end + + for invalid_site_param <- [[], 1, :invalid] do + test "init rejects invalid site_param #{inspect(invalid_site_param)}" do + assert_raise ArgumentError, fn -> + AuthorizeSiteAccess.init({[:super_admin], unquote(invalid_site_param)}) + end + end + end + + test "returns 404 on non-existent site", %{conn: conn} do + opts = AuthorizeSiteAccess.init(:all_roles) + + conn = + conn + |> bypass_through(PlausibleWeb.Router) + |> get("/plug-tests/invalid.domain/with-domain") + |> AuthorizeSiteAccess.call(opts) + + assert conn.halted + assert html_response(conn, 404) + end + + test "returns 404 on hacky :domain input", %{conn: conn} do + opts = AuthorizeSiteAccess.init(:all_roles) + + conn = + conn + |> bypass_through(PlausibleWeb.Router) + |> get("/plug-tests/%c0%ae/with-domain") + |> AuthorizeSiteAccess.call(opts) + + assert conn.halted + assert html_response(conn, 404) + end + + test "rejects user completely unrelated to the site", %{conn: conn} do + opts = AuthorizeSiteAccess.init(:all_roles) + + site = new_site() + + conn = + conn + |> bypass_through(PlausibleWeb.Router) + |> get("/plug-tests/#{site.domain}/with-domain") + |> AuthorizeSiteAccess.call(opts) + + assert conn.halted + assert html_response(conn, 404) + end + + test "can be configured to expect site domain at conn.params['some_key'], fails when this is not met", + %{ + conn: conn, + site: site + } do + opts = + AuthorizeSiteAccess.init({:all_roles, "some_key"}) + + conn = + conn + |> bypass_through(PlausibleWeb.Router) + |> get("/plug-tests/basic", %{"wrong_key" => site.domain}) + |> AuthorizeSiteAccess.call(opts) + + assert conn.halted + assert conn.status == 404 + end + + test "can be configured to expect site domain at conn.params['some_key'], succeeds when it is met", + %{ + conn: conn, + site: site + } do + opts = + AuthorizeSiteAccess.init({:all_roles, "some_key"}) + + conn = + conn + |> bypass_through(PlausibleWeb.Router) + |> get("/plug-tests/basic", %{"some_key" => site.domain}) + |> AuthorizeSiteAccess.call(opts) + + refute conn.halted + assert conn.assigns.site.id == site.id + end + + test "doesn't allow bypassing :domain in path with :domain in query param", %{ + conn: conn, + site: site + } do + other_site = new_site() + + opts = AuthorizeSiteAccess.init([:admin, :owner]) + + conn = + conn + |> bypass_through(PlausibleWeb.Router) + |> get("/plug-tests/#{other_site.domain}/with-domain", %{ + "domain" => site.domain + }) + |> AuthorizeSiteAccess.call(opts) + + assert conn.halted + assert conn.status == 404 + assert conn.path_params == %{"domain" => other_site.domain} + end + + test "returns 404 with custom error message for failed API routes", %{conn: conn, user: user} do + site = new_site() + add_guest(site, user: user, role: :viewer) + + opts = AuthorizeSiteAccess.init([:admin, :owner, :super_admin]) + + conn = + conn + |> bypass_through(PlausibleWeb.Router) + |> get("/plug-tests/#{site.domain}/api-with-domain") + |> AuthorizeSiteAccess.call(opts) + + assert conn.halted + + assert json_response(conn, 404) == %{ + "error" => "Site does not exist or user does not have sufficient access." + } + end + + on_ee do + test "returns 404 for consolidated views by default", %{conn: conn, user: user} do + {:ok, team} = Plausible.Teams.get_or_create(user) + new_site(team: team) + consolidated_view = new_consolidated_view(team) + + opts = AuthorizeSiteAccess.init([:super_admin, :admin, :owner]) + + conn = + conn + |> bypass_through(PlausibleWeb.Router) + |> get("/plug-tests/#{consolidated_view.domain}/with-domain") + |> AuthorizeSiteAccess.call(opts) + + assert conn.halted + assert conn.status == 404 + end + end + + test "rejects unrelated shared link slug even if user is permitted for site", %{ + conn: conn, + site: site + } do + shared_link_other_site = insert(:shared_link, site: new_site()) + + params = %{"shared_link" => %{"name" => "some name"}} + + opts = AuthorizeSiteAccess.init([:super_admin, :admin, :owner]) + + conn = + conn + |> bypass_through(PlausibleWeb.Router) + |> get("/plug-tests/#{site.domain}/shared-link/#{shared_link_other_site.slug}", params) + |> AuthorizeSiteAccess.call(opts) + + assert conn.halted + assert conn.status == 404 + end + + test "rejects user not permitted to site trying to use unrelated shared link for another one", + %{ + conn: conn, + user: user, + site: site + } do + shared_link = insert(:shared_link, site: site) + other_site = new_site(owner: user) + + opts = AuthorizeSiteAccess.init([:super_admin, :admin, :owner]) + + conn = + conn + |> bypass_through(PlausibleWeb.Router) + |> get("/plug-tests/#{other_site.domain}/with-domain", %{"auth" => shared_link.slug}) + |> AuthorizeSiteAccess.call(opts) + + assert conn.halted + assert conn.status == 404 + end + + test "doesn't allow bypassing :slug in path with :slug or :auth in query param", %{ + conn: conn, + site: site + } do + shared_link = insert(:shared_link, site: site) + shared_link_other_site = insert(:shared_link, site: build(:site)) + + params = %{ + "shared_link" => %{"name" => "some name"}, + "slug" => shared_link.slug, + "auth" => shared_link.slug + } + + opts = AuthorizeSiteAccess.init([:super_admin, :admin, :owner]) + + conn = + conn + |> bypass_through(PlausibleWeb.Router) + |> get("/plug-tests/#{site.domain}/shared-link/#{shared_link_other_site.slug}", params) + |> AuthorizeSiteAccess.call(opts) + + assert conn.halted + assert conn.status == 404 + end + + test "rejects user on mismatched membership role", %{conn: conn, user: user} do + site = new_site() + add_guest(site, user: user, role: :editor) + + opts = AuthorizeSiteAccess.init([:owner]) + + conn = + conn + |> bypass_through(PlausibleWeb.Router) + |> get("/plug-tests/#{site.domain}/with-domain") + |> AuthorizeSiteAccess.call(opts) + + assert conn.halted + assert html_response(conn, 404) + end + + test "allows user based on ownership", %{conn: conn, user: user} do + site = new_site(owner: user) + + opts = AuthorizeSiteAccess.init([:owner]) + + conn = + conn + |> bypass_through(PlausibleWeb.Router) + |> get("/plug-tests/#{site.domain}/with-domain") + |> AuthorizeSiteAccess.call(opts) + + refute conn.halted + assert conn.assigns.site.id == site.id + assert conn.assigns.site_role == :owner + end + + for role <- [:viewer, :editor] do + test "allows user based on their #{role} guest membership", %{conn: conn, user: user} do + site = new_site() + add_guest(site, user: user, role: unquote(role)) + + opts = AuthorizeSiteAccess.init([unquote(role)]) + + conn = + conn + |> bypass_through(PlausibleWeb.Router) + |> get("/plug-tests/#{site.domain}/with-domain") + |> AuthorizeSiteAccess.call(opts) + + # Does not set current team for guest membership + refute get_session(conn, "current_team_id") == site.team.identifier + refute conn.assigns.current_team.id == site.team.id + + refute conn.halted + assert conn.assigns.site.id == site.id + assert conn.assigns.site_role == unquote(role) + end + end + + for role <- [:viewer, :editor, :admin, :billing] do + test "allows user based on their #{role} team membership", %{conn: conn, user: user} do + site = new_site() + add_member(site.team, user: user, role: unquote(role)) + + opts = AuthorizeSiteAccess.init([unquote(role)]) + + conn = + conn + |> bypass_through(PlausibleWeb.Router) + |> get("/plug-tests/#{site.domain}/with-domain") + |> AuthorizeSiteAccess.call(opts) + + # Sets current team for team membership + assert get_session(conn, "current_team_id") == site.team.identifier + assert conn.assigns.current_team.id == site.team.id + + refute conn.halted + assert conn.assigns.site.id == site.id + assert conn.assigns.site_role == unquote(role) + end + end + + @tag :ee_only + test "allows user based on their superadmin status", %{conn: conn, user: user} do + site = new_site() + + patch_env(:super_admin_user_ids, [user.id]) + + opts = AuthorizeSiteAccess.init([:super_admin]) + + conn = + conn + |> bypass_through(PlausibleWeb.Router) + |> get("/plug-tests/#{site.domain}/with-domain") + |> AuthorizeSiteAccess.call(opts) + + refute conn.halted + assert conn.assigns.site.id == site.id + assert conn.assigns.site_role == :super_admin + end + + test "allows user based on website visibility (authenticated user)", %{conn: conn} do + site = new_site(public: true) + + opts = AuthorizeSiteAccess.init([:public]) + + conn = + conn + |> bypass_through(PlausibleWeb.Router) + |> get("/plug-tests/#{site.domain}/with-domain") + |> AuthorizeSiteAccess.call(opts) + + refute conn.halted + assert conn.assigns.site.id == site.id + end + + test "allows user based on website visibility (anonymous request)" do + site = new_site(public: true) + + opts = AuthorizeSiteAccess.init([:public]) + + conn = + build_conn() + |> bypass_through(PlausibleWeb.Router) + |> get("/plug-tests/#{site.domain}/with-domain") + |> AuthorizeSiteAccess.call(opts) + + refute conn.halted + assert conn.assigns.site.id == site.id + end + + test "allows user based on shared link auth (authenticated user)", %{conn: conn} do + site = new_site() + shared_link = insert(:shared_link, site: site) + + opts = AuthorizeSiteAccess.init([:public]) + + conn = + conn + |> bypass_through(PlausibleWeb.Router) + |> get("/plug-tests/#{site.domain}/with-domain", %{"auth" => shared_link.slug}) + |> AuthorizeSiteAccess.call(opts) + + refute conn.halted + assert conn.assigns.site.id == site.id + end + + test "allows user based on shared link auth (anonymous request)" do + site = new_site() + shared_link = insert(:shared_link, site: site) + + opts = AuthorizeSiteAccess.init([:public]) + + conn = + build_conn() + |> bypass_through(PlausibleWeb.Router) + |> get("/plug-tests/#{site.domain}/with-domain", %{"auth" => shared_link.slug}) + |> AuthorizeSiteAccess.call(opts) + + refute conn.halted + assert conn.assigns.site.id == site.id + end +end diff --git a/test/plausible_web/plugs/authorize_team_access_test.exs b/test/plausible_web/plugs/authorize_team_access_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..3bdf2d3a91ce581fbc74bcf45001e7486da3fa00 --- /dev/null +++ b/test/plausible_web/plugs/authorize_team_access_test.exs @@ -0,0 +1,54 @@ +defmodule PlausibleWeb.Plugs.AuthorizeTeamAccessTest do + use PlausibleWeb.ConnCase, async: true + + alias Plausible.Plugs.AuthorizeTeamAccess + import Plug.Conn + + for role <- Plausible.Teams.Membership.roles() -- [:guest] do + test "passes when valid current role: #{role}" do + conn = + build_conn() + |> assign(:current_team_role, unquote(role)) + |> AuthorizeTeamAccess.call() + + refute conn.halted + end + end + + for role <- Plausible.Teams.Membership.roles() -- [:guest] do + test "inits with role: #{role}" do + assert AuthorizeTeamAccess.init([unquote(role)]) + end + end + + test "fails to init with invalid role" do + assert_raise MatchError, fn -> + AuthorizeTeamAccess.init([:guest]) + end + + assert_raise MatchError, fn -> + AuthorizeTeamAccess.init([:unknown]) + end + end + + test "redirects to /sites on mismatch" do + conn = + build_conn() + |> assign(:current_team, :some) + |> assign(:current_team_role, :admin) + |> AuthorizeTeamAccess.call([:owner]) + + assert conn.halted + assert redirected_to(conn, 302) == "/sites" + end + + test "is permissive when no :current_team assigned" do + conn = + build_conn() + |> assign(:current_team, nil) + |> assign(:current_team_role, :admin) + |> AuthorizeTeamAccess.call([:owner]) + + refute conn.halted + end +end diff --git a/test/plausible_web/plugs/favicon_test.exs b/test/plausible_web/plugs/favicon_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..97db7d6dbf34acd599a5300a02ccd3bb5cc9d000 --- /dev/null +++ b/test/plausible_web/plugs/favicon_test.exs @@ -0,0 +1,234 @@ +defmodule PlausibleWeb.FaviconTest do + use Plausible.DataCase, async: true + import Plug.Test + alias PlausibleWeb.Favicon + + import Mox + setup :verify_on_exit! + + setup_all do + opts = PlausibleWeb.Favicon.init(nil) + + %{plug_opts: opts} + end + + test "ignores request on a URL it does not need to handle", %{plug_opts: plug_opts} do + old_conn = conn(:get, "/irrelevant") + new_conn = Favicon.call(old_conn, plug_opts) + + refute new_conn.halted + assert old_conn == new_conn + end + + test "proxies request on favicon URL to duckduckgo", %{plug_opts: plug_opts} do + expect( + Plausible.HTTPClient.Mock, + :get, + fn "https://icons.duckduckgo.com/ip3/plausible.io.ico" -> + {:ok, %Finch.Response{status: 200, body: "favicon response body"}} + end + ) + + conn = + conn(:get, "/favicon/sources/plausible.io") + |> Favicon.call(plug_opts) + + assert conn.halted + assert conn.status == 200 + assert conn.resp_body == "favicon response body" + end + + test "requests favicon from DDG by hostname only (strips pathname)", %{plug_opts: plug_opts} do + expect( + Plausible.HTTPClient.Mock, + :get, + fn "https://icons.duckduckgo.com/ip3/site.com.ico" -> + {:ok, %Finch.Response{status: 200, body: "favicon response body"}} + end + ) + + conn = + conn(:get, "/favicon/sources/site.com/subfolder") + |> Favicon.call(plug_opts) + + assert conn.halted + assert conn.status == 200 + assert conn.resp_body == "favicon response body" + end + + test "sets content-disposition and content-security-policy", %{plug_opts: plug_opts} do + expect( + Plausible.HTTPClient.Mock, + :get, + fn "https://icons.duckduckgo.com/ip3/plausible.io.ico" -> + {:ok, %Finch.Response{status: 200, body: "favicon response body"}} + end + ) + + conn = + conn(:get, "/favicon/sources/plausible.io") + |> Favicon.call(plug_opts) + + assert conn.halted + assert conn.status == 200 + assert conn.resp_body == "favicon response body" + assert Plug.Conn.get_resp_header(conn, "content-security-policy") == ["script-src 'none'"] + assert Plug.Conn.get_resp_header(conn, "content-disposition") == ["attachment"] + end + + test "maps a categorized source to URL for favicon", %{plug_opts: plug_opts} do + expect( + Plausible.HTTPClient.Mock, + :get, + fn "https://icons.duckduckgo.com/ip3/facebook.com.ico" -> + {:ok, %Finch.Response{status: 200, body: "favicon response body"}} + end + ) + + conn = + conn(:get, "/favicon/sources/Facebook") + |> Favicon.call(plug_opts) + + assert conn.halted + assert conn.status == 200 + assert conn.resp_body == "favicon response body" + end + + for {source, domain} <- %{ + "Linktree" => "linktr.ee", + "Bluesky" => "bsky.app", + "Mastodon" => "mastodon.social", + "X (Twitter)" => "x.com", + "Kagi" => "kagi.com", + "Microsoft 365" => "office.com" + } do + test "maps custom source #{source} to #{domain} for favicon", %{plug_opts: plug_opts} do + expect( + Plausible.HTTPClient.Mock, + :get, + fn "https://icons.duckduckgo.com/ip3/#{unquote(domain)}.ico" -> + {:ok, %Finch.Response{status: 200, body: "favicon response body"}} + end + ) + + conn = + conn(:get, "/favicon/sources/#{URI.encode_www_form(unquote(source))}") + |> Favicon.call(plug_opts) + + assert conn.halted + assert conn.status == 200 + assert conn.resp_body == "favicon response body" + end + end + + test "copies content-type header from the proxied response", %{plug_opts: plug_opts} do + expect( + Plausible.HTTPClient.Mock, + :get, + fn "https://icons.duckduckgo.com/ip3/plausible.io.ico" -> + {:ok, + %Finch.Response{ + status: 200, + body: "favicon response body", + headers: [ + {"transfer-encoding", "chunked"}, + {"content-type", "should-pass-through"} + ] + }} + end + ) + + conn = + conn(:get, "/favicon/sources/plausible.io") + |> Favicon.call(plug_opts) + + assert conn.halted + assert conn.status == 200 + assert Plug.Conn.get_resp_header(conn, "content-type") == ["should-pass-through"] + end + + test "overrides content-type header if proxied response starts with + {:ok, + %Finch.Response{ + status: 200, + body: "icon", + headers: [{"content-type", "image/x-icon"}] + }} + end + ) + + conn = + conn(:get, "/favicon/sources/plausible.io") + |> Favicon.call(plug_opts) + + assert conn.halted + assert Plug.Conn.get_resp_header(conn, "content-type") == ["image/svg+xml; charset=utf-8"] + end + + describe "Fallback to placeholder icon" do + @placeholder_icon File.read!("priv/link_favicon.svg") + + test "falls back to placeholder when DDG returns a non-2xx response", %{plug_opts: plug_opts} do + expect( + Plausible.HTTPClient.Mock, + :get, + fn "https://icons.duckduckgo.com/ip3/plausible.io.ico" -> + res = %Finch.Response{status: 503, body: "bad gateway"} + {:error, Plausible.HTTPClient.Non200Error.new(res)} + end + ) + + conn = + conn(:get, "/favicon/sources/plausible.io") + |> Favicon.call(plug_opts) + + assert conn.halted + assert conn.status == 200 + assert conn.resp_body == @placeholder_icon + end + + test "falls back to placeholder in case of a network error", %{plug_opts: plug_opts} do + expect( + Plausible.HTTPClient.Mock, + :get, + fn "https://icons.duckduckgo.com/ip3/plausible.io.ico" -> + {:error, %Finch.TransportError{reason: :closed}} + end + ) + + conn = + conn(:get, "/favicon/sources/plausible.io") + |> Favicon.call(plug_opts) + + assert conn.halted + assert conn.status == 200 + assert conn.resp_body == @placeholder_icon + end + + test "falls back to placeholder when DDG returns a broken image response", %{ + plug_opts: plug_opts + } do + expect( + Plausible.HTTPClient.Mock, + :get, + fn "https://icons.duckduckgo.com/ip3/plausible.io.ico" -> + {:ok, %Finch.Response{status: 200, body: <<137, 80, 78, 71, 13, 10, 26, 10>>}} + end + ) + + conn = + conn(:get, "/favicon/sources/plausible.io") + |> Favicon.call(plug_opts) + + assert conn.halted + assert conn.status == 200 + assert conn.resp_body == @placeholder_icon + end + end +end diff --git a/test/plausible_web/plugs/feature_flag_check_plug_test.exs b/test/plausible_web/plugs/feature_flag_check_plug_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..ead037ed4b9db0d182011e816d6e4fad3f334a38 --- /dev/null +++ b/test/plausible_web/plugs/feature_flag_check_plug_test.exs @@ -0,0 +1,46 @@ +defmodule PlausibleWeb.Plugs.FeatureFlagCheckPlugTest do + use PlausibleWeb.ConnCase, async: true + use Plausible.Teams.Test + alias PlausibleWeb.Plugs.{AuthorizeSiteAccess, FeatureFlagCheckPlug} + + setup [:create_user, :log_in, :create_site] + + test "returns 404 when any of the expected feature flags is not enabled", %{ + conn: conn, + site: site + } do + # currently enabled flags are defined test/test_helper.exs + required_flags = + FeatureFlagCheckPlug.init([:missing_feature_flag, :annotations]) + + conn = + conn + |> get_conn_with_current_user_and_site(site) + |> FeatureFlagCheckPlug.call(required_flags) + + assert conn.halted + assert %{"error" => "Not found"} == json_response(conn, 404) + end + + test "passes conn when required flags are enabled", %{ + conn: conn, + site: site + } do + # currently enabled flags are defined test/test_helper.exs + required_flags = FeatureFlagCheckPlug.init([:annotations]) + + conn = + conn + |> get_conn_with_current_user_and_site(site) + |> FeatureFlagCheckPlug.call(required_flags) + + assert !conn.halted + end + + defp get_conn_with_current_user_and_site(%Plug.Conn{} = conn, %Plausible.Site{} = site) do + conn + |> bypass_through(PlausibleWeb.Router) + |> get("/plug-tests/api-basic?site=#{site.domain}") + |> AuthorizeSiteAccess.call(AuthorizeSiteAccess.init({:all_roles, "site"})) + end +end diff --git a/test/plausible_web/plugs/first_launch_plug_test.exs b/test/plausible_web/plugs/first_launch_plug_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..c5b21b957f0be6b2a5307094165ef7e862ce9056 --- /dev/null +++ b/test/plausible_web/plugs/first_launch_plug_test.exs @@ -0,0 +1,62 @@ +defmodule PlausibleWeb.FirstLaunchPlugTest do + use PlausibleWeb.ConnCase + @moduletag :ce_build_only + import Plug.Test + + alias PlausibleWeb.FirstLaunchPlug + alias Plausible.Release + + describe "init/1" do + test "requires :redirect_to option" do + assert_raise KeyError, ~r"key :redirect_to not found", fn -> + FirstLaunchPlug.init(_no_opts = []) + end + + path = FirstLaunchPlug.init(redirect_to: "/register") + assert path == "/register" + end + end + + @opts FirstLaunchPlug.init(redirect_to: "/register") + + describe "call/2" do + test "no-op for paths == :redirect_to" do + conn = conn("GET", "/register") + conn = FirstLaunchPlug.call(conn, @opts) + refute conn.halted + + # even when it's the first launch + assert Release.should_be_first_launch?() + + conn = conn("GET", "/register") + conn = FirstLaunchPlug.call(conn, @opts) + refute conn.halted + end + + test "no-op when not first launch" do + insert(:user) + refute Release.should_be_first_launch?() + conn = conn("GET", "/sites") + conn = FirstLaunchPlug.call(conn, @opts) + refute conn.halted + end + + test "redirects to :redirect_to when first launch" do + assert Release.should_be_first_launch?() + + conn = conn("GET", "/sites") + conn = FirstLaunchPlug.call(conn, @opts) + assert conn.halted + assert redirected_to(conn) == "/register" + end + end + + describe "first launch plug in :browser pipeline" do + test "redirects to /register on first launch", %{conn: conn} do + assert Release.should_be_first_launch?() + + conn = get(conn, "/") + assert redirected_to(conn) == "/register" + end + end +end diff --git a/test/plausible_web/plugs/handle_expired_session_test.exs b/test/plausible_web/plugs/handle_expired_session_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..506284eca9b29d8ba99d7de5f0add340f6c7d4bf --- /dev/null +++ b/test/plausible_web/plugs/handle_expired_session_test.exs @@ -0,0 +1,75 @@ +defmodule Plausible.Plugs.HandleExpiredSessionTest do + use PlausibleWeb.ConnCase, async: true + + on_ee do + alias Plausible.Auth.UserSessions + alias Plausible.Plugs.HandleExpiredSession + alias Plausible.Repo + + alias PlausibleWeb.Router.Helpers, as: Routes + + test "passes through when there's no expired_session" do + conn = HandleExpiredSession.call(build_conn(), []) + refute conn.halted + end + + test "passes through when the expired session belongs to standard user" do + user = new_user() + in_the_past = NaiveDateTime.add(NaiveDateTime.utc_now(:second), -1, :hour) + session = UserSessions.create!(user, "Unknown", timeout_at: in_the_past) + + conn = + build_conn() + |> assign(:expired_session, session) + |> HandleExpiredSession.call([]) + + refute conn.halted + assert Repo.reload(session) + end + + test "halts and redirects to login for expired session of SSO user with return" do + user = new_user(type: :sso) + in_the_past = NaiveDateTime.add(NaiveDateTime.utc_now(:second), -1, :hour) + session = UserSessions.create!(user, "Unknown", timeout_at: in_the_past) + + conn = + build_conn(:get, "/some/url", %{with: "param"}) + |> assign(:expired_session, session) + |> HandleExpiredSession.call([]) + + assert conn.halted + + assert redirected_to(conn, 302) == + Routes.sso_path(conn, :login_form, + prefer: "manual", + email: user.email, + autosubmit: true, + return_to: "/some/url?with=param" + ) + + refute Repo.reload(session) + end + + test "halts and redirects to login without redirect if request method other than GET" do + user = new_user(type: :sso) + in_the_past = NaiveDateTime.add(NaiveDateTime.utc_now(:second), -1, :hour) + session = UserSessions.create!(user, "Unknown", timeout_at: in_the_past) + + conn = + build_conn(:post, "/some/url") + |> assign(:expired_session, session) + |> HandleExpiredSession.call([]) + + assert conn.halted + + assert redirected_to(conn, 302) == + Routes.sso_path(conn, :login_form, + prefer: "manual", + email: user.email, + autosubmit: true + ) + + refute Repo.reload(session) + end + end +end diff --git a/test/plausible_web/plugs/no_robots_test.exs b/test/plausible_web/plugs/no_robots_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..c4fc3755128733aa7a688c8cdcd2e5c75cfe5c7d --- /dev/null +++ b/test/plausible_web/plugs/no_robots_test.exs @@ -0,0 +1,37 @@ +defmodule PlausibleWeb.Plugs.NoRobotsTest do + use Plausible.DataCase, async: true + import Plug.Test + import Plug.Conn + + alias PlausibleWeb.Plugs.NoRobots + + test "non-bots pass - when no user agent is supplied" do + conn = :get |> conn("/") |> NoRobots.call() + assert get_resp_header(conn, "x-robots-tag") == ["noindex, nofollow"] + assert get_resp_header(conn, "x-plausible-forbidden-reason") == [] + assert conn.private.robots == "noindex, nofollow" + + refute conn.halted + refute conn.status + end + + test "non-bots pass - when user agent is supplied" do + conn = :get |> conn("/") |> NoRobots.call() + assert get_resp_header(conn, "x-robots-tag") == ["noindex, nofollow"] + assert get_resp_header(conn, "x-plausible-forbidden-reason") == [] + assert conn.private.robots == "noindex, nofollow" + + refute conn.halted + refute conn.status + end + + test "writes index, nofollow for plausible.io live demo" do + conn = :get |> conn("/plausible.io") |> NoRobots.call() + + assert get_resp_header(conn, "x-robots-tag") == ["index, nofollow"] + assert get_resp_header(conn, "x-plausible-forbidden-reason") == [] + + refute conn.halted + refute conn.status + end +end diff --git a/test/plausible_web/plugs/require_account_plug_test.exs b/test/plausible_web/plugs/require_account_plug_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..cb9066b4ca2d4ef607f4da4ce9efd1d924d4d0ea --- /dev/null +++ b/test/plausible_web/plugs/require_account_plug_test.exs @@ -0,0 +1,79 @@ +defmodule PlausibleWeb.RequireAccountPlugTest do + use PlausibleWeb.ConnCase, async: true + + import Plug.Conn + + alias PlausibleWeb.RequireAccountPlug + alias PlausibleWeb.Router.Helpers, as: Routes + + describe "enforcing 2FA" do + test "passes when 2FA enforcement is disabled" do + user = new_user() + team = new_site(owner: user).team + team = Plausible.Teams.complete_setup(team) + + conn = + build_conn() + |> assign(:current_user, user) + |> assign(:current_team, team) + |> RequireAccountPlug.call(nil) + + refute conn.halted + end + + test "redirects when 2FA enforcement is enabled" do + user = new_user() + team = new_site(owner: user).team + team = Plausible.Teams.complete_setup(team) + {:ok, team} = Plausible.Teams.enable_force_2fa(team, user) + + conn = + build_conn() + |> assign(:current_user, user) + |> assign(:current_team, team) + |> RequireAccountPlug.call(nil) + + assert conn.halted + assert redirected_to(conn, 302) == Routes.auth_path(conn, :force_initiate_2fa_setup) + end + + test "does not override for unverified account" do + user = new_user(email_verified: false) + team = new_site(owner: user).team + team = Plausible.Teams.complete_setup(team) + {:ok, team} = Plausible.Teams.enable_force_2fa(team, user) + + conn = + build_conn() + |> assign(:current_user, user) + |> assign(:current_team, team) + |> RequireAccountPlug.call(nil) + + assert conn.halted + assert redirected_to(conn, 302) == Routes.auth_path(conn, :activate_form) + end + + @force_2fa_exceptions [ + "/2fa/setup/force-initiate", + "/2fa/setup/initiate", + "/2fa/setup/verify" + ] + + for path <- @force_2fa_exceptions do + test "does not redirect if the path is #{path}" do + user = new_user() + team = new_site(owner: user).team + team = Plausible.Teams.complete_setup(team) + {:ok, team} = Plausible.Teams.enable_force_2fa(team, user) + + conn = + build_conn(:get, unquote(path)) + |> assign(:current_user, user) + |> assign(:current_team, team) + |> RequireAccountPlug.call(nil) + + refute conn.halted + end + end + end +end diff --git a/test/plausible_web/plugs/sso_team_access_test.exs b/test/plausible_web/plugs/sso_team_access_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..167f85f38dfd1bcc0252c0b5488dcad13eedc17b --- /dev/null +++ b/test/plausible_web/plugs/sso_team_access_test.exs @@ -0,0 +1,143 @@ +defmodule Plausible.Plugs.SSOTeamAccessTest do + use PlausibleWeb.ConnCase, async: true + + on_ee do + alias Plausible.Auth + alias Plausible.Plugs.SSOTeamAccess + alias PlausibleWeb.AuthPlug + + describe "no user" do + test "passes through when there's no user" do + conn = SSOTeamAccess.call(build_conn(), []) + refute conn.halted + end + end + + describe "no team" do + setup [:create_user, :log_in] + + test "passes through when there's no team", %{conn: conn} do + conn = + conn + |> Plug.Conn.fetch_query_params() + |> AuthPlug.call([]) + |> SSOTeamAccess.call([]) + + refute conn.halted + end + end + + describe "non-SSO team" do + setup [:create_user, :create_team, :log_in, :setup_team] + + test "passes through for non-SSO team", %{conn: conn} do + conn = + conn + |> Plug.Conn.fetch_query_params() + |> AuthPlug.call([]) + |> SSOTeamAccess.call([]) + + refute conn.halted + end + end + + describe "SSO team with provisioned user" do + setup [ + :create_user, + :create_site, + :create_team, + :setup_sso, + :provision_sso_user, + :log_in, + :setup_team + ] + + test "passes for SSO team without force SSO", %{conn: conn} do + conn = + conn + |> Plug.Conn.fetch_query_params() + |> AuthPlug.call([]) + |> SSOTeamAccess.call([]) + + refute conn.halted + end + + test "passes for SSO team with force SSO", %{conn: conn, team: team, user: user} do + {:ok, user, _} = Auth.TOTP.initiate(user) + {:ok, _user, _} = Auth.TOTP.enable(user, :skip_verify) + {:ok, _team} = Auth.SSO.set_force_sso(team, :all_but_owners) + + conn = + conn + |> Plug.Conn.fetch_query_params() + |> AuthPlug.call([]) + |> SSOTeamAccess.call([]) + + refute conn.halted + end + end + + describe "SSO team with non-provisioned user" do + setup [:create_user, :create_site, :create_team, :setup_sso, :log_in, :setup_team] + + test "passes for SSO team without force SSO", %{conn: conn} do + conn = + conn + |> Plug.Conn.fetch_query_params() + |> AuthPlug.call([]) + |> SSOTeamAccess.call([]) + + refute conn.halted + end + + test "redirects to notice for SSO team with force SSO", %{ + conn: conn, + team: team, + user: user, + sso_integration: integration + } do + {:ok, user, _} = Auth.TOTP.initiate(user) + {:ok, _user, _} = Auth.TOTP.enable(user, :skip_verify) + identity = new_identity("Woozy Wooster", "woozy@example.com", integration) + Auth.SSO.provision_user(identity) + {:ok, _team} = Auth.SSO.set_force_sso(team, :all_but_owners) + + conn = + conn + |> Plug.Conn.fetch_query_params() + |> AuthPlug.call([]) + |> SSOTeamAccess.call([]) + + assert conn.halted + assert redirected_to(conn, 302) == Routes.sso_path(conn, :provision_notice) + end + + test "redirects to issue notice for SSO team with force SSO and user in invalid state", %{ + conn: conn, + team: team, + user: user, + sso_integration: integration + } do + another_team = new_site().team |> Plausible.Teams.complete_setup() + add_member(another_team, user: user, role: :viewer) + + {:ok, user, _} = Auth.TOTP.initiate(user) + {:ok, _user, _} = Auth.TOTP.enable(user, :skip_verify) + identity = new_identity("Woozy Wooster", "woozy@example.com", integration) + Auth.SSO.provision_user(identity) + {:ok, _team} = Auth.SSO.set_force_sso(team, :all_but_owners) + + conn = + conn + |> Plug.Conn.fetch_query_params() + |> AuthPlug.call([]) + |> SSOTeamAccess.call([]) + + assert conn.halted + + assert redirected_to(conn, 302) == + Routes.sso_path(conn, :provision_issue, issue: "multiple_memberships") + end + end + end +end diff --git a/test/plausible_web/plugs/tracker_plug_test.exs b/test/plausible_web/plugs/tracker_plug_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..488b5b229267ea7e4c9298b909a222bdf7374155 --- /dev/null +++ b/test/plausible_web/plugs/tracker_plug_test.exs @@ -0,0 +1,137 @@ +defmodule PlausibleWeb.TrackerPlugTest do + @moduledoc """ + This test module uses auto-generated JavaScript tracker files in priv/tracker/js. + + To speed up running Elixir tests locally, the tracker files are not automatically generated + with every `mix test` command. That means, you have to manually run the below command + once before executing `mix test`. This is to make sure all tracker files are in place and up to date. + + ``` + npm run deploy --prefix tracker + ``` + + If you're making changes in the tracker template files (`src/plausible.js`, `compile.js`), + do regenerate the files before running tests, so they're up to date. + """ + use PlausibleWeb.ConnCase, async: true + import Plug.Test + + alias PlausibleWeb.Tracker + + describe "plausible-web.js" do + @example_config %{ + installation_type: :manual, + track_404_pages: true, + hash_based_routing: true, + file_downloads: false, + outbound_links: false, + pageview_props: false, + tagged_events: true, + revenue_tracking: false, + form_submissions: true + } + + test "returns the script for an existing site", %{conn: conn} do + site = new_site() + + tracker_script_configuration = + Tracker.update_script_configuration!( + site, + Map.put(@example_config, :site_id, site.id), + :installation + ) + + response = get(conn, "/js/#{tracker_script_configuration.id}.js") |> response(200) + + assert String.contains?(response, "!function(){var") + assert String.contains?(response, "domain:\"#{site.domain}\"") + assert String.contains?(response, "formSubmissions:!0") + refute String.contains?(response, "outboundLinks:!0") + refute String.contains?(response, "fileDownloads:!0") + end + + # window.plausible is a substring checked for by the wordpress plugin to avoid 'optimization' by other wordpress plugins + test "script contains window.plausible", %{conn: conn} do + site = new_site() + + tracker_script_configuration = + Tracker.update_script_configuration!( + site, + Map.put(@example_config, :site_id, site.id), + :installation + ) + + response = get(conn, "/js/#{tracker_script_configuration.id}.js") |> response(200) + + assert String.contains?(response, "window.plausible") + end + + test "returns 404 for unknown site", %{conn: conn} do + conn + |> get("/js/pa-a14125a2-000-000-0000-000000000000.js") + |> response(404) + end + end + + describe "legacy variants" do + test "returns legacy script p.js" do + assert String.contains?(get_script("p.js"), "; samesite=strict; path=/") + end + + test "returns plausible script with every alias" do + plausible_js_response = get_script("plausible.js") + + assert plausible_js_response == get_script("script.js") + assert plausible_js_response == get_script("analytics.js") + end + + test "returns the right script extensions no matter the order" do + response = get_script("plausible.compat.file-downloads.hash.outbound-links.js") + + assert String.contains?(response, "getElementById(\"plausible\")") + assert String.contains?(response, "file-types") + assert String.contains?(response, "hashchange") + assert String.contains?(response, "Outbound Link: Click") + + refute String.contains?(response, "data-exclude") + # local extension disabled + assert String.contains?(response, "/^localhost$|^127(\\.[0-9]+)") + + assert response == get_script("plausible.outbound-links.file-downloads.compat.hash.js") + end + + test "pageleave extension" do + # Some customers who have participated in the private preview of the + # scroll depth feature, have updated their tracking scripts to + # `script.pageleave.js` per our request. With the public release of + # scroll depth, this functionality is included in the default script, + # but we must continue to serve `script.pageleave.js` for as long as + # those customers are still using it. + assert get_script("script.pageleave.js") == get_script("script.js") + assert get_script("script.manual.pageleave.js") == get_script("script.manual.js") + end + + for variant <- [ + "plausible.js", + "script.manual.pageview-props.tagged-events.pageleave.js", + "script.compat.local.exclusions.js", + "script.hash.revenue.file-downloads.pageview-props.js" + ] do + test "variant #{variant} is available" do + script = get_script(unquote(variant)) + assert String.starts_with?(script, "!function(){var") + assert String.length(script) > 200 + + assert String.contains?(script, "window.plausible") + end + end + end + + def get_script(filename) do + opts = PlausibleWeb.TrackerPlug.init([]) + + conn(:get, "/js/#{filename}") + |> PlausibleWeb.TrackerPlug.call(opts) + |> response(200) + end +end diff --git a/test/plausible_web/plugs/user_session_touch_test.exs b/test/plausible_web/plugs/user_session_touch_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..3ecfb9ca61079bf1bac72a48a284fd7cb2aa9379 --- /dev/null +++ b/test/plausible_web/plugs/user_session_touch_test.exs @@ -0,0 +1,38 @@ +defmodule PlausibleWeb.Plugs.UserSessionTouchTest do + use PlausibleWeb.ConnCase, async: true + + alias Plausible.Repo + alias PlausibleWeb.AuthPlug + alias PlausibleWeb.Plugs.UserSessionTouch + + setup [:create_user, :log_in] + + test "refreshes session", %{conn: conn, user: user} do + now = NaiveDateTime.utc_now(:second) + one_day_ago = NaiveDateTime.shift(now, day: -1) + %{sessions: [user_session]} = Repo.preload(user, sessions: :user) + + user_session + |> Plausible.Auth.UserSession.touch_session(one_day_ago) + |> Repo.update!(allow_stale: true) + + assert %{assigns: %{current_user_session: user_session}} = + conn + |> Plug.Conn.fetch_query_params() + |> AuthPlug.call([]) + |> UserSessionTouch.call([]) + + assert NaiveDateTime.compare(user_session.last_used_at, now) in [:gt, :eq] + assert NaiveDateTime.compare(user_session.timeout_at, user_session.last_used_at) == :gt + end + + test "passes through when there's no authenticated session" do + conn = + build_conn() + |> init_session() + |> UserSessionTouch.call([]) + + refute conn.halted + refute get_session(conn, :user_token) + end +end diff --git a/test/plausible_web/tracker_test.exs b/test/plausible_web/tracker_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..0a870cca683085fd682bed08e58a490a72c7efbd --- /dev/null +++ b/test/plausible_web/tracker_test.exs @@ -0,0 +1,235 @@ +defmodule PlausibleWeb.TrackerTest do + use Plausible.DataCase + use Oban.Testing, repo: Plausible.Repo + + alias Plausible.Site.TrackerScriptConfiguration + alias PlausibleWeb.Tracker + + @example_config %{ + installation_type: :manual, + track_404_pages: true, + hash_based_routing: true, + file_downloads: false, + outbound_links: false, + pageview_props: false, + tagged_events: true, + revenue_tracking: false, + form_submissions: true + } + + test "plausible_main_config/1" do + site = new_site() + tracker_script_configuration = create_config(site) + + assert Tracker.plausible_main_config(tracker_script_configuration) == %{ + domain: site.domain, + endpoint: "#{PlausibleWeb.Endpoint.url()}/api/event", + outboundLinks: false, + fileDownloads: false, + formSubmissions: true + } + end + + describe "get_or_create_tracker_script_configuration!/1 and update_script_configuration!/3" do + test "can create config with params" do + site = new_site() + + tracker_script_configuration = + Tracker.get_or_create_tracker_script_configuration!(site, %{ + outbound_links: true, + form_submissions: true, + installation_type: :manual + }) + + assert tracker_script_configuration.outbound_links + assert tracker_script_configuration.form_submissions + refute tracker_script_configuration.file_downloads + assert tracker_script_configuration.installation_type == :manual + end + + test "goals are created when config is created" do + site = new_site() + + Tracker.get_or_create_tracker_script_configuration!(site, %{ + outbound_links: true, + installation_type: :manual + }) + + assert Repo.get_by(Plausible.Goal, site_id: site.id, display_name: "Outbound Link: Click") + refute Repo.get_by(Plausible.Goal, site_id: site.id, display_name: "File Download") + end + + test "can update config" do + site = new_site() + tracker_script_configuration = create_config(site) + + assert tracker_script_configuration.installation_type == :manual + + Tracker.update_script_configuration!( + site, + %{installation_type: :wordpress, outbound_links: true}, + :installation + ) + + tracker_script_configuration = Repo.reload!(tracker_script_configuration) + + assert tracker_script_configuration.installation_type == :wordpress + assert tracker_script_configuration.outbound_links == true + end + + on_ee do + test "CDN purge is scheduled when config is updated" do + site = new_site() + + tracker_script_configuration = + Tracker.get_or_create_tracker_script_configuration!(site) + + Tracker.update_script_configuration!( + site, + %{installation_type: :wordpress, outbound_links: true}, + :installation + ) + + assert_enqueued( + worker: Plausible.Workers.PurgeCDNCache, + args: %{id: tracker_script_configuration.id} + ) + end + + test "CDN purge is not scheduled when only installation type is updated" do + site = new_site() + + tracker_script_configuration = + Tracker.get_or_create_tracker_script_configuration!(site) + + Tracker.update_script_configuration!( + site, + %{installation_type: :wordpress}, + :installation + ) + + refute_enqueued( + worker: Plausible.Workers.PurgeCDNCache, + args: %{id: tracker_script_configuration.id} + ) + end + end + end + + describe "build_script/1" do + test "can turn config into a script tag" do + site = new_site() + tracker_script_configuration = create_config(site) + + script_tag = PlausibleWeb.Tracker.build_script(tracker_script_configuration) + + assert script_tag =~ + ~s(={endpoint:"#{PlausibleWeb.Endpoint.url()}/api/event",domain:"#{site.domain}",formSubmissions:!0}) + end + + test "script tag escapes problematic characters as expected" do + site = new_site(domain: "naughty domain &<> \"\'\nfoo ") + tracker_script_configuration = create_config(site) + script_tag = PlausibleWeb.Tracker.build_script(tracker_script_configuration) + + assert script_tag =~ ~s(domain:"naughty domain &<> \\"'\\nfoo ") + end + end + + describe "get_plausible_main_script/1" do + setup %{test: test} do + site = new_site() + tracker_script_configuration = create_config(site) + + opts = start_test_cache(test) + + {:ok, %{opts: opts, site: site, tracker_script_configuration: tracker_script_configuration}} + end + + test "does 0 database queries on non-existing config", %{opts: opts} do + query_count = attach_query_counter() + script = Tracker.get_plausible_main_script("non-existing-id", opts) + assert is_nil(script) + assert query_count.() == 0 + end + + test "does minimal database queries on existing config", %{ + opts: opts, + tracker_script_configuration: tracker_script_configuration + } do + query_count = attach_query_counter() + script = Tracker.get_plausible_main_script(tracker_script_configuration.id, opts) + assert is_binary(script) + + on_ee do + assert query_count.() == 1 + else + # On self-hosted, we have a pre-warmed cache for the script + assert query_count.() == 0 + end + end + end + + def create_config(site) do + Tracker.update_script_configuration!( + site, + Map.put(@example_config, :site_id, site.id), + :installation + ) + + Plausible.Repo.one( + from(c in TrackerScriptConfiguration, where: c.site_id == ^site.id, preload: [:site]) + ) + end + + on_ee do + defp start_test_cache(cache_name) do + opts = [cache_name: cache_name, force?: true] + + %{start: {m, f, a}} = + Plausible.Site.TrackerScriptIdCache.child_spec( + cache_name: cache_name, + ets_options: [read_concurrency: true] + ) + + {:ok, _} = apply(m, f, a) + Plausible.Site.TrackerScriptIdCache.refresh_all(opts) + + opts + end + else + defp start_test_cache(cache_name) do + opts = [cache_name: cache_name, force?: true] + + %{start: {m, f, a}} = + Plausible.Site.TrackerScriptCache.child_spec( + cache_name: cache_name, + ets_options: [read_concurrency: true] + ) + + {:ok, _} = apply(m, f, a) + Plausible.Site.TrackerScriptCache.refresh_all(opts) + + opts + end + end + + defp attach_query_counter() do + query_count = :counters.new(1, []) + + :telemetry.attach( + "test-query-counter", + [:plausible, :repo, :query], + fn _event, _measurements, _metadata, _config -> + :counters.add(query_count, 1, 1) + end, + %{} + ) + + on_exit(fn -> + :telemetry.detach("test-query-counter") + end) + + fn -> :counters.get(query_count, 1) end + end +end diff --git a/test/plausible_web/user_auth_test.exs b/test/plausible_web/user_auth_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..657c23edab5b82b9f1f3ff6546314ba81bb40b27 --- /dev/null +++ b/test/plausible_web/user_auth_test.exs @@ -0,0 +1,365 @@ +defmodule PlausibleWeb.UserAuthTest do + use PlausibleWeb.ConnCase, async: true + + alias Plausible.Repo + alias PlausibleWeb.UserAuth + + on_ee do + alias Plausible.Auth.SSO + end + + alias PlausibleWeb.Router.Helpers, as: Routes + + describe "log_in_user/2,3" do + setup [:create_user] + + test "sets up user session and redirects to sites list", %{conn: conn, user: user} do + now = NaiveDateTime.utc_now(:second) + + conn = + conn + |> init_session() + |> UserAuth.log_in_user(user) + + assert %{sessions: [session]} = user |> Repo.reload!() |> Repo.preload(:sessions) + assert session.user_id == user.id + assert NaiveDateTime.compare(session.last_used_at, now) in [:eq, :gt] + assert NaiveDateTime.compare(session.timeout_at, session.last_used_at) == :gt + + assert redirected_to(conn, 302) == Routes.site_path(conn, :index) + assert conn.private[:plug_session_info] == :renew + assert conn.resp_cookies["logged_in"].max_age > 0 + assert get_session(conn, :user_token) == session.token + end + + test "redirects to `redirect_path` if present", %{conn: conn, user: user} do + conn = + conn + |> init_session() + |> UserAuth.log_in_user(user, "/next") + + assert redirected_to(conn, 302) == "/next" + end + + on_ee do + test "sets up user session from SSO identity", %{conn: conn, user: user} do + team = new_site().team + integration = SSO.initiate_saml_integration(team) + domain = "example-#{Enum.random(1..10_000)}.com" + user = user |> Ecto.Changeset.change(email: "jane@" <> domain) |> Repo.update!() + add_member(team, user: user, role: :editor) + + {:ok, sso_domain} = SSO.Domains.add(integration, domain) + _sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true) + + identity = new_identity(user.name, user.email, integration) + + conn = + conn + |> init_session() + |> UserAuth.log_in_user(identity) + + assert %{sessions: [session]} = user |> Repo.reload!() |> Repo.preload(:sessions) + assert session.user_id == user.id + assert NaiveDateTime.compare(session.timeout_at, identity.expires_at) == :eq + + assert redirected_to(conn, 302) == Routes.site_path(conn, :index) + assert conn.private[:plug_session_info] == :renew + assert conn.resp_cookies["logged_in"].max_age > 0 + assert get_session(conn, :current_team_id) == team.identifier + assert get_session(conn, :user_token) == session.token + end + + test "logs in existing SSO owner using standard login correctly", %{ + conn: conn, + user: user + } do + team = new_site(owner: user).team |> Plausible.Teams.complete_setup() + integration = SSO.initiate_saml_integration(team) + domain = "example-#{Enum.random(1..10_000)}.com" + user = user |> Ecto.Changeset.change(email: "jane@" <> domain) |> Repo.update!() + {:ok, sso_domain} = SSO.Domains.add(integration, domain) + _sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true) + + identity = new_identity(user.name, user.email, integration) + {:ok, :standard, _, user} = SSO.provision_user(identity) + + assert user.type == :sso + + conn = + conn + |> init_session() + |> UserAuth.log_in_user(user) + + assert %{sessions: [session]} = user |> Repo.reload!() |> Repo.preload(:sessions) + assert session.user_id == user.id + assert session.token == get_session(conn, :user_token) + + assert redirected_to(conn, 302) == Routes.site_path(conn, :index) + end + + test "invalidates any existing sessions of user logging in when converting", %{ + conn: conn, + user: user + } do + team = new_site().team + integration = SSO.initiate_saml_integration(team) + domain = "example-#{Enum.random(1..10_000)}.com" + user = user |> Ecto.Changeset.change(email: "jane@" <> domain) |> Repo.update!() + add_member(team, user: user, role: :editor) + + {:ok, sso_domain} = SSO.Domains.add(integration, domain) + _sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true) + + identity = new_identity(user.name, user.email, integration) + + conn |> init_session() |> UserAuth.log_in_user(user) + + assert [standard_session] = Repo.preload(user, :sessions).sessions + + conn = + conn + |> init_session() + |> UserAuth.log_in_user(identity) + + assert [sso_session] = Repo.preload(user, :sessions).sessions + + assert standard_session.id != sso_session.id + assert standard_session.token != sso_session.token + assert get_session(conn, :user_token) == sso_session.token + end + + test "tries to log out and redirects if SSO identity is not matched", %{ + conn: conn, + user: user + } do + identity = + new_identity("Willy Wonka", "wonka@example.com", %{identifier: Ecto.UUID.generate()}) + + conn = + conn + |> init_session() + |> fetch_flash() + |> UserAuth.log_in_user(identity) + + assert %{sessions: []} = user |> Repo.reload!() |> Repo.preload(:sessions) + + assert redirected_to(conn, 302) == Routes.sso_path(conn, :login_form, return_to: "") + + assert Phoenix.Flash.get(conn.assigns.flash, :login_error) == + "We couldn't find a Single Sign-On account for that email." + + assert conn.private[:plug_session_info] == :renew + refute get_session(conn, :user_token) + end + + test "tries to log out and redirects if SSO identity exceeds team members limit", %{ + conn: conn, + user: user + } do + team = new_site().team + insert(:growth_subscription, team: team) + integration = SSO.initiate_saml_integration(team) + domain = "example-#{Enum.random(1..10_000)}.com" + add_member(team, role: :viewer) + add_member(team, role: :viewer) + add_member(team, role: :viewer) + + {:ok, sso_domain} = SSO.Domains.add(integration, domain) + _sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true) + + identity = new_identity("Jane Doe", "jane@" <> domain, integration) + + conn = + conn + |> init_session() + |> fetch_flash() + |> UserAuth.log_in_user(identity) + + assert %{sessions: []} = user |> Repo.reload!() |> Repo.preload(:sessions) + + assert redirected_to(conn, 302) == Routes.sso_path(conn, :login_form, return_to: "") + + assert Phoenix.Flash.get(conn.assigns.flash, :login_error) == + "Team can't accept more members. Please contact the owner." + + assert conn.private[:plug_session_info] == :renew + refute get_session(conn, :user_token) + end + + test "tries to log out for user matching SSO identity on multiple teams, redirecting to issue notice", + %{ + conn: conn, + user: user + } do + team = new_site().team |> Plausible.Teams.complete_setup() + integration = SSO.initiate_saml_integration(team) + domain = "example-#{Enum.random(1..10_000)}.com" + user = user |> Ecto.Changeset.change(email: "jane@" <> domain) |> Repo.update!() + add_member(team, user: user, role: :editor) + another_team = new_site().team |> Plausible.Teams.complete_setup() + add_member(another_team, user: user, role: :viewer) + + {:ok, sso_domain} = SSO.Domains.add(integration, domain) + _sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true) + + identity = new_identity(user.name, user.email, integration) + + conn = + conn + |> init_session() + |> UserAuth.log_in_user(identity) + + assert redirected_to(conn, 302) == + Routes.sso_path(conn, :provision_issue, issue: "multiple_memberships_noforce") + + refute get_session(conn, :user_token) + end + + test "tries to log out for user matching SSO identity with active personal team, redirecting to issue notice", + %{ + conn: conn, + user: user + } do + team = new_site().team + integration = SSO.initiate_saml_integration(team) + domain = "example-#{Enum.random(1..10_000)}.com" + user = user |> Ecto.Changeset.change(email: "jane@" <> domain) |> Repo.update!() + add_member(team, user: user, role: :editor) + # personal team with site created + new_site(owner: user) + + {:ok, sso_domain} = SSO.Domains.add(integration, domain) + _sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true) + + identity = new_identity(user.name, user.email, integration) + + conn = + conn + |> init_session() + |> UserAuth.log_in_user(identity) + + assert redirected_to(conn, 302) == + Routes.sso_path(conn, :provision_issue, issue: "active_personal_team_noforce") + + refute get_session(conn, :user_token) + end + end + end + + describe "log_out_user/1" do + setup [:create_user] + + test "logs user out", %{conn: conn, user: user} do + # another independent session for the same user + {:ok, conn: another_conn} = log_in(%{conn: conn, user: user}) + another_session_token = get_session(another_conn, :user_token) + + {:ok, conn: conn} = log_in(%{conn: conn, user: user}) + + conn = + conn + |> init_session() + |> UserAuth.log_out_user() + + # the other session remains intact + assert %{sessions: [another_session]} = Repo.preload(user, :sessions) + assert another_session.token == another_session_token + assert conn.private[:plug_session_info] == :renew + assert conn.resp_cookies["logged_in"].max_age == 0 + end + end + + describe "get_user_session/1" do + setup [:create_user, :log_in] + + test "gets session from session data in conn", %{conn: conn, user: user} do + assert {:ok, user_session} = UserAuth.get_user_session(conn) + assert user_session.user_id == user.id + end + + test "gets session from session data map", %{user: user} do + %{sessions: [user_session]} = Repo.preload(user, :sessions) + + assert {:ok, session_from_token} = + UserAuth.get_user_session(%{"user_token" => user_session.token}) + + assert session_from_token.id == user_session.id + end + + test "returns error on invalid or missing session data" do + conn = init_session(build_conn()) + assert {:error, :no_valid_token} = UserAuth.get_user_session(conn) + assert {:error, :no_valid_token} = UserAuth.get_user_session(%{}) + assert {:error, :no_valid_token} = UserAuth.get_user_session(%{"current_user_id" => 123}) + end + + test "returns error on missing session", %{conn: conn, user: user} do + %{sessions: [user_session]} = Repo.preload(user, :sessions) + Repo.delete!(user_session) + + assert {:error, :session_not_found} = UserAuth.get_user_session(conn) + + assert {:error, :session_not_found} = + UserAuth.get_user_session(%{"user_token" => user_session.token}) + end + + test "returns error on expired session", %{conn: conn} do + now = NaiveDateTime.utc_now(:second) + in_the_past = NaiveDateTime.add(now, -1, :hour) + {:ok, user_session} = UserAuth.get_user_session(conn) + user_session |> Ecto.Changeset.change(timeout_at: in_the_past) |> Repo.update!() + + assert {:error, :session_expired, expired_session} = UserAuth.get_user_session(conn) + assert expired_session.id == user_session.id + end + end + + describe "set_logged_in_cookie/1" do + test "sets logged_in_cookie", %{conn: conn} do + conn = UserAuth.set_logged_in_cookie(conn) + + assert cookie = conn.resp_cookies["logged_in"] + assert cookie.max_age > 0 + assert cookie.value == "true" + end + end + + @user_agent "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.110 Safari/537.36" + @user_agent_mobile "Mozilla/5.0 (Linux; Android 6.0; U007 Pro Build/MRA58K; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/44.0.2403.119 Mobile Safari/537.36" + @user_agent_tablet "Mozilla/5.0 (Linux; U; Android 4.2.2; it-it; Surfing TAB B 9.7 3G Build/JDQ39) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30" + + describe "device name detection" do + setup [:create_user] + + test "detects browser and os when possible", %{conn: conn, user: user} do + assert login_device(conn, user, @user_agent) == "Chrome (Mac)" + assert login_device(conn, user, @user_agent_mobile) == "Mobile App (Android)" + assert login_device(conn, user, @user_agent_tablet) == "Android Browser (Android)" + end + + test "falls back to unknown when can't detect browser", %{conn: conn, user: user} do + assert login_device(conn, user, nil) == "Unknown" + assert login_device(conn, user, "Bogus UA") == "Unknown" + end + + test "skips os when can't detect it", %{conn: conn, user: user} do + assert login_device(conn, user, "Mozilla Firefox") == "Firefox" + end + end + + defp login_device(conn, user, ua_string) do + conn = + if ua_string do + Plug.Conn.put_req_header(conn, "user-agent", ua_string) + else + conn + end + + {:ok, conn: conn} = log_in(%{conn: conn, user: user}) + + {:ok, user_session} = conn |> UserAuth.get_user_session() + + user_session.device + end +end diff --git a/test/plausible_web/views/error_view_test.exs b/test/plausible_web/views/error_view_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..dd704dd850beb9285f2708500295e45ead37532a --- /dev/null +++ b/test/plausible_web/views/error_view_test.exs @@ -0,0 +1,24 @@ +defmodule PlausibleWeb.ErrorViewTest do + use PlausibleWeb.ConnCase, async: false + + test "renders 500.html", %{conn: conn} do + conn = get(conn, "/test") + layout = Application.get_env(:plausible, PlausibleWeb.Endpoint)[:render_errors][:layout] + + error_html = + Phoenix.View.render_to_string(PlausibleWeb.ErrorView, "500.html", + conn: conn, + layout: layout + ) + + refute error_html =~ "data-domain=" + end + + test "renders json errors" do + assert Phoenix.View.render_to_string(PlausibleWeb.ErrorView, "500.json", %{}) == + ~s[{"message":"Server error","status":500}] + + assert Phoenix.View.render_to_string(PlausibleWeb.ErrorView, "406.json", %{}) == + ~s[{"message":"Not Acceptable","status":406}] + end +end diff --git a/test/plausible_web/views/stats_view_test.exs b/test/plausible_web/views/stats_view_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..adf11e8a6ec714e6269dc397fa512a8881cc79eb --- /dev/null +++ b/test/plausible_web/views/stats_view_test.exs @@ -0,0 +1,63 @@ +defmodule PlausibleWeb.StatsViewTest do + use PlausibleWeb.ConnCase, async: true + alias PlausibleWeb.StatsView + doctest PlausibleWeb.StatsView + + describe "large_number_format" do + test "numbers under 1000 stay the same" do + assert StatsView.large_number_format(100) == "100" + end + + test "1000 becomes 1k" do + assert StatsView.large_number_format(1000) == "1k" + end + + test "1111 becomes 1.1k" do + assert StatsView.large_number_format(1111) == "1.1k" + end + + test "10_000 becomes 10k" do + assert StatsView.large_number_format(10_000) == "10k" + end + + test "15_993 becomes 15.9k" do + assert StatsView.large_number_format(15_923) == "15.9k" + end + + test "wat" do + assert StatsView.large_number_format(49012) == "49k" + end + + test "999_999 becomes 999k" do + assert StatsView.large_number_format(999_999) == "999k" + end + + test "1_000_000 becomes 1m" do + assert StatsView.large_number_format(1_000_000) == "1M" + end + + test "2_590_000 becomes 2.5m" do + assert StatsView.large_number_format(2_590_000) == "2.5M" + end + + test "99_999_999 becomes 99.9m" do + assert StatsView.large_number_format(99_999_999) == "99.9M" + end + + test "101_000_000 becomes 101m" do + assert StatsView.large_number_format(101_000_000) == "101M" + end + + test "2_500_000_000 becomes 2.5bn" do + assert StatsView.large_number_format(2_500_000_000) == "2.5B" + end + + test "25_500_000_000 becomes 25bn" do + assert StatsView.large_number_format(25_500_000_000) == "25.5B" + end + + test "250_500_000_000 becomes 250bn" do + assert StatsView.large_number_format(250_500_000_000) == "250B" + end + end +end diff --git a/test/plausible_web/views/text_helpers_test.exs b/test/plausible_web/views/text_helpers_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..488ec321b8c8365a8370edfe0669b3e22f5b241f --- /dev/null +++ b/test/plausible_web/views/text_helpers_test.exs @@ -0,0 +1,44 @@ +defmodule PlausibleWeb.TextHelpersTest do + use PlausibleWeb.ConnCase, async: true + alias PlausibleWeb.TextHelpers + doctest PlausibleWeb.TextHelpers + + describe "number_format" do + test "numbers under 1000 stay the same" do + assert TextHelpers.number_format(0) == "0" + assert TextHelpers.number_format(1) == "1" + assert TextHelpers.number_format(123) == "123" + assert TextHelpers.number_format(999) == "999" + end + + test "thousands get comma separator" do + assert TextHelpers.number_format(1_000) == "1,000" + assert TextHelpers.number_format(1_234) == "1,234" + assert TextHelpers.number_format(12_345) == "12,345" + assert TextHelpers.number_format(123_456) == "123,456" + end + + test "millions get multiple comma separators" do + assert TextHelpers.number_format(1_000_000) == "1,000,000" + assert TextHelpers.number_format(1_234_567) == "1,234,567" + assert TextHelpers.number_format(12_345_678) == "12,345,678" + assert TextHelpers.number_format(123_456_789) == "123,456,789" + end + + test "billions get multiple comma separators" do + assert TextHelpers.number_format(1_000_000_000) == "1,000,000,000" + assert TextHelpers.number_format(1_234_567_890) == "1,234,567,890" + assert TextHelpers.number_format(12_345_678_901) == "12,345,678,901" + end + + test "handles negative numbers" do + assert TextHelpers.number_format(-1234) == "-1,234" + assert TextHelpers.number_format(-1_234_567) == "-1,234,567" + end + + test "handles edge cases" do + assert TextHelpers.number_format(0) == "0" + assert TextHelpers.number_format(-0) == "0" + end + end +end diff --git a/test/priv/GeoLite2-City-Test.mmdb b/test/priv/GeoLite2-City-Test.mmdb new file mode 100644 index 0000000000000000000000000000000000000000..ed9adc6547fd91b24329c903ace3cc60cab8a408 Binary files /dev/null and b/test/priv/GeoLite2-City-Test.mmdb differ diff --git a/test/priv/README.md b/test/priv/README.md new file mode 100644 index 0000000000000000000000000000000000000000..eb99937238f82afaf841caa532eed576babead16 --- /dev/null +++ b/test/priv/README.md @@ -0,0 +1 @@ +`GeoLite2-City-Test.mmdb` is downloaded from https://github.com/maxmind/MaxMind-DB diff --git a/test/support/assert_matches.ex b/test/support/assert_matches.ex new file mode 100644 index 0000000000000000000000000000000000000000..c2f26c31d7df5cbb217980cce156502a0f374d5a --- /dev/null +++ b/test/support/assert_matches.ex @@ -0,0 +1,406 @@ +defmodule Plausible.AssertMatches do + @moduledoc """ + Pattern match assertions wrapper macro extending it with checks expressed + directly within the pattern. + + The idea here is that the pin (^) operator does not only rebind existing + binding in the scope but also allows embedding basically any other expression + to match against the given part of the pattern. Normal pattern matching is + also supported and both can be mixed. The only caveat so far is that when + normal patterns fail, only they are listed in the error even if there are + potentially failing expressions. However, once the normal pattern is fixed, + they surface. + + Currently, the following expressions can be pinned: + + * `any(:atom)` + * `any(:string)` + * `any(:binary)` + * `any(:integer)` + * `any(:pos_integer)` + * `any(:number)` + * `any(:float)` + * `any(:boolean)` + * `any(:map)` + * `any(:list)` + * `any(:tuple)` + * `any(:iso8601_date)` + * `any(:iso8601_datetime)` + * `any(:iso8601_naive_datetime)` + * all above variants of any with a one argument predicate function accepting + value and returning a boolean, like: `any(:integer, & &1 > 20)` + * a special case of `any(:string, ~r/regex pattern/)` checking that value is + a string and matches a pattern + * shorthand version of the above, `~r/regex pattern/` + * any artibrary one argument function returning a boolean, like `&is_float/1` + or `&(&1 < 40 or &1 > 300)` + * exactly(expression) where expression is compared using equality, so that can + enforce full equality inside a pattern, like: `exactly(%{foo: 2})` which will + fail if the value is something like `%{foo: 2, other: "something}` + * any other arbitrary expression which is compared the way as if it was wrapped + with `exactly()`; this allows "interpolating" values from schemas and maps without + rebinding like `user.id` (instead of having to rebind to `user_id` first) + + There's also a special pin type, `strict_map(...)` which can wrap around any map + in the pattern. It's enforcing that the pattern has enumerated all the keys + present in the respective map in the pattern matched value. All the above mentioned + pin expressions can be also used inside `strict_map(...)` and `strict_map()` pins + can be nested. + + Usage example: + + n = %{z: 2} + + assert_matches %{ + a: ^any(:integer, &(&1 > 2)), + b: ^any(:string, ~r/baz/), + d: [_ | _], + e: ^~r/invalid/, + f: ^n.z, + g: ^(&is_float/1), + h: ^exactly(%{foo: :bar}) + } = %{ + a: 1, + b: "twofer", + c: :other, + d: [1, 2, 3], + e: "another string", + f: 1, + g: 4.2, + h: %{foo: :bar, other: "stuff"} + } + """ + + @doc @moduledoc + defmacro assert_matches({:=, meta, [pattern, value]}) do + {base_strict_pattern, strict_vars} = build_base_strict_pattern(pattern) + + base_strict_pattern = clear_bindings_except(base_strict_pattern, :match_var) + + strict_patterns = + Enum.map(strict_vars, fn strict_var -> + build_strict_pattern(base_strict_pattern, strict_var) + end) + + strict_pattern_matches = + Enum.map(strict_patterns, fn {strict_pattern, _} -> + quote do + assert unquote(strict_pattern) = unquote(value) + end + end) + + strict_pattern_checks = + Enum.map(strict_patterns, fn {strict_pattern, [{strict_var, map_pattern_keys}]} -> + build_strict_pattern_check(strict_pattern, strict_var, map_pattern_keys, pattern, value) + end) + + {var_pattern, pins} = build_var_pattern(pattern) + + clean_pattern = + Enum.reduce(pins, var_pattern, fn {var, _predicate}, pattern -> + Macro.postwalk(pattern, fn + ^var -> {:_, [], __MODULE__} + other -> other + end) + end) + + var_pattern = clear_bindings_except(var_pattern, :assert_match) + + predicate_pattern = build_predicate_pattern(var_pattern, pins) + + quote do + value = unquote(value) + assert unquote(clean_pattern) = value + unquote(strict_pattern_matches) + unquote(strict_pattern_checks) + + assert unquote(var_pattern) = value + + if unquote(length(pins) > 0) do + {errors?, predicate_pattern} = unquote(predicate_pattern) + + if errors? do + raise ExUnit.AssertionError, + message: "match (=) failed", + left: predicate_pattern, + right: value, + expr: + {:assert_matches, unquote(meta), + [{:=, [], [unquote(Macro.escape(pattern)), Macro.escape(value)]}]}, + context: {:match, []} + end + end + end + end + + defp build_base_strict_pattern(pattern) do + Macro.postwalk(pattern, [], fn + {:^, _, [{:strict_map, _, _}]} = pin, acc -> + pinned_var = Macro.unique_var(:match, __MODULE__) + + pin = Macro.update_meta(pin, &Keyword.put(&1, :match_var, pinned_var)) + + {pin, [pinned_var | acc]} + + {:^, _, _}, acc -> + {{:_, [], __MODULE__}, acc} + + other, acc -> + {other, acc} + end) + end + + defp build_strict_pattern(base_strict_pattern, strict_var) do + Macro.postwalk(base_strict_pattern, [], fn + {:^, meta, [{:strict_map, _, [pinned]}]}, acc -> + if meta[:match_var] == strict_var do + {:%{}, _, map_pattern_values} = pinned + map_pattern_keys = map_pattern_values |> Enum.map(&elem(&1, 0)) |> Enum.sort() + {strict_var, [{strict_var, map_pattern_keys} | acc]} + else + { + Macro.postwalk(pinned, fn + {:^, _, _} -> + {:_, [], __MODULE__} + + other -> + other + end), + acc + } + end + + other, acc -> + {other, acc} + end) + end + + defp build_strict_pattern_check(strict_pattern, strict_var, map_pattern_keys, pattern, value) do + quote bind_quoted: [ + pattern: Macro.escape(pattern), + value: value, + strict_pattern: Macro.escape(strict_pattern), + var: strict_var, + escaped_var: Macro.escape(strict_var), + pattern_keys: map_pattern_keys + ] do + var_keys = var |> Map.keys() |> Enum.sort() + + if pattern_keys != var_keys do + missing_keys = var_keys -- pattern_keys + + map_pattern_values = + pattern_keys + |> Enum.map(&{&1, {:_, [], __MODULE__}}) + |> Enum.concat(Enum.map(missing_keys, &{&1, :_MISSING_KEY__})) + + error_pattern = + Macro.postwalk(strict_pattern, fn + ^escaped_var -> {:%{}, [], map_pattern_values} + other -> other + end) + + raise ExUnit.AssertionError, + message: "match (=) failed", + left: error_pattern, + right: value, + expr: {:assert_matches, [], [{:=, [], [pattern, Macro.escape(value)]}]}, + context: {:match, []} + end + end + end + + defp build_predicate_pattern(var_pattern, pins) do + quote bind_quoted: [ + var_pattern: Macro.escape(var_pattern), + escaped_pins: Macro.escape(pins), + pins: pins + ] do + escaped_pins + |> Enum.zip(pins) + |> Enum.reduce({false, var_pattern}, fn {{escaped_var, escaped_predicate}, {var, predicate}}, + {errors?, pattern} -> + result = + if is_function(predicate, 1) do + not predicate.(var) + else + predicate != var + end + + if result do + escaped_predicate = Plausible.AssertMatches.Internal.strip_prefix(escaped_predicate) + + {true, + Macro.postwalk(pattern, fn + ^escaped_var -> escaped_predicate + other -> other + end)} + else + {errors?, + Macro.postwalk(pattern, fn + ^escaped_var -> {:_, [], __MODULE__} + other -> other + end)} + end + end) + end + end + + defp build_var_pattern(pattern) do + Macro.postwalk(pattern, [], fn + {:^, _meta, [{pinned, _, module}]} = normal_pin, acc + when is_atom(pinned) and is_atom(module) -> + {normal_pin, acc} + + {:^, _meta, [{:strict_map, _, [pinned]}]}, acc -> + {pinned, acc} + + {:^, _meta, [pinned]}, acc -> + pinned = Plausible.AssertMatches.Internal.transform_predicate(pinned) + + pinned_var = + Macro.unique_var(:match, __MODULE__) + |> Macro.update_meta(&Keyword.put(&1, :assert_match, true)) + + {pinned_var, [{pinned_var, pinned} | acc]} + + other, acc -> + {other, acc} + end) + end + + defp clear_bindings_except(pattern, except_meta) do + pattern + |> Macro.postwalk(fn + {:^, _, [{name, meta, module}]} = pin when is_atom(name) and is_atom(module) -> + if meta[except_meta] do + pin + else + {:_, [], __MODULE__} + end + + other -> + other + end) + |> Macro.postwalk(fn + {name, meta, module} = var when is_atom(name) and is_atom(module) -> + if meta[except_meta] do + var + else + {:_, [], __MODULE__} + end + + other -> + other + end) + end + + defmodule Internal do + @moduledoc false + + def transform_predicate({:any, _, [value]}) do + quote do + Plausible.AssertMatches.Internal.any(unquote(value)) + end + end + + def transform_predicate({:exactly, _, [value]}) do + quote do + Plausible.AssertMatches.Internal.exactly(unquote(value)) + end + end + + def transform_predicate({:any, _, [value, extra_predicate]}) do + quote do + Plausible.AssertMatches.Internal.any( + unquote(value), + unquote(extra_predicate) + ) + end + end + + def transform_predicate({:sigil_r, _, _} = regex) do + quote do + Plausible.AssertMatches.Internal.regex(unquote(regex)) + end + end + + def transform_predicate(other), do: other + + def strip_prefix({{:., _, [_prefix, f]}, _, args}) when f in [:any, :regex, :exactly] do + {f, [], args} + end + + def strip_prefix(predicate) do + predicate + end + + def any(:atom), do: &is_atom/1 + def any(:string), do: &is_binary/1 + def any(:binary), do: &is_binary/1 + def any(:integer), do: &is_integer/1 + def any(:number), do: &is_number/1 + def any(:float), do: &is_float/1 + def any(:boolean), do: &is_boolean/1 + def any(:map), do: &is_map/1 + def any(:list), do: &is_list/1 + def any(:tuple), do: &is_tuple/1 + + def any(:pos_integer) do + fn value -> + is_integer(value) and value > 0 + end + end + + def any(:iso8601_date) do + fn value -> + case Date.from_iso8601(value) do + {:ok, _} -> true + _ -> false + end + end + end + + def any(:iso8601_datetime) do + fn value -> + case DateTime.from_iso8601(value) do + {:ok, _, _} -> true + _ -> false + end + end + end + + def any(:iso8601_naive_datetime) do + fn value -> + case NaiveDateTime.from_iso8601(value) do + {:ok, _} -> true + _ -> false + end + end + end + + def any(:string, %Regex{} = regex) do + fn value -> + any(:string).(value) and regex(regex).(value) + end + end + + def any(type, predicate_fn) when is_function(predicate_fn, 1) do + fn value -> + any(type).(value) and predicate_fn.(value) + end + end + + def regex(regex) do + fn value -> + String.match?(value, regex) + end + end + + def exactly(expr) do + fn value -> + value == expr + end + end + end +end diff --git a/test/support/audit/test_schema.ex b/test/support/audit/test_schema.ex new file mode 100644 index 0000000000000000000000000000000000000000..0ae99ef2056ff50e8f3b54e54726a9d2f1757cb7 --- /dev/null +++ b/test/support/audit/test_schema.ex @@ -0,0 +1,39 @@ +defmodule Plausible.Audit.TestSchema do + @moduledoc false + + use Plausible + + on_ee do + use Ecto.Schema + + @derive {Plausible.Audit.Encoder, only: [:id, :name]} + + schema "tests" do + field :name, :string + end + + defmodule VariantWithAssociation do + @moduledoc false + use Ecto.Schema + + on_ee do + @derive {Plausible.Audit.Encoder, only: [:id, :team]} + end + + schema "tests" do + belongs_to :team, Plausible.Teams.Team + end + end + + defmodule VariantWithAssociationAllowNotLoaded do + @moduledoc false + use Ecto.Schema + + @derive {Plausible.Audit.Encoder, only: [:id, :team], allow_not_loaded: [:team]} + + schema "tests" do + belongs_to :team, Plausible.Teams.Team + end + end + end +end diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex new file mode 100644 index 0000000000000000000000000000000000000000..13344a8bcf931a2f9d04cba50263e77cb0a174fa --- /dev/null +++ b/test/support/conn_case.ex @@ -0,0 +1,48 @@ +defmodule PlausibleWeb.ConnCase do + @moduledoc """ + This module defines the test case to be used by + tests that require setting up a connection. + + Such tests rely on `Phoenix.ConnTest` and also + import other functionality to make it easier + to build common data structures and query the data layer. + + Finally, if the test case interacts with the database, + it cannot be async. For this reason, every test runs + inside a transaction which is reset at the beginning + of the test unless the test case is marked as async. + """ + + use ExUnit.CaseTemplate + + using do + quote do + use Plausible.TestUtils + use Plausible + use Plausible.Teams.Test + + import Plausible.Test.Support.HTML + import Plug.Conn + import Phoenix.ConnTest + + alias PlausibleWeb.Router.Helpers, as: Routes + import Plausible.Factory + import Plausible.AssertMatches + + @endpoint PlausibleWeb.Endpoint + end + end + + setup tags do + :ok = Ecto.Adapters.SQL.Sandbox.checkout(Plausible.Repo) + + unless tags[:async] do + Ecto.Adapters.SQL.Sandbox.mode(Plausible.Repo, {:shared, self()}) + end + + Plausible.Test.Support.Sandbox.allow_salts_process() + + conn = Phoenix.ConnTest.build_conn() |> Plausible.TestUtils.prepare_conn() + {:ok, conn: conn} + end +end diff --git a/test/support/data_case.ex b/test/support/data_case.ex new file mode 100644 index 0000000000000000000000000000000000000000..e379994e68053aeae92deb00a1f1f4cb463f14c3 --- /dev/null +++ b/test/support/data_case.ex @@ -0,0 +1,44 @@ +defmodule Plausible.DataCase do + @moduledoc """ + This module defines the setup for tests requiring + access to the application's data layer. + + You may define functions here to be used as helpers in + your tests. + + Finally, if the test case interacts with the database, + it cannot be async. For this reason, every test runs + inside a transaction which is reset at the beginning + of the test unless the test case is marked as async. + """ + + use ExUnit.CaseTemplate + + using do + quote do + use Plausible.Repo + use Plausible.TestUtils + use Plausible + use Plausible.Teams.Test + + import Plausible.Test.Support.HTML + + import Ecto.Changeset + import Plausible.DataCase + import Plausible.Factory + import Plausible.AssertMatches + end + end + + setup tags do + :ok = Ecto.Adapters.SQL.Sandbox.checkout(Plausible.Repo) + + unless tags[:async] do + Ecto.Adapters.SQL.Sandbox.mode(Plausible.Repo, {:shared, self()}) + end + + Plausible.Test.Support.Sandbox.allow_salts_process() + + :ok + end +end diff --git a/test/support/dev/billing/dev_paddle_api_mock.ex b/test/support/dev/billing/dev_paddle_api_mock.ex new file mode 100644 index 0000000000000000000000000000000000000000..fab1b154f266658d4bfb498540876f6832228503 --- /dev/null +++ b/test/support/dev/billing/dev_paddle_api_mock.ex @@ -0,0 +1,78 @@ +defmodule Plausible.Billing.DevPaddleApiMock do + @moduledoc """ + This module mocks API requests made to Paddle in the :dev environment. + + Note that not creating and cancelling subscriptions are handled separately. See + `Plausible.Billing.DevSubscriptions`. + """ + + import Ecto.Query + alias Plausible.Repo + alias Plausible.Billing.EnterprisePlan + + @prices_file_path Application.app_dir(:plausible, ["priv", "plan_prices.json"]) + @prices File.read!(@prices_file_path) |> Jason.decode!() + + # https://hexdocs.pm/elixir/1.15/Module.html#module-external_resource + @external_resource @prices_file_path + + def all_prices() do + enterprise_plan_prices = + Repo.all(from p in EnterprisePlan, select: {p.paddle_plan_id, 123}) + |> Map.new() + + Map.merge(@prices, enterprise_plan_prices) + end + + @doc """ + Mocks the real `Plausible.Billing.PaddleApi.fetch_prices`, but: + + 1. instead of filtering by a list of product IDs simply returns the prices for + all plans + + 2. always returns "EUR" as the currency + + The prices of all production plans are duplicated into `/priv/plan_prices.json` + in order to avoid relying on Paddle in the :dev env. + """ + def fetch_prices(_, _) do + prices_as_money = + all_prices() + |> Map.new(fn {plan_id, price} -> + {plan_id, Money.from_integer(price * 100, "EUR")} + end) + + {:ok, prices_as_money} + end + + def update_subscription_preview(paddle_subscription_id, new_plan_id) do + {:ok, + %{ + "immediate_payment" => %{ + "amount" => all_prices()[new_plan_id], + "currency" => "EUR", + "date" => Date.utc_today() |> Date.to_iso8601() + }, + "next_payment" => %{ + "amount" => all_prices()[new_plan_id], + "currency" => "EUR", + "date" => Date.utc_today() |> Date.shift(month: 1) |> Date.to_iso8601() + }, + "plan_id" => String.to_integer(new_plan_id), + "subscription_id" => paddle_subscription_id + }} + end + + def update_subscription(_, %{plan_id: plan_id}) do + {:ok, + %{ + "next_payment" => %{ + "amount" => all_prices()[plan_id], + "date" => Date.utc_today() |> Date.shift(month: 1) |> Date.to_iso8601() + }, + "plan_id" => String.to_integer(plan_id) + }} + end + + def get_invoices(_), do: {:error, :no_invoices} +end diff --git a/test/support/dev/billing/dev_subscriptions.ex b/test/support/dev/billing/dev_subscriptions.ex new file mode 100644 index 0000000000000000000000000000000000000000..2622cf360de844cb5423b7832f16137b8acc70f0 --- /dev/null +++ b/test/support/dev/billing/dev_subscriptions.ex @@ -0,0 +1,142 @@ +defmodule Plausible.Billing.DevSubscriptions do + @moduledoc """ + Module for conveniently handling subscriptions in the :dev environment. + """ + + use Plausible + + on_ee do + import Ecto.Query + + alias Plausible.{Repo, Billing} + alias Plausible.Billing.{Plan, EnterprisePlan, DevPaddleApiMock} + alias PlausibleWeb.Router.Helpers, as: Routes + + def create_after_1s(team_id, plan_id) do + Task.start(fn -> + Process.sleep(1000) + create(team_id, plan_id) + end) + end + + def create(team_id, plan_id, opts \\ []) do + plan_or_enterprise_plan = get_plan_or_enterprise_plan(plan_id) + + if plan_or_enterprise_plan do + if Keyword.get(opts, :clean_existing, true) do + delete(team_id) + end + + %{ + "event_time" => to_string(NaiveDateTime.utc_now(:second)), + "alert_name" => "subscription_created", + "passthrough" => "ee:true;user:0;team:#{team_id}", + "email" => "", + "subscription_id" => Ecto.UUID.generate(), + "subscription_plan_id" => plan_id, + "update_url" => Routes.dev_subscription_path(PlausibleWeb.Endpoint, :update_form), + "cancel_url" => Routes.dev_subscription_path(PlausibleWeb.Endpoint, :cancel_form), + "status" => "active", + "next_bill_date" => next_bill_date(plan_or_enterprise_plan, plan_id), + "unit_price" => "#{to_string(DevPaddleApiMock.all_prices()[plan_id])}.00", + "currency" => "EUR" + } + |> Plausible.Billing.subscription_created() + + {:ok, Repo.reload(get_team_subscription(team_id))} + else + {:error, "Plan with id '#{plan_id}' not found."} + end + end + + def update(team_id, new_status) do + if subscription = get_team_subscription(team_id) do + current_plan_id = subscription.paddle_plan_id + current_plan = get_plan_or_enterprise_plan(current_plan_id) + + next_bill_date = + case new_status do + "active" -> next_bill_date(current_plan, current_plan_id) + "past_due" -> Date.utc_today() |> Date.shift(day: 5) |> Date.to_iso8601() + "paused" -> subscription.next_bill_date |> Date.to_iso8601() + end + + %{ + "subscription_id" => subscription.paddle_subscription_id, + "subscription_plan_id" => current_plan_id, + "update_url" => subscription.update_url, + "cancel_url" => subscription.cancel_url, + "old_status" => to_string(subscription.status), + "status" => new_status, + "next_bill_date" => next_bill_date, + "new_unit_price" => "#{to_string(DevPaddleApiMock.all_prices()[current_plan_id])}.00", + "currency" => "EUR" + } + |> Plausible.Billing.subscription_updated() + + :ok + else + {:error, :no_subscription} + end + end + + def delete(team_id, opts \\ []) do + delete_enterprise? = Keyword.get(opts, :delete_enterprise?, false) + + if subscription = get_team_subscription(team_id) do + Repo.delete(subscription) + + if delete_enterprise? do + Plausible.Repo.delete_all(from(e in EnterprisePlan, where: e.team_id == ^team_id)) + end + + :ok + else + {:error, :no_subscription} + end + end + + def cancel(team_id, opts \\ []) do + set_expired? = Keyword.get(opts, :set_expired?, false) + + if subscription = get_team_subscription(team_id) do + %{"subscription_id" => subscription.paddle_subscription_id, "status" => "deleted"} + |> Billing.subscription_cancelled() + + subscription = Repo.reload!(subscription) + + if set_expired? do + subscription + |> Ecto.Changeset.change(%{next_bill_date: Date.utc_today() |> Date.add(-1)}) + |> Repo.update() + else + {:ok, subscription} + end + else + {:error, :no_subscription} + end + end + + defp get_plan_or_enterprise_plan(plan_id) do + Billing.Plans.find(plan_id) || Repo.get_by(EnterprisePlan, paddle_plan_id: plan_id) + end + + defp get_team_subscription(team_id) do + Plausible.Teams.Team + |> Plausible.Repo.get(team_id) + |> Plausible.Teams.with_subscription() + |> Map.get(:subscription) + end + + defp next_bill_date(%EnterprisePlan{} = plan, _) do + if plan.billing_interval == :monthly, do: next_month(), else: next_year() + end + + defp next_bill_date(%Plan{} = plan, plan_id) do + if plan_id == plan.monthly_product_id, do: next_month(), else: next_year() + end + + defp next_month(), do: Date.utc_today() |> Date.shift(month: 1) |> Date.to_iso8601() + defp next_year(), do: Date.utc_today() |> Date.shift(year: 1) |> Date.to_iso8601() + end +end diff --git a/test/support/dev/controllers/dev_subscription_controller.ex b/test/support/dev/controllers/dev_subscription_controller.ex new file mode 100644 index 0000000000000000000000000000000000000000..0d2f9cb356cb267f51b0f5f0e97587b1002f5eb4 --- /dev/null +++ b/test/support/dev/controllers/dev_subscription_controller.ex @@ -0,0 +1,97 @@ +defmodule PlausibleWeb.DevSubscriptionController do + use Plausible + + on_ee do + use PlausibleWeb, :controller + + alias Plausible.Billing.DevSubscriptions + alias Plausible.Auth.User + alias Plausible.Teams.Team + alias Plausible.Teams + + require Plausible.Billing + + plug PlausibleWeb.RequireAccountPlug + + plug Plausible.Plugs.AuthorizeTeamAccess, Plausible.Billing.allowed_roles() + + def create_form(conn, %{"plan_id" => plan_id}) do + render(conn, "create_dev_subscription.html", + back_link: Routes.billing_path(conn, :choose_plan), + plan_id: plan_id + ) + end + + def update_form(conn, _params) do + team = conn.assigns.current_team |> Teams.with_subscription() + + if is_nil(team.subscription), + do: raise("Can't render subscription update form without subscription") + + render(conn, "update_dev_subscription.html", + back_link: Routes.settings_path(conn, :subscription), + current_status: team.subscription.status + ) + end + + def cancel_form(conn, _params) do + team = conn.assigns.current_team |> Teams.with_subscription() + + if is_nil(team.subscription), + do: raise("Can't render subscription cancel form without subscription") + + render(conn, "cancel_dev_subscription.html", + back_link: Routes.settings_path(conn, :subscription), + enterprise_plan?: Teams.Billing.enterprise_configured?(team) + ) + end + + def create(conn, %{"plan_id" => plan_id}) do + for_team = + case conn.assigns do + %{current_team: %Team{} = team} -> team + %{current_user: %User{} = user} -> Teams.force_create_my_team(user) + end + + DevSubscriptions.create_after_1s(for_team.id, plan_id) + redirect(conn, to: Routes.billing_path(PlausibleWeb.Endpoint, :upgrade_success)) + end + + def update(conn, %{"status" => status}) do + team = conn.assigns.current_team + + :ok = DevSubscriptions.update(team.id, status) + + conn + |> put_flash(:success, "Subscription status set to '#{status}'") + |> redirect(to: Routes.settings_path(conn, :subscription)) + end + + def cancel(conn, %{"action" => action}) do + team = conn.assigns.current_team + + flash_msg = + case action do + "cancel" -> + DevSubscriptions.cancel(team.id) + "Subscription cancelled" + + "cancel_and_expire" -> + DevSubscriptions.cancel(team.id, set_expired?: true) + "Subscription cancelled and set as 'expired'" + + "delete" -> + DevSubscriptions.delete(team.id) + "Subscription deleted" + + "delete_enterprise" -> + DevSubscriptions.delete(team.id, delete_enterprise?: true) + "Subscription and enterprise plans deleted" + end + + conn + |> put_flash(:success, flash_msg) + |> redirect(to: Routes.settings_path(conn, :subscription)) + end + end +end diff --git a/test/support/dev/controllers/e2e_controller.ex b/test/support/dev/controllers/e2e_controller.ex new file mode 100644 index 0000000000000000000000000000000000000000..bd61fdfbf2e185f3b5bf00c4e702287245d41ef3 --- /dev/null +++ b/test/support/dev/controllers/e2e_controller.ex @@ -0,0 +1,194 @@ +defmodule PlausibleWeb.E2EController do + use Plausible + + on_ee do + use PlausibleWeb, :controller + + @event_types Map.new( + [ + :event, + :imported_visitors, + :imported_sources, + :imported_pages, + :imported_entry_pages, + :imported_exit_pages, + :imported_locations, + :imported_devices, + :imported_browsers, + :imported_operating_systems + ], + fn type -> + {Atom.to_string(type), type} + end + ) + + def populate_stats(conn, %{"domain" => domain, "events" => events}) do + site = Plausible.Repo.get_by!(Plausible.Site, domain: domain) + + events = + events + |> Enum.map(&deserialize/1) + |> Enum.map(&build/1) + |> maybe_set_import_id(site) + + time_fun = fn event -> + if Map.has_key?(event, :timestamp) do + event.timestamp + else + NaiveDateTime.new!(event.date, ~T[00:00:00]) + end + end + + {stats_start_time, stats_start_date} = + case Enum.min_by(events, time_fun, NaiveDateTime) do + %{date: d} -> + {NaiveDateTime.new!(d, ~T[00:00:00]), d} + + %{timestamp: ts} -> + {ts, NaiveDateTime.to_date(ts)} + end + + site + |> Plausible.Site.set_native_stats_start_at(stats_start_time) + |> Plausible.Site.set_stats_start_date(stats_start_date) + |> Plausible.Repo.update!() + + populate(events, site) + + send_resp(conn, 200, Jason.encode!(%{"ok" => true})) + end + + def create_goal(conn, %{"domain" => domain} = params) do + site = Plausible.Repo.get_by!(Plausible.Site, domain: domain) + + case Plausible.Goals.create(site, params) do + {:ok, _} -> + send_resp(conn, 200, Jason.encode!(%{"ok" => true})) + + {:error, changeset} -> + unique_errors = + Ecto.Changeset.traverse_errors(changeset, fn {_msg, opts} -> + opts[:constraint] == :unique + end) + + # Ignore duplicate goal errors because site installation might + # create goals before this is called via tracker script configuration + # update. + if unique_errors[:display_name] || unique_errors[:event_name] do + send_resp(conn, 200, Jason.encode!(%{"ok" => true})) + else + send_resp(conn, 422, Jason.encode!(%{"error" => inspect(changeset)})) + end + end + end + + def create_funnel(conn, %{"domain" => domain, "name" => name, "steps" => steps}) do + site = Plausible.Repo.get_by!(Plausible.Site, domain: domain) + + steps = + Enum.map(steps, fn step -> + goal = get_goal(site, step) + %{"goal_id" => goal.id} + end) + + {:ok, _} = Plausible.Funnels.create(site, name, steps) + + send_resp(conn, 200, Jason.encode!(%{"ok" => true})) + end + + defp get_goal(site, name) do + Plausible.Repo.get_by!(Plausible.Goal, site_id: site.id, display_name: name) + end + + defp deserialize(event) do + type = Map.fetch!(@event_types, event["type"] || "event") + + # to ensure relevant key atoms are in scope + Plausible.Factory.build(type) + + attrs = + Enum.map(event, fn + {"timestamp", value} -> + {:timestamp, to_timestamp(value)} + + {"date", value} -> + {:date, to_date(value)} + + {"revenue_reporting_amount", value} -> + {:revenue_reporting_amount, Decimal.new(value)} + + {key, value} -> + {String.to_existing_atom(key), value} + end) + + Keyword.put(attrs, :type, type) + end + + defp build(attrs) do + {type, attrs} = Keyword.pop!(attrs, :type) + + attrs = + cond do + type == :event and attrs[:timestamp] -> + attrs + + type == :event and is_nil(attrs[:timestamp]) -> + timestamp = NaiveDateTime.utc_now(:second) |> NaiveDateTime.add(-48, :hour) + Keyword.put(attrs, :timestamp, timestamp) + + attrs[:date] -> + attrs + + true -> + date = Date.utc_today() |> Date.add(-2) + Keyword.put(attrs, :date, date) + end + + Plausible.Factory.build(type, attrs) + end + + defp maybe_set_import_id(events, site) do + if Enum.any?(events, &Map.has_key?(&1, :table)) do + import = Plausible.Factory.insert(:site_import, site: site) + + Enum.map(events, &set_import_id(&1, import.id)) + else + events + end + end + + defp set_import_id(%{table: _} = event, import_id) do + Map.put(event, :import_id, import_id) + end + + defp set_import_id(event, _import_id), do: event + + defp populate(events, site) do + Plausible.TestUtils.populate_stats(site, events) + end + + defp to_date(%{"daysAgo" => offset}) do + Date.utc_today() |> Date.add(-offset) + end + + defp to_date(d) when is_binary(d) do + Date.from_iso8601!(d) + end + + defp to_timestamp(%{"daysAgo" => offset}) do + NaiveDateTime.utc_now(:second) |> NaiveDateTime.add(-offset, :day) + end + + defp to_timestamp(%{"hoursAgo" => offset}) do + NaiveDateTime.utc_now(:second) |> NaiveDateTime.add(-offset, :hour) + end + + defp to_timestamp(%{"minutesAgo" => offset}) do + NaiveDateTime.utc_now(:second) |> NaiveDateTime.add(-offset, :minute) + end + + defp to_timestamp(ts) when is_binary(ts) do + NaiveDateTime.from_iso8601!(ts) + end + end +end diff --git a/test/support/dev/templates/dev_subscription/cancel_dev_subscription.heex b/test/support/dev/templates/dev_subscription/cancel_dev_subscription.heex new file mode 100644 index 0000000000000000000000000000000000000000..b16d8edd681c4d20e8c4f90ffc49b3af9a502932 --- /dev/null +++ b/test/support/dev/templates/dev_subscription/cancel_dev_subscription.heex @@ -0,0 +1,88 @@ +<.focus_box> +
+ [Test Mode] Cancel your subscription +
+
+
+ In development mode you can choose how exactly you want to "cancel" your subscription.

+
+ + +
+

+ Access to stats will still be granted until next_bill_date +

+
+ + +
+

+ Cancel the subscription and set next_bill_date to yesterday +

+
+ + +
+

Back to "trial" or "expired trial" state

+ + <%= if @enterprise_plan? do %> +
+ +
+

+ Also restores the ability to subscribe to regular plans +

+ <% end %> + +
+ + + Back + + + +
+ + +
+ diff --git a/test/support/dev/templates/dev_subscription/create_dev_subscription.heex b/test/support/dev/templates/dev_subscription/create_dev_subscription.heex new file mode 100644 index 0000000000000000000000000000000000000000..c23228ea9960410d4fd8ca177ade2bc9e36c5bb4 --- /dev/null +++ b/test/support/dev/templates/dev_subscription/create_dev_subscription.heex @@ -0,0 +1,42 @@ +<.focus_box> +
+ [Test Mode] Subscribe to plan {@plan_id} +
+
+ To make subscriptions easier to handle on localhost, there's no integration with Paddle and everything works in-house instead.

+ Normally when proceeding to upgrade to a plan from/billing/choose-plan you would go through a Paddle checkout flow: +

+ +
    +
  • 1. Providing payment details
  • +
  • 2. Paddle processing the information and trying to charge the card
  • +
  • 3. Receiving a webhook from Paddle (subscription_created)
  • +
  • 4. We create the subscription
  • +
+ +
+ This local implementation skips steps 1-3, redirecting you first to /billing/upgrade-success, and after a short artificial delay, to /settings, with the subscription created. +

+ Read more about dev and staging subscriptions in the knowledge base. +
+ +
+ + Back + + <.button_link + href={Routes.dev_subscription_path(PlausibleWeb.Endpoint, :create, @plan_id)} + method="post" + mt?={false} + > + Subscribe to plan + +
+ diff --git a/test/support/dev/templates/dev_subscription/update_dev_subscription.heex b/test/support/dev/templates/dev_subscription/update_dev_subscription.heex new file mode 100644 index 0000000000000000000000000000000000000000..9e02f7b23dc2a514d691a9d634a665e6f02250e1 --- /dev/null +++ b/test/support/dev/templates/dev_subscription/update_dev_subscription.heex @@ -0,0 +1,127 @@ +<.focus_box> +
+ [Test Mode] Update your subscription status +
+
+
+ Simulate subscription status change events. On production, this happens through Paddle webhooks. + This implementation is calling the underlying functions as close as possible to how it happens on + production. Check out the below links to find out more in Paddle documentation.

+ - + + Paddle "Classic" docs homepage + +
- + + Paddle subscription status reference + +
- + + Payment Failures + +
+
+
+ + <%= if @current_status == Plausible.Billing.Subscription.Status.active() do %> + Current subscription status: ACTIVE +

+ + +
+

+ Simulate a "subscription_updated" webhook notifying us about a failed payment. +

+
+ + +
+

+ Simulate a "subscription_updated" webhook notifying us about the failure of the final payment attempt. +

+
+ <% end %> + + <%= if @current_status == Plausible.Billing.Subscription.Status.past_due() do %> + Current subscription status: PAST DUE +

+ + +
+

+ Simulate a "subscription_updated" webhook notifying us about a successful payment. +

+
+ + +
+

+ Simulate a "subscription_updated" webhook notifying us about the failure of the final payment attempt. +

+
+ <% end %> + + <%= if @current_status == Plausible.Billing.Subscription.Status.paused() do %> + Current subscription status: PAUSED +

+ + +
+

+ Simulate a "subscription_payment_succeeded" webhook notifying us about a successful payment. On production + this action would correspond to the customer updating their billing details, and Paddle charging their card + successfully. +

+
+ + +
+

+ A status change from "paused" to "past_due" cannot happen naturally, and is therefore prohibited in this flow. Please update first to "active", and then to "past_due". +

+
+ <% end %> + +
+ + + Back + + + +
+
+ + diff --git a/test/support/dev/views/dev_subscription_view.ex b/test/support/dev/views/dev_subscription_view.ex new file mode 100644 index 0000000000000000000000000000000000000000..1e051e91ee144b6251782acb0b5378f9f38c343d --- /dev/null +++ b/test/support/dev/views/dev_subscription_view.ex @@ -0,0 +1,12 @@ +defmodule PlausibleWeb.DevSubscriptionView do + use Plausible + + on_ee do + use Phoenix.View, + root: "test/support/dev/templates" + + require Plausible.Billing.Subscription.Status + import PlausibleWeb.Components.Generic + alias PlausibleWeb.Router.Helpers, as: Routes + end +end diff --git a/test/support/dns.ex b/test/support/dns.ex new file mode 100644 index 0000000000000000000000000000000000000000..c3e3800f2d42c1c75813c27037ca411c385e981f --- /dev/null +++ b/test/support/dns.ex @@ -0,0 +1,17 @@ +defmodule Plausible.Test.Support.DNS do + @moduledoc false + defmacro __using__(_) do + quote do + import Mox + + def stub_lookup_a_records(domain, a_records \\ [{192, 168, 1, 1}]) do + lookup_domain = to_charlist(domain) + + Plausible.DnsLookup.Mock + |> expect(:lookup, fn ^lookup_domain, _type, _record, _opts, _timeout -> + a_records + end) + end + end + end +end diff --git a/test/support/dns_server.ex b/test/support/dns_server.ex new file mode 100644 index 0000000000000000000000000000000000000000..f977fe47ab5838d31d1a82ac1a54a5b880157995 --- /dev/null +++ b/test/support/dns_server.ex @@ -0,0 +1,63 @@ +defmodule Plasusible.Test.Support.DNSServer do + @moduledoc """ + A simple DNS server that responds to TXT queries with fixed sample values. + """ + + def start(fixed_response) do + {:ok, socket} = :gen_udp.open(0, [:binary, active: true, ip: {0, 0, 0, 0}]) + {:ok, port} = :inet.port(socket) + child = spawn(fn -> loop(socket, fixed_response) end) + :ok = :gen_udp.controlling_process(socket, child) + {:ok, port} + end + + defp loop(socket, fixed_response) do + receive do + {:udp, _socket, client_ip, client_port, query} -> + response = build_response(query, fixed_response) + :gen_udp.send(socket, client_ip, client_port, response) + loop(socket, fixed_response) + end + end + + defp build_response(query, response) do + <> = query + + header = << + # Transaction ID + transaction_id::16, + # Flags: Standard query response, no error + 0b10000100_00000000::16, + # Questions: Echo the number of questions + qdcount::16, + # Answer RRs: 1 + 1::16, + # Authority RRs: 0 + 0::16, + # Additional RRs: 0 + 0::16 + >> + + <<_header::binary-size(12), question::binary>> = query + txt_data = encode_txt_data(response) + + answer = << + # Name: Pointer to the question + 0xC00C::16, + # Type: TXT + 16::16, + # Class: IN (Internet) + 1::16, + # TTL: 60 seconds + 60::32, + byte_size(txt_data)::16, + txt_data::binary + >> + + [header, question, answer] + end + + defp encode_txt_data(txt_value) do + <> + end +end diff --git a/test/support/exchange_rate_mock.ex b/test/support/exchange_rate_mock.ex new file mode 100644 index 0000000000000000000000000000000000000000..6be2bdc70f4dc7e77e72961f61c9f96d7a4069fe --- /dev/null +++ b/test/support/exchange_rate_mock.ex @@ -0,0 +1,20 @@ +defmodule Plausible.ExchangeRateMock do + @moduledoc false + @behaviour Money.ExchangeRates + + def init(config) do + config + end + + def decode_rates(rates) do + Money.ExchangeRates.OpenExchangeRates.decode_rates(rates) + end + + def get_latest_rates(_config) do + {:ok, %{BRL: Decimal.new("0.7"), EUR: Decimal.new("1.2"), USD: Decimal.new(1)}} + end + + def get_historic_rates(_date, _config) do + {:ok, %{BRL: Decimal.new("0.8"), EUR: Decimal.new("1.3"), USD: Decimal.new(2)}} + end +end diff --git a/test/support/factory.ex b/test/support/factory.ex new file mode 100644 index 0000000000000000000000000000000000000000..6e2fa3a49032b24327b628882d635e62c5139d7e --- /dev/null +++ b/test/support/factory.ex @@ -0,0 +1,415 @@ +defmodule Plausible.Factory do + @moduledoc """ + See https://ecto.hexdocs.pm/test-factories.html + """ + use ExMachina.Ecto, repo: Plausible.Repo + require Plausible.Billing.Subscription.Status + alias Plausible.Billing.Subscription + + def team_factory do + %Plausible.Teams.Team{ + name: Plausible.Teams.default_name(), + trial_expiry_date: Date.utc_today() |> Date.shift(day: 30), + setup_complete: true, + setup_at: NaiveDateTime.utc_now() + } + end + + def team_membership_factory do + %Plausible.Teams.Membership{ + user: build(:user), + role: :viewer + } + end + + def guest_membership_factory do + %Plausible.Teams.GuestMembership{ + team_membership: build(:team_membership, role: :guest) + } + end + + def team_invitation_factory do + %Plausible.Teams.Invitation{ + invitation_id: Nanoid.generate(), + email: sequence(:email, &"email-#{&1}@example.com"), + role: :admin + } + end + + def guest_invitation_factory do + %Plausible.Teams.GuestInvitation{ + invitation_id: Nanoid.generate(), + role: :editor, + team_invitation: build(:team_invitation, role: :guest) + } + end + + def site_transfer_factory do + %Plausible.Teams.SiteTransfer{ + transfer_id: Nanoid.generate(), + email: sequence(:email, &"email-#{&1}@example.com") + } + end + + def user_factory(attrs) do + pw = Map.get(attrs, :password, "password") + + user = %Plausible.Auth.User{ + name: "Jane Smith", + email: sequence(:email, &"email-#{&1}@example.com"), + password_hash: Plausible.Auth.Password.hash(pw), + email_verified: true + } + + merge_attributes(user, attrs) + end + + def spike_notification_factory do + %Plausible.Site.TrafficChangeNotification{ + threshold: 10, + type: :spike + } + end + + def drop_notification_factory do + %Plausible.Site.TrafficChangeNotification{ + threshold: 1, + type: :drop + } + end + + def site_factory(attrs) do + # The é exercises unicode support in domain names + domain = sequence(:domain, &"é-#{&1}.example.com") + + site = %Plausible.Site{ + native_stats_start_at: ~N[2000-01-01 00:00:00], + domain: domain, + timezone: "Etc/UTC" + } + + merge_attributes(site, attrs) + end + + def site_import_factory do + today = Date.utc_today() + + %Plausible.Imported.SiteImport{ + site: build(:site), + imported_by: build(:user), + start_date: ~D[2005-01-01], + end_date: today, + source: :universal_analytics, + status: :completed, + legacy: false + } + end + + def ch_session_factory do + hostname = sequence(:domain, &"example-#{&1}.com") + + %Plausible.ClickhouseSessionV2{ + sign: 1, + session_id: SipHash.hash!(hash_key(), Ecto.UUID.generate()), + user_id: SipHash.hash!(hash_key(), Ecto.UUID.generate()), + hostname: hostname, + site_id: Enum.random(1_000_000_000..2_000_000_000), + entry_page: "/", + pageviews: 1, + events: 1, + start: DateTime.utc_now(), + timestamp: DateTime.utc_now(), + is_bounce: false + } + end + + def pageview_factory(attrs) do + Map.put(event_factory(attrs), :name, "pageview") + end + + def engagement_factory(attrs) do + Map.put(event_factory(attrs), :name, "engagement") + end + + def event_factory(attrs) do + if Map.get(attrs, :acquisition_channel) do + raise "Acquisition channel cannot be written directly since it's a materialized column." + end + + hostname = sequence(:domain, &"example-#{&1}.com") + + event = %Plausible.ClickhouseEventV2{ + hostname: hostname, + site_id: Enum.random(1_000_000_000..2_000_000_000), + pathname: "/", + timestamp: NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second), + user_id: SipHash.hash!(hash_key(), Ecto.UUID.generate()), + session_id: SipHash.hash!(hash_key(), Ecto.UUID.generate()) + } + + event + |> merge_attributes(attrs) + |> evaluate_lazy_attributes() + end + + def goal_factory(attrs) do + display_name_provided? = Map.has_key?(attrs, :display_name) + + attrs = + case {attrs, display_name_provided?} do + {%{page_path: path}, false} when is_binary(path) -> + Map.put(attrs, :display_name, "Visit " <> path) + + {%{page_path: path}, false} when is_function(path, 0) -> + attrs + |> Map.put(:display_name, "Visit " <> path.()) + |> Map.put(:page_path, path.()) + + {%{event_name: event_name}, false} when is_binary(event_name) -> + Map.put(attrs, :display_name, event_name) + + {%{event_name: event_name}, false} when is_function(event_name, 0) -> + attrs + |> Map.put(:display_name, event_name.()) + |> Map.put(:event_name, event_name.()) + + _ -> + attrs + end + + merge_attributes(%Plausible.Goal{}, attrs) + end + + def subscription_factory do + %Plausible.Billing.Subscription{ + paddle_subscription_id: sequence(:paddle_subscription_id, &"subscription-#{&1}"), + paddle_plan_id: sequence(:paddle_plan_id, &"plan-#{&1}"), + cancel_url: "cancel.com", + update_url: "cancel.com", + status: Subscription.Status.active(), + next_bill_amount: "6.00", + next_bill_date: Date.utc_today(), + last_bill_date: Date.utc_today(), + currency_code: "USD" + } + end + + def starter_subscription_factory do + build(:subscription, paddle_plan_id: "910413") + end + + def growth_subscription_factory do + build(:subscription, paddle_plan_id: "857097") + end + + def business_subscription_factory do + build(:subscription, paddle_plan_id: "857087") + end + + def enterprise_plan_factory do + %Plausible.Billing.EnterprisePlan{ + paddle_plan_id: sequence(:paddle_plan_id, &"plan-#{&1}"), + billing_interval: :monthly, + monthly_pageview_limit: 1_000_000, + hourly_api_request_limit: 3000, + site_limit: 100, + team_member_limit: 10 + } + end + + def google_auth_factory do + %Plausible.Site.GoogleAuth{ + email: sequence(:google_auth_email, &"email-#{&1}@example.com"), + refresh_token: "123", + access_token: "123", + expires: DateTime.utc_now() |> DateTime.shift(day: 1) + } + end + + def weekly_report_factory do + %Plausible.Site.WeeklyReport{} + end + + def monthly_report_factory do + %Plausible.Site.MonthlyReport{} + end + + def shared_link_factory do + slug = Nanoid.generate() + name = "Link - #{slug}" + + %Plausible.Site.SharedLink{ + name: name, + slug: slug + } + end + + def api_key_factory do + key = :crypto.strong_rand_bytes(64) |> Base.url_encode64() |> binary_part(0, 64) + + %Plausible.Auth.ApiKey{ + name: "api-key-name", + key: key, + key_hash: Plausible.Auth.ApiKey.do_hash(key), + key_prefix: binary_part(key, 0, 6) + } + end + + def imported_visitors_factory do + %{ + table: "imported_visitors", + date: Date.utc_today(), + visitors: 1, + pageviews: 1, + bounces: 0, + visits: 1, + visit_duration: 10 + } + end + + def imported_sources_factory do + %{ + table: "imported_sources", + date: Date.utc_today(), + source: "", + visitors: 1, + visits: 1, + bounces: 0, + visit_duration: 10 + } + end + + def imported_pages_factory do + %{ + table: "imported_pages", + date: Date.utc_today(), + page: "", + visitors: 1, + pageviews: 1, + exits: 0, + total_time_on_page: 10, + total_time_on_page_visits: 1 + } + end + + def imported_entry_pages_factory do + %{ + table: "imported_entry_pages", + date: Date.utc_today(), + entry_page: "", + visitors: 1, + entrances: 1, + bounces: 0, + visit_duration: 10 + } + end + + def imported_exit_pages_factory do + %{ + table: "imported_exit_pages", + date: Date.utc_today(), + exit_page: "", + visitors: 1, + exits: 1 + } + end + + def imported_custom_events_factory do + %{ + table: "imported_custom_events", + date: Date.utc_today(), + name: "", + link_url: "", + visitors: 1, + events: 1 + } + end + + def imported_locations_factory do + %{ + table: "imported_locations", + date: Date.utc_today(), + country: "", + region: "", + city: 0, + visitors: 1, + visits: 1, + bounces: 0, + visit_duration: 10 + } + end + + def imported_devices_factory do + %{ + table: "imported_devices", + date: Date.utc_today(), + device: "", + visitors: 1, + visits: 1, + bounces: 0, + visit_duration: 10 + } + end + + def imported_browsers_factory do + %{ + table: "imported_browsers", + date: Date.utc_today(), + browser: "", + visitors: 1, + visits: 1, + bounces: 0, + visit_duration: 10 + } + end + + def imported_operating_systems_factory do + %{ + table: "imported_operating_systems", + date: Date.utc_today(), + operating_system: "", + visitors: 1, + visits: 1, + bounces: 0, + visit_duration: 10 + } + end + + def ip_rule_factory do + %Plausible.Shield.IPRule{ + inet: Plausible.TestUtils.random_ip(), + description: "Test IP Rule", + added_by: "Mr Seed " + } + end + + def country_rule_factory do + %Plausible.Shield.CountryRule{ + added_by: "Mr Seed " + } + end + + def segment_factory do + %Plausible.Segments.Segment{ + segment_data: %{"filters" => [["is", "visit:entry_page", ["/blog"]]]} + } + end + + def annotation_factory do + %Plausible.Annotations.Annotation{ + note: "a test annotation", + type: :site, + datetime: ~U[2026-01-04 00:00:00Z], + date: ~D[2026-01-04], + granularity: :date + } + end + + defp hash_key() do + Keyword.fetch!( + Application.get_env(:plausible, PlausibleWeb.Endpoint), + :secret_key_base + ) + |> binary_part(0, 16) + end +end diff --git a/test/support/google_api_mock.ex b/test/support/google_api_mock.ex new file mode 100644 index 0000000000000000000000000000000000000000..292222837833751c2f6a58662e85bd75f7d2136f --- /dev/null +++ b/test/support/google_api_mock.ex @@ -0,0 +1,51 @@ +defmodule Plausible.Google.API.Mock do + @moduledoc """ + Mock of API to Google services. + """ + + @doc """ + This function uses the event:page filter (which can be passed as a query + parameter into StatsController) as a hack to mock different responses. + """ + def fetch_stats(_auth, query, _pagination, _search) do + case page_filter_value(query) do + ["/empty"] -> + {:ok, []} + + ["/unsupported-filters"] -> + {:error, :unsupported_filters} + + ["/not-configured"] -> + {:error, :google_property_not_configured} + + ["/unexpected-error"] -> + {:error, :some_unexpected_error} + + _ -> + {:ok, + [ + %{ + name: "simple web analytics", + visitors: 25, + impressions: 50, + ctr: 37.0, + position: 2.0 + }, + %{ + name: "open-source analytics", + visitors: 15, + impressions: 25, + ctr: 50.0, + position: 4.0 + } + ]} + end + end + + defp page_filter_value(query) do + Enum.find_value(query.filters, fn + [:is, "event:page", value] -> value + _ -> nil + end) + end +end diff --git a/test/support/html.ex b/test/support/html.ex new file mode 100644 index 0000000000000000000000000000000000000000..50b2db81bcc8605c50eb3c792858eb62889911d2 --- /dev/null +++ b/test/support/html.ex @@ -0,0 +1,96 @@ +defmodule Plausible.Test.Support.HTML do + @moduledoc """ + LazyHTML wrappers to help make assertions about HTML/DOM structures + """ + + def element_exists?(html, selector) do + html + |> find(selector) + |> Enum.empty?() + |> Kernel.not() + end + + def find(%LazyHTML{} = html, selector) do + html + |> LazyHTML.query(selector) + end + + def find(html, selector) do + html + |> lazy_parse() + |> find(selector) + end + + def submit_button(html, form) do + find(html, "#{form} button[type=\"submit\"]") + end + + def form_exists?(html, action_path) do + element_exists?(html, "form[action=\"" <> action_path <> "\"]") + end + + def text_of_element(html, selector) do + html + |> find(selector) + |> text() + end + + def elem_count(html, selector) do + find(html, selector) |> Enum.count() + end + + def text(element) do + element + |> lazy_parse() + |> LazyHTML.text() + |> String.trim() + |> String.replace(~r/\s+/, " ") + end + + def attr_defined?(html, element, attr) do + empty? = + html + |> find(element) + |> LazyHTML.attribute(attr) + |> Enum.empty?() + + not empty? + end + + def class_of_element(html, element) do + html + |> find(element) + |> text_of_attr("class") + end + + def text_of_attr(html, selector, attr) do + html + |> find(selector) + |> text_of_attr(attr) + end + + def text_of_attr(element, attr) do + case LazyHTML.attribute(lazy_parse(element), attr) do + [] -> + nil + + [value] -> + value + + [_ | _] -> + raise "Multiple attributes found. Narrow down the element you are looking for" + end + end + + def name_of(element) do + text_of_attr(element, "name") + end + + defp lazy_parse(%LazyHTML{} = lazy) do + lazy + end + + defp lazy_parse(element) when is_binary(element) do + LazyHTML.from_fragment(element) + end +end diff --git a/test/support/http_mocker.ex b/test/support/http_mocker.ex new file mode 100644 index 0000000000000000000000000000000000000000..3f74efb826eac0b07dca8eca2c1bf9f67ebc29d3 --- /dev/null +++ b/test/support/http_mocker.ex @@ -0,0 +1,54 @@ +defmodule Plausible.Test.Support.HTTPMocker do + @moduledoc """ + Currently only supports post request, it's a drop-in replacement + for our exvcr usage that wasn't ever needed (e.g. we had no way to + re-record the cassettes anyway). + """ + defmacro __using__(_) do + quote do + import Mox + + def mock_http_with(http_mock_fixture) do + mocks = + "fixture/http_mocks/#{http_mock_fixture}" + |> File.read!() + |> Jason.decode!() + |> Enum.into(%{}, &{{&1["url"], &1["request_body"]}, &1}) + + stub( + Plausible.HTTPClient.Mock, + :post, + fn url, _, params, _ -> http_mocker_stub(mocks, url, params) end + ) + + stub( + Plausible.HTTPClient.Mock, + :post, + fn url, _, params -> http_mocker_stub(mocks, url, params) end + ) + end + + defp http_mocker_stub(mocks, url, params) do + params = + case Jason.encode(params) do + {:ok, p} -> Jason.decode!(p) + {:error, _} -> params + end + + mock = Map.fetch!(mocks, {url, params}) + + response = %Finch.Response{ + status: mock["status"], + headers: [{"content-type", "application/json"}], + body: mock["response_body"] + } + + if mock["status"] >= 200 and mock["status"] < 300 do + {:ok, response} + else + {:error, Plausible.HTTPClient.Non200Error.new(response)} + end + end + end + end +end diff --git a/test/support/plugins_api_case.ex b/test/support/plugins_api_case.ex new file mode 100644 index 0000000000000000000000000000000000000000..f0eec2e631c177e07d5e2671b88f5e71c5ca2d8b --- /dev/null +++ b/test/support/plugins_api_case.ex @@ -0,0 +1,53 @@ +defmodule PlausibleWeb.PluginsAPICase do + @moduledoc """ + This module defines the test case to be used by + tests that require setting up a Plugins API connection. + """ + + use ExUnit.CaseTemplate + + using do + quote do + @endpoint PlausibleWeb.Endpoint + + use Plausible.TestUtils + use Plausible.Teams.Test + use Plausible + + import Plug.Conn + import Phoenix.ConnTest + import PlausibleWeb.Plugins.API.Spec, only: [spec: 0] + import Plausible.Factory + + import OpenApiSpex.TestAssertions + + alias PlausibleWeb.Router.Helpers, as: Routes + alias PlausibleWeb.Plugins.API.Schemas + + def authenticate(conn, domain, raw_token) do + conn + |> Plug.Conn.put_req_header( + "authorization", + Plug.BasicAuth.encode_basic_auth(domain, raw_token) + ) + end + end + end + + setup %{test: test} = tags do + :ok = Ecto.Adapters.SQL.Sandbox.checkout(Plausible.Repo) + + unless tags[:async] do + Ecto.Adapters.SQL.Sandbox.mode(Plausible.Repo, {:shared, self()}) + end + + Plausible.Test.Support.Sandbox.allow_salts_process() + + conn = Phoenix.ConnTest.build_conn() + + site = Plausible.Teams.Test.new_site() + {:ok, _token, raw_token} = Plausible.Plugins.API.Tokens.create(site, Atom.to_string(test)) + + {:ok, conn: conn, site: site, token: raw_token} + end +end diff --git a/test/support/sandbox.ex b/test/support/sandbox.ex new file mode 100644 index 0000000000000000000000000000000000000000..1c75890a3a050166141420340ba8a69b69bb97a0 --- /dev/null +++ b/test/support/sandbox.ex @@ -0,0 +1,15 @@ +defmodule Plausible.Test.Support.Sandbox do + @moduledoc """ + Prevent background processes from interfering with tests + (e.g. exiting before properly returning the connection) by allowing them to access sandbox + """ + def allow_salts_process do + case Process.whereis(Plausible.Session.Salts) do + nil -> + :ok + + pid -> + Ecto.Adapters.SQL.Sandbox.allow(Plausible.Repo, self(), pid) + end + end +end diff --git a/test/support/sentry.ex b/test/support/sentry.ex new file mode 100644 index 0000000000000000000000000000000000000000..8b9f3f537f840a8aa96fe39d6573b0020f2d53b7 --- /dev/null +++ b/test/support/sentry.ex @@ -0,0 +1,28 @@ +defmodule Plausible.Test.Support.Sentry do + @moduledoc """ + Helper for setting up Sentry test mode in ExUnit tests. + + Usage inside a `setup` callback: + + setup %{test_pid: test_pid} do + Plausible.Test.Support.Sentry.setup(test_pid) + end + + This enables Sentry's test mode, starts collecting reports owned by the + test process, and restores the original config on exit. + """ + + def setup(test_pid) do + Sentry.put_config(:test_mode, true) + Sentry.put_config(:send_result, :sync) + Sentry.put_config(:dedup_events, false) + + :ok = Sentry.Test.start_collecting(owner: test_pid) + + ExUnit.Callbacks.on_exit(fn -> + Sentry.put_config(:test_mode, false) + Sentry.put_config(:send_result, :none) + Sentry.put_config(:dedup_events, true) + end) + end +end diff --git a/test/support/teams/test.ex b/test/support/teams/test.ex new file mode 100644 index 0000000000000000000000000000000000000000..7f3b71ad28ce024e62d95206f5ab58e0bd809cf2 --- /dev/null +++ b/test/support/teams/test.ex @@ -0,0 +1,466 @@ +defmodule Plausible.Teams.Test do + @moduledoc """ + Convenience assertions for teams schema transition + """ + use Plausible + + alias Plausible.Repo + alias Plausible.Teams + + import Ecto.Query + + use ExUnit.CaseTemplate + + import Plausible.Factory + + defmacro __using__(_) do + quote do + import Plausible.Teams.Test + end + end + + def set_current_team(conn, team) do + Plug.Conn.put_session(conn, :current_team_id, team.identifier) + end + + on_ee do + def new_consolidated_view(team) do + team = Teams.complete_setup(team) + {:ok, site} = Plausible.ConsolidatedView.enable(team) + site + end + end + + def new_site(args \\ []) do + args = + cond do + user = args[:owner] -> + {owner, args} = Keyword.pop(args, :owner) + {:ok, team} = Teams.get_or_create(user) + + args + |> Keyword.put(:owners, [owner]) + |> Keyword.put(:team, team) + + args[:team] -> + args + + true -> + user = new_user() + {:ok, team} = Teams.get_or_create(user) + + args + |> Keyword.put(:team, team) + end + + :site + |> insert(args) + end + + def new_user(args \\ []) do + {team_args, args} = Keyword.pop(args, :team, []) + {trial_expiry_date, args} = Keyword.pop(args, :trial_expiry_date) + + on_ee do + args = Keyword.merge([type: :standard], args) + end + + user = insert(:user, args) + + trial_expiry_date = + if team_args != [] && !trial_expiry_date do + Date.add(Date.utc_today(), 30) + else + trial_expiry_date + end + + if trial_expiry_date do + {:ok, team} = Teams.get_or_create(user) + + team_args = + Keyword.merge(team_args, trial_expiry_date: trial_expiry_date) + + team + |> Ecto.Changeset.change(team_args) + |> Repo.update!() + end + + Repo.preload(user, team_memberships: :team) + end + + def team_of(subject, opts \\ []) + + def team_of(%{team_memberships: [%{role: :owner, team: %Teams.Team{} = team}]}, opts) do + if opts[:with_subscription?] do + Plausible.Teams.with_subscription(team) + else + team + end + end + + def team_of(user, opts) do + case Plausible.Teams.get_by_owner(user) do + {:ok, team} -> + if opts[:with_subscription?] do + Plausible.Teams.with_subscription(team) + else + team + end + + _ -> + nil + end + end + + def add_site_guest_or_team_member(site, args \\ []) do + case Keyword.pop!(args, :membership_type) do + {:site_guest, args} -> add_guest(site, args) + {:team_member, args} -> add_member(site.team, args) + end + end + + def add_guest(site, args \\ []) do + user = Keyword.get(args, :user, new_user()) + role = Keyword.fetch!(args, :role) + team = Repo.preload(site, :team).team + + team_membership = + build(:team_membership, team: team, user: user, role: :guest) + |> Repo.insert!( + on_conflict: [set: [updated_at: NaiveDateTime.utc_now()]], + conflict_target: [:team_id, :user_id], + returning: true + ) + + insert(:guest_membership, site: site, team_membership: team_membership, role: role) + + user |> Repo.preload(:team_memberships) + end + + def add_member(team, args \\ []) do + user = Keyword.get(args, :user, new_user()) + role = Keyword.fetch!(args, :role) + + insert(:team_membership, team: team, user: user, role: role) + + user |> Repo.preload(:team_memberships) + end + + def invite_guest(site, invitee_or_email, args \\ []) when not is_nil(invitee_or_email) do + {role, args} = Keyword.pop!(args, :role) + {inviter, args} = Keyword.pop!(args, :inviter) + {team_invitation_args, args} = Keyword.pop(args, :team_invitation, []) + team = Repo.preload(site, :team).team + + email = + case invitee_or_email do + %{email: email} -> email + email when is_binary(email) -> email + end + + team_invitation = + insert( + :team_invitation, + Keyword.merge( + [ + team: team, + email: email, + inviter: inviter, + role: :guest + ], + team_invitation_args + ) + ) + + insert( + :guest_invitation, + Keyword.merge( + [ + team_invitation: team_invitation, + site: site, + role: role + ], + args + ) + ) + end + + def invite_member(team, invitee_or_email, args \\ []) when not is_nil(invitee_or_email) do + {role, args} = Keyword.pop!(args, :role) + {inviter, args} = Keyword.pop!(args, :inviter) + + email = + case invitee_or_email do + %{email: email} -> email + email when is_binary(email) -> email + end + + insert( + :team_invitation, + Keyword.merge( + [ + team: team, + email: email, + inviter: inviter, + role: role + ], + args + ) + ) + end + + def invite_transfer(site, invitee_or_email, args \\ []) do + {inviter, args} = Keyword.pop!(args, :inviter) + + email = + case invitee_or_email do + %{email: email} -> email + email when is_binary(email) -> email + end + + insert( + :site_transfer, + Keyword.merge( + [ + email: email, + site: site, + initiator: inviter + ], + args + ) + ) + end + + def revoke_membership(site, user) do + Repo.delete_all( + from tm in Plausible.Teams.Membership, + where: tm.user_id == ^user.id and tm.team_id == ^site.team.id + ) + + user |> Repo.preload(:team_memberships) + end + + def subscribe_to_starter_plan(user, attrs \\ []) do + {:ok, team} = Teams.get_or_create(user) + attrs = Keyword.merge([team: team], attrs) + + insert(:starter_subscription, attrs) + user + end + + def subscribe_to_growth_plan(user_or_team, attrs \\ []) + + def subscribe_to_growth_plan(%Plausible.Auth.User{} = user, attrs) do + {:ok, team} = Teams.get_or_create(user) + attrs = Keyword.merge([team: team], attrs) + + subscribe_to_growth_plan(team, attrs) + user + end + + def subscribe_to_growth_plan(%Plausible.Teams.Team{} = team, attrs) do + attrs = Keyword.merge([team: team], attrs) + + insert(:growth_subscription, attrs) + team + end + + def subscribe_to_business_plan(%Plausible.Auth.User{} = user) do + {:ok, team} = Teams.get_or_create(user) + subscribe_to_business_plan(team) + + user + end + + def subscribe_to_business_plan(%Plausible.Teams.Team{} = team) do + insert(:business_subscription, team: team) + team + end + + def subscribe_to_plan(user, paddle_plan_id, attrs \\ []) do + {:ok, team} = Teams.get_or_create(user) + attrs = Keyword.merge([team: team, paddle_plan_id: paddle_plan_id], attrs) + + insert(:subscription, attrs) + + user + end + + def subscribe_to_enterprise_plan(user, attrs \\ []) do + {:ok, team} = Teams.get_or_create(user) + + {subscription?, attrs} = Keyword.pop(attrs, :subscription?, true) + {subscription_attrs, attrs} = Keyword.pop(attrs, :subscription, []) + + enterprise_plan = insert(:enterprise_plan, Keyword.merge([team: team], attrs)) + + if subscription? do + insert( + :subscription, + Keyword.merge( + [ + team: team, + paddle_plan_id: enterprise_plan.paddle_plan_id + ], + subscription_attrs + ) + ) + end + + user + end + + def assert_team_exists(user, team_id \\ nil) do + assert %{team_memberships: memberships} = Repo.preload(user, team_memberships: :team) + + tm = + case memberships do + [tm] -> tm + _ -> raise "Team doesn't exist for user #{user.id}" + end + + assert tm.role == :owner + assert tm.team.id + + if team_id do + assert tm.team.id == team_id + end + + tm.team + end + + def assert_team_membership(user, team, role \\ :owner) do + assert membership = + Repo.get_by(Teams.Membership, + team_id: team.id, + user_id: user.id, + role: role + ) + + membership + end + + def refute_team_member(user, team) do + refute Repo.get_by(Teams.Membership, + team_id: team.id, + user_id: user.id + ) + end + + def assert_team_attached(site, team_id \\ nil) do + assert site = %{team: team} = site |> Repo.reload!() |> Repo.preload([:team, :owners]) + + for owner <- site.owners do + assert membership = assert_team_membership(owner, team) + + assert membership.team_id == team.id + end + + if team_id do + assert team.id == team_id + end + + team + end + + def assert_guest_invitation(team, site, email, role) do + assert team_invitation = + Repo.get_by(Plausible.Teams.Invitation, + email: email, + team_id: team.id, + role: :guest + ) + + assert Repo.get_by(Plausible.Teams.GuestInvitation, + team_invitation_id: team_invitation.id, + site_id: site.id, + role: role + ) + end + + def refute_team_invitation(team, email) do + refute Repo.get_by(Plausible.Teams.Invitation, + email: email, + team_id: team.id + ) + end + + def assert_site_transfer(site, %Plausible.Auth.User{} = user) do + assert_site_transfer(site, user.email) + end + + def assert_site_transfer(site, email) when is_binary(email) do + assert Repo.get_by(Plausible.Teams.SiteTransfer, + site_id: site.id, + email: email + ) + end + + def assert_guest_membership(team, site, user, role) do + assert team_membership = + Repo.get_by(Plausible.Teams.Membership, + user_id: user.id, + team_id: team.id, + role: :guest + ) + + assert Repo.get_by(Plausible.Teams.GuestMembership, + team_membership_id: team_membership.id, + site_id: site.id, + role: role + ) + end + + def assert_non_guest_membership(team, site, user) do + assert team_membership = + Repo.get_by(Plausible.Teams.Membership, + user_id: user.id, + team_id: team.id + ) + + assert team_membership.role != :guest + + refute Repo.get_by(Plausible.Teams.GuestMembership, + team_membership_id: team_membership.id, + site_id: site.id + ) + end + + def subscription_of(%Plausible.Auth.User{} = user) do + user + |> team_of() + |> subscription_of() + end + + def subscription_of(team) do + team + |> Plausible.Teams.with_subscription() + |> Map.fetch!(:subscription) + end + + on_ee do + def audited_entry(name, attrs \\ []) do + attrs = Keyword.put(attrs, :name, name) + + case Plausible.Audit.list_entries(attrs) do + [entry] -> + entry + + _ -> + raise "Expected audited entry #{inspect(attrs)} but only found #{inspect(Plausible.Audit.list_entries([]), pretty: true)}." + end + end + + def audited_entries(length, name, attrs \\ []) do + attrs = Keyword.put(attrs, :name, name) + + case Plausible.Audit.list_entries(attrs) do + entries when is_list(entries) and length(entries) == length -> + entries + + _ -> + raise "Expected audited entry #{inspect(attrs)} but only found #{inspect(Plausible.Audit.list_entries([]), pretty: true)}." + end + end + end +end diff --git a/test/support/test_controller.ex b/test/support/test_controller.ex new file mode 100644 index 0000000000000000000000000000000000000000..053e6d665682e4eef5cc41a72f9a4aa45ce41b8d --- /dev/null +++ b/test/support/test_controller.ex @@ -0,0 +1,11 @@ +defmodule PlausibleWeb.TestController do + use PlausibleWeb, :controller + + def browser_basic(conn, _params) do + send_resp(conn, 200, "ok") + end + + def api_basic(conn, _params) do + send_resp(conn, 200, Jason.encode!(%{"ok" => true})) + end +end diff --git a/test/support/test_paddle_api_mock.ex b/test/support/test_paddle_api_mock.ex new file mode 100644 index 0000000000000000000000000000000000000000..bd7f5de446fa2f2b8f5ed73ab822708e17356af4 --- /dev/null +++ b/test/support/test_paddle_api_mock.ex @@ -0,0 +1,80 @@ +defmodule Plausible.Billing.TestPaddleApiMock do + @moduledoc false + + def get_subscription(_) do + {:ok, + %{ + "next_payment" => %{ + "date" => "2019-07-10", + "amount" => 6 + }, + "last_payment" => %{ + "date" => "2019-06-10", + "amount" => 6 + } + }} + end + + def update_subscription(_, %{plan_id: new_plan_id}) do + new_plan_id = String.to_integer(new_plan_id) + + {:ok, + %{ + "plan_id" => new_plan_id, + "next_payment" => %{ + "date" => "2019-07-10", + "amount" => 6 + } + }} + end + + def update_subscription_preview(_user, _new_plan_id) do + {:ok, + %{ + "immediate_payment" => %{ + "amount" => -72.6, + "currency" => "EUR", + "date" => "2023-11-05" + }, + "next_payment" => %{ + "amount" => 47.19, + "currency" => "EUR", + "date" => "2023-12-05" + }, + "plan_id" => 63_852, + "subscription_id" => 600_279, + "user_id" => 666_317 + }} + end + + def get_invoices(nil), do: {:error, :no_invoices} + def get_invoices(%{paddle_subscription_id: nil}), do: {:error, :no_invoices} + + def get_invoices(subscription) do + case subscription.paddle_subscription_id do + "invalid_subscription_id" -> + {:error, :request_failed} + + _ -> + {:ok, + [ + %{ + "amount" => 11.11, + "currency" => "EUR", + "payout_date" => "2020-12-24", + "receipt_url" => "https://some-receipt-url.com" + }, + %{ + "amount" => 22, + "currency" => "USD", + "payout_date" => "2020-11-24", + "receipt_url" => "https://other-receipt-url.com" + } + ]} + end + end + + def fetch_prices(product_ids, customer_ip) do + Plausible.Billing.DevPaddleApiMock.fetch_prices(product_ids, customer_ip) + end +end diff --git a/test/support/test_utils.ex b/test/support/test_utils.ex new file mode 100644 index 0000000000000000000000000000000000000000..b4a7266b9f2ed1484e186bbad36c4519525fadb3 --- /dev/null +++ b/test/support/test_utils.ex @@ -0,0 +1,385 @@ +defmodule Plausible.TestUtils do + @moduledoc false + + use Plausible.Repo + use Plausible + alias Plausible.Factory + + defmacro __using__(_) do + quote do + require Plausible.TestUtils + import Plausible.TestUtils + end + end + + defmacro patch_env(env_key, value) do + quote do + if __MODULE__.__info__(:attributes)[:ex_unit_async] == [true] do + raise "Patching env is unsafe in asynchronous tests. maybe extract the case elsewhere?" + end + + original_env = Application.get_env(:plausible, unquote(env_key)) + Application.put_env(:plausible, unquote(env_key), unquote(value)) + + on_exit(fn -> + Application.put_env(:plausible, unquote(env_key), original_env) + end) + + {:ok, %{patched_env: true}} + end + end + + defmacro setup_patch_env(env_key, value) do + quote do + setup do + patch_env(unquote(env_key), unquote(value)) + end + end + end + + def setup_do(context \\ %{}, step_fn) do + case step_fn.(context) do + {:ok, ctx} -> Map.merge(context, Map.new(ctx)) + ctx -> Map.merge(context, Map.new(ctx)) + end + end + + def create_user(_) do + {:ok, user: Plausible.Teams.Test.new_user()} + end + + def create_site(%{user: user}) do + {:ok, site: Plausible.Teams.Test.new_site(owner: user)} + end + + def create_team(%{user: user}) do + {:ok, team} = Plausible.Teams.get_or_create(user) + {:ok, team: team} + end + + def setup_team(%{conn: conn, team: team}) do + team = Plausible.Teams.complete_setup(team) + + conn = + conn + |> Plug.Conn.fetch_session() + |> Plug.Conn.put_session(:current_team_id, team.identifier) + + {:ok, conn: conn, team: team} + end + + def create_legacy_site_import(%{site: site}) do + create_site_import(%{site: site, create_legacy_import?: true}) + end + + def create_site_import(%{site: site} = opts) do + site_import = + Factory.insert(:site_import, + site: site, + start_date: ~D[2005-01-01], + end_date: Date.utc_today(), + source: :universal_analytics, + legacy: opts[:create_legacy_import?] == true + ) + + {:ok, site_import: site_import} + end + + def create_api_key(%{user: user}) do + team = Plausible.Teams.Test.team_of(user) + api_key = Factory.insert(:api_key, user: user, team: team) + + {:ok, api_key: api_key.key} + end + + def use_api_key(%{conn: conn, api_key: api_key}) do + conn = Plug.Conn.put_req_header(conn, "authorization", "Bearer #{api_key}") + + {:ok, conn: conn} + end + + def log_in(%{user: user, conn: conn}) do + conn = + conn + |> init_session() + |> PlausibleWeb.UserAuth.log_in_user(user) + |> Phoenix.ConnTest.recycle() + |> Map.put(:secret_key_base, secret_key_base()) + |> init_session() + + {:ok, conn: conn} + end + + on_ee do + alias Plausible.Auth.SSO + + def setup_sso(%{team: team} = ctx) do + team = Plausible.Teams.complete_setup(team) + integration = SSO.initiate_saml_integration(team) + + {:ok, sso_domain} = SSO.Domains.add(integration, ctx[:domain] || "example.com") + _sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true) + + {:ok, team: team, sso_integration: integration, sso_domain: sso_domain} + end + + def provision_sso_user(%{user: user, sso_integration: integration}) do + identity = new_identity(user.name, user.email, integration) + {:ok, _, _, sso_user} = SSO.provision_user(identity) + + {:ok, user: sso_user} + end + end + + def init_session(conn) do + opts = + Plug.Session.init( + store: :cookie, + key: "foobar", + encryption_salt: "encrypted cookie salt", + signing_salt: "signing salt", + log: false, + encrypt: false + ) + + conn + |> Plug.Session.call(opts) + |> Plug.Conn.fetch_session() + end + + def generate_usage_for(site, i, timestamp \\ NaiveDateTime.utc_now()) do + events = for _i <- 1..i, do: Factory.build(:pageview, timestamp: timestamp) + populate_stats(site, events) + :ok + end + + def populate_stats(site, import_id, events) do + Enum.map(events, fn event -> + event = Map.put(event, :site_id, site.id) + + case event do + %Plausible.ClickhouseEventV2{} -> + event + + imported_event -> + Map.put(imported_event, :import_id, import_id) + end + end) + |> populate_stats + end + + def populate_stats(site, events) do + Enum.map(events, fn event -> + Map.put(event, :site_id, site.id) + end) + |> populate_stats + end + + def populate_stats(events) do + {native, imported} = + events + |> Enum.map(fn event -> + case event do + %{timestamp: timestamp} -> + %{event | timestamp: to_naive_truncate(timestamp)} + + _other -> + event + end + end) + |> Enum.split_with(fn event -> + case event do + %Plausible.ClickhouseEventV2{} -> + true + + _ -> + false + end + end) + + populate_native_stats(native) + populate_imported_stats(imported) + end + + defp populate_native_stats(events) do + for event_params <- events do + {:ok, session} = + Plausible.Session.CacheStore.on_event(event_params, event_params, nil, + skip_balancer?: true + ) + + event_params + |> Plausible.ClickhouseEventV2.merge_session(session) + |> Plausible.Event.WriteBuffer.insert() + end + + Plausible.Session.WriteBuffer.flush() + Plausible.Event.WriteBuffer.flush() + end + + defp populate_imported_stats(events) do + Enum.group_by(events, &Map.fetch!(&1, :table), &Map.delete(&1, :table)) + |> Enum.map(fn {table, events} -> Plausible.Imported.Buffer.insert_all(table, events) end) + end + + def relative_time(shifts) do + NaiveDateTime.utc_now() + |> NaiveDateTime.shift(shifts) + |> NaiveDateTime.truncate(:second) + end + + def to_naive_truncate(%DateTime{} = dt) do + to_naive_truncate(DateTime.to_naive(dt)) + end + + def to_naive_truncate(%NaiveDateTime{} = naive) do + NaiveDateTime.truncate(naive, :second) + end + + def to_naive_truncate(%Date{} = date) do + NaiveDateTime.new!(date, ~T[00:00:00]) + end + + def eventually(expectation, wait_time_ms \\ 50, retries \\ 10) do + Enum.reduce_while(1..retries, nil, fn attempt, _acc -> + case expectation.() do + {true, result} -> + {:halt, result} + + {false, _} -> + Process.sleep(wait_time_ms * attempt) + {:cont, nil} + end + end) + end + + def await_clickhouse_count(query, expected) do + eventually( + fn -> + count = Plausible.ClickhouseRepo.aggregate(query, :count) + + {count == expected, count} + end, + 100, + 10 + ) + end + + def random_ip() do + Enum.map_join(1..4, ".", fn _ -> Enum.random(1..254) end) + end + + def htmlize_quotes(string) do + String.replace(string, "'", "'") + end + + def minio_running? do + %{host: host, port: port} = ExAws.Config.new(:s3) + healthcheck_req = Finch.build(:head, "http://#{host}:#{port}") + + case Finch.request(healthcheck_req, Plausible.Finch) do + {:ok, %Finch.Response{}} -> true + {:error, %Finch.TransportError{reason: :econnrefused}} -> false + end + end + + def ensure_minio do + unless minio_running?() do + %{host: host, port: port} = ExAws.Config.new(:s3) + + IO.puts(""" + #{IO.ANSI.red()} + You are trying to run MinIO tests (--include minio) \ + but nothing is running on #{"http://#{host}:#{port}"}. + #{IO.ANSI.blue()}Please make sure to start MinIO with `make minio`#{IO.ANSI.reset()} + """) + + :init.stop(1) + end + end + + if Mix.env() == :test do + def maybe_fake_minio(_context) do + unless minio_running?() do + %{port: port} = ExAws.Config.new(:s3) + bypass = Bypass.open(port: port) + + Bypass.expect(bypass, fn conn -> + # we only need to fake HeadObject, all the other S3 requests are "controlled" + "HEAD" = conn.method + + # we pretent the object is not found + Plug.Conn.send_resp(conn, 404, []) + end) + end + + :ok + end + else + def maybe_fake_minio(_context) do + :ok + end + end + + def monthly_pageview_usage_stub(penultimate_usage, last_usage) do + last_bill_date = Date.utc_today() |> Date.shift(day: -1) + + Plausible.Teams.Billing + |> Double.stub(:monthly_pageview_usage, fn _user -> + %{ + last_cycle: %{ + date_range: + Date.range( + Date.shift(last_bill_date, month: -1), + Date.shift(last_bill_date, day: -1) + ), + total: last_usage + }, + penultimate_cycle: %{ + date_range: + Date.range( + Date.shift(last_bill_date, month: -2), + Date.shift(last_bill_date, day: -1, month: -1) + ), + total: penultimate_usage + } + } + end) + end + + defp secret_key_base() do + :plausible + |> Application.fetch_env!(PlausibleWeb.Endpoint) + |> Keyword.fetch!(:secret_key_base) + end + + # normal `@tag :tmp_dir` might not work in Plausible.Session.Transfer tests + # if the path is too long for unix domain sockets (>104) + # this one makes paths a bit shorter + def tmp_dir do + name = "plausible-#{System.unique_integer([:positive])}" + tmp_dir = Path.join(System.tmp_dir!(), name) + File.rm_rf!(tmp_dir) + File.mkdir_p!(tmp_dir) + ExUnit.Callbacks.on_exit(fn -> File.rm_rf!(tmp_dir) end) + tmp_dir + end + + on_ee do + def new_identity(name, email, integration, id \\ Ecto.UUID.generate()) do + %Plausible.Auth.SSO.Identity{ + id: id, + integration_id: integration.identifier, + name: name, + email: email, + expires_at: NaiveDateTime.add(NaiveDateTime.utc_now(:second), 6, :hour) + } + end + end + + def prepare_conn(conn) do + conn + |> Map.put(:secret_key_base, secret_key_base()) + |> Plug.Conn.put_req_header("x-forwarded-for", random_ip()) + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000000000000000000000000000000000000..63f401e791228333454c6683eb51aef9b11628f2 --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1,47 @@ +if not Enum.empty?(Path.wildcard("lib/**/*_test.exs")) do + raise "Oops, test(s) found in `lib/` directory. Move them to `test/`." +end + +{:ok, _} = Application.ensure_all_started(:ex_machina) + +if Mix.env() != :e2e_test do + Mox.defmock(Plausible.HTTPClient.Mock, for: Plausible.HTTPClient.Interface) + + Mox.defmock(Plausible.DnsLookup.Mock, + for: Plausible.DnsLookupInterface + ) + + Application.ensure_all_started(:double) + + FunWithFlags.enable(:annotations) + + Ecto.Adapters.SQL.Sandbox.mode(Plausible.Repo, :manual) +end + +# warn about minio if it's included in tests but not running +if :minio in Keyword.fetch!(ExUnit.configuration(), :include) do + Plausible.TestUtils.ensure_minio() +end + +default_exclude = [:slow, :minio, :migrations] + +# avoid slowdowns contacting the code server +for {app, _, _} <- Application.loaded_applications() do + if modules = Application.spec(app, :modules) do + Code.ensure_all_loaded(modules) + end +end + +case Mix.env() do + :ce_test -> + IO.puts("Test mode: Community Edition") + ExUnit.configure(exclude: [:ee_only, :e2e | default_exclude]) + + :e2e_test -> + IO.puts("Test mode: End-to-End Tests") + ExUnit.configure(exclude: [:test], include: [:e2e]) + + _ -> + IO.puts("Test mode: Enterprise Edition") + ExUnit.configure(exclude: [:ce_build_only, :e2e | default_exclude]) +end diff --git a/test/workers/accept_traffic_until_test.exs b/test/workers/accept_traffic_until_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..3450ddd007720176583d510cb943ed13d0528026 --- /dev/null +++ b/test/workers/accept_traffic_until_test.exs @@ -0,0 +1,174 @@ +defmodule Plausible.Workers.AcceptTrafficUntilTest do + use Plausible.DataCase, async: true + use Bamboo.Test + + alias Plausible.Workers.AcceptTrafficUntil + + @moduletag :ee_only + + test "does not send any notifications when sites have no stats" do + today = Date.utc_today() + next_week = today |> Date.add(+7) + tomorrow = today |> Date.add(+1) + + user1 = new_user(team: [accept_traffic_until: next_week]) + + user2 = new_user(team: [accept_traffic_until: tomorrow]) + + _site1 = new_site(owner: user1) + + _site2 = new_site(owner: user2) + + {:ok, 2} = AcceptTrafficUntil.perform(nil) + + refute_notifications(user1.email) + refute_notifications(user2.email) + end + + test "does not send any notifications when site has stats older than 2d" do + today = Date.utc_today() + next_week = today |> Date.add(+7) + + user = new_user(team: [accept_traffic_until: next_week]) + + new_site(owner: user) + |> populate_stats([build(:pageview, timestamp: Date.add(today, -3))]) + + {:ok, 1} = AcceptTrafficUntil.perform(nil) + + refute_notifications(user.email) + end + + test "does send notification when last stat is 2d old" do + today = Date.utc_today() + next_week = today |> Date.add(+7) + + user = new_user(team: [accept_traffic_until: next_week]) + + new_site(owner: user) + |> populate_stats([build(:pageview, timestamp: Date.add(today, -2))]) + + {:ok, 1} = AcceptTrafficUntil.perform(nil) + + assert_weekly_notification(user.email) + end + + test "tomorrow: sends one e-mail" do + tomorrow = Date.utc_today() |> Date.add(+1) + user = new_user(team: [accept_traffic_until: tomorrow]) + + new_site(owner: user) |> populate_stats([build(:pageview)]) + + {:ok, 1} = AcceptTrafficUntil.perform(nil) + assert_final_notification(user.email) + end + + test "next week: sends one e-mail" do + next_week = Date.utc_today() |> Date.add(+7) + user = new_user(team: [accept_traffic_until: next_week]) + + new_site(owner: user) |> populate_stats([build(:pageview)]) + + {:ok, 1} = AcceptTrafficUntil.perform(nil) + assert_weekly_notification(user.email) + end + + test "sends combined warnings in one shot" do + today = Date.utc_today() + next_week = today |> Date.add(+7) + tomorrow = today |> Date.add(+1) + in_8_days = today |> Date.add(+8) + + user1 = new_user(team: [accept_traffic_until: next_week]) + user2 = new_user(team: [accept_traffic_until: tomorrow]) + user3 = new_user(team: [accept_traffic_until: in_8_days], email: "nope@example.com") + user4 = new_user(team: [accept_traffic_until: today], email: "nope2@example.com") + + new_site(owner: user1) |> populate_stats([build(:pageview)]) + new_site(owner: user2) |> populate_stats([build(:pageview)]) + new_site(owner: user3) |> populate_stats([build(:pageview)]) + new_site(owner: user4) |> populate_stats([build(:pageview)]) + + {:ok, 2} = AcceptTrafficUntil.perform(nil) + assert_weekly_notification(user1.email) + assert_final_notification(user2.email) + refute_notifications("nope@example.com") + refute_notifications("nope2@example.com") + end + + test "sends multiple notifications per recipient as the time passes" do + today = Date.utc_today() + email = "cycle@example.com" + + user = + new_user( + email: email, + trial_expiry_date: Plausible.Teams.Team.trial_expiry() + ) + + team = team_of(user) + + site = new_site(owner: user) + + populate_stats(site, [ + build(:pageview, timestamp: today), + build(:pageview, timestamp: Date.add(team.trial_expiry_date, 7)), + build(:pageview, timestamp: Date.add(team.trial_expiry_date, 8)), + build(:pageview, timestamp: Date.add(team.trial_expiry_date, 13)) + ]) + + # today's worker is no-op + {:ok, 0} = AcceptTrafficUntil.perform(nil, today) + refute_notifications(email) + + # trial_expiry + 7 days worker => we'll stop counting next week + {:ok, 1} = AcceptTrafficUntil.perform(nil, Date.add(team.trial_expiry_date, 7)) + assert_weekly_notification(email) + + # another call on the same day == no-op, e-mail already sent + {:ok, 0} = AcceptTrafficUntil.perform(nil, Date.add(team.trial_expiry_date, 7)) + refute_notifications(email) + + # another call on another day == no-op, no notification type applies + {:ok, 0} = AcceptTrafficUntil.perform(nil, Date.add(team.trial_expiry_date, 8)) + refute_notifications(email) + + # day before accept_stats_until (trial_expiry_date + 14 - 1) + {:ok, 1} = AcceptTrafficUntil.perform(nil, Date.add(team.trial_expiry_date, 13)) + assert_final_notification(email) + + # another one on the day before accept_stats_until (trial_expiry_date + 14 - 1) == no-op + {:ok, 0} = AcceptTrafficUntil.perform(nil, Date.add(team.trial_expiry_date, 13)) + refute_notifications(email) + end + + defp assert_weekly_notification(email) when is_binary(email) do + assert_email_delivered_with( + html_body: ~r/Hey Jane,/, + to: [nil: email], + subject: + PlausibleWeb.Email.approaching_accept_traffic_until(%{ + name: "", + email: email, + team: build(:team, identifier: Ecto.UUID.generate()) + }).subject + ) + end + + defp assert_final_notification(email) when is_binary(email) do + assert_email_delivered_with( + html_body: ~r/Hey Jane,/, + to: [nil: email], + subject: + PlausibleWeb.Email.approaching_accept_traffic_until_tomorrow(%{ + name: "", + email: email, + team: build(:team, identifier: Ecto.UUID.generate()) + }).subject + ) + end + + defp refute_notifications(email) when is_binary(email) do + refute_email_delivered_with(to: [nil: email]) + end +end diff --git a/test/workers/check_usage_test.exs b/test/workers/check_usage_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..9a012f4210c88b6713b2ab9664c9aa00d7247262 --- /dev/null +++ b/test/workers/check_usage_test.exs @@ -0,0 +1,581 @@ +defmodule Plausible.Workers.CheckUsageTest do + use Plausible.DataCase, async: true + use Bamboo.Test + import Double + + alias Plausible.Workers.CheckUsage + + require Plausible.Billing.Subscription.Status + + setup [:create_user, :create_site] + @paddle_id_10k "558018" + @date_range Date.range(Date.utc_today(), Date.utc_today()) + + @accepted_status_values [ + Plausible.Billing.Subscription.Status.active(), + Plausible.Billing.Subscription.Status.past_due(), + Plausible.Billing.Subscription.Status.deleted() + ] + + test "ignores user without subscription" do + CheckUsage.perform(nil) + + assert_no_emails_delivered() + end + + test "operates on the current subscription", + %{ + user: user + } do + usage_stub = + Plausible.Teams.Billing + |> stub(:monthly_pageview_usage, fn _user -> + %{ + penultimate_cycle: %{date_range: @date_range, total: 11_000}, + last_cycle: %{date_range: @date_range, total: 11_000} + } + end) + + subscribe_to_plan( + user, + @paddle_id_10k, + last_bill_date: Date.shift(Date.utc_today(), day: -1), + status: :active + ) + + subscribe_to_plan( + user, + "wont-exist-should-crash", + last_bill_date: Date.shift(Date.utc_today(), day: -1), + inserted_at: DateTime.shift(DateTime.utc_now(), day: -2), + status: :deleted + ) + + CheckUsage.perform(nil, usage_stub) + + assert_email_delivered_with( + to: [user], + subject: "[Action required] You have outgrown your Plausible subscription tier" + ) + end + + test "sends more than one email", %{user: user} do + usage_stub = + Plausible.Teams.Billing + |> stub(:monthly_pageview_usage, fn _user -> + %{ + penultimate_cycle: %{date_range: @date_range, total: 11_000}, + last_cycle: %{date_range: @date_range, total: 11_000} + } + end) + + user2 = new_user() + new_site(owner: user2) + + for u <- [user, user2] do + subscribe_to_plan( + u, + @paddle_id_10k, + last_bill_date: Date.shift(Date.utc_today(), day: -1), + next_bill_date: Date.shift(Date.utc_today(), day: +5), + status: :active + ) + end + + CheckUsage.perform(nil, usage_stub) + + assert_email_delivered_with( + to: [user], + subject: "[Action required] You have outgrown your Plausible subscription tier" + ) + + assert_email_delivered_with( + to: [user2], + subject: "[Action required] You have outgrown your Plausible subscription tier" + ) + end + + test "sends emails to billing members if available", %{user: user} do + usage_stub = + Plausible.Teams.Billing + |> stub(:monthly_pageview_usage, fn _user -> + %{ + penultimate_cycle: %{date_range: @date_range, total: 11_000}, + last_cycle: %{date_range: @date_range, total: 11_000} + } + end) + + user2 = new_user() + user3 = new_user() + new_site(owner: user) + team = team_of(user) + + add_member(team, user: user2, role: :billing) + add_member(team, user: user3, role: :viewer) + + subscribe_to_plan( + user, + @paddle_id_10k, + last_bill_date: Date.shift(Date.utc_today(), day: -1), + next_bill_date: Date.shift(Date.utc_today(), day: +5), + status: :active + ) + + CheckUsage.perform(nil, usage_stub) + + assert_email_delivered_with( + to: [user], + subject: "[Action required] You have outgrown your Plausible subscription tier" + ) + + assert_email_delivered_with( + to: [user2], + subject: "[Action required] You have outgrown your Plausible subscription tier" + ) + + refute_email_delivered_with( + to: [user3], + subject: "[Action required] You have outgrown your Plausible subscription tier" + ) + end + + test "ignores user with paused subscription", %{user: user} do + subscribe_to_plan( + user, + @paddle_id_10k, + last_bill_date: Date.shift(Date.utc_today(), day: -1), + status: Plausible.Billing.Subscription.Status.paused() + ) + + CheckUsage.perform(nil) + + assert_no_emails_delivered() + end + + for status <- @accepted_status_values do + describe "#{status} subscription, regular customers" do + test "ignores user with subscription but no usage", %{user: user} do + subscribe_to_plan(user, @paddle_id_10k, + last_bill_date: Date.shift(Date.utc_today(), day: -1), + status: unquote(status) + ) + + CheckUsage.perform(nil) + + assert_no_emails_delivered() + assert Repo.reload(team_of(user)).grace_period == nil + end + + test "does not send an email if account has been over the limit for one billing month", %{ + user: user + } do + usage_stub = + Plausible.Teams.Billing + |> stub(:monthly_pageview_usage, fn _user -> + %{ + penultimate_cycle: %{date_range: @date_range, total: 9_000}, + last_cycle: %{date_range: @date_range, total: 11_000} + } + end) + + subscribe_to_plan(user, @paddle_id_10k, + last_bill_date: Date.shift(Date.utc_today(), day: -1), + status: unquote(status) + ) + + CheckUsage.perform(nil, usage_stub) + + assert_no_emails_delivered() + assert Repo.reload(team_of(user)).grace_period == nil + end + + test "does not send an email if account is over the limit by less than 10%", %{ + user: user + } do + usage_stub = + Plausible.Teams.Billing + |> stub(:monthly_pageview_usage, fn _user -> + %{ + penultimate_cycle: %{date_range: @date_range, total: 10_999}, + last_cycle: %{date_range: @date_range, total: 11_000} + } + end) + + subscribe_to_plan(user, @paddle_id_10k, + last_bill_date: Date.shift(Date.utc_today(), day: -1), + status: unquote(status) + ) + + CheckUsage.perform(nil, usage_stub) + + assert_no_emails_delivered() + assert Repo.reload(team_of(user)).grace_period == nil + end + + test "sends an email when an account is over their limit for two consecutive billing months", + %{ + user: user + } do + usage_stub = + Plausible.Teams.Billing + |> stub(:monthly_pageview_usage, fn _user -> + %{ + penultimate_cycle: %{date_range: @date_range, total: 11_000}, + last_cycle: %{date_range: @date_range, total: 11_000} + } + end) + + subscribe_to_plan( + user, + @paddle_id_10k, + last_bill_date: Date.shift(Date.utc_today(), day: -1), + status: unquote(status) + ) + + CheckUsage.perform(nil, usage_stub) + + assert_email_delivered_with( + to: [user], + subject: "[Action required] You have outgrown your Plausible subscription tier" + ) + + assert Repo.reload(team_of(user)).grace_period.end_date == + Date.shift(Date.utc_today(), day: 7) + end + + test "sends an email suggesting enterprise plan when usage is greater than 10M ", %{ + user: user + } do + usage_stub = + Plausible.Teams.Billing + |> stub(:monthly_pageview_usage, fn _user -> + %{ + penultimate_cycle: %{date_range: @date_range, total: 11_000_000}, + last_cycle: %{date_range: @date_range, total: 11_000_000} + } + end) + + subscribe_to_plan( + user, + @paddle_id_10k, + last_bill_date: Date.shift(Date.utc_today(), day: -1), + status: unquote(status) + ) + + CheckUsage.perform(nil, usage_stub) + + assert_delivered_email_matches(%{html_body: html_body}) + + assert html_body =~ + "Your usage exceeds our standard plans, so please reply back to this email for a tailored quote" + end + + test "skips checking users who already have a grace period", %{user: user} do + %{grace_period: existing_grace_period} = Plausible.Teams.start_grace_period(team_of(user)) + + usage_stub = + Plausible.Teams.Billing + |> stub(:monthly_pageview_usage, fn _user -> + %{ + penultimate_cycle: %{date_range: @date_range, total: 11_000}, + last_cycle: %{date_range: @date_range, total: 11_000} + } + end) + + subscribe_to_plan(user, @paddle_id_10k, + last_bill_date: Date.shift(Date.utc_today(), day: -1), + status: unquote(status) + ) + + CheckUsage.perform(nil, usage_stub) + + assert_no_emails_delivered() + assert Repo.reload(team_of(user)).grace_period.id == existing_grace_period.id + end + + test "recommends a plan to upgrade to", %{ + user: user + } do + usage_stub = + Plausible.Teams.Billing + |> stub(:monthly_pageview_usage, fn _user -> + %{ + penultimate_cycle: %{date_range: @date_range, total: 11_000}, + last_cycle: %{date_range: @date_range, total: 11_000} + } + end) + + subscribe_to_plan( + user, + @paddle_id_10k, + last_bill_date: Date.shift(Date.utc_today(), day: -1), + status: unquote(status) + ) + + CheckUsage.perform(nil, usage_stub) + + assert_delivered_email_matches(%{ + html_body: html_body + }) + + assert html_body =~ "We recommend you upgrade to the 100k pageviews/month plan" + end + + test "clears grace period when plan is applicable again", %{user: user} do + usage_stub = + Plausible.Teams.Billing + |> stub(:monthly_pageview_usage, fn _user -> + %{ + penultimate_cycle: %{date_range: @date_range, total: 11_000}, + last_cycle: %{date_range: @date_range, total: 11_000} + } + end) + + subscribe_to_plan( + user, + @paddle_id_10k, + last_bill_date: Date.shift(Date.utc_today(), day: -1), + status: unquote(status) + ) + + CheckUsage.perform(nil, usage_stub) + assert user |> team_of() |> Repo.reload() |> Plausible.Teams.GracePeriod.active?() + + usage_stub = + Plausible.Teams.Billing + |> stub(:monthly_pageview_usage, fn _user -> + %{ + penultimate_cycle: %{date_range: @date_range, total: 11_000}, + last_cycle: %{date_range: @date_range, total: 9_000} + } + end) + + CheckUsage.perform(nil, usage_stub) + refute user |> team_of() |> Repo.reload() |> Plausible.Teams.GracePeriod.active?() + end + end + end + + for status <- @accepted_status_values do + describe "#{status} subscription, enterprise customers" do + test "skips checking enterprise users who already have a grace period", %{user: user} do + %{grace_period: existing_grace_period} = + Plausible.Teams.start_manual_lock_grace_period(team_of(user)) + + usage_stub = + Plausible.Teams.Billing + |> stub(:monthly_pageview_usage, fn _user -> + %{ + penultimate_cycle: %{date_range: @date_range, total: 1_100_000}, + last_cycle: %{date_range: @date_range, total: 1_100_000} + } + end) + + subscribe_to_enterprise_plan(user, + monthly_pageview_limit: 1_000_000, + subscription: [ + last_bill_date: Date.shift(Date.utc_today(), day: -1), + status: unquote(status) + ] + ) + + CheckUsage.perform(nil, usage_stub) + + assert_no_emails_delivered() + assert Repo.reload(team_of(user)).grace_period.id == existing_grace_period.id + end + + test "checks billable pageview usage for enterprise customer, sends usage information to enterprise@plausible.io", + %{ + user: user + } do + usage_stub = + Plausible.Teams.Billing + |> stub(:monthly_pageview_usage, fn _user -> + %{ + penultimate_cycle: %{date_range: @date_range, total: 1_100_000}, + last_cycle: %{date_range: @date_range, total: 1_100_000} + } + end) + + subscribe_to_enterprise_plan( + user, + monthly_pageview_limit: 1_000_000, + subscription: [ + last_bill_date: Date.shift(Date.utc_today(), day: -1), + status: unquote(status) + ] + ) + + CheckUsage.perform(nil, usage_stub) + + assert_email_delivered_with( + to: [{nil, "enterprise@plausible.io"}], + subject: "#{user.email} has outgrown their enterprise plan" + ) + end + + test "will only check usage if enterprise plan matches subscription's paddle plan id", + %{ + user: user + } do + usage_stub = + Plausible.Teams.Billing + |> stub(:monthly_pageview_usage, fn _user -> + %{ + penultimate_cycle: %{date_range: @date_range, total: 1_100_000}, + last_cycle: %{date_range: @date_range, total: 1_100_000} + } + end) + + subscribe_to_enterprise_plan( + user, + monthly_pageview_limit: 1_000_000, + subscription: [ + last_bill_date: Date.shift(Date.utc_today(), day: -1), + status: unquote(status), + # non-matching ID + paddle_plan_id: @paddle_id_10k + ] + ) + + CheckUsage.perform(nil, usage_stub) + + refute_email_delivered_with( + to: [{nil, "enterprise@plausible.io"}], + subject: "#{user.email} has outgrown their enterprise plan" + ) + end + + test "checks site limit for enterprise customer, sends usage information to enterprise@plausible.io", + %{ + user: user + } do + usage_stub = + Plausible.Teams.Billing + |> stub(:monthly_pageview_usage, fn _user -> + %{ + penultimate_cycle: %{date_range: @date_range, total: 1}, + last_cycle: %{date_range: @date_range, total: 1} + } + end) + + subscribe_to_enterprise_plan(user, + site_limit: 2, + subscription: [ + last_bill_date: Date.shift(Date.utc_today(), day: -1), + status: unquote(status) + ] + ) + + new_site(owner: user) + new_site(owner: user) + new_site(owner: user) + + CheckUsage.perform(nil, usage_stub) + + assert_email_delivered_with( + to: [{nil, "enterprise@plausible.io"}], + subject: "#{user.email} has outgrown their enterprise plan" + ) + end + + test "starts grace period when plan is outgrown", %{user: user} do + usage_stub = + Plausible.Teams.Billing + |> stub(:monthly_pageview_usage, fn _user -> + %{ + penultimate_cycle: %{date_range: @date_range, total: 1_100_000}, + last_cycle: %{date_range: @date_range, total: 1_100_000} + } + end) + + subscribe_to_enterprise_plan( + user, + monthly_pageview_limit: 1_000_000, + subscription: [ + last_bill_date: Date.shift(Date.utc_today(), day: -1), + status: unquote(status) + ] + ) + + CheckUsage.perform(nil, usage_stub) + assert user |> team_of() |> Repo.reload() |> Plausible.Teams.GracePeriod.active?() + end + end + end + + describe "timing" do + test "checks usage one day after the last_bill_date", %{ + user: user + } do + usage_stub = + Plausible.Teams.Billing + |> stub(:monthly_pageview_usage, fn _user -> + %{ + penultimate_cycle: %{date_range: @date_range, total: 11_000}, + last_cycle: %{date_range: @date_range, total: 11_000} + } + end) + + subscribe_to_plan( + user, + @paddle_id_10k, + last_bill_date: Date.shift(Date.utc_today(), day: -1) + ) + + CheckUsage.perform(nil, usage_stub) + + assert_email_delivered_with( + to: [user], + subject: "[Action required] You have outgrown your Plausible subscription tier" + ) + end + + test "does not check exactly one month after last_bill_date", %{ + user: user + } do + usage_stub = + Plausible.Teams.Billing + |> stub(:monthly_pageview_usage, fn _user -> + %{ + penultimate_cycle: %{date_range: @date_range, total: 11_000}, + last_cycle: %{date_range: @date_range, total: 11_000} + } + end) + + subscribe_to_plan(user, @paddle_id_10k, last_bill_date: ~D[2021-03-28]) + + CheckUsage.perform(nil, usage_stub, ~D[2021-03-28]) + + assert_no_emails_delivered() + end + + test "for yearly subscriptions, checks usage multiple months + one day after the last_bill_date", + %{ + user: user + } do + usage_stub = + Plausible.Teams.Billing + |> stub(:monthly_pageview_usage, fn _user -> + %{ + penultimate_cycle: %{date_range: @date_range, total: 11_000}, + last_cycle: %{date_range: @date_range, total: 11_000} + } + end) + + subscribe_to_plan( + user, + @paddle_id_10k, + last_bill_date: ~D[2021-06-29] + ) + + CheckUsage.perform(nil, usage_stub, ~D[2021-08-30]) + + assert_email_delivered_with( + to: [user], + subject: "[Action required] You have outgrown your Plausible subscription tier" + ) + end + end +end diff --git a/test/workers/clean_invitations_test.exs b/test/workers/clean_invitations_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..e17f82374d26db22776f580eda04a165bca83bb4 --- /dev/null +++ b/test/workers/clean_invitations_test.exs @@ -0,0 +1,54 @@ +defmodule Plausible.Workers.CleanInvitationsTest do + use Plausible.DataCase + alias Plausible.Workers.CleanInvitations + + test "cleans invitations and transfers that are more than 48h old" do + now = NaiveDateTime.utc_now(:second) + + owner = new_user() + site = new_site(owner: owner) + + invite_guest(site, new_user(), + role: :viewer, + inviter: owner, + inserted_at: NaiveDateTime.shift(now, hour: -49), + team_invitation: [inserted_at: NaiveDateTime.shift(now, hour: -49)] + ) + + invite_transfer(site, new_user(), + inviter: owner, + inserted_at: NaiveDateTime.shift(now, hour: -49) + ) + + CleanInvitations.perform(nil) + + refute Repo.exists?(Plausible.Teams.Invitation) + refute Repo.exists?(Plausible.Teams.GuestInvitation) + refute Repo.exists?(Plausible.Teams.SiteTransfer) + end + + test "does not clean invitations and transfers that are less than 48h old" do + now = NaiveDateTime.utc_now(:second) + + owner = new_user() + site = new_site(owner: owner) + + invite_guest(site, new_user(), + role: :viewer, + inviter: owner, + inserted_at: NaiveDateTime.shift(now, hour: -47), + team_invitation: [inserted_at: NaiveDateTime.shift(now, hour: -47)] + ) + + invite_transfer(site, new_user(), + inviter: owner, + inserted_at: NaiveDateTime.shift(now, hour: -47) + ) + + CleanInvitations.perform(nil) + + assert Repo.exists?(Plausible.Teams.Invitation) + assert Repo.exists?(Plausible.Teams.GuestInvitation) + assert Repo.exists?(Plausible.Teams.SiteTransfer) + end +end diff --git a/test/workers/clean_user_sessions_test.exs b/test/workers/clean_user_sessions_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..8d677c1e6525b0e2e5c43f61d14ba9075560b07d --- /dev/null +++ b/test/workers/clean_user_sessions_test.exs @@ -0,0 +1,34 @@ +defmodule Plausible.Workers.CleanUserSessionsTest do + use Plausible.DataCase + + alias Plausible.Auth.UserSession + alias Plausible.Workers.CleanUserSessions + + test "cleans invitation that is more than timeout_at + grace_period days old" do + grace_cutoff = + NaiveDateTime.utc_now(:second) + |> NaiveDateTime.shift(Duration.negate(UserSession.timeout_duration())) + |> NaiveDateTime.shift(CleanUserSessions.grace_period_duration()) + + ten_days_after = NaiveDateTime.shift(grace_cutoff, day: 10) + one_day_after = NaiveDateTime.shift(grace_cutoff, day: 1) + one_day_before = NaiveDateTime.shift(grace_cutoff, day: -1) + session_to_clean = insert_session(one_day_before) + session_to_leave1 = insert_session(one_day_after) + session_to_leave2 = insert_session(ten_days_after) + + CleanUserSessions.perform(nil) + + refute Repo.reload(session_to_clean) + assert Repo.reload(session_to_leave1) + assert Repo.reload(session_to_leave2) + end + + defp insert_session(now) do + user = insert(:user) + + user + |> UserSession.new_session("Unknown", now: now) + |> Repo.insert!() + end +end diff --git a/test/workers/clickhouse_clean_sites_test.exs b/test/workers/clickhouse_clean_sites_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..18cc2c96f75986549652e8439091b6c301328e4d --- /dev/null +++ b/test/workers/clickhouse_clean_sites_test.exs @@ -0,0 +1,68 @@ +defmodule Plausible.Workers.ClickhouseCleanSitesTest do + use Plausible.DataCase + import Plausible.Factory + + alias Plausible.Workers.ClickhouseCleanSites + + @tag :slow + test "deletes data from events and sessions tables" do + site = insert(:site) + deleted_site = insert(:site) + + populate_stats(site, [ + build(:pageview) + ]) + + populate_stats(deleted_site, [ + build(:pageview), + build(:pageview), + build(:imported_visitors), + build(:imported_sources), + build(:imported_pages), + build(:imported_entry_pages), + build(:imported_exit_pages), + build(:imported_locations), + build(:imported_devices), + build(:imported_browsers), + build(:imported_operating_systems) + ]) + + Repo.delete!(deleted_site) + + assert Enum.member?( + ClickhouseCleanSites.get_deleted_sites_with_clickhouse_data(), + deleted_site.id + ) + + assert not Enum.member?( + ClickhouseCleanSites.get_deleted_sites_with_clickhouse_data(), + site.id + ) + + ClickhouseCleanSites.perform(nil) + + assert_count(deleted_site, "events_v2", 0) + assert_count(deleted_site, "sessions_v2", 0) + assert_count(deleted_site, "imported_visitors", 0) + assert_count(deleted_site, "imported_sources", 0) + assert_count(deleted_site, "imported_pages", 0) + assert_count(deleted_site, "imported_entry_pages", 0) + assert_count(deleted_site, "imported_exit_pages", 0) + assert_count(deleted_site, "imported_locations", 0) + assert_count(deleted_site, "imported_devices", 0) + assert_count(deleted_site, "imported_browsers", 0) + assert_count(deleted_site, "imported_operating_systems", 0) + assert_count(site, "events_v2", 1) + assert_count(site, "sessions_v2", 1) + + assert not Enum.member?( + ClickhouseCleanSites.get_deleted_sites_with_clickhouse_data(), + deleted_site.id + ) + end + + def assert_count(site, table, expected_count) do + q = from(e in table, select: %{count: fragment("count()")}, where: e.site_id == ^site.id) + await_clickhouse_count(q, expected_count) + end +end diff --git a/test/workers/expire_domain_change_transitions_test.exs b/test/workers/expire_domain_change_transitions_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..928a917583af5017da9e9b173de9d5b7e4650376 --- /dev/null +++ b/test/workers/expire_domain_change_transitions_test.exs @@ -0,0 +1,41 @@ +defmodule Plausible.Workers.ExpireDomainChangeTransitionsTest do + use Plausible.DataCase, async: true + alias Plausible.Workers.ExpireDomainChangeTransitions + alias Plausible.Site + alias Plausible.Sites + + import ExUnit.CaptureLog + + test "doesn't log when there is nothing to do" do + log = + capture_log(fn -> + assert :ok = ExpireDomainChangeTransitions.perform(nil) + end) + + refute log =~ "Expired" + end + + test "expires domains selectively after change and logs the result" do + now = NaiveDateTime.utc_now() + yesterday = now |> NaiveDateTime.add(-60 * 60 * 24, :second) + three_days_ago = now |> NaiveDateTime.add(-60 * 60 * 72, :second) + long_time_ago = now |> NaiveDateTime.add(-60 * 60 * 24 * 365, :second) + + insert(:site) |> Site.Domain.change("site1.example.com") + insert(:site) |> Site.Domain.change("site2.example.com", at: yesterday) + insert(:site) |> Site.Domain.change("site3.example.com", at: three_days_ago) + insert(:site) |> Site.Domain.change("site4.example.com", at: long_time_ago) + + log = + capture_log(fn -> + assert :ok = ExpireDomainChangeTransitions.perform(nil) + end) + + assert log =~ "Expired 2 from the domain change transition period" + + assert Sites.get_by_domain("site1.example.com").domain_changed_from + assert Sites.get_by_domain("site2.example.com").domain_changed_from + refute Sites.get_by_domain("site3.example.com").domain_changed_from + refute Sites.get_by_domain("site4.example.com").domain_changed_from + end +end diff --git a/test/workers/import_analytics_test.exs b/test/workers/import_analytics_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..42613cbaa4a6470990eaa66141fd79cba433491f --- /dev/null +++ b/test/workers/import_analytics_test.exs @@ -0,0 +1,324 @@ +defmodule Plausible.Workers.ImportAnalyticsTest do + use Plausible.DataCase + use Bamboo.Test + + alias Plausible.Imported.SiteImport + alias Plausible.Workers.ImportAnalytics + + require Plausible.Imported.SiteImport + + @moduletag capture_log: true + + describe "perform/1" do + setup do + %{ + import_opts: [ + start_date: Date.utc_today() |> Date.shift(day: -7), + end_date: Date.utc_today() + ] + } + end + + test "updates site import after successful import", %{ + import_opts: import_opts + } do + user = new_user(trial_expiry_date: Date.utc_today() |> Date.shift(day: 1)) + site = new_site(owner: user) + + {:ok, job} = Plausible.Imported.NoopImporter.new_import(site, user, import_opts) + + assert [%{status: SiteImport.pending()}] = Plausible.Imported.list_all_imports(site) + + # before_start callback triggered + assert_received {:before_start, import_id} + + assert :ok = + job + |> Repo.reload!() + |> ImportAnalytics.perform() + + assert [%{id: ^import_id, status: SiteImport.completed()}] = + Plausible.Imported.list_all_imports(site) + + # on_success callback triggered + assert_received {:on_success, ^import_id} + end + + test "clears stats_start_date field for the site after successful import", %{ + import_opts: import_opts + } do + user = new_user(trial_expiry_date: Date.utc_today() |> Date.shift(day: 1)) + site = new_site(owner: user, stats_start_date: ~D[2005-01-01]) + + {:ok, job} = Plausible.Imported.NoopImporter.new_import(site, user, import_opts) + + assert :ok = + job + |> Repo.reload!() + |> ImportAnalytics.perform() + + site = Repo.reload!(site) + assert site.stats_start_date == nil + assert Plausible.Sites.stats_start_date(site) == import_opts[:start_date] + assert Repo.reload!(site).stats_start_date == import_opts[:start_date] + end + + test "sends email to owner after successful import", %{import_opts: import_opts} do + user = new_user(trial_expiry_date: Date.utc_today() |> Date.shift(day: 1)) + site = new_site(owner: user) + + {:ok, job} = Plausible.Imported.NoopImporter.new_import(site, user, import_opts) + + assert :ok = + job + |> Repo.reload!() + |> ImportAnalytics.perform() + + assert_email_delivered_with( + to: [user], + subject: "Noop data imported for #{site.domain}" + ) + end + + test "send email after successful import only to the user who ran the import", %{ + import_opts: import_opts + } do + owner = new_user(trial_expiry_date: Date.utc_today() |> Date.shift(day: 1)) + site = new_site(owner: owner) + + importing_user = new_user() + + add_guest(site, user: importing_user, role: :editor) + + {:ok, job} = Plausible.Imported.NoopImporter.new_import(site, importing_user, import_opts) + + assert :ok = + job + |> Repo.reload!() + |> ImportAnalytics.perform() + + assert_email_delivered_with( + to: [importing_user], + subject: "Noop data imported for #{site.domain}" + ) + + refute_email_delivered_with( + to: [owner], + subject: "Noop data imported for #{site.domain}" + ) + end + + test "updates site import record after failed import", %{import_opts: import_opts} do + user = new_user(trial_expiry_date: Date.utc_today() |> Date.shift(day: 1)) + site = new_site(owner: user) + import_opts = Keyword.put(import_opts, :error, true) + + {:ok, job} = Plausible.Imported.NoopImporter.new_import(site, user, import_opts) + + assert {:discard, "Something went wrong"} = + job + |> Repo.reload!() + |> ImportAnalytics.perform() + + assert [%{status: SiteImport.failed()}] = Plausible.Imported.list_all_imports(site) + end + + test "clears any orphaned data during import", %{import_opts: import_opts} do + user = new_user(trial_expiry_date: Date.utc_today() |> Date.shift(day: 1)) + site = new_site(owner: user) + import_opts = Keyword.put(import_opts, :error, true) + + {:ok, job} = Plausible.Imported.NoopImporter.new_import(site, user, import_opts) + + populate_stats(site, [ + build(:imported_visitors, import_id: job.args.import_id, pageviews: 10) + ]) + + assert {:discard, _} = + job + |> Repo.reload!() + |> ImportAnalytics.perform() + + assert eventually(fn -> + count = Plausible.Stats.Clickhouse.imported_pageview_count(site) + {count == 0, count} + end) + + # on_failure callback triggered + assert_received {:on_failure, _import_id} + end + + test "sends email to owner after failed import", %{import_opts: import_opts} do + user = new_user(trial_expiry_date: Date.utc_today() |> Date.shift(day: 1)) + site = new_site(owner: user) + import_opts = Keyword.put(import_opts, :error, true) + + {:ok, job} = Plausible.Imported.NoopImporter.new_import(site, user, import_opts) + + assert {:discard, _} = + job + |> Repo.reload!() + |> ImportAnalytics.perform() + + assert_email_delivered_with( + to: [user], + subject: "Noop import failed for #{site.domain}" + ) + end + + test "sends email after failed import only to the user who ran the import", %{ + import_opts: import_opts + } do + owner = new_user(trial_expiry_date: Date.utc_today() |> Date.shift(day: 1)) + site = new_site(owner: owner) + import_opts = Keyword.put(import_opts, :error, true) + + importing_user = new_user() + + add_guest(site, user: importing_user, role: :editor) + + {:ok, job} = Plausible.Imported.NoopImporter.new_import(site, importing_user, import_opts) + + assert {:discard, _} = + job + |> Repo.reload!() + |> ImportAnalytics.perform() + + assert_email_delivered_with( + to: [importing_user], + subject: "Noop import failed for #{site.domain}" + ) + + refute_email_delivered_with( + to: [owner], + subject: "Noop import failed for #{site.domain}" + ) + end + end + + describe "perform/1 notifications" do + setup do + on_exit(fn -> + Ecto.Adapters.SQL.Sandbox.unboxed_run(Plausible.Repo, fn -> + Repo.delete_all(Plausible.Site) + Repo.delete_all(Plausible.Auth.User) + Repo.delete_all(Oban.Job) + end) + + :ok + end) + + %{ + import_opts: [ + start_date: Date.utc_today() |> Date.shift(day: -7), + end_date: Date.utc_today() + ] + } + end + + test "sends oban notification to calling process on completion when instructed", %{ + import_opts: import_opts + } do + Ecto.Adapters.SQL.Sandbox.unboxed_run(Plausible.Repo, fn -> + user = new_user(trial_expiry_date: Date.utc_today() |> Date.shift(day: 1)) + site = new_site(owner: user) + site_id = site.id + import_opts = Keyword.put(import_opts, :listen?, true) + + {:ok, job} = Plausible.Imported.NoopImporter.new_import(site, user, import_opts) + import_id = job.args[:import_id] + + job + |> Repo.reload!() + |> ImportAnalytics.perform() + + assert_receive {:notification, :analytics_imports_jobs, + %{"event" => "complete", "import_id" => ^import_id, "site_id" => ^site_id}} + end) + end + + test "sends oban notification to calling process on permanent failure when instructed", %{ + import_opts: import_opts + } do + Ecto.Adapters.SQL.Sandbox.unboxed_run(Plausible.Repo, fn -> + user = new_user(trial_expiry_date: Date.utc_today() |> Date.shift(day: 1)) + site = new_site(owner: user) + site_id = site.id + + import_opts = + import_opts + |> Keyword.put(:listen?, true) + |> Keyword.put(:error, true) + + {:ok, job} = Plausible.Imported.NoopImporter.new_import(site, user, import_opts) + import_id = job.args[:import_id] + + job + |> Repo.reload!() + |> ImportAnalytics.perform() + + assert_receive {:notification, :analytics_imports_jobs, + %{"event" => "fail", "import_id" => ^import_id, "site_id" => ^site_id}} + end) + end + + test "sends oban notification to calling process on transient failure when instructed", %{ + import_opts: import_opts + } do + Ecto.Adapters.SQL.Sandbox.unboxed_run(Plausible.Repo, fn -> + user = new_user(trial_expiry_date: Date.utc_today() |> Date.shift(day: 1)) + site = new_site(owner: user) + site_id = site.id + + import_opts = + import_opts + |> Keyword.put(:listen?, true) + |> Keyword.put(:crash, true) + + {:ok, job} = Plausible.Imported.NoopImporter.new_import(site, user, import_opts) + import_id = job.args[:import_id] + + site_import = Repo.get(Plausible.Imported.SiteImport, import_id) + + # emulate oban error handler + try do + job + |> Repo.reload!() + |> ImportAnalytics.perform() + rescue + _ -> ImportAnalytics.import_fail_transient(site_import) + end + + assert_receive {:notification, :analytics_imports_jobs, + %{ + "event" => "transient_fail", + "import_id" => ^import_id, + "site_id" => ^site_id + }} + end) + end + + test "sends oban notification to calling process on completion when listener setup separately", + %{ + import_opts: import_opts + } do + Ecto.Adapters.SQL.Sandbox.unboxed_run(Plausible.Repo, fn -> + user = new_user(trial_expiry_date: Date.utc_today() |> Date.shift(day: 1)) + site = new_site(owner: user) + site_id = site.id + + {:ok, job} = Plausible.Imported.NoopImporter.new_import(site, user, import_opts) + import_id = job.args[:import_id] + + :ok = Plausible.Imported.Importer.listen() + + job + |> Repo.reload!() + |> ImportAnalytics.perform() + + assert_receive {:notification, :analytics_imports_jobs, + %{"event" => "complete", "import_id" => ^import_id, "site_id" => ^site_id}} + end) + end + end +end diff --git a/test/workers/lock_sites_test.exs b/test/workers/lock_sites_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..fb8489fd0b07ea0b9a8064898311b812423c8b66 --- /dev/null +++ b/test/workers/lock_sites_test.exs @@ -0,0 +1,113 @@ +defmodule Plausible.Workers.LockSitesTest do + use Plausible.DataCase, async: true + require Plausible.Billing.Subscription.Status + alias Plausible.Workers.LockSites + alias Plausible.Billing.Subscription + + @moduletag :ee_only + + test "does not lock enterprise site on grace period" do + user = new_user() + site = new_site(owner: user) + + user + |> team_of() + |> Plausible.Teams.start_manual_lock_grace_period() + + LockSites.perform(nil) + + refute Repo.reload!(site.team).locked + end + + test "does not lock trial user's site" do + user = new_user(trial_expiry_date: Date.utc_today() |> Date.shift(day: 1)) + site = new_site(owner: user) + + LockSites.perform(nil) + + refute Repo.reload!(site.team).locked + end + + test "locks site for user whose trial has expired" do + user = new_user(trial_expiry_date: Date.utc_today() |> Date.shift(day: -1)) + site = new_site(owner: user) + + LockSites.perform(nil) + + assert Repo.reload!(site.team).locked + end + + test "does not lock active subsriber's sites" do + user = new_user() |> subscribe_to_growth_plan(status: Subscription.Status.active()) + site = new_site(owner: user) + + LockSites.perform(nil) + + refute Repo.reload!(site.team).locked + end + + test "does not lock user who is past due" do + user = new_user() |> subscribe_to_growth_plan(status: Subscription.Status.past_due()) + site = new_site(owner: user) + + LockSites.perform(nil) + + refute Repo.reload!(site.team).locked + end + + test "does not lock user who cancelled subscription but it hasn't expired yet" do + user = new_user() |> subscribe_to_growth_plan(status: Subscription.Status.deleted()) + site = new_site(owner: user) + + LockSites.perform(nil) + + refute Repo.reload!(site.team).locked + end + + test "locks user who cancelled subscription and the cancelled subscription has expired" do + user = + new_user(trial_expiry_date: Date.utc_today() |> Date.shift(day: -1)) + |> subscribe_to_growth_plan( + status: Subscription.Status.deleted(), + next_bill_date: Date.utc_today() |> Date.shift(day: -1) + ) + + site = new_site(owner: user) + + LockSites.perform(nil) + + assert Repo.reload!(site.team).locked + end + + test "does not lock if user has an old cancelled subscription and a new active subscription" do + user = + new_user(trial_expiry_date: Date.utc_today() |> Date.shift(day: -1)) + |> subscribe_to_growth_plan( + status: Subscription.Status.deleted(), + next_bill_date: Date.utc_today() |> Date.shift(day: -1), + inserted_at: NaiveDateTime.utc_now() |> NaiveDateTime.shift(day: -1) + ) + |> subscribe_to_growth_plan(status: Subscription.Status.deleted()) + + site = new_site(owner: user) + + LockSites.perform(nil) + + refute Repo.reload!(site.team).locked + end + + describe "locking" do + test "only locks sites that the user owns" do + user = new_user(trial_expiry_date: Date.utc_today() |> Date.shift(day: -1)) + + owner_site = new_site(owner: user) + viewer_site = new_site() + add_guest(viewer_site, user: user, role: :viewer) + + LockSites.perform(nil) + + assert Repo.reload!(owner_site.team).locked + refute Repo.reload!(viewer_site.team).locked + end + end +end diff --git a/test/workers/notify_annual_renewal_test.exs b/test/workers/notify_annual_renewal_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..76ea02f54c3addfde00754e805aff59a5777aca3 --- /dev/null +++ b/test/workers/notify_annual_renewal_test.exs @@ -0,0 +1,187 @@ +defmodule Plausible.Workers.NotifyAnnualRenewalTest do + use Plausible.DataCase, async: true + use Bamboo.Test + require Plausible.Billing.Subscription.Status + alias Plausible.Workers.NotifyAnnualRenewal + alias Plausible.Billing.Subscription + + setup [:create_user, :create_site] + @monthly_plan "558018" + @yearly_plan "572810" + @v2_pricing_yearly_plan "653232" + + test "ignores user without subscription" do + NotifyAnnualRenewal.perform(nil) + + assert_no_emails_delivered() + end + + test "ignores user with monthly subscription", %{user: user} do + subscribe_to_plan(user, @monthly_plan, next_bill_date: Date.shift(Date.utc_today(), day: 7)) + + NotifyAnnualRenewal.perform(nil) + + assert_no_emails_delivered() + end + + test "ignores user with yearly subscription that's not due for renewal in 7 days", %{user: user} do + subscribe_to_plan(user, @yearly_plan, next_bill_date: Date.shift(Date.utc_today(), day: 10)) + + NotifyAnnualRenewal.perform(nil) + + assert_no_emails_delivered() + end + + test "ignores user with old yearly subscription that's been superseded by a newer one", %{ + user: user + } do + subscribe_to_plan( + user, + @yearly_plan, + next_bill_date: Date.shift(Date.utc_today(), day: 5), + inserted_at: NaiveDateTime.shift(NaiveDateTime.utc_now(), day: -1) + ) + + subscribe_to_plan( + user, + @yearly_plan, + next_bill_date: Date.shift(Date.utc_today(), day: 30), + inserted_at: NaiveDateTime.utc_now() + ) + + NotifyAnnualRenewal.perform(nil) + + assert_no_emails_delivered() + end + + test "sends renewal notification to user whose subscription is due for renewal in 7 days", %{ + user: user + } do + subscribe_to_plan( + user, + @yearly_plan, + next_bill_date: Date.shift(Date.utc_today(), day: 7) + ) + + team = team_of(user) + billing_member = new_user() + add_member(team, user: billing_member, role: :billing) + + NotifyAnnualRenewal.perform(nil) + + assert_email_delivered_with( + to: [{user.name, user.email}], + subject: "Your Plausible subscription is up for renewal" + ) + + assert_email_delivered_with( + to: [{billing_member.name, billing_member.email}], + subject: "Your Plausible subscription is up for renewal" + ) + end + + test "sends renewal notification to user whose subscription is due for renewal in 2 days", %{ + user: user + } do + subscribe_to_plan( + user, + @yearly_plan, + next_bill_date: Date.shift(Date.utc_today(), day: 2) + ) + + NotifyAnnualRenewal.perform(nil) + + assert_email_delivered_with( + to: [{user.name, user.email}], + subject: "Your Plausible subscription is up for renewal" + ) + end + + test "does not send renewal notification multiple times", %{user: user} do + subscribe_to_plan(user, @yearly_plan, next_bill_date: Date.shift(Date.utc_today(), day: 7)) + + NotifyAnnualRenewal.perform(nil) + + assert_email_delivered_with( + to: [{user.name, user.email}], + subject: "Your Plausible subscription is up for renewal" + ) + + NotifyAnnualRenewal.perform(nil) + + assert_no_emails_delivered() + end + + test "sends a renewal notification again a year after the previous one", %{user: user} do + subscribe_to_plan(user, @yearly_plan, next_bill_date: Date.shift(Date.utc_today(), day: 7)) + + Repo.insert_all("sent_renewal_notifications", [ + %{ + user_id: user.id, + timestamp: Date.shift(Date.utc_today(), year: -1) |> NaiveDateTime.new!(~T[00:00:00]) + } + ]) + + NotifyAnnualRenewal.perform(nil) + + assert_email_delivered_with( + to: [{user.name, user.email}], + subject: "Your Plausible subscription is up for renewal" + ) + end + + test "does not send multiple notifications on second year", %{user: user} do + subscribe_to_plan(user, @yearly_plan, next_bill_date: Date.shift(Date.utc_today(), day: 7)) + + year_ago = Date.shift(Date.utc_today(), year: -1) + + Repo.insert_all("sent_renewal_notifications", [ + %{ + user_id: user.id, + timestamp: NaiveDateTime.new!(year_ago, ~T[00:00:00]) + } + ]) + + NotifyAnnualRenewal.perform(nil) + + assert_email_delivered_with( + to: [{user.name, user.email}], + subject: "Your Plausible subscription is up for renewal" + ) + + NotifyAnnualRenewal.perform(nil) + + assert_no_emails_delivered() + end + + test "sends renewal notification to user on v2 yearly pricing plans", %{ + user: user + } do + subscribe_to_plan(user, @v2_pricing_yearly_plan, + next_bill_date: Date.shift(Date.utc_today(), day: 7) + ) + + NotifyAnnualRenewal.perform(nil) + + assert_email_delivered_with( + to: [{user.name, user.email}], + subject: "Your Plausible subscription is up for renewal" + ) + end + + describe "expiration" do + test "if user subscription is 'deleted', notify them about expiration instead", %{user: user} do + subscribe_to_plan(user, @yearly_plan, + next_bill_date: Date.shift(Date.utc_today(), day: 7), + status: Subscription.Status.deleted() + ) + + NotifyAnnualRenewal.perform(nil) + + assert_email_delivered_with( + to: [{user.name, user.email}], + subject: "Your Plausible subscription is about to expire" + ) + end + end +end diff --git a/test/workers/notify_exported_analytics_test.exs b/test/workers/notify_exported_analytics_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..967770a58e9b474bf28590538f0ef33565e14960 --- /dev/null +++ b/test/workers/notify_exported_analytics_test.exs @@ -0,0 +1,32 @@ +defmodule Plausible.Workers.NotifyExportedAnalyticsTest do + use Plausible.DataCase + use Bamboo.Test + + describe "perform/1" do + setup do + user = new_user() + site = new_site(owner: user) + {:ok, user: user, site: site} + end + + # for 'success' case please see Plausible.Imported.CSVImporterTest + test "delivers 'failure' email", %{user: user, site: site} do + job = + Plausible.Workers.NotifyExportedAnalytics.new(%{ + "status" => "failure", + "storage" => on_ee(do: "s3", else: "local"), + "email_to" => user.email, + "site_id" => site.id + }) + + Oban.insert!(job) + + assert %{success: 1} = + Oban.drain_queue(queue: :notify_exported_analytics, with_safety: false) + + assert_receive {:delivered_email, email} + assert email.html_body =~ "was unsuccessful." + assert email.text_body =~ "was unsuccessful." + end + end +end diff --git a/test/workers/oban_error_reporter_test.exs b/test/workers/oban_error_reporter_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..397689d4f8c65cc754c475fb3ad51bd06a34c79a --- /dev/null +++ b/test/workers/oban_error_reporter_test.exs @@ -0,0 +1,44 @@ +defmodule ObanErrorReporterTest do + use ExUnit.Case, async: true + + describe "handle_event/4" do + setup do + :telemetry.attach_many( + "oban-errors-test", + [[:oban, :job, :exception], [:oban, :notifier, :exception], [:oban, :plugin, :exception]], + &ObanErrorReporter.handle_event/4, + %{} + ) + + on_exit(fn -> :ok = :telemetry.detach("oban-errors-test") end) + end + + @tag :capture_log + test "doesn't detach on failure" do + :ok = + :telemetry.execute( + [:oban, :job, :exception], + _bad_measurements = %{}, + _bad_metadata = %{job: :bad_job} + ) + + handlers = :telemetry.list_handlers([:oban, :job, :exception]) + assert Enum.any?(handlers, &(&1.id == "oban-errors-test")) + end + + test "logs an error on failure" do + log = + ExUnit.CaptureLog.capture_log(fn -> + :ok = + :telemetry.execute( + [:oban, :job, :exception], + _bad_measurements = %{}, + _bad_metadata = %{job: :bad_job} + ) + end) + + assert log =~ "(BadMapError)" + assert log =~ ":bad_job" + end + end +end diff --git a/test/workers/purge_cdn_cache_test.exs b/test/workers/purge_cdn_cache_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..aa87ab69f376d96ea67e1fee3d0a1d1b2b195fee --- /dev/null +++ b/test/workers/purge_cdn_cache_test.exs @@ -0,0 +1,77 @@ +defmodule Plausible.Workers.PurgeCDNCacheTest do + use Plausible.DataCase, async: true + import ExUnit.CaptureLog + import Plug.Conn + + alias Plausible.Workers.PurgeCDNCache + + describe "perform/1" do + setup do + # Set test configuration + Application.put_env(:plausible, Plausible.Workers.PurgeCDNCache, + pullzone_id: "456", + api_key: "test-api-key", + req_opts: [ + plug: {Req.Test, Plausible.Workers.PurgeCDNCache} + ] + ) + + :ok + end + + test "successfully purges cache" do + Req.Test.expect(Plausible.Workers.PurgeCDNCache, &send_resp(&1, 204, "ok")) + + assert {:ok, :success} = PurgeCDNCache.perform(%Oban.Job{args: %{"id" => "adf"}}) + end + + test "handles missing configuration" do + # Clear configuration + Application.put_env(:plausible, Plausible.Workers.PurgeCDNCache, []) + + {result, log} = + with_log(fn -> PurgeCDNCache.perform(%Oban.Job{args: %{"id" => "adf"}}) end) + + assert {:discard, "Configuration missing"} = result + assert log =~ "Ignoring purge CDN cache for tracker script adf: Configuration missing" + end + + test "handles unexpected status code" do + Req.Test.expect( + Plausible.Workers.PurgeCDNCache, + &send_resp(&1, 500, "internal server error") + ) + + {result, log} = + with_log(fn -> PurgeCDNCache.perform(%Oban.Job{args: %{"id" => "adf"}}) end) + + assert {:error, "Unexpected status: 500"} = result + assert log =~ "Failed to purge CDN cache for tracker script adf: Unexpected status: 500" + end + + test "handles network errors" do + Req.Test.expect( + Plausible.Workers.PurgeCDNCache, + &Req.Test.transport_error(&1, :econnrefused) + ) + + {result, log} = + with_log(fn -> PurgeCDNCache.perform(%Oban.Job{args: %{"id" => "adf"}}) end) + + assert {:error, %Req.TransportError{reason: :econnrefused}} = result + + assert log =~ + "Failed to purge CDN cache for tracker script adf: %Req.TransportError{reason: :econnrefused}" + end + end + + describe "backoff/1" do + test "implements exponential backoff starting at 3 minutes" do + assert PurgeCDNCache.backoff(%Oban.Job{attempt: 1}) == 180 + assert PurgeCDNCache.backoff(%Oban.Job{attempt: 2}) == 360 + assert PurgeCDNCache.backoff(%Oban.Job{attempt: 3}) == 720 + assert PurgeCDNCache.backoff(%Oban.Job{attempt: 4}) == 1440 + assert PurgeCDNCache.backoff(%Oban.Job{attempt: 5}) == 2880 + end + end +end diff --git a/test/workers/schedule_email_reports_test.exs b/test/workers/schedule_email_reports_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..70f46ac7bd0c81c5e3cd14bedc72e3d76c0e5028 --- /dev/null +++ b/test/workers/schedule_email_reports_test.exs @@ -0,0 +1,136 @@ +defmodule Plausible.Workers.ScheduleEmailReportsTest do + use Plausible.DataCase + use Oban.Testing, repo: Plausible.Repo + + alias Plausible.Workers.{ScheduleEmailReports, SendEmailReport} + + describe "weekly reports" do + test "schedules weekly report on Monday 9am local timezone" do + site = new_site(domain: "test-site.com", timezone: "US/Eastern") + insert(:weekly_report, site: site, recipients: ["user@email.com"]) + + perform_job(ScheduleEmailReports, %{}) + + assert_enqueued( + worker: SendEmailReport, + args: %{site_id: site.id, interval: "weekly"}, + scheduled_at: ScheduleEmailReports.monday_9am(site.timezone) + ) + end + + on_ee do + test "schedules weekly report for a consolidated view" do + {:ok, team} = new_user() |> Plausible.Teams.get_or_create() + new_site(team: team) + new_site(team: team) + consolidated_view = new_consolidated_view(team) + + insert(:weekly_report, site: consolidated_view, recipients: ["user@email.com"]) + + perform_job(ScheduleEmailReports, %{}) + + assert_enqueued( + worker: SendEmailReport, + args: %{site_id: consolidated_view.id, interval: "weekly"}, + scheduled_at: ScheduleEmailReports.monday_9am(consolidated_view.timezone) + ) + end + end + + test "does not schedule more than one weekly report at a time" do + site = new_site(domain: "test-site.com", timezone: "US/Eastern") + insert(:weekly_report, site: site, recipients: ["user@email.com"]) + + perform_job(ScheduleEmailReports, %{}) + perform_job(ScheduleEmailReports, %{}) + + assert Enum.count(all_enqueued(worker: SendEmailReport)) == 1 + end + + test "does not schedule a weekly report for locked site" do + site = new_site(domain: "test-site.com", timezone: "US/Eastern") + site.team |> Ecto.Changeset.change(locked: true) |> Repo.update!() + insert(:weekly_report, site: site, recipients: ["user@email.com"]) + + perform_job(ScheduleEmailReports, %{}) + + assert Enum.empty?(all_enqueued(worker: SendEmailReport)) + end + + test "schedules a new report as soon as a previous one is completed" do + site = new_site(domain: "test-site.com", timezone: "US/Eastern") + insert(:weekly_report, site: site, recipients: ["user@email.com"]) + + perform_job(ScheduleEmailReports, %{}) + Repo.update_all("oban_jobs", set: [state: "completed"]) + assert Enum.empty?(all_enqueued(worker: SendEmailReport)) + perform_job(ScheduleEmailReports, %{}) + assert Enum.count(all_enqueued(worker: SendEmailReport)) == 1 + end + end + + describe "monthly_reports" do + test "schedules monthly report on first of the next month at 9am local timezone" do + site = new_site(domain: "test-site.com", timezone: "US/Eastern") + insert(:monthly_report, site: site, recipients: ["user@email.com"]) + + perform_job(ScheduleEmailReports, %{}) + + assert_enqueued( + worker: SendEmailReport, + args: %{site_id: site.id, interval: "monthly"}, + scheduled_at: ScheduleEmailReports.first_of_month_9am(site.timezone) + ) + end + + on_ee do + test "schedules monthly report for a consolidated view" do + {:ok, team} = new_user() |> Plausible.Teams.get_or_create() + new_site(team: team) + new_site(team: team) + consolidated_view = new_consolidated_view(team) + + insert(:monthly_report, site: consolidated_view, recipients: ["user@email.com"]) + + perform_job(ScheduleEmailReports, %{}) + + assert_enqueued( + worker: SendEmailReport, + args: %{site_id: consolidated_view.id, interval: "monthly"}, + scheduled_at: ScheduleEmailReports.first_of_month_9am(consolidated_view.timezone) + ) + end + end + + test "does not schedule more than one monthly report at a time" do + site = new_site(domain: "test-site.com", timezone: "US/Eastern") + insert(:monthly_report, site: site, recipients: ["user@email.com"]) + + perform_job(ScheduleEmailReports, %{}) + perform_job(ScheduleEmailReports, %{}) + + assert Enum.count(all_enqueued(worker: SendEmailReport)) == 1 + end + + test "does not schedule a monthly report for locked site" do + site = new_site(domain: "test-site.com", timezone: "US/Eastern") + site.team |> Ecto.Changeset.change(locked: true) |> Repo.update!() + insert(:monthly_report, site: site, recipients: ["user@email.com"]) + + perform_job(ScheduleEmailReports, %{}) + + assert Enum.empty?(all_enqueued(worker: SendEmailReport)) + end + + test "schedules a new report as soon as a previous one is completed" do + site = new_site(domain: "test-site.com", timezone: "US/Eastern") + insert(:monthly_report, site: site, recipients: ["user@email.com"]) + + perform_job(ScheduleEmailReports, %{}) + Repo.update_all("oban_jobs", set: [state: "completed"]) + assert Enum.empty?(all_enqueued(worker: SendEmailReport)) + perform_job(ScheduleEmailReports, %{}) + assert Enum.count(all_enqueued(worker: SendEmailReport)) == 1 + end + end +end diff --git a/test/workers/score_trial_prospects_test.exs b/test/workers/score_trial_prospects_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..496a26eeb3fdaafeffef71440a85fc706b6378aa --- /dev/null +++ b/test/workers/score_trial_prospects_test.exs @@ -0,0 +1,341 @@ +defmodule Plausible.Workers.ScoreTrialProspectsTest do + use Plausible.DataCase + @moduletag :ee_only + + on_ee do + use Oban.Testing, repo: Plausible.Repo + + alias Plausible.Workers.ScoreTrialProspects + alias Plausible.CustomerSupport.{TrialProspect, TrialProspects} + + describe "TrialProspects scoring (pure)" do + # No feature/site/member gate (`[], 1, 0`) keeps kind :starter so these + # assertions isolate the pageview ladder in `score`. + test "score maps the monthly estimate to the smallest pageview rung >= estimate" do + assert %{pageview_limit: 10_000, over_top_tier: false} = + TrialProspects.score(6_000, [], 1, 0) + + assert %{pageview_limit: 100_000, over_top_tier: false} = + TrialProspects.score(80_000, [], 1, 0) + + assert %{pageview_limit: 500_000, over_top_tier: false} = + TrialProspects.score(350_000, [], 1, 0) + + assert %{pageview_limit: 10_000_000, over_top_tier: false} = + TrialProspects.score(10_000_000, [], 1, 0) + + assert %{pageview_limit: nil, over_top_tier: true} = + TrialProspects.score(14_000_000, [], 1, 0) + end + + test "score escalates plan kind to the highest tier a used feature forces" do + assert %{kind: :starter, forced_by: []} = TrialProspects.score(6_000, [], 1, 0) + + assert %{kind: :starter, forced_by: []} = + TrialProspects.score(6_000, [Plausible.Billing.Feature.Goals], 1, 0) + + assert %{kind: :growth, forced_by: ["shared_links"]} = + TrialProspects.score(6_000, [Plausible.Billing.Feature.SharedLinks], 1, 0) + + assert %{kind: :business, forced_by: ["funnels", "props"]} = + TrialProspects.score( + 6_000, + [ + Plausible.Billing.Feature.Funnels, + Plausible.Billing.Feature.Props, + Plausible.Billing.Feature.SharedLinks + ], + 1, + 0 + ) + end + + test "score escalates plan kind on site count (plans_v5 site_limit)" do + # starter allows 1 site, growth 3, business 10 + assert %{kind: :starter, forced_by: []} = TrialProspects.score(6_000, [], 1, 0) + assert %{kind: :growth, forced_by: ["site_limit"]} = TrialProspects.score(6_000, [], 2, 0) + assert %{kind: :growth, forced_by: ["site_limit"]} = TrialProspects.score(6_000, [], 3, 0) + + assert %{kind: :business, forced_by: ["site_limit"]} = + TrialProspects.score(6_000, [], 4, 0) + + assert %{kind: :business, forced_by: ["site_limit"]} = + TrialProspects.score(6_000, [], 25, 0) + end + + test "score escalates plan kind on team member count (plans_v5 team_member_limit)" do + # starter is solo (0 extra members), growth allows 3, business 10 + assert %{kind: :starter, forced_by: []} = TrialProspects.score(6_000, [], 1, 0) + + assert %{kind: :growth, forced_by: ["team_member_limit"]} = + TrialProspects.score(6_000, [], 1, 1) + + assert %{kind: :growth, forced_by: ["team_member_limit"]} = + TrialProspects.score(6_000, [], 1, 3) + + assert %{kind: :business, forced_by: ["team_member_limit"]} = + TrialProspects.score(6_000, [], 1, 4) + end + + test "score takes the highest tier across features, sites and members" do + # a growth feature but a business-sized site count -> business; no business + # feature forced it, so only the site limit is listed + assert %{kind: :business, forced_by: ["site_limit"]} = + TrialProspects.score(6_000, [Plausible.Billing.Feature.SharedLinks], 5, 0) + + # a business feature with tiny site/member counts still wins on features + assert %{kind: :business, forced_by: ["funnels"]} = + TrialProspects.score(6_000, [Plausible.Billing.Feature.Funnels], 1, 0) + + # both a business feature and the site count force business -> both listed + assert %{kind: :business, forced_by: ["funnels", "site_limit"]} = + TrialProspects.score(6_000, [Plausible.Billing.Feature.Funnels], 4, 0) + end + + test "score prices the estimate against the plan kind, nil over the top tier" do + # starter @ 10k rung + assert %{kind: :starter, estimated_mrr: 9} = TrialProspects.score(6_000, [], 1, 0) + + # growth @ 2M rung (estimate lands between the 1M and 2M rungs) + assert %{kind: :growth, estimated_mrr: 134} = + TrialProspects.score(1_500_000, [Plausible.Billing.Feature.SharedLinks], 1, 0) + + # business @ 10M rung + assert %{kind: :business, estimated_mrr: 339} = + TrialProspects.score(8_000_000, [Plausible.Billing.Feature.Funnels], 1, 0) + + # over the top tier -> no price + assert %{over_top_tier: true, estimated_mrr: nil} = + TrialProspects.score(14_000_000, [Plausible.Billing.Feature.Funnels], 1, 0) + end + + test "score combines feature usage and estimate" do + assert %{ + kind: :business, + forced_by: ["funnels"], + pageview_limit: 500_000, + over_top_tier: false, + estimated_mrr: 99 + } = TrialProspects.score(350_000, [Plausible.Billing.Feature.Funnels], 1, 0) + end + end + + describe "worker" do + test "scores a trial team with traffic" do + user = new_user(trial_expiry_date: Date.add(Date.utc_today(), 7)) + site = new_site(owner: user) + populate_stats(site, pageviews_on(Date.add(Date.utc_today(), -10), 10)) + + assert :ok = perform_job(ScoreTrialProspects, %{}) + + prospect = Repo.get_by!(TrialProspect, team_id: team_of(user).id) + assert prospect.observed_days == 10 + assert prospect.estimated_monthly == 30 + assert prospect.first_data_day == Date.add(Date.utc_today(), -10) + assert prospect.kind == :starter + assert prospect.forced_by == [] + assert prospect.pageview_limit == 10_000 + assert prospect.over_top_tier == false + assert prospect.estimated_mrr == 9 + end + + test "estimate spans from earliest traffic day across multiple days" do + user = new_user(trial_expiry_date: Date.add(Date.utc_today(), 7)) + site = new_site(owner: user) + # earliest traffic 10 days ago, more 3 days ago -> observed over 10 days + populate_stats( + site, + pageviews_on(Date.add(Date.utc_today(), -10), 4) ++ + pageviews_on(Date.add(Date.utc_today(), -3), 6) + ) + + assert :ok = perform_job(ScoreTrialProspects, %{}) + + prospect = Repo.get_by!(TrialProspect, team_id: team_of(user).id) + assert prospect.first_data_day == Date.add(Date.utc_today(), -10) + assert prospect.observed_days == 10 + # 10 events / 10 days * 30.4 + assert prospect.estimated_monthly == 30 + end + + test "premium feature usage forces a higher plan kind and price" do + user = new_user(trial_expiry_date: Date.add(Date.utc_today(), 7)) + site = new_site(owner: user, allowed_event_props: ["author"]) + populate_stats(site, pageviews_on(Date.add(Date.utc_today(), -10), 10)) + + assert :ok = perform_job(ScoreTrialProspects, %{}) + + prospect = Repo.get_by!(TrialProspect, team_id: team_of(user).id) + assert prospect.kind == :business + assert prospect.forced_by == ["props"] + assert prospect.estimated_mrr == 19 + end + + test "site count alone forces a higher plan kind" do + user = new_user(trial_expiry_date: Date.add(Date.utc_today(), 7)) + # 4 owned sites, no premium features -> over growth's 3-site limit -> business + site = new_site(owner: user) + for _ <- 1..3, do: new_site(owner: user) + populate_stats(site, pageviews_on(Date.add(Date.utc_today(), -10), 10)) + + assert :ok = perform_job(ScoreTrialProspects, %{}) + + prospect = Repo.get_by!(TrialProspect, team_id: team_of(user).id) + assert prospect.kind == :business + assert prospect.forced_by == ["site_limit"] + assert prospect.estimated_mrr == 19 + end + + test "team member count alone forces a higher plan kind" do + user = new_user(trial_expiry_date: Date.add(Date.utc_today(), 7)) + site = new_site(owner: user) + # one extra member -> over starter's solo (0-member) limit -> growth + add_member(team_of(user), role: :viewer) + populate_stats(site, pageviews_on(Date.add(Date.utc_today(), -10), 10)) + + assert :ok = perform_job(ScoreTrialProspects, %{}) + + prospect = Repo.get_by!(TrialProspect, team_id: team_of(user).id) + assert prospect.kind == :growth + assert prospect.forced_by == ["team_member_limit"] + assert prospect.estimated_mrr == 14 + end + + test "does not score a team with no complete day of traffic" do + user = new_user(trial_expiry_date: Date.add(Date.utc_today(), 7)) + site = new_site(owner: user) + # only today (partial, excluded) + populate_stats(site, pageviews_on(Date.utc_today(), 5)) + + assert :ok = perform_job(ScoreTrialProspects, %{}) + + assert Repo.get_by(TrialProspect, team_id: team_of(user).id) == nil + end + + test "does not score teams with a subscription" do + user = new_user(trial_expiry_date: Date.add(Date.utc_today(), 7)) + site = new_site(owner: user) + subscribe_to_growth_plan(user) + populate_stats(site, pageviews_on(Date.add(Date.utc_today(), -10), 10)) + + assert :ok = perform_job(ScoreTrialProspects, %{}) + + assert Repo.get_by(TrialProspect, team_id: team_of(user).id) == nil + end + + test "leaves rows for teams no longer in the population untouched" do + # eligible team + user = new_user(trial_expiry_date: Date.add(Date.utc_today(), 7)) + site = new_site(owner: user) + populate_stats(site, pageviews_on(Date.add(Date.utc_today(), -10), 10)) + + # a trial that expired beyond the window, with a leftover row that is no + # longer part of the population -> must be preserved, not deleted + old_user = new_user(trial_expiry_date: Date.add(Date.utc_today(), -90)) + old_team = team_of(old_user) + + %TrialProspect{} + |> Ecto.Changeset.change( + team_id: old_team.id, + estimated_monthly: 100, + observed_days: 5, + first_data_day: Date.utc_today(), + kind: :starter, + computed_at: DateTime.utc_now() |> DateTime.truncate(:second) + ) + |> Repo.insert!() + + assert :ok = perform_job(ScoreTrialProspects, %{}) + + assert Repo.get_by(TrialProspect, team_id: old_team.id) + assert Repo.get_by(TrialProspect, team_id: team_of(user).id) + end + + test "upserts on re-run rather than duplicating" do + user = new_user(trial_expiry_date: Date.add(Date.utc_today(), 7)) + site = new_site(owner: user) + populate_stats(site, pageviews_on(Date.add(Date.utc_today(), -10), 10)) + + assert :ok = perform_job(ScoreTrialProspects, %{}) + assert :ok = perform_job(ScoreTrialProspects, %{}) + + assert [_only_one] = + Repo.all(from p in TrialProspect, where: p.team_id == ^team_of(user).id) + end + + test "scores a recently expired trial that still has in-window traffic" do + # trial ended 2 days ago -> within the 60-day population window (spec §2) + user = new_user(trial_expiry_date: Date.add(Date.utc_today(), -2)) + site = new_site(owner: user) + populate_stats(site, pageviews_on(Date.add(Date.utc_today(), -5), 10)) + + assert :ok = perform_job(ScoreTrialProspects, %{}) + + prospect = Repo.get_by!(TrialProspect, team_id: team_of(user).id) + assert prospect.observed_days == 5 + # 10 events / 5 days * 30 -> 60 + assert prospect.estimated_monthly == 60 + assert prospect.kind == :starter + assert prospect.estimated_mrr == 9 + end + + test "counts pageviews and custom events but excludes engagement" do + user = new_user(trial_expiry_date: Date.add(Date.utc_today(), 7)) + site = new_site(owner: user) + # single complete day -> observed_days = 1 + t = NaiveDateTime.new!(Date.add(Date.utc_today(), -1), ~T[12:00:00]) + + # engagement events need a preceding pageview (shared user_id) for a session + events = + [ + build(:pageview, user_id: 1, timestamp: t), + build(:pageview, user_id: 2, timestamp: t), + build(:pageview, user_id: 3, timestamp: t), + build(:pageview, user_id: 4, timestamp: t), + build(:engagement, user_id: 1, timestamp: t, engagement_time: 5000), + build(:engagement, user_id: 2, timestamp: t, engagement_time: 5000) + ] ++ for(_ <- 1..6, do: build(:event, name: "Signup", user_id: 5, timestamp: t)) + + populate_stats(site, events) + + assert :ok = perform_job(ScoreTrialProspects, %{}) + + prospect = Repo.get_by!(TrialProspect, team_id: team_of(user).id) + assert prospect.observed_days == 1 + # billable total = 4 pageviews + 6 custom events; the engagements are + # excluded. 10 events * 30 -> 300 (would be higher if engagement counted). + assert prospect.estimated_monthly == 300 + end + + test "ignores traffic older than the sampling window and caps observed_days at 30" do + user = new_user(trial_expiry_date: Date.add(Date.utc_today(), 7)) + site = new_site(owner: user) + + populate_stats( + site, + # 40 days ago: before the 30-day window -> must be ignored entirely + # window floor (30 complete days ago): sets first_data_day, observed_days = 30 + pageviews_on(Date.add(Date.utc_today(), -40), 1000) ++ + pageviews_on(Date.add(Date.utc_today(), -30), 1) ++ + pageviews_on(Date.add(Date.utc_today(), -1), 59) + ) + + assert :ok = perform_job(ScoreTrialProspects, %{}) + + prospect = Repo.get_by!(TrialProspect, team_id: team_of(user).id) + # earliest day *within* the window, not the 40-day-old batch + assert prospect.first_data_day == Date.add(Date.utc_today(), -30) + # clamped to the window length + assert prospect.observed_days == 30 + # only the 60 in-window events count (1 + 59); 60 / 30 * 30 -> 60. + # Had the 40-day-old batch leaked in it would be ~1074. + assert prospect.estimated_monthly == 60 + end + end + + defp pageviews_on(date, count) do + timestamp = NaiveDateTime.new!(date, ~T[12:00:00]) + for _ <- 1..count, do: build(:pageview, timestamp: timestamp) + end + end +end diff --git a/test/workers/send_check_stats_emails_test.exs b/test/workers/send_check_stats_emails_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..dc4b784eb25b239cec5f93bce639def133523e7c --- /dev/null +++ b/test/workers/send_check_stats_emails_test.exs @@ -0,0 +1,74 @@ +defmodule Plausible.Workers.SendCheckStatsEmailsTest do + use Plausible.DataCase, async: true + use Oban.Testing, repo: Plausible.Repo + use Bamboo.Test + + alias Plausible.Workers.SendCheckStatsEmails + + test "does not send an email before a week has passed" do + user = new_user(inserted_at: days_ago(6), last_seen: days_ago(6)) + new_site(domain: "test-site.com", owner: user) + + perform_job(SendCheckStatsEmails, %{}) + + assert_no_emails_delivered() + end + + test "does not send an email if the user has logged in recently" do + user = new_user(inserted_at: days_ago(9), last_seen: days_ago(6)) + new_site(domain: "test-site.com", owner: user) + + perform_job(SendCheckStatsEmails, %{}) + + assert_no_emails_delivered() + end + + test "does not send an email if the user has configured a weekly report" do + user = new_user(inserted_at: days_ago(9), last_seen: days_ago(7)) + site = new_site(domain: "test-site.com", owner: user) + + populate_stats(site, [build(:pageview)]) + insert(:weekly_report, site: site, recipients: ["user@email.com"]) + + perform_job(SendCheckStatsEmails, %{}) + + assert_no_emails_delivered() + end + + test "sends an email after a week of signup if the user hasn't logged in" do + user = new_user(inserted_at: days_ago(8), last_seen: days_ago(8)) + site = new_site(domain: "test-site.com", owner: user) + populate_stats(site, [build(:pageview)]) + + perform_job(SendCheckStatsEmails, %{}) + + assert_email_delivered_with( + to: [{user.name, user.email}], + subject: "How Plausible is different" + ) + end + + test "team members idling will get the email" do + user = new_user(last_seen: NaiveDateTime.utc_now(:second), inserted_at: days_ago(8)) + site = new_site(owner: user) + populate_stats(site, [build(:pageview)]) + + user2 = + add_member(team_of(user), + user: new_user(inserted_at: days_ago(8), last_seen: days_ago(8)), + role: :editor + ) + + perform_job(SendCheckStatsEmails, %{}) + + assert_email_delivered_with( + to: [{user2.name, user2.email}], + subject: "How Plausible is different" + ) + end + + defp days_ago(days) do + NaiveDateTime.utc_now(:second) + |> NaiveDateTime.shift(day: -days) + end +end diff --git a/test/workers/send_email_report_test.exs b/test/workers/send_email_report_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..9d79b3c86f3e89f5368fa7e294402f4e160897d4 --- /dev/null +++ b/test/workers/send_email_report_test.exs @@ -0,0 +1,582 @@ +defmodule Plausible.Workers.SendEmailReportTest do + use Plausible.DataCase + use Bamboo.Test + use Oban.Testing, repo: Plausible.Repo + + alias Plausible.Workers.SendEmailReport + + @green "#15803d" + @red "#b91c1c" + + describe "weekly reports" do + test "sends weekly report to all recipients" do + site = new_site(domain: "test-site.com", timezone: "US/Eastern") + insert(:weekly_report, site: site, recipients: ["user@email.com", "user2@email.com"]) + + perform_job(SendEmailReport, %{"site_id" => site.id, "interval" => "weekly"}) + + assert_email_delivered_with( + subject: "Weekly report for #{site.domain}", + to: [nil: "user@email.com"] + ) + + assert_email_delivered_with( + subject: "Weekly report for #{site.domain}", + to: [nil: "user2@email.com"] + ) + end + + test "does not crash for deleted sites" do + assert :discard = + perform_job(SendEmailReport, %{"site_id" => 28_378_237, "interval" => "weekly"}) + end + + test "does not crash when weekly report has been deleted since scheduling job" do + site = new_site(domain: "test-site.com", timezone: "US/Eastern") + + assert :discard = + perform_job(SendEmailReport, %{"site_id" => site.id, "interval" => "weekly"}) + end + + test "calculates timezone correctly" do + site = + new_site(timezone: "US/Eastern") + + insert(:weekly_report, site: site, recipients: ["user@email.com"]) + + now = DateTime.now!(site.timezone) + last_monday = DateTime.shift(now, week: -1) |> Plausible.Times.beginning_of_week() + last_sunday = DateTime.shift(now, week: -1) |> Plausible.Times.end_of_week() + sunday_before_last = DateTime.shift(last_monday, minute: -1) + this_monday = Plausible.Times.beginning_of_week(now) + + populate_stats(site, [ + # Sunday before last, not counted + build(:pageview, timestamp: DateTime.shift_zone!(sunday_before_last, "UTC")), + # Sunday before last, not counted + build(:pageview, timestamp: DateTime.shift_zone!(sunday_before_last, "UTC")), + # Last monday, counted + build(:pageview, timestamp: DateTime.shift_zone!(last_monday, "UTC")), + # Last sunday, counted + build(:pageview, timestamp: DateTime.shift_zone!(last_sunday, "UTC")), + # This monday, not counted + build(:pageview, timestamp: DateTime.shift_zone!(this_monday, "UTC")), + # This monday, not counted + build(:pageview, timestamp: DateTime.shift_zone!(this_monday, "UTC")) + ]) + + perform_job(SendEmailReport, %{"site_id" => site.id, "interval" => "weekly"}) + + assert_delivered_email_matches(%{ + to: [nil: "user@email.com"], + html_body: html_body + }) + + # Should find 2 visiors + assert text_of_element(html_body, ".page-count") == "2" + end + + test "includes the correct stats" do + now = NaiveDateTime.utc_now(:second) + site = new_site(domain: "test-site.com", inserted_at: NaiveDateTime.shift(now, day: -8)) + insert(:weekly_report, site: site, recipients: ["user@email.com"]) + + populate_stats(site, [ + build(:pageview, + user_id: 123, + timestamp: NaiveDateTime.shift(now, day: -7), + referrer_source: "Google" + ), + build(:pageview, user_id: 123, timestamp: NaiveDateTime.shift(now, day: -7)), + build(:pageview, timestamp: NaiveDateTime.shift(now, day: -7)) + ]) + + perform_job(SendEmailReport, %{"site_id" => site.id, "interval" => "weekly"}) + + assert_delivered_email_matches(%{ + to: [nil: "user@email.com"], + html_body: html_body + }) + + assert text_of_element(html_body, ".visitors") == "2" + assert text_of_element(html_body, ".pageviews") == "3" + assert text_of_element(html_body, ".referrer-name") == "Google" + assert text_of_element(html_body, ".referrer-count") == "1" + assert text_of_element(html_body, ".page-name") == "/" + assert text_of_element(html_body, ".page-count") == "2" + end + + on_ee do + test "includes the correct stats for consolidated view" do + now = NaiveDateTime.utc_now(:second) + {:ok, team} = new_user() |> Plausible.Teams.get_or_create() + + site1 = + new_site( + domain: "testsite1.com", + team: team, + inserted_at: NaiveDateTime.shift(now, day: -8) + ) + + site2 = + new_site( + domain: "testsite2.com", + team: team, + inserted_at: NaiveDateTime.shift(now, day: -8) + ) + + consolidated_view = new_consolidated_view(team) + + insert(:weekly_report, site: consolidated_view, recipients: ["user@email.com"]) + + populate_stats(site1, [ + build(:pageview, + user_id: 123, + timestamp: NaiveDateTime.shift(now, day: -7), + referrer_source: "Google" + ), + build(:pageview, user_id: 123, timestamp: NaiveDateTime.shift(now, day: -7)) + ]) + + populate_stats(site2, [ + build(:pageview, timestamp: NaiveDateTime.shift(now, day: -7)) + ]) + + perform_job(SendEmailReport, %{"site_id" => consolidated_view.id, "interval" => "weekly"}) + + assert_delivered_email_matches(%{ + to: [nil: "user@email.com"], + html_body: html_body + }) + + assert html_body =~ "Consolidated view" + assert text_of_element(html_body, ".visitors") == "2" + assert text_of_element(html_body, ".pageviews") == "3" + assert text_of_element(html_body, ".referrer-name") == "Google" + assert text_of_element(html_body, ".referrer-count") == "1" + assert text_of_element(html_body, ".page-name") == "/" + assert text_of_element(html_body, ".page-count") == "2" + end + + test "does not send weekly report for consolidated view with ok_to_display? false" do + {:ok, team} = new_user() |> Plausible.Teams.get_or_create() + + site = new_site(team: team) + new_site(team: team) + + consolidated_view = new_consolidated_view(team) + + insert(:weekly_report, site: consolidated_view, recipients: ["user@email.com"]) + + Plausible.Repo.delete!(site) + + refute Plausible.ConsolidatedView.ok_to_display?(team) + + perform_job(SendEmailReport, %{"site_id" => consolidated_view.id, "interval" => "weekly"}) + + assert_no_emails_delivered() + end + end + + test "renders correct signs (+/-) and trend colors for positive percentage changes" do + now = NaiveDateTime.utc_now(:second) + week_ago = now |> NaiveDateTime.shift(day: -7) + two_weeks_ago = now |> NaiveDateTime.shift(day: -14) + + site = new_site(inserted_at: NaiveDateTime.shift(now, day: -15)) + insert(:weekly_report, site: site, recipients: ["user@email.com"]) + + populate_stats(site, [ + build(:pageview, timestamp: two_weeks_ago), + build(:pageview, user_id: 1, timestamp: week_ago), + build(:pageview, user_id: 2, timestamp: week_ago), + build(:pageview, user_id: 2, timestamp: week_ago) + ]) + + perform_job(SendEmailReport, %{"site_id" => site.id, "interval" => "weekly"}) + + assert_delivered_email_matches(%{ + to: [nil: "user@email.com"], + html_body: html_body + }) + + visitors_change_container = find(html_body, ".change-visitors div") + assert text(visitors_change_container) == "+100%" + assert text_of_attr(visitors_change_container, "style") =~ @green + + pageviews_change_container = find(html_body, ".change-pageviews div") + assert text(pageviews_change_container) == "+200%" + assert text_of_attr(pageviews_change_container, "style") =~ @green + + bounce_rate_change_container = find(html_body, ".change-bounce-rate div") + assert text(bounce_rate_change_container) == "-50%" + assert text_of_attr(bounce_rate_change_container, "style") =~ @green + end + + test "renders correct signs (+/-) and trend colors for negative percentage changes" do + now = NaiveDateTime.utc_now(:second) + week_ago = now |> NaiveDateTime.shift(day: -7) + two_weeks_ago = now |> NaiveDateTime.shift(day: -14) + + site = new_site(inserted_at: NaiveDateTime.shift(now, day: -15)) + insert(:weekly_report, site: site, recipients: ["user@email.com"]) + + populate_stats(site, [ + build(:pageview, user_id: 1, timestamp: two_weeks_ago), + build(:pageview, user_id: 2, timestamp: two_weeks_ago), + build(:pageview, user_id: 2, timestamp: two_weeks_ago), + build(:pageview, timestamp: week_ago) + ]) + + perform_job(SendEmailReport, %{"site_id" => site.id, "interval" => "weekly"}) + + assert_delivered_email_matches(%{ + to: [nil: "user@email.com"], + html_body: html_body + }) + + visitors_change_container = find(html_body, ".change-visitors div") + assert text(visitors_change_container) == "-50%" + assert text_of_attr(visitors_change_container, "style") =~ @red + + pageviews_change_container = find(html_body, ".change-pageviews div") + assert text(pageviews_change_container) == "-67%" + assert text_of_attr(pageviews_change_container, "style") =~ @red + + bounce_rate_change_container = find(html_body, ".change-bounce-rate div") + assert text(bounce_rate_change_container) == "+50%" + assert text_of_attr(bounce_rate_change_container, "style") =~ @red + end + + test "renders 0% changes with a green color and without a sign" do + now = NaiveDateTime.utc_now(:second) + week_ago = now |> NaiveDateTime.shift(day: -7) + two_weeks_ago = now |> NaiveDateTime.shift(day: -14) + + site = new_site(inserted_at: NaiveDateTime.shift(now, day: -15)) + insert(:weekly_report, site: site, recipients: ["user@email.com"]) + + populate_stats(site, [ + build(:pageview, timestamp: two_weeks_ago), + build(:pageview, timestamp: week_ago) + ]) + + perform_job(SendEmailReport, %{"site_id" => site.id, "interval" => "weekly"}) + + assert_delivered_email_matches(%{ + to: [nil: "user@email.com"], + html_body: html_body + }) + + visitors_change_container = find(html_body, ".change-visitors div") + assert text(visitors_change_container) == "0%" + assert text_of_attr(visitors_change_container, "style") =~ @green + + pageviews_change_container = find(html_body, ".change-pageviews div") + assert text(pageviews_change_container) == "0%" + assert text_of_attr(pageviews_change_container, "style") =~ @green + + bounce_rate_change_container = find(html_body, ".change-bounce-rate div") + assert text(bounce_rate_change_container) == "0%" + assert text_of_attr(bounce_rate_change_container, "style") =~ @green + end + + test "includes goal conversions when goals exist" do + last_monday = + NaiveDateTime.utc_now(:second) + |> NaiveDateTime.shift(day: -7) + |> Plausible.Times.beginning_of_week() + + site = + new_site(domain: "test-site.com", inserted_at: NaiveDateTime.shift(last_monday, day: -1)) + + insert(:weekly_report, site: site, recipients: ["user@email.com"]) + + _goal1 = insert(:goal, site: site, event_name: "Signup") + _goal2 = insert(:goal, site: site, event_name: "Purchase") + _goal3 = insert(:goal, site: site, page_path: "/thank-you") + + populate_stats(site, [ + build(:pageview, + user_id: 123, + timestamp: last_monday + ), + build(:pageview, + user_id: 124, + timestamp: NaiveDateTime.shift(last_monday, day: 1) + ), + build(:event, + user_id: 123, + name: "Signup", + timestamp: last_monday + ), + build(:event, + user_id: 124, + name: "Signup", + timestamp: NaiveDateTime.shift(last_monday, day: 1) + ), + build(:event, + user_id: 125, + name: "Purchase", + timestamp: NaiveDateTime.shift(last_monday, day: 2) + ), + build(:pageview, + user_id: 126, + pathname: "/thank-you", + timestamp: NaiveDateTime.shift(last_monday, day: 3) + ) + ]) + + perform_job(SendEmailReport, %{"site_id" => site.id, "interval" => "weekly"}) + + assert_delivered_email_matches(%{ + to: [nil: "user@email.com"], + html_body: html_body + }) + + goal_names = find(html_body, ".goal-name") |> Enum.map(&text/1) + goal_conversions = find(html_body, ".goal-conversions") |> Enum.map(&text/1) + + assert goal_names == ["Signup", "Purchase", "Visit /thank-you"] + assert goal_conversions == ["2", "1", "1"] + end + end + + describe "monthly_reports" do + test "sends monthly report to all recipients" do + site = new_site(domain: "test-site.com", timezone: "US/Eastern") + insert(:monthly_report, site: site, recipients: ["user@email.com", "user2@email.com"]) + + last_month = + DateTime.now!(site.timezone) + |> DateTime.shift(month: -1) + |> Plausible.Times.beginning_of_month() + |> Calendar.strftime("%B") + + perform_job(SendEmailReport, %{"site_id" => site.id, "interval" => "monthly"}) + + assert_email_delivered_with( + subject: "#{last_month} report for #{site.domain}", + to: [nil: "user@email.com"] + ) + + assert_email_delivered_with( + subject: "#{last_month} report for #{site.domain}", + to: [nil: "user2@email.com"] + ) + end + + test "does not crash when monthly report has been deleted since scheduling job" do + site = new_site(domain: "test-site.com", timezone: "US/Eastern") + + assert :discard = + perform_job(SendEmailReport, %{"site_id" => site.id, "interval" => "monthly"}) + end + + test "calculates timezone correctly" do + site = + new_site(timezone: "US/Eastern") + + insert(:monthly_report, site: site, recipients: ["user@email.com"]) + + now = DateTime.now!(site.timezone) + last_month_first = DateTime.shift(now, month: -1) |> Plausible.Times.beginning_of_month() + last_month_last = DateTime.shift(now, month: -1) |> Plausible.Times.end_of_month() + month_before_last = DateTime.shift(last_month_first, minute: -1) + this_month_first = Plausible.Times.beginning_of_month(now) + + populate_stats(site, [ + # Month before last, not counted + build(:pageview, + timestamp: DateTime.shift_zone!(month_before_last, "UTC") + ), + # Month before last, not counted + build(:pageview, + timestamp: DateTime.shift_zone!(month_before_last, "UTC") + ), + # Last month first day, counted + build(:pageview, + user_id: 123, + timestamp: DateTime.shift_zone!(last_month_first, "UTC") + ), + # Last month last day, counted + build(:pageview, + user_id: 124, + timestamp: DateTime.shift_zone!(last_month_last, "UTC") + ), + # This month first day, not counted + build(:pageview, + timestamp: DateTime.shift_zone!(this_month_first, "UTC") + ), + # This month first day, not counted + build(:pageview, + timestamp: DateTime.shift_zone!(this_month_first, "UTC") + ) + ]) + + perform_job(SendEmailReport, %{"site_id" => site.id, "interval" => "monthly"}) + + assert_delivered_email_matches(%{ + to: [nil: "user@email.com"], + html_body: html_body + }) + + # Should find 2 visitors + assert text_of_element(html_body, ".page-count") == "2" + end + + test "limits all stats sections to 5 entries" do + now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) + site = new_site(domain: "test-site.com", inserted_at: Timex.shift(now, months: -2)) + insert(:monthly_report, site: site, recipients: ["user@email.com"]) + + for i <- 1..6, do: insert(:goal, site: site, event_name: "Goal#{i}") + + last_month_stats = + for i <- 1..6 do + [ + build(:pageview, + user_id: 100 + i, + pathname: "/page#{i}", + referrer_source: if(i == 6, do: "Direct / None", else: "Source#{i}"), + timestamp: Date.shift(now, month: -1) + ), + build(:event, + user_id: 100 + i, + name: "Goal#{i}", + timestamp: Date.shift(now, month: -1) + ) + ] + end + |> List.flatten() + + populate_stats(site, last_month_stats) + + perform_job(SendEmailReport, %{"site_id" => site.id, "interval" => "monthly"}) + + assert_delivered_email_matches(%{ + to: [nil: "user@email.com"], + html_body: html_body + }) + + assert elem_count(html_body, ".page-name") == 5 + assert elem_count(html_body, ".referrer-name") == 5 + assert elem_count(html_body, ".goal-name") == 5 + end + + test "email subject includes month name" do + site = new_site(domain: "test-site.com", timezone: "US/Eastern") + insert(:monthly_report, site: site, recipients: ["user@email.com"]) + + last_month = + site.timezone + |> DateTime.now!() + |> Date.shift(month: -1) + |> Date.beginning_of_month() + |> Calendar.strftime("%B") + + perform_job(SendEmailReport, %{"site_id" => site.id, "interval" => "monthly"}) + + assert_email_delivered_with( + subject: "#{last_month} report for #{site.domain}", + to: [nil: "user@email.com"] + ) + end + + on_ee do + test "includes goal conversions when goals exist on a consolidate view" do + beginning_of_last_month = + Date.utc_today() + |> Date.shift(month: -1) + |> Date.beginning_of_month() + |> NaiveDateTime.new!(~T[00:00:00]) + + {:ok, team} = new_user() |> Plausible.Teams.get_or_create() + + [site1, site2] = + for _ <- 1..2 do + new_site( + team: team, + inserted_at: NaiveDateTime.shift(beginning_of_last_month, day: -1) + ) + end + + consolidated_view = new_consolidated_view(team) + + insert(:monthly_report, site: consolidated_view, recipients: ["user@email.com"]) + + _goal1 = insert(:goal, site: consolidated_view, event_name: "Signup") + _goal2 = insert(:goal, site: consolidated_view, event_name: "Purchase") + _goal3 = insert(:goal, site: consolidated_view, page_path: "/thank-you") + + populate_stats(site1, [ + build(:pageview, + user_id: 123, + timestamp: beginning_of_last_month + ), + build(:event, + user_id: 123, + name: "Signup", + timestamp: beginning_of_last_month + ), + build(:event, + user_id: 125, + name: "Purchase", + timestamp: NaiveDateTime.shift(beginning_of_last_month, day: 2) + ) + ]) + + populate_stats(site2, [ + build(:pageview, + user_id: 124, + timestamp: NaiveDateTime.shift(beginning_of_last_month, day: 1) + ), + build(:event, + user_id: 124, + name: "Signup", + timestamp: NaiveDateTime.shift(beginning_of_last_month, day: 1) + ), + build(:pageview, + user_id: 126, + pathname: "/thank-you", + timestamp: NaiveDateTime.shift(beginning_of_last_month, day: 3) + ) + ]) + + perform_job(SendEmailReport, %{"site_id" => consolidated_view.id, "interval" => "monthly"}) + + assert_delivered_email_matches(%{ + to: [nil: "user@email.com"], + html_body: html_body + }) + + assert html_body =~ "Consolidated view" + + goal_names = find(html_body, ".goal-name") |> Enum.map(&text/1) + goal_conversions = find(html_body, ".goal-conversions") |> Enum.map(&text/1) + + assert goal_names == ["Signup", "Purchase", "Visit /thank-you"] + assert goal_conversions == ["2", "1", "1"] + end + + test "does not send monthly report for consolidated view with ok_to_display? false" do + {:ok, team} = new_user() |> Plausible.Teams.get_or_create() + + site = new_site(team: team) + new_site(team: team) + + consolidated_view = new_consolidated_view(team) + + insert(:monthly_report, site: consolidated_view, recipients: ["user@email.com"]) + + Plausible.Repo.delete!(site) + + refute Plausible.ConsolidatedView.ok_to_display?(site.team) + + perform_job(SendEmailReport, %{"site_id" => consolidated_view.id, "interval" => "monthly"}) + + assert_no_emails_delivered() + end + end + end +end diff --git a/test/workers/send_site_setup_emails_test.exs b/test/workers/send_site_setup_emails_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..530a67efa616b475aea8ae53f4b87dee54eec5be --- /dev/null +++ b/test/workers/send_site_setup_emails_test.exs @@ -0,0 +1,136 @@ +defmodule Plausible.Workers.SendSiteSetupEmailsTest do + use Plausible.DataCase, async: true + use Bamboo.Test + use Oban.Testing, repo: Plausible.Repo + + alias Plausible.Workers.SendSiteSetupEmails + + describe "when user has not managed to set up the site" do + test "does not send an email 47 hours after site creation" do + user = new_user() + new_site(owner: user, inserted_at: hours_ago(47)) + + perform_job(SendSiteSetupEmails, %{}) + + assert_no_emails_delivered() + end + + test "sends a setup help email 48 hours after site has been created" do + user = new_user() + new_site(owner: user, inserted_at: hours_ago(49)) + + perform_job(SendSiteSetupEmails, %{}) + + assert_email_delivered_with( + to: [{user.name, user.email}], + subject: "No traffic recorded yet" + ) + end + + test "does not send an email more than 72 hours after signup" do + user = new_user() + new_site(owner: user, inserted_at: hours_ago(73)) + + perform_job(SendSiteSetupEmails, %{}) + + assert_no_emails_delivered() + end + end + + describe "when user has managed to set up their site" do + test "sends the setup completed email as soon as possible" do + user = new_user() + site = new_site(owner: user) + + populate_stats(site, [build(:pageview)]) + + perform_job(SendSiteSetupEmails, %{}) + + assert_email_delivered_with( + to: [{user.name, user.email}], + subject: "Your first visitor just showed up" + ) + end + + test "sends the setup completed email after the help email has been sent" do + user = new_user() + site = new_site(owner: user, inserted_at: hours_ago(49)) + + perform_job(SendSiteSetupEmails, %{}) + + assert_email_delivered_with( + to: [{user.name, user.email}], + subject: "No traffic recorded yet" + ) + + populate_stats(site, [ + build(:pageview) + ]) + + perform_job(SendSiteSetupEmails, %{}) + + assert_email_delivered_with( + to: [{user.name, user.email}], + subject: "Your first visitor just showed up" + ) + end + + test "do not send the setup help email for consolidated site" do + user = new_user() + site = new_site(owner: user) + + populate_stats(site, [build(:pageview)]) + + perform_job(SendSiteSetupEmails, %{}) + + assert_email_delivered_with( + to: [{user.name, user.email}], + subject: "Your first visitor just showed up" + ) + + new_site(owner: user, consolidated: true, inserted_at: hours_ago(49)) + + perform_job(SendSiteSetupEmails, %{}) + + assert_no_emails_delivered() + end + + test "do not send the setup success email for consolidated website" do + user = new_user() + + # for test purposes, set stats_start_date for a consolidated site + new_site(owner: user, consolidated: true, stats_start_date: Date.utc_today()) + + perform_job(SendSiteSetupEmails, %{}) + + assert_no_emails_delivered() + end + end + + describe "trial user who has not set up a website" do + test "does not send an email before 48h have passed" do + new_user(inserted_at: hours_ago(47)) + + perform_job(SendSiteSetupEmails, %{}) + + assert_no_emails_delivered() + end + + test "sends the create site email after 48h" do + user = new_user(inserted_at: hours_ago(49)) + + perform_job(SendSiteSetupEmails, %{}) + + assert_email_delivered_with( + to: [{user.name, user.email}], + subject: "Add your first site to start tracking" + ) + end + end + + defp hours_ago(hours) do + NaiveDateTime.utc_now() + |> NaiveDateTime.truncate(:second) + |> NaiveDateTime.shift(hour: -hours) + end +end diff --git a/test/workers/send_trial_notifications_test.exs b/test/workers/send_trial_notifications_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..e408be147c6b1ff38272e95131873c70dcd4d2d5 --- /dev/null +++ b/test/workers/send_trial_notifications_test.exs @@ -0,0 +1,300 @@ +defmodule Plausible.Workers.SendTrialNotificationsTest do + use Plausible.DataCase + use Bamboo.Test + use Oban.Testing, repo: Plausible.Repo + alias Plausible.Workers.SendTrialNotifications + + test "does not send a notification if user didn't create a site" do + new_user(trial_expiry_date: Date.utc_today() |> Date.shift(day: 7)) + new_user(trial_expiry_date: Date.utc_today() |> Date.shift(day: 1)) + new_user(trial_expiry_date: Date.utc_today() |> Date.shift(day: 0)) + new_user(trial_expiry_date: Date.utc_today() |> Date.shift(day: -1)) + + perform_job(SendTrialNotifications, %{}) + + assert_no_emails_delivered() + end + + test "does not send a notification if user does not have a trial" do + user = new_user() + new_site(owner: user) + user |> team_of() |> Ecto.Changeset.change(trial_expiry_date: nil) |> Plausible.Repo.update!() + + perform_job(SendTrialNotifications, %{}) + + assert_no_emails_delivered() + end + + test "does not send a notification if user created a site but there are no pageviews" do + user = new_user(trial_expiry_date: Date.utc_today() |> Date.shift(day: 7)) + new_site(owner: user) + + perform_job(SendTrialNotifications, %{}) + + assert_no_emails_delivered() + end + + test "does not send a notification if user is a collaborator on sites but not an owner" do + user = new_user(trial_expiry_date: Date.utc_today()) + site = new_site() + add_guest(site, user: user, role: :editor) + + populate_stats(site, [build(:pageview)]) + + perform_job(SendTrialNotifications, %{}) + + assert_no_emails_delivered() + end + + describe "with site and pageviews" do + test "sends a reminder 7 days before trial ends (16 days after user signed up)" do + user = new_user(trial_expiry_date: Date.utc_today() |> Date.shift(day: 7)) + site = new_site(owner: user) + populate_stats(site, [build(:pageview)]) + + perform_job(SendTrialNotifications, %{}) + + assert_delivered_email(PlausibleWeb.Email.trial_one_week_reminder(user, site.team)) + end + + test "includes billing member in recipients" do + user = new_user(trial_expiry_date: Date.utc_today() |> Date.shift(day: 7)) + site = new_site(owner: user) + team = team_of(user) + billing_member = new_user() + add_member(team, user: billing_member, role: :billing) + + populate_stats(site, [build(:pageview)]) + + perform_job(SendTrialNotifications, %{}) + + assert_delivered_email(PlausibleWeb.Email.trial_one_week_reminder(user, team)) + assert_delivered_email(PlausibleWeb.Email.trial_one_week_reminder(billing_member, team)) + end + + test "sends an upgrade email the day before the trial ends" do + user = new_user(trial_expiry_date: Date.utc_today() |> Date.shift(day: 1)) + site = new_site(owner: user) + usage = %{total: 3, custom_events: 0} + suggested_volume = Plausible.Billing.Plans.suggest_volume(site.team, usage.total) + + populate_stats(site, [ + build(:pageview), + build(:pageview), + build(:pageview) + ]) + + perform_job(SendTrialNotifications, %{}) + + assert_delivered_email( + PlausibleWeb.Email.trial_ending_tomorrow_email( + user, + site.team, + usage, + suggested_volume + ) + ) + end + + test "sends an upgrade email the day the trial ends" do + user = new_user(trial_expiry_date: Date.utc_today()) + site = new_site(owner: user) + usage = %{total: 3, custom_events: 0} + suggested_volume = Plausible.Billing.Plans.suggest_volume(site.team, usage.total) + + populate_stats(site, [ + build(:pageview), + build(:pageview), + build(:pageview) + ]) + + perform_job(SendTrialNotifications, %{}) + + assert_delivered_email( + PlausibleWeb.Email.trial_ending_today_email(user, site.team, usage, suggested_volume) + ) + end + + test "does not include custom event note if user has not used custom events" do + user = new_user(trial_expiry_date: Date.utc_today()) + site = new_site(owner: user) + usage = %{total: 9_000, custom_events: 0} + suggested_volume = Plausible.Billing.Plans.suggest_volume(site.team, usage.total) + + email = + PlausibleWeb.Email.trial_ending_today_email(user, site.team, usage, suggested_volume) + + assert email.html_body =~ "So far, your account has used 9,000 billable pageviews." + end + + test "includes custom event note if user has used custom events" do + user = new_user(trial_expiry_date: Date.utc_today()) + site = new_site(owner: user) + usage = %{total: 9_100, custom_events: 100} + suggested_volume = Plausible.Billing.Plans.suggest_volume(site.team, usage.total) + + email = + PlausibleWeb.Email.trial_ending_today_email(user, site.team, usage, suggested_volume) + + assert email.html_body =~ + "So far, your account has used 9,100 billable pageviews and custom events in total." + end + + test "sends a trial over email the day after the trial ends" do + user = new_user(trial_expiry_date: Date.utc_today() |> Date.shift(day: -1)) + site = new_site(owner: user) + + populate_stats(site, [ + build(:pageview), + build(:pageview), + build(:pageview) + ]) + + perform_job(SendTrialNotifications, %{}) + + assert_delivered_email(PlausibleWeb.Email.trial_over_email(user, site.team)) + end + + test "does not send a notification if user has a subscription" do + user = new_user(trial_expiry_date: Date.utc_today() |> Date.shift(day: 7)) + site = new_site(owner: user) + + populate_stats(site, [ + build(:pageview), + build(:pageview), + build(:pageview) + ]) + + subscribe_to_growth_plan(user) + + perform_job(SendTrialNotifications, %{}) + + assert_no_emails_delivered() + end + end + + describe "Suggested plans" do + test "suggests 10k/mo plan" do + user = new_user() + site = new_site(owner: user) + usage = %{total: 9_000, custom_events: 0} + suggested_volume = Plausible.Billing.Plans.suggest_volume(site.team, usage.total) + + email = + PlausibleWeb.Email.trial_ending_today_email(user, site.team, usage, suggested_volume) + + assert email.html_body =~ "select the 10k pageviews/month plan." + end + + test "suggests 100k/mo plan" do + user = new_user() + site = new_site(owner: user) + usage = %{total: 90_000, custom_events: 0} + suggested_volume = Plausible.Billing.Plans.suggest_volume(site.team, usage.total) + + email = + PlausibleWeb.Email.trial_ending_today_email(user, site.team, usage, suggested_volume) + + assert email.html_body =~ "select the 100k pageviews/month plan." + end + + test "suggests 200k/mo plan" do + user = new_user() + site = new_site(owner: user) + usage = %{total: 180_000, custom_events: 0} + suggested_volume = Plausible.Billing.Plans.suggest_volume(site.team, usage.total) + + email = + PlausibleWeb.Email.trial_ending_today_email(user, site.team, usage, suggested_volume) + + assert email.html_body =~ "select the 200k pageviews/month plan." + end + + test "suggests 500k/mo plan" do + user = new_user() + site = new_site(owner: user) + usage = %{total: 450_000, custom_events: 0} + suggested_volume = Plausible.Billing.Plans.suggest_volume(site.team, usage.total) + + email = + PlausibleWeb.Email.trial_ending_today_email(user, site.team, usage, suggested_volume) + + assert email.html_body =~ "select the 500k pageviews/month plan." + end + + test "suggests 1m/mo plan" do + user = new_user() + site = new_site(owner: user) + usage = %{total: 900_000, custom_events: 0} + suggested_volume = Plausible.Billing.Plans.suggest_volume(site.team, usage.total) + + email = + PlausibleWeb.Email.trial_ending_today_email(user, site.team, usage, suggested_volume) + + assert email.html_body =~ "select the 1M pageviews/month plan." + end + + test "suggests 2m/mo plan" do + user = new_user() + site = new_site(owner: user) + usage = %{total: 1_800_000, custom_events: 0} + suggested_volume = Plausible.Billing.Plans.suggest_volume(site.team, usage.total) + + email = + PlausibleWeb.Email.trial_ending_today_email(user, site.team, usage, suggested_volume) + + assert email.html_body =~ "select the 2M pageviews/month plan." + end + + test "suggests 5m/mo plan" do + user = new_user() + site = new_site(owner: user) + usage = %{total: 4_500_000, custom_events: 0} + suggested_volume = Plausible.Billing.Plans.suggest_volume(site.team, usage.total) + + email = + PlausibleWeb.Email.trial_ending_today_email(user, site.team, usage, suggested_volume) + + assert email.html_body =~ "select the 5M pageviews/month plan." + end + + test "suggests 10m/mo plan" do + user = new_user() + site = new_site(owner: user) + usage = %{total: 9_000_000, custom_events: 0} + suggested_volume = Plausible.Billing.Plans.suggest_volume(site.team, usage.total) + + email = + PlausibleWeb.Email.trial_ending_today_email(user, site.team, usage, suggested_volume) + + assert email.html_body =~ "select the 10M pageviews/month plan." + end + + test "does not suggest a plan above that" do + user = new_user() + site = new_site(owner: user) + usage = %{total: 20_000_000, custom_events: 0} + suggested_volume = Plausible.Billing.Plans.suggest_volume(site.team, usage.total) + + email = + PlausibleWeb.Email.trial_ending_today_email(user, site.team, usage, suggested_volume) + + assert email.html_body =~ + "Please reply to this email and we'll prepare a custom plan for your volume" + end + + test "does not suggest a plan when user is switching to an enterprise plan" do + user = new_user() + site = new_site(owner: user) + usage = %{total: 10_000, custom_events: 0} + subscribe_to_enterprise_plan(user, paddle_plan_id: "enterprise-plan-id") + suggested_volume = Plausible.Billing.Plans.suggest_volume(site.team, usage.total) + + email = + PlausibleWeb.Email.trial_ending_today_email(user, site.team, usage, suggested_volume) + + assert email.html_body =~ + "Please reply to this email and we'll prepare a custom plan for your volume" + end + end +end diff --git a/test/workers/set_legacy_time_on_page_cutoff_test.exs b/test/workers/set_legacy_time_on_page_cutoff_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..979ecd087c6d1fdb430d19a293647d455e89cd34 --- /dev/null +++ b/test/workers/set_legacy_time_on_page_cutoff_test.exs @@ -0,0 +1,103 @@ +defmodule Plausible.Workers.SetLegacyTimeOnPageCutoffTest do + use Plausible.DataCase + + alias Plausible.Workers.SetLegacyTimeOnPageCutoff + + test "sets cutoff for small site with engagement" do + site = create_site_with_cutoff(nil) + + populate_stats(site, [ + build(:pageview, user_id: 13, timestamp: ~U[2025-03-03 23:05:00Z]), + build(:engagement, user_id: 13, engagement_time: 10, timestamp: ~U[2025-03-03 23:05:00Z]) + ]) + + perform(~D[2025-03-05]) + assert Repo.reload!(site).legacy_time_on_page_cutoff == ~D[2025-03-05] + end + + test "does not update an already set cutoff" do + site = create_site_with_cutoff(~D[1970-01-01]) + + populate_stats(site, [ + build(:pageview, user_id: 13, timestamp: ~U[2025-03-03 23:05:00Z]), + build(:engagement, user_id: 13, engagement_time: 10, timestamp: ~U[2025-03-03 23:05:00Z]) + ]) + + perform(~D[2025-03-05]) + assert Repo.reload!(site).legacy_time_on_page_cutoff == ~D[1970-01-01] + end + + test "sets cutoff for large site with complete engagement" do + site = create_site_with_cutoff(nil) + + 1..2500 + |> Enum.flat_map(fn i -> + hour = 25 + rem(i, 24) + timestamp = ~U[2025-03-05 00:00:00Z] |> DateTime.add(-hour, :hour) + + [ + build(:pageview, user_id: i, timestamp: timestamp), + build(:engagement, user_id: i, engagement_time: 10, timestamp: timestamp) + ] + end) + |> then(&populate_stats(site, &1)) + + perform(~D[2025-03-05]) + assert Repo.reload!(site).legacy_time_on_page_cutoff == ~D[2025-03-05] + end + + test "does not update large site with incomplete engagement" do + site = create_site_with_cutoff(nil) + + 1..2500 + |> Enum.flat_map(fn i -> + [ + build(:pageview, user_id: i, timestamp: ~U[2025-03-03 23:05:00Z]), + build(:engagement, user_id: i, engagement_time: 10, timestamp: ~U[2025-03-03 23:05:00Z]) + ] + end) + |> then(&populate_stats(site, &1)) + + perform(~D[2025-03-05]) + assert Repo.reload!(site).legacy_time_on_page_cutoff == nil + end + + test "does not update site that already has cutoff set" do + site = create_site_with_cutoff(~D[2025-01-01]) + + populate_stats(site, [ + build(:pageview, user_id: 13, timestamp: ~U[2025-03-03 23:05:00Z]), + build(:engagement, user_id: 13, engagement_time: 10, timestamp: ~U[2025-03-03 23:05:00Z]) + ]) + + perform(~D[2025-03-05]) + assert Repo.reload!(site).legacy_time_on_page_cutoff == ~D[2025-01-01] + end + + test "ignores sites without relevant data" do + site = create_site_with_cutoff(nil) + + populate_stats(site, [ + build(:pageview, user_id: 13, timestamp: ~U[2025-03-04 22:05:00Z]), + build(:engagement, user_id: 13, engagement_time: 10, timestamp: ~U[2025-03-04 22:05:00Z]) + ]) + + perform(~D[2025-03-05]) + assert Repo.reload!(site).legacy_time_on_page_cutoff == nil + end + + defp perform(date) do + SetLegacyTimeOnPageCutoff.perform(%Oban.Job{args: %{"cutoff_date" => date}}) + end + + defp create_site_with_cutoff(cutoff) do + site = insert(:site) + + # Work around model defaults + site + |> Plausible.Site.changeset(%{legacy_time_on_page_cutoff: cutoff}) + |> Plausible.Repo.update() + + site + end +end diff --git a/test/workers/sso_domain_verification_worker_test.exs b/test/workers/sso_domain_verification_worker_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..d75a013cf69b76fd0e5ed17d0e7a281c8b99d6c9 --- /dev/null +++ b/test/workers/sso_domain_verification_worker_test.exs @@ -0,0 +1,123 @@ +defmodule Plausible.Auth.SSO.Domain.Verification.WorkerTest do + use Plausible.DataCase + + on_ee do + use Bamboo.Test, shared: true + use Oban.Testing, repo: Plausible.Repo + use Plausible.Auth.SSO.Domain.Status + + alias Plausible.Auth.SSO + alias Plausible.Auth.SSO.Domain.Verification.Worker + + test "no sso domain cancels the job" do + assert {:cancel, :domain_not_found} = + perform_job(Worker, %{"domain" => "hello.example.com"}) + end + + test "enqueue works" do + {:ok, _} = Worker.enqueue("example.com") + assert_enqueued(worker: Worker, args: %{domain: "example.com"}) + end + + test "enqueue is idempotent" do + {:ok, %{id: id}} = Worker.enqueue("example.com") + {:ok, %{id: ^id, conflict?: true}} = Worker.enqueue("example.com") + assert_enqueued(worker: Worker, args: %{domain: "example.com"}) + end + + test "enqueue then cancel" do + {:ok, _} = Worker.enqueue("example.com") + assert_enqueued(worker: Worker, args: %{domain: "example.com"}) + :ok = Worker.cancel("example.com") + refute_enqueued(worker: Worker, args: %{domain: "example.com"}) + end + + describe "integration set up" do + setup do + owner = new_user() + team = new_site(owner: owner).team + integration = SSO.initiate_saml_integration(team) + domain = "#{Enum.random(1..10_000)}.example.com" + {:ok, sso_domain} = SSO.Domains.add(integration, domain) + + {:ok, + owner: owner, + team: team, + integration: integration, + domain: domain, + sso_domain: sso_domain} + end + + test "domain is marked as in progress and job is snoozed", %{domain: domain} do + assert {:ok, %{status: Status.pending()}} = SSO.Domains.get(domain) + + assert {:snooze, 15} = + perform_job(Worker, %{"domain" => domain}, meta: %{bypass_checks: true}) + + assert {:ok, %{status: Status.in_progress()}} = SSO.Domains.get(domain) + + assert {:snooze, 7680} = + perform_job(Worker, %{"domain" => domain}, + attempt: 10, + meta: %{bypass_checks: true} + ) + + assert {:ok, %{status: Status.in_progress()}} = SSO.Domains.get(domain) + end + + test "domain is marked as verified and emails are sent", %{ + domain: domain, + team: team, + owner: owner + } do + owner2 = add_member(team, role: :owner) + + assert {:ok, %{status: Status.verified()}} = + perform_job(Worker, %{"domain" => domain}, meta: %{skip_checks: true}) + + assert_email_delivered_with( + to: [nil: owner.email], + subject: "Your SSO domain #{domain} is ready!" + ) + + assert_email_delivered_with( + to: [nil: owner2.email], + subject: "Your SSO domain #{domain} is ready!" + ) + + {:ok, domain} = SSO.Domains.get(domain) + + assert audited_entry("sso_domain_verification_success", + team_id: team.id, + entity_id: "#{domain.id}" + ) + end + + test "domain is marked as unverified when max snoozes exhausted", %{ + domain: domain, + team: team + } do + assert {:snooze, _} = + perform_job(Worker, %{"domain" => domain}, + attempt: 14, + meta: %{bypass_checks: true} + ) + + assert {:cancel, :max_snoozes} = + perform_job(Worker, %{"domain" => domain}, + attempt: 15, + meta: %{bypass_checks: true} + ) + + assert_email_delivered_with(subject: "SSO domain #{domain} verification failure") + + {:ok, domain} = SSO.Domains.get(domain) + + assert audited_entry("sso_domain_verification_failure", + team_id: team.id, + entity_id: "#{domain.id}" + ) + end + end + end +end diff --git a/test/workers/traffic_change_notifier_test.exs b/test/workers/traffic_change_notifier_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..a77d87027ee6b6163e610ce86e55a652642e5cce --- /dev/null +++ b/test/workers/traffic_change_notifier_test.exs @@ -0,0 +1,504 @@ +defmodule Plausible.Workers.TrafficChangeNotifierTest do + use Plausible.DataCase, async: true + use Bamboo.Test + alias Plausible.Workers.TrafficChangeNotifier + + @view_dashboard_text "View dashboard" + @review_installation_text "review your installation" + + describe "drops" do + test "does not notify anyone if we've stopped accepting traffic for the owner" do + user = new_user(team: [accept_traffic_until: Date.utc_today()]) + site = new_site(owner: user) + + insert(:drop_notification, + site: site, + threshold: 10, + recipients: ["jerod@example.com", "uku@example.com"] + ) + + TrafficChangeNotifier.perform(nil) + + assert_no_emails_delivered() + end + + test "does notify if threshold reached and we're accepting traffic" do + user = new_user(team: [accept_traffic_until: Date.utc_today() |> Date.add(+1)]) + site = new_site(owner: user) + + insert(:drop_notification, + site: site, + threshold: 10, + recipients: ["jerod@example.com"] + ) + + TrafficChangeNotifier.perform(nil) + + assert_email_delivered_with( + subject: "Traffic drop on #{site.domain}", + to: [nil: "jerod@example.com"] + ) + end + + test "does not notify anyone if current visitors does not drop below notification threshold" do + site = insert(:site) + + insert(:drop_notification, + site: site, + threshold: 2, + recipients: ["jerod@example.com", "uku@example.com"] + ) + + populate_stats(site, [ + build(:pageview, timestamp: minutes_ago(2)), + build(:pageview, timestamp: minutes_ago(1)) + ]) + + TrafficChangeNotifier.perform(nil) + + assert_no_emails_delivered() + end + + test "notifies all recipients when traffic drops under configured threshold" do + site = new_site() + + insert(:drop_notification, + site: site, + threshold: 10, + recipients: ["jerod@example.com", "uku@example.com"] + ) + + TrafficChangeNotifier.perform(nil) + + assert_email_delivered_with( + subject: "Traffic drop on #{site.domain}", + to: [nil: "jerod@example.com"] + ) + + assert_email_delivered_with( + subject: "Traffic drop on #{site.domain}", + to: [nil: "uku@example.com"] + ) + end + + test "includes dashboard and installation links only when recipient is guest or team member" do + owner = new_user() + {:ok, team} = Plausible.Teams.get_or_create(owner) + site = new_site(team: team) + team_member = add_member(team, role: :admin) + viewer_guest = add_guest(site, role: :viewer, user: new_user()) + random_email = "random@example.com" + + insert(:drop_notification, + site: site, + threshold: 10, + recipients: [owner.email, team_member.email, viewer_guest.email, random_email] + ) + + TrafficChangeNotifier.perform(nil) + + expected_subject = "Traffic drop on #{site.domain}" + + four_emails = + for _ <- 1..4 do + assert_delivered_email_matches(%{ + html_body: html_body, + subject: ^expected_subject, + to: [nil: email] + }) + + %{to: email, html_body: html_body} + end + + {[random_recipient_email], site_member_emails} = + Enum.split_with(four_emails, &(&1.to == random_email)) + + refute random_recipient_email.html_body =~ @view_dashboard_text + + Enum.each(site_member_emails, fn email -> + assert email.html_body =~ @view_dashboard_text + assert email.html_body =~ @review_installation_text + end) + end + + on_ee do + test "does not link to site installation in a consolidated view traffic drop notification" do + %{email: user_email} = user = new_user() + {:ok, team} = Plausible.Teams.get_or_create(user) + new_site(team: team) + new_site(team: team) + + consolidated_view = new_consolidated_view(team) + + insert(:drop_notification, + site: consolidated_view, + threshold: 10, + recipients: [user.email] + ) + + TrafficChangeNotifier.perform(nil) + + assert_delivered_email_matches(%{ + html_body: html_body, + subject: "Traffic drop on your sites", + to: [nil: ^user_email] + }) + + assert html_body =~ "across all your sites" + assert html_body =~ @view_dashboard_text + refute html_body =~ @review_installation_text + end + + test "does not notify traffic drop on consolidated view when ok_to_display? is false" do + user = new_user() + {:ok, team} = Plausible.Teams.get_or_create(user) + site = new_site(team: team) + new_site(team: team) + + consolidated_view = new_consolidated_view(team) + + Plausible.Repo.delete(site) + + refute Plausible.ConsolidatedView.ok_to_display?(team) + + insert(:drop_notification, + site: consolidated_view, + threshold: 10, + recipients: [user.email] + ) + + TrafficChangeNotifier.perform(nil) + + assert_no_emails_delivered() + end + end + + test "does not send notifications more than once every 12 hours" do + site = new_site() + + insert(:drop_notification, + site: site, + threshold: 1, + recipients: ["uku@example.com"] + ) + + TrafficChangeNotifier.perform(nil, ~N[2021-01-01 00:00:00]) + + assert_email_delivered_with( + subject: "Traffic drop on #{site.domain}", + to: [nil: "uku@example.com"] + ) + + TrafficChangeNotifier.perform(nil, ~N[2021-01-01 11:59:59]) + + assert_no_emails_delivered() + + TrafficChangeNotifier.perform(nil, ~N[2021-01-01 12:00:01]) + + assert_email_delivered_with( + subject: "Traffic drop on #{site.domain}", + to: [nil: "uku@example.com"] + ) + end + + test "adds settings link if recipient has access to the site" do + user = new_user(email: "robert@example.com") + site = new_site(domain: "example.com", owner: user) + + insert(:drop_notification, + site: site, + threshold: 10, + recipients: ["robert@example.com"] + ) + + TrafficChangeNotifier.perform(nil) + + assert_email_delivered_with( + html_body: ~r|http://localhost:8000/example.com/installation\?flow=review| + ) + end + end + + describe "spikes" do + test "does not notify anyone if current visitors does not exceed notification threshold" do + site = insert(:site) + + insert(:spike_notification, + site: site, + threshold: 2, + recipients: ["jerod@example.com", "uku@example.com"] + ) + + populate_stats(site, [build(:pageview, timestamp: minutes_ago(1))]) + + TrafficChangeNotifier.perform(nil) + + assert_no_emails_delivered() + end + + test "notifies all recipients when traffic is higher than configured threshold" do + site = new_site() + + insert(:spike_notification, + site: site, + threshold: 2, + recipients: ["jerod@example.com", "uku@example.com"] + ) + + populate_stats(site, [ + build(:pageview, timestamp: minutes_ago(3)), + build(:pageview, timestamp: minutes_ago(2)), + build(:pageview, timestamp: minutes_ago(1)) + ]) + + TrafficChangeNotifier.perform(nil) + + assert_email_delivered_with( + subject: "Traffic spike on #{site.domain}", + to: [nil: "jerod@example.com"] + ) + + assert_email_delivered_with( + subject: "Traffic spike on #{site.domain}", + to: [nil: "uku@example.com"] + ) + end + + on_ee do + test "notifies traffic spike on consolidated view" do + {:ok, team} = new_user() |> Plausible.Teams.get_or_create() + site1 = new_site(team: team) + site2 = new_site(team: team) + + consolidated_view = new_consolidated_view(team) + + insert(:spike_notification, + site: consolidated_view, + threshold: 2, + recipients: ["uku@example.com"] + ) + + populate_stats(site1, [ + build(:pageview, referrer_source: "Google", pathname: "/b", timestamp: minutes_ago(1)), + build(:pageview, referrer_source: "Google", pathname: "/a", timestamp: minutes_ago(2)) + ]) + + populate_stats(site2, [ + build(:pageview, referrer_source: "Twitter", pathname: "/a", timestamp: minutes_ago(3)) + ]) + + TrafficChangeNotifier.perform(nil) + + assert_delivered_email_matches(%{ + html_body: html_body, + subject: "Traffic spike on your sites", + to: [nil: "uku@example.com"] + }) + + assert html_body =~ "across all your sites" + + assert html_body =~ "The top sources for current visitors:" + assert html_body =~ "2 visitors from Google" + assert html_body =~ "1 visitor from Twitter" + + assert html_body =~ "Your top pages being visited:" + assert html_body =~ "2 visitors on /a" + assert html_body =~ "1 visitor on /b" + end + + test "does not notify traffic spike on consolidated view when ok_to_display? is false" do + user = new_user() + {:ok, team} = Plausible.Teams.get_or_create(user) + site1 = new_site(team: team) + site2 = new_site(team: team) + + consolidated_view = new_consolidated_view(team) + + insert(:spike_notification, + site: consolidated_view, + threshold: 2, + recipients: ["uku@example.com"] + ) + + populate_stats(site1, [ + build(:pageview, timestamp: minutes_ago(1)), + build(:pageview, timestamp: minutes_ago(2)), + build(:pageview, timestamp: minutes_ago(3)) + ]) + + Plausible.Repo.delete(site2) + + refute Plausible.ConsolidatedView.ok_to_display?(team) + + insert(:drop_notification, + site: consolidated_view, + threshold: 10, + recipients: [user.email] + ) + + TrafficChangeNotifier.perform(nil) + + assert_no_emails_delivered() + end + end + + test "ignores 'Direct / None' source" do + site = new_site() + + insert(:spike_notification, + site: site, + threshold: 5, + recipients: ["uku@example.com"] + ) + + populate_stats(site, [ + build(:pageview, referrer_source: "A", timestamp: minutes_ago(1)), + build(:pageview, referrer_source: "A", timestamp: minutes_ago(1)), + build(:pageview, referrer_source: "A", timestamp: minutes_ago(1)), + build(:pageview, referrer_source: "B", timestamp: minutes_ago(1)), + build(:pageview, referrer_source: "B", timestamp: minutes_ago(1)), + build(:pageview, referrer_source: "C", timestamp: minutes_ago(1)), + build(:pageview, timestamp: minutes_ago(1)), + build(:pageview, timestamp: minutes_ago(1)) + ]) + + TrafficChangeNotifier.perform(nil) + + assert_delivered_email_matches(%{ + to: [nil: "uku@example.com"], + html_body: html_body + }) + + assert html_body =~ "The top sources for current visitors:" + assert html_body =~ "3 visitors from A" + assert html_body =~ "2 visitors from B" + assert html_body =~ "1 visitor from C" + assert html_body =~ "There are currently 8" + end + + test "does not list sources at all when everything is 'Direct / None'" do + site = new_site() + + insert(:spike_notification, + site: site, + threshold: 1, + recipients: ["uku@example.com"] + ) + + populate_stats(site, [ + build(:pageview, timestamp: minutes_ago(1)), + build(:pageview, timestamp: minutes_ago(1)) + ]) + + TrafficChangeNotifier.perform(nil) + + assert_delivered_email_matches(%{ + to: [nil: "uku@example.com"], + html_body: html_body + }) + + refute html_body =~ "The top sources for current visitors:" + assert html_body =~ "There are currently 2" + end + + test "includes top 3 pages" do + site = new_site() + + insert(:spike_notification, + site: site, + threshold: 10, + recipients: ["uku@example.com"] + ) + + populate_stats(site, [ + build(:pageview, pathname: "/one", timestamp: minutes_ago(1)), + build(:pageview, pathname: "/one", timestamp: minutes_ago(1)), + build(:pageview, pathname: "/one", timestamp: minutes_ago(1)), + build(:pageview, pathname: "/one", timestamp: minutes_ago(1)), + build(:pageview, pathname: "/two", timestamp: minutes_ago(1)), + build(:pageview, pathname: "/two", timestamp: minutes_ago(1)), + build(:pageview, pathname: "/two", timestamp: minutes_ago(1)), + build(:pageview, timestamp: minutes_ago(1)), + build(:pageview, timestamp: minutes_ago(1)), + build(:pageview, pathname: "/not-this-one", timestamp: minutes_ago(1)) + ]) + + TrafficChangeNotifier.perform(nil) + + assert_delivered_email_matches(%{ + to: [nil: "uku@example.com"], + html_body: html_body + }) + + assert html_body =~ "There are currently 10" + + assert html_body =~ "Your top pages being visited:" + assert html_body =~ "4 visitors on /one" + assert html_body =~ "3 visitors on /two" + assert html_body =~ "2 visitors on /" + + refute html_body =~ "/not-this-one" + end + + test "does not check site if it is locked" do + site = new_site() + site.team |> Ecto.Changeset.change(locked: true) |> Repo.update!() + + insert(:spike_notification, + site: site, + threshold: 1, + recipients: ["uku@example.com"] + ) + + populate_stats(site, [ + build(:pageview, timestamp: minutes_ago(1)), + build(:pageview, timestamp: minutes_ago(1)) + ]) + + TrafficChangeNotifier.perform(nil) + + assert_no_emails_delivered() + end + + test "does not send notifications more than once every 12 hours" do + site = new_site() + insert(:spike_notification, site: site, threshold: 1, recipients: ["uku@example.com"]) + + populate_stats(site, [ + build(:pageview, timestamp: minutes_ago(1)), + build(:pageview, timestamp: minutes_ago(1)) + ]) + + TrafficChangeNotifier.perform(nil) + + assert_email_delivered_with( + subject: "Traffic spike on #{site.domain}", + to: [nil: "uku@example.com"] + ) + + TrafficChangeNotifier.perform(nil) + + assert_no_emails_delivered() + end + + test "adds a dashboard link if recipient has access to the site" do + user = new_user(email: "robert@example.com") + site = new_site(domain: "example.com", owner: user) + insert(:spike_notification, site: site, threshold: 1, recipients: ["robert@example.com"]) + + populate_stats(site, [ + build(:pageview, timestamp: minutes_ago(1)), + build(:pageview, timestamp: minutes_ago(1)) + ]) + + TrafficChangeNotifier.perform(nil) + + assert_delivered_email_matches(%{html_body: html_body}) + assert html_body =~ @view_dashboard_text + end + end + + def minutes_ago(min) do + NaiveDateTime.utc_now(:second) |> NaiveDateTime.add(-min, :minute) + end +end diff --git a/tracker/.prettierignore b/tracker/.prettierignore new file mode 100644 index 0000000000000000000000000000000000000000..7a3a848c23fc9bc241a6dacfa390dc3b475e635f --- /dev/null +++ b/tracker/.prettierignore @@ -0,0 +1,4 @@ +src/p.js +src/plausible.js +npm_package/plausible.js +node_modules/ diff --git a/tracker/.prettierrc.json b/tracker/.prettierrc.json new file mode 100644 index 0000000000000000000000000000000000000000..81c51f9894bdf06e1cbe947905f6d871f7bc8923 --- /dev/null +++ b/tracker/.prettierrc.json @@ -0,0 +1,5 @@ +{ + "singleQuote": true, + "trailingComma": "none", + "semi": false +} diff --git a/tracker/ARCHITECTURE.md b/tracker/ARCHITECTURE.md new file mode 100644 index 0000000000000000000000000000000000000000..b1881d38a68dc13e35da106ddb4376f663dd4470 --- /dev/null +++ b/tracker/ARCHITECTURE.md @@ -0,0 +1,101 @@ +# Architecture + +This document describes the design goals informing the architecture of the Plausible tracker script codebase as well as a map +to how the code is laid out. + +## Installation Support + +The tracker subdirectory also includes site verification and pre-installation checks that are run in headless browser, via +browserless.io. These files live under the `/tracker/installation-support/` director and are meant to provide Plausible +installation support - checking the site for what technologies to recommend and verifying whether Plausible has been +installed correctly. Please see `lib/plausible/installation_support/checks/installation.ex` for the Elixir context and how +this JS code ends up being used. + +While this logic could be separated from the tracker script, it's convenient for installation support to reuse the Playwright test structure and JS compilation logic without introducing yet another subdirectory with its own dependencies. + +## Design goals + +We want to provide users a javascript suite to capture page views and activities done on the web page. + +Over time, the following key design goals have emerged: + +### 1. Minimal script size + +Smaller scripts lead to better user experience and reduce the amount of data we miss due to script download and runtime. + +We minify our scripts using the speedy `@swc/core` library and track script size changes on every pull request. + +### 2. Many targets, single codebase + +Plausible provides a web 'snippet' users can include on their site, tooling for wordpress and other plugins as well as an npm package. + +As Plausible doesn't have the workforce to maintain multiple code bases, everything is built from the same underlying source code. + +This is achieved by: + +- Having a canonical list of compiled variants in `tracker/compiler/variants.json` +- Using `COMPILE` globals to toggle certain functionality on/off depending on the variant. +- Having the script minifier drop dead branches of the code from each variant. +- Having `compile.js` compile all needed variants + +### 3. Flexible user configuration + +Rather than capturing everything, we want to provide configurability allowing Plausible users to toggle tracking features on/off. + +To make life simpler for these users, we want to allow changing configuration on web without users needing to change their integration code or deploying. + +Plausible tracker endpoints dynamically interpolate site-specific configuration into the minified plausible.js. For integrations such as the wordpress plugin, we provide tooling like the plugins API. + +### 4. Legacy support + +We want to avoid intentionally breaking existing installations of the script. + +In a previous version of the script, Plausible allowed toggling features by changing their script extension. We still generate and serve each of these legacy variants. + +### 5. Great development experience + +To make sure development of the tracker works like a well-oiled machine, we: + +- Ensure compilation is snappy and done in parallel. Currently compiling over a 1000 variants around 3 seconds on a development machine. +- Use a bundler (rollup) to split code into multiple files. +- Use automated browser-based testing to cover new functionality and changes. +- Leverage CI-based workflows for testing and releasing each new version + +## Code Map + +This section provides a key overview of code that is relevant to understand the tracker codebase. + +### `tracker/src/` + +Contains tracker code itself which is to be compiled. `plausible.js` is the entrypoint during compilation. + +### `tracker/installation_support/` + +Contains source code to verify that the tracker script is installed correctly with Plausible.InstallationSupport modules. There are multiple entrypoints, see variants.json for details. + +### `tracker/compiler/variants.json` + +Contains a list of variants and associated global `COMPILE` flags. + +- `manualVariants` are the main variants we need to care for, containing both the web snippet and npm package. +- `legacyVariants` is a list of all the legacy variants we support, generated by `tracker/compiler/generate-variants.js` + +### `tracker/compile.js` and `tracker/compiler/index.js` + +This script is responsible for bundling and minifying each variant in parallel. + +Compilation adds code under `priv/tracker/js` + +### `tracker/npm_package` + +Contains types, package.json, README.md, CHANGELOG.md and other related files to the npm package. +Does not contain the actual code which is placed there during compilation. + +### `tracker/test/` + +Playwright tests. These tests usually load a specific html fixture, configure the tracker script, emulate user behavior +and check what events get sent. + +### `lib/plausible_web/plugs/tracker_plug.ex` and `lib/plausible_web/tracker.ex` + +Elixir code responsible for serving tracker script, interpolating and managing site tracker script configuration. diff --git a/tracker/CHANGELOG.md b/tracker/CHANGELOG.md new file mode 100644 index 0000000000000000000000000000000000000000..67463f87277d3f983674726ed18481a8b5515167 --- /dev/null +++ b/tracker/CHANGELOG.md @@ -0,0 +1,72 @@ +# Changelog + +All notable changes to this npm library will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## Unreleased + +## [0.4.5] - 2026-05-05 + +- Use ResizeObserver over polling for getting scroll metrics + +## [0.4.4] - 2025-10-31 + +- Convert all TypeScript definition comments from `//` style to JSDoc `/** */` style for better IDE integration and TypeScript tooling support + +## [0.4.3] - 2025-09-15 + +- Fix formatting issues. + +## [0.4.2] - 2025-09-04 + +- Remove redeclared variables from the tracker. + +## [0.4.1] - 2025-09-01 + +- Minor update: Allow setting `lib` option for other tools integrating with Plausible. + +## [0.4.0] - 2025-08-12 + +- Move package under `@plausible-analytics/tracker` + +## [0.3.6] - 2025-08-04 + +- Fix issue with a second init() call changing the config option unexpectedly. + +## [0.3.5] - 2025-08-04 + +- Fix issue with link tracking features (tagged events, file downloads, outbound links) causing errors on the page when clicking on `a` tags within `svg` tags. + +## [0.3.4] - 2025-07-23 + +- Plausible loaded indicator `window.plausible.l = true` is set last in initialisation functions + +## [0.3.3] - 2025-07-22 + +- Bind the `track` function into `window.plausible`. This makes it possible for the Plausible verification agent to verify a successful installation. Can be disabled setting the `bindToWindow` config option to `false`. + +## [0.3.2] - 2025-07-14 + +- "Form: Submission" event payload does not need to contain props.path any more: it is saved to be the same as the pathname of the event + +## [0.3.1] - 2025-07-08 + +- Do not send "Form: Submission" event if the form is tagged + +## [0.3.0] - 2025-06-27 + +- Remove now unnecessary navigation delays on link clicks and form submissions. + +## [0.2.4] - 2025-06-19 + +- Add `logging` option +- Improve `callback` option +- Improve `fileDownloads` typing, export `DEFAULT_FILE_TYPES` + +## [0.2.2] - 2025-06-16 + +- Support for `config.transformRequest` +- Support for passing `url` as option when calling `track` +- Drop support for `meta` argument. diff --git a/tracker/LICENSE.md b/tracker/LICENSE.md new file mode 100644 index 0000000000000000000000000000000000000000..addb30cee09b39abc1bf1ffd0699ac8247324f0d --- /dev/null +++ b/tracker/LICENSE.md @@ -0,0 +1,9 @@ +MIT License + +Copyright 2020 Plausible Insights OÜ + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/tracker/README.md b/tracker/README.md new file mode 100644 index 0000000000000000000000000000000000000000..df3b99311d8f8cd8285fec83466e5616b4d705ea --- /dev/null +++ b/tracker/README.md @@ -0,0 +1,52 @@ +# Plausible tracker + +This contains Plausible tracker script and NPM package which Plausible users integrate into +their site and which captures pageviews and more. + +## Development + +See also [ARCHITECTURE.md](./ARCHITECTURE.md) + +### Dependencies + +To download dependencies, do: + +```bash +npm install +npx playwright install # installs browsers used by playwright +``` + +### Compilation + +Compile tracker code by `node compile.js`. + +Use `node compile.js --watch` to watch for changes. + +Use `node compile.js --web-snippet` if you need to update web snippet code. + +### Tests + +Tests can be run in UI mode via `npx playwright test --ui`. This helps with debugging. + +### NPM package + +To test changes to the npm package by installing the local version against a test project, we recommend: + +- installing [yalc](https://github.com/wclr/yalc) via `npm install yalc -g` +- compiling the tracker +- running `yalc publish` in `tracker/npm_package` directory + +More instructions can be found in [yalc repo](https://github.com/wclr/yalc). + +## Cloud deployment + +Handled via PRs. When making tracker changes, it's required to: + +- Tag your PR with a `tracker release:` label +- Update `tracker/CHANGELOG.md` + +After merge github actions automatically: + +- includes the updated tracker scripts in the next cloud deploy +- updates npm package package.json and CHANGELOG.md with the new version +- releases the new package version on NPM. diff --git a/tracker/compile.js b/tracker/compile.js new file mode 100644 index 0000000000000000000000000000000000000000..5c9a4527d79984b6606d7a2b7d9fa8ff1b9e1c19 --- /dev/null +++ b/tracker/compile.js @@ -0,0 +1,66 @@ +import { parseArgs } from 'node:util' +import { compileAll, compileWebSnippet } from './compiler/index.js' +import chokidar from 'chokidar' + +const { values } = parseArgs({ + options: { + watch: { + type: 'boolean', + short: 'w' + }, + help: { + type: 'boolean' + }, + suffix: { + type: 'string', + default: '' + }, + 'web-snippet': { + type: 'boolean' + } + } +}) + +if (values.help) { + console.log('Usage: node compile.js [flags]') + console.log('Options:') + console.log( + ' --watch, -w Watch src/ directory for changes and recompile' + ) + console.log( + ' --suffix, -s Suffix to add to the output file name. Used for testing script size changes' + ) + console.log( + ' --help Show this help message' + ) + console.log( + ' --web-snippet Compile and output the web snippet' + ) + process.exit(0) +} + +if (values['web-snippet']) { + console.log(compileWebSnippet()) + process.exit(0) +} + +const compileOptions = { + suffix: values.suffix +} + +await compileAll(compileOptions) + +if (values.watch) { + console.log('Watching src/ directory for changes...') + + chokidar.watch('./src').on('change', async (_event, path) => { + if (path) { + console.log(`\nFile changed: ${path}`) + console.log('Recompiling...') + + await compileAll(compileOptions) + + console.log('Done. Watching for changes...') + } + }) +} diff --git a/tracker/compiler/analyze-sizes.js b/tracker/compiler/analyze-sizes.js new file mode 100644 index 0000000000000000000000000000000000000000..8d214d66a49ab012cc3f9db2506213b25e354259 --- /dev/null +++ b/tracker/compiler/analyze-sizes.js @@ -0,0 +1,320 @@ +/* +This script analyzes the size changes of tracker script variants across different versions and git branches. + +Requires clickhouse-local to be installed. + +To run it: +- Switch to the desired baseline branch +- Run `node compile.js --suffix baseline` +- Switch to the current branch +- Run `node compile.js --suffix current` +- Run `node compiler/analyze-sizes.js --baselineSuffix baseline --currentSuffix current` + +It will output tables outlining tracker script size changes. +*/ + +import fs from 'fs' +import path from 'path' +import { fileURLToPath } from 'url' +import { execSync } from 'child_process' +import { parseArgs } from 'node:util' +import { markdownTable } from 'markdown-table' +import variantsFile from './variants.json' with { type: 'json' } + +const { values } = parseArgs({ + options: { + help: { + type: 'boolean' + }, + currentSuffix: { + type: 'string', + default: 'current' + }, + baselineSuffix: { + type: 'string', + default: 'master' + }, + usePreviousData: { + type: 'boolean', + default: false + } + } +}) + +const { currentSuffix, baselineSuffix } = values +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const TRACKER_FILES_DIR = path.join(__dirname, '../../priv/tracker/js/') +const NPM_PACKAGE_FILES_DIR = path.join(__dirname, '../npm_package') + +const HEADER = ['', 'Brotli', 'Gzip', 'Uncompressed'] + +if (values.help) { + console.log('Usage: node analyze-sizes.js master current') + console.log('Options:') + console.log(' --help Show this help message') + console.log( + ' --currentSuffix The suffix of the current script variants (see suffix flag for compile.js). Default: current' + ) + console.log( + ' --baselineSuffix The suffix of the previous script variants (see suffix flag for compile.js). Default: master' + ) + console.log( + ' --usePreviousData Use data from a previous run, speeding up the analysis.' + ) + process.exit(0) +} + +let fileData +if (values.usePreviousData) { + fileData = JSON.parse( + fs.readFileSync(path.join(__dirname, '.analyze-sizes.json'), 'utf8') + ) +} else { + fileData = readPlausibleScriptSizes() + fs.writeFileSync( + path.join(__dirname, '.analyze-sizes.json'), + JSON.stringify(fileData) + ) +} + +const manualVariants = variantsFile.manualVariants + .map((variant) => `'${variant.name}'`) + .join(', ') +const ctes = ` +WITH + array(${manualVariants}) as manual_variants, + array( + 'plausible.js', + 'plausible.hash.js', + 'plausible.pageview-props.tagged-events.js', + 'plausible.file-downloads.hash.pageview-props.revenue.js', + 'plausible.compat.exclusions.file-downloads.outbound-links.pageview-props.revenue.tagged-events.js' + ) as important_variants, + data AS ( + SELECT + variant, + not has(manual_variants, variant) as is_legacy_variant, + sumIf(uncompressed, suffix = '${baselineSuffix}') as baseline_uncompressed, + sumIf(gzip, suffix = '${baselineSuffix}') as baseline_gzip, + sumIf(brotli, suffix = '${baselineSuffix}') as baseline_brotli, + sumIf(uncompressed, suffix = '${currentSuffix}') as current_uncompressed, + sumIf(gzip, suffix = '${currentSuffix}') as current_gzip, + sumIf(brotli, suffix = '${currentSuffix}') as current_brotli, + current_uncompressed - baseline_uncompressed as uncompressed_increase, + current_gzip - baseline_gzip as gzip_increase, + current_brotli - baseline_brotli as brotli_increase, + ifNotFinite((current_uncompressed / baseline_uncompressed - 1.0) * 100.0, NULL) as uncompressed_increase_percentage, + ifNotFinite((current_gzip / baseline_gzip - 1.0) * 100.0, NULL) as gzip_increase_percentage, + ifNotFinite((current_brotli / baseline_brotli - 1.0) * 100.0, NULL) as brotli_increase_percentage + FROM table + GROUP BY variant + ) +` + +const mainVariantResults = clickhouseLocal( + ` + ${ctes} + SELECT * + FROM data + WHERE not is_legacy_variant + ORDER BY variant +`, + fileData +) + +const legacyVariantResults = clickhouseLocal( + ` + ${ctes} + SELECT * + FROM data + WHERE is_legacy_variant AND has(important_variants, variant) + ORDER BY length(variant) +`, + fileData +) + +const rowAsMap = ` +map( + 'variant', variant, + 'baseline_uncompressed', toString(baseline_uncompressed), + 'baseline_gzip', toString(baseline_gzip), + 'baseline_brotli', toString(baseline_brotli), + 'current_uncompressed', toString(current_uncompressed), + 'current_gzip', toString(current_gzip), + 'current_brotli', toString(current_brotli), + 'uncompressed_increase', toString(uncompressed_increase), + 'gzip_increase', toString(gzip_increase), + 'brotli_increase', toString(brotli_increase), + 'uncompressed_increase_percentage', toString(uncompressed_increase_percentage), + 'gzip_increase_percentage', toString(gzip_increase_percentage), + 'brotli_increase_percentage', toString(brotli_increase_percentage) +) +` + +const [summary] = clickhouseLocal( + ` + ${ctes} + SELECT + count() AS total_variants, + countIf(brotli_increase_percentage > 0.0) AS brotli_increase_percentaged_variants, + countIf(brotli_increase_percentage < 0.0) AS brotli_decreased_variants, + argMax( + ${rowAsMap}, + brotli_increase_percentage + ) AS max_increase_variant, + argMin( + ${rowAsMap}, + brotli_increase_percentage + ) AS min_increase_variant, + argMaxIf( + ${rowAsMap}, + current_brotli, + is_legacy_variant + ) AS largest_variant, + map( + 'variant', 'Median change', + 'baseline_uncompressed', toString(median(baseline_uncompressed)), + 'baseline_gzip', toString(median(baseline_gzip)), + 'baseline_brotli', toString(median(baseline_brotli)), + 'current_uncompressed', toString(median(current_uncompressed)), + 'current_gzip', toString(median(current_gzip)), + 'current_brotli', toString(median(current_brotli)), + 'uncompressed_increase', toString(median(uncompressed_increase)), + 'gzip_increase', toString(median(gzip_increase)), + 'brotli_increase', toString(median(brotli_increase)), + 'uncompressed_increase_percentage', toString(median(uncompressed_increase_percentage)), + 'gzip_increase_percentage', toString(median(gzip_increase_percentage)), + 'brotli_increase_percentage', toString(median(brotli_increase_percentage)) + ) AS median_result + FROM data +`, + fileData +) + +console.log( + `Analyzed ${summary.total_variants} tracker script variants for size changes.` +) +console.log( + `The following tables summarize the results, with comparison with the baseline version in parentheses.\n` +) + +console.log('Main variants:') +console.log(createMarkdownTable(mainVariantResults)) + +console.log('\nImportant legacy variants:') +console.log(createMarkdownTable(legacyVariantResults)) + +console.log('\nSummary:') +console.log( + createMarkdownTable([ + { + ...summary.largest_variant, + variant: `Largest variant (${summary.largest_variant.variant})` + }, + { + ...summary.max_increase_variant, + variant: `Max change (${summary.max_increase_variant.variant})` + }, + { + ...summary.min_increase_variant, + variant: `Min change (${summary.min_increase_variant.variant})` + }, + summary.median_result + ]) +) + +console.log( + `\nIn total, ${summary.brotli_increase_percentaged_variants} variants brotli size increased and ${summary.brotli_decreased_variants} variants brotli size decreased.` +) + +function createMarkdownTable(rows) { + return markdownTable([HEADER].concat(rows.map(markdownRow))) +} + +function markdownRow(row) { + if (Array.isArray(row)) { + return row + } + + const isNew = row.baseline_uncompressed === null + + return [ + isNew ? `${row.variant} (new variant)` : row.variant, + sizeColumn(row, 'brotli'), + sizeColumn(row, 'gzip'), + sizeColumn(row, 'uncompressed') + ] +} + +function sizeColumn(row, key) { + const currentSize = row[`current_${key}`] + const previousIncrease = row[`${key}_increase`] + const increasePercentage = row[`${key}_increase_percentage`] + if (previousIncrease === null) { + return `${currentSize}B` + } else { + return `${currentSize}B (${addSign(previousIncrease)}B / ${formatPercentage(increasePercentage)})` + } +} + +function formatPercentage(value) { + const prefix = +value > 0 ? '+' : '' + return `${prefix}${Math.round(+value * 10) / 10}%` +} + +function addSign(value) { + return +value >= 0 ? `+${value}` : +value +} + +function readPlausibleScriptSizes() { + const trackerFileSizes = fs + .readdirSync(TRACKER_FILES_DIR) + .filter(isRelevantFile) + .map((filename) => readFileSize(filename, TRACKER_FILES_DIR)) + + const npmPackageFileSizes = fs + .readdirSync(NPM_PACKAGE_FILES_DIR) + .filter(isRelevantFile) + .map((filename) => readFileSize(filename, NPM_PACKAGE_FILES_DIR)) + + return trackerFileSizes.concat(npmPackageFileSizes) +} + +function readFileSize(filename, basepath) { + const filePath = path.join(basepath, filename) + const [_, variant, suffix] = /(.*)[.]js(.*)/.exec(filename) + + return { + variant: + basepath === TRACKER_FILES_DIR + ? `${variant}.js` + : 'npm_package/plausible.js', + suffix, + uncompressed: fs.statSync(filePath).size, + gzip: execSync(`gzip -c -9 "${filePath}"`).length, + brotli: execSync(`brotli -c -q 11 "${filePath}"`).length + } +} + +function isRelevantFile(filename) { + return ( + !['.gitkeep', 'p.js'].includes(filename) && + filename.includes('.js') && + (filename.includes(currentSuffix) || filename.includes(baselineSuffix)) + ) +} + +function clickhouseLocal(sql, inputLines = null) { + const options = {} + if (inputLines) { + options.input = inputLines.map(JSON.stringify).join('\n') + } + + const result = execSync( + `clickhouse-local --query="${sql}" --format=JSON ${inputLines ? '--input-format=JSONLines' : ''}`, + options + ) + const json = JSON.parse(result.toString()) + + return json.data +} diff --git a/tracker/compiler/can-skip-compile.js b/tracker/compiler/can-skip-compile.js new file mode 100644 index 0000000000000000000000000000000000000000..b2d47b7f359dea61f54c1cdd94ca992915d85a17 --- /dev/null +++ b/tracker/compiler/can-skip-compile.js @@ -0,0 +1,55 @@ +import fs from 'fs' +import path from 'path' +import crypto from 'crypto' +import { fileURLToPath } from 'url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +const LAST_HASH_FILEPATH = path.join(__dirname, './last-hash.txt') + +// Re-compilation is only required if any of these files have been changed. +const COMPILE_DEPENDENCIES = [ + path.join(__dirname, './index.js'), + path.join(__dirname, '../compile.js'), + path.join(__dirname, '../src/plausible.js') +] + +function currentHash() { + const combinedHash = crypto.createHash('sha256') + + for (const filePath of COMPILE_DEPENDENCIES) { + try { + const fileContent = fs.readFileSync(filePath) + const fileHash = crypto.createHash('sha256').update(fileContent).digest() + combinedHash.update(fileHash) + } catch (error) { + throw new Error(`Failed to read or hash ${filePath}: ${error.message}`) + } + } + + return combinedHash.digest('hex') +} + +function lastHash() { + if (fs.existsSync(LAST_HASH_FILEPATH)) { + return fs.readFileSync(LAST_HASH_FILEPATH).toString() + } +} + +/** + * Returns a boolean indicating whether the tracker compilation can be skipped. + * Every time this function gets executed, the hash of the tracker dependencies + * will be updated. Compilation can be skipped if the hash hasn't changed since + * the last execution. + */ +export function canSkipCompile() { + const current = currentHash() + const last = lastHash() + + if (current === last) { + return true + } else { + fs.writeFileSync(LAST_HASH_FILEPATH, current) + return false + } +} diff --git a/tracker/compiler/generate-variants.js b/tracker/compiler/generate-variants.js new file mode 100644 index 0000000000000000000000000000000000000000..e68f7a8c711091538b957e5df889d99e3c861d3e --- /dev/null +++ b/tracker/compiler/generate-variants.js @@ -0,0 +1,41 @@ +import fs from 'fs' +import path from 'path' +import { fileURLToPath } from 'url' +import g from 'generatorics' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +function idToGlobal(id) { + return `COMPILE_${id.replace('-', '_').toUpperCase()}` +} + +const LEGACY_VARIANT_NAMES = [ + 'hash', + 'outbound-links', + 'exclusions', + 'compat', + 'local', + 'manual', + 'file-downloads', + 'pageview-props', + 'tagged-events', + 'revenue' +] +let legacyVariants = [...g.clone.powerSet(LEGACY_VARIANT_NAMES)] + .map((a) => a.sort()) + .map((variant) => ({ + name: + variant.length > 0 ? `plausible.${variant.join('.')}.js` : 'plausible.js', + globals: { + ...Object.fromEntries(variant.map((id) => [idToGlobal(id), true])), + COMPILE_PLAUSIBLE_LEGACY_VARIANT: true + } + })) + +const variantsFile = path.join(__dirname, 'variants.json') +const existingData = JSON.parse(fs.readFileSync(variantsFile, 'utf8')) + +fs.writeFileSync( + variantsFile, + JSON.stringify({ ...existingData, legacyVariants }, null, 2) + '\n' +) diff --git a/tracker/compiler/index.js b/tracker/compiler/index.js new file mode 100644 index 0000000000000000000000000000000000000000..cb3341616f138594dca44b22fc86dcbf1a0148d5 --- /dev/null +++ b/tracker/compiler/index.js @@ -0,0 +1,175 @@ +import { minifySync } from '@swc/core' +import { rollup } from 'rollup' +import fs from 'fs' +import path from 'path' +import { fileURLToPath } from 'url' +import variantsFile from './variants.json' with { type: 'json' } +import { canSkipCompile } from './can-skip-compile.js' +import packageJson from '../package.json' with { type: 'json' } +import progress from 'cli-progress' +import { spawn, Worker, Pool } from 'threads' +import json from '@rollup/plugin-json' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +export const DEFAULT_GLOBALS = { + COMPILE_PLAUSIBLE_WEB: false, + COMPILE_PLAUSIBLE_NPM: false, + COMPILE_PLAUSIBLE_LEGACY_VARIANT: false, + COMPILE_CONFIG: false, + COMPILE_HASH: false, + COMPILE_OUTBOUND_LINKS: false, + COMPILE_COMPAT: false, + COMPILE_LOCAL: false, + COMPILE_MANUAL: false, + COMPILE_FILE_DOWNLOADS: false, + COMPILE_PAGEVIEW_PROPS: false, + COMPILE_CUSTOM_PROPERTIES: false, + COMPILE_TAGGED_EVENTS: false, + COMPILE_REVENUE: false, + COMPILE_EXCLUSIONS: false, + COMPILE_TRACKER_SCRIPT_VERSION: packageJson.tracker_script_version +} + +const ALL_VARIANTS = variantsFile.legacyVariants.concat( + variantsFile.manualVariants +) + +function shouldCompileVariant(variant) { + const IS_CE = ['ce', 'ce_test', 'ce_dev'].includes(process.env.MIX_ENV) + const shouldCompileVariant = IS_CE && variant.ee_only ? false : true + return shouldCompileVariant +} + +export async function compileAll(options = {}) { + if (process.env.NODE_ENV === 'dev' && canSkipCompile()) { + console.info( + 'COMPILATION SKIPPED: No changes detected in tracker dependencies' + ) + return + } + + const bundledCode = await bundleCode() + + const startTime = Date.now() + const variantsToCompile = ALL_VARIANTS.filter(shouldCompileVariant) + console.log(`Starting compilation of ${variantsToCompile.length} variants...`) + + const bar = new progress.SingleBar( + { clearOnComplete: true }, + progress.Presets.shades_classic + ) + bar.start(variantsToCompile.length, 0) + + const workerPool = Pool(() => spawn(new Worker('./worker-thread.js'))) + variantsToCompile.forEach((variant) => { + workerPool.queue(async (worker) => { + await worker.compileFile(variant, { ...options, bundledCode }) + bar.increment() + }) + }) + + await workerPool.completed() + await workerPool.terminate() + bar.stop() + + console.log( + `Completed compilation of ${variantsToCompile.length} variants in ${((Date.now() - startTime) / 1000).toFixed(2)}s` + ) +} + +export async function compileFile(variant, options) { + const globals = { ...DEFAULT_GLOBALS, ...variant.globals } + let code + + if (variant.entry_point) { + code = await bundleCode(variant.entry_point) + } else { + code = options.bundledCode || (await bundleCode()) + } + + if (!variant.npm_package) { + code = wrapInstantlyEvaluatingFunction(code) + } + + code = minify(code, globals, variant) + + if (variant.npm_package) { + code = addExports(code) + } + + if (options.returnCode) { + return code + } else { + fs.writeFileSync(outputPath(variant, options), code) + } +} + +function wrapInstantlyEvaluatingFunction(baseCode) { + return `(function(){${baseCode}})()` +} + +// Works around minification limitation of swc not allowing exports +function addExports(code) { + return `${code}\nexport { init, track, DEFAULT_FILE_TYPES }` +} + +export function compileWebSnippet() { + const code = fs.readFileSync(relPath('../src/web-snippet.js')).toString() + return ` + + ` +} + +async function bundleCode(entryPoint = 'src/plausible.js') { + const bundle = await rollup({ + input: entryPoint, + plugins: [json({ compact: true })] + }) + + const { output } = await bundle.generate({ format: 'esm' }) + + return output[0].code +} + +function outputPath(variant, options) { + if (variant.output_path) { + return relPath(`../../${variant.output_path}${options.suffix || ''}`) + } else if (variant.npm_package) { + return relPath(`../${variant.name}${options.suffix || ''}`) + } else { + return relPath( + `../../priv/tracker/js/${variant.name}${options.suffix || ''}` + ) + } +} + +function minify(code, globals, variant = {}) { + const minifyOptions = { + compress: { + global_defs: globals, + passes: 4 + }, + mangle: {} + } + + if (variant.npm_package) { + minifyOptions.mangle.reserved = ['init', 'track', 'DEFAULT_FILE_TYPES'] + minifyOptions.mangle.toplevel = true + } + + const result = minifySync(code, minifyOptions) + + if (result.code) { + return result.code + } else { + throw result.error + } +} + +function relPath(segment) { + return path.join(__dirname, segment) +} diff --git a/tracker/compiler/variants.json b/tracker/compiler/variants.json new file mode 100644 index 0000000000000000000000000000000000000000..75bed72fdf9badb6eb7488ecf74b629a52379382 --- /dev/null +++ b/tracker/compiler/variants.json @@ -0,0 +1,11315 @@ +{ + "manualVariants": [ + { + "name": "plausible-web.js", + "globals": { + "COMPILE_PLAUSIBLE_WEB": true, + "COMPILE_CONFIG": true, + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_CUSTOM_PROPERTIES": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_REVENUE": true + } + }, + { + "name": "npm_package/plausible.js", + "globals": { + "COMPILE_PLAUSIBLE_NPM": true, + "COMPILE_CONFIG": true, + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_CUSTOM_PROPERTIES": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_REVENUE": true + }, + "npm_package": "esm" + }, + { + "ee_only": true, + "name": "detector.js", + "entry_point": "installation_support/detector.js", + "output_path": "priv/tracker/installation_support/detector.js", + "globals": {} + }, + { + "ee_only": true, + "name": "verifier.js", + "entry_point": "installation_support/verifier.js", + "output_path": "priv/tracker/installation_support/verifier.js", + "globals": {} + } + ], + "legacyVariants": [ + { + "name": "plausible.js", + "globals": { + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.outbound-links.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.outbound-links.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.outbound-links.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.local.outbound-links.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.local.manual.outbound-links.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.local.manual.outbound-links.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.local.manual.outbound-links.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.local.manual.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.local.manual.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.local.manual.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.local.manual.outbound-links.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.local.manual.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.local.manual.outbound-links.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.local.manual.outbound-links.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.local.manual.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.local.manual.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.local.manual.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.local.manual.outbound-links.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.local.manual.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.local.manual.outbound-links.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.local.outbound-links.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.local.outbound-links.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.local.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.local.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.local.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.local.outbound-links.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.local.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.local.outbound-links.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.local.outbound-links.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.local.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.local.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.local.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.local.outbound-links.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.local.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.local.outbound-links.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.manual.outbound-links.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.manual.outbound-links.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.manual.outbound-links.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.manual.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.manual.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.manual.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.manual.outbound-links.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.manual.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.manual.outbound-links.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.manual.outbound-links.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.manual.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.manual.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.manual.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.manual.outbound-links.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.manual.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.manual.outbound-links.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.outbound-links.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.outbound-links.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.outbound-links.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.outbound-links.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.outbound-links.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.outbound-links.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.outbound-links.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.local.outbound-links.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.local.manual.outbound-links.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.local.manual.outbound-links.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.local.manual.outbound-links.pageview-props.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.local.manual.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.local.manual.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.local.manual.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.local.manual.outbound-links.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.local.manual.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.local.manual.outbound-links.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.local.manual.outbound-links.pageview-props.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.local.manual.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.local.manual.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.local.manual.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.local.manual.outbound-links.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.local.manual.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.local.manual.outbound-links.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.local.outbound-links.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.local.outbound-links.pageview-props.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.local.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.local.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.local.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.local.outbound-links.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.local.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.local.outbound-links.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.local.outbound-links.pageview-props.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.local.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.local.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.local.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.local.outbound-links.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.local.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.local.outbound-links.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.manual.outbound-links.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.manual.outbound-links.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.manual.outbound-links.pageview-props.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.manual.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.manual.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.manual.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.manual.outbound-links.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.manual.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.manual.outbound-links.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.manual.outbound-links.pageview-props.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.manual.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.manual.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.manual.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.manual.outbound-links.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.manual.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.manual.outbound-links.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.outbound-links.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.outbound-links.pageview-props.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.outbound-links.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.outbound-links.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.outbound-links.pageview-props.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.outbound-links.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.outbound-links.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.outbound-links.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.local.outbound-links.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.local.manual.outbound-links.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.local.manual.outbound-links.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.local.manual.outbound-links.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.local.manual.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.local.manual.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.local.manual.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.local.manual.outbound-links.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.local.manual.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.local.manual.outbound-links.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.local.manual.outbound-links.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.local.manual.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.local.manual.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.local.manual.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.local.manual.outbound-links.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.local.manual.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.local.manual.outbound-links.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.local.outbound-links.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.local.outbound-links.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.local.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.local.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.local.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.local.outbound-links.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.local.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.local.outbound-links.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.local.outbound-links.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.local.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.local.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.local.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.local.outbound-links.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.local.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.local.outbound-links.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.manual.outbound-links.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.manual.outbound-links.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.manual.outbound-links.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.manual.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.manual.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.manual.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.manual.outbound-links.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.manual.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.manual.outbound-links.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.manual.outbound-links.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.manual.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.manual.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.manual.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.manual.outbound-links.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.manual.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.manual.outbound-links.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.outbound-links.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.outbound-links.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.outbound-links.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.outbound-links.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.outbound-links.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.outbound-links.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.outbound-links.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.local.outbound-links.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.local.manual.outbound-links.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.local.manual.outbound-links.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.local.manual.outbound-links.pageview-props.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.local.manual.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.local.manual.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.local.manual.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.local.manual.outbound-links.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.local.manual.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.local.manual.outbound-links.revenue.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.local.manual.outbound-links.pageview-props.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.local.manual.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.local.manual.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.local.manual.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.local.manual.outbound-links.tagged-events.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.local.manual.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.local.manual.outbound-links.revenue.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.local.outbound-links.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.local.outbound-links.pageview-props.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.local.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.local.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.local.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.local.outbound-links.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.local.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.local.outbound-links.revenue.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.local.outbound-links.pageview-props.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.local.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.local.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.local.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.local.outbound-links.tagged-events.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.local.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.local.outbound-links.revenue.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.manual.outbound-links.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.manual.outbound-links.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.manual.outbound-links.pageview-props.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.manual.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.manual.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.manual.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.manual.outbound-links.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.manual.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.manual.outbound-links.revenue.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.manual.outbound-links.pageview-props.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.manual.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.manual.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.manual.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.manual.outbound-links.tagged-events.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.manual.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.manual.outbound-links.revenue.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.outbound-links.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.outbound-links.pageview-props.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.outbound-links.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.outbound-links.revenue.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.outbound-links.pageview-props.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.outbound-links.tagged-events.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.outbound-links.revenue.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.local.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.local.manual.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.local.manual.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.local.manual.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.local.manual.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.local.manual.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.local.manual.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.local.manual.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.local.manual.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.local.manual.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.local.manual.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.local.manual.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.local.manual.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.local.manual.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.local.manual.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.local.manual.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.local.manual.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.local.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.local.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.local.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.local.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.local.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.local.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.local.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.local.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.local.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.local.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.local.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.local.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.local.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.local.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.local.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.manual.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.manual.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.manual.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.manual.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.manual.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.manual.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.manual.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.manual.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.manual.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.manual.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.manual.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.manual.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.manual.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.manual.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.manual.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.manual.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.hash.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.hash.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.local.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.local.manual.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.local.manual.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.local.manual.pageview-props.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.local.manual.pageview-props.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.local.manual.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.local.manual.pageview-props.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.local.manual.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.local.manual.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.local.manual.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.local.manual.pageview-props.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.local.manual.pageview-props.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.local.manual.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.local.manual.pageview-props.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.local.manual.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.local.manual.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.local.manual.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.local.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.local.pageview-props.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.local.pageview-props.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.local.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.local.pageview-props.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.local.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.local.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.local.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.local.pageview-props.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.local.pageview-props.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.local.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.local.pageview-props.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.local.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.local.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.local.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.manual.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.manual.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.manual.pageview-props.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.manual.pageview-props.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.manual.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.manual.pageview-props.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.manual.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.manual.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.manual.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.manual.pageview-props.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.manual.pageview-props.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.manual.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.manual.pageview-props.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.manual.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.manual.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.manual.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.pageview-props.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.pageview-props.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.pageview-props.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.hash.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.pageview-props.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.pageview-props.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.pageview-props.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.hash.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_HASH": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.local.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.local.manual.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.local.manual.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.local.manual.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.local.manual.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.local.manual.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.local.manual.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.local.manual.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.local.manual.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.local.manual.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.local.manual.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.local.manual.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.local.manual.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.local.manual.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.local.manual.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.local.manual.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.local.manual.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.local.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.local.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.local.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.local.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.local.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.local.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.local.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.local.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.local.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.local.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.local.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.local.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.local.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.local.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.local.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.manual.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.manual.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.manual.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.manual.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.manual.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.manual.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.manual.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.manual.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.manual.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.manual.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.manual.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.manual.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.manual.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.manual.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.manual.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.manual.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.hash.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.hash.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_HASH": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.local.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.local.manual.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.local.manual.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.local.manual.pageview-props.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.local.manual.pageview-props.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.local.manual.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.local.manual.pageview-props.revenue.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.local.manual.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.local.manual.revenue.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.local.manual.revenue.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.local.manual.pageview-props.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.local.manual.pageview-props.tagged-events.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.local.manual.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.local.manual.pageview-props.revenue.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.local.manual.tagged-events.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.local.manual.revenue.tagged-events.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.local.manual.revenue.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.local.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.local.pageview-props.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.local.pageview-props.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.local.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.local.pageview-props.revenue.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.local.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.local.revenue.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.local.revenue.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.local.pageview-props.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.local.pageview-props.tagged-events.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.local.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.local.pageview-props.revenue.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.local.tagged-events.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.local.revenue.tagged-events.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.local.revenue.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_LOCAL": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.manual.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.manual.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.manual.pageview-props.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.manual.pageview-props.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.manual.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.manual.pageview-props.revenue.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.manual.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.manual.revenue.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.manual.revenue.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.manual.pageview-props.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.manual.pageview-props.tagged-events.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.manual.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.manual.pageview-props.revenue.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.manual.tagged-events.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.manual.revenue.tagged-events.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.manual.revenue.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.pageview-props.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.pageview-props.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.pageview-props.revenue.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.revenue.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.hash.revenue.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_HASH": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.pageview-props.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.pageview-props.tagged-events.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.pageview-props.revenue.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.tagged-events.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.revenue.tagged-events.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.hash.revenue.js", + "globals": { + "COMPILE_HASH": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.outbound-links.js", + "globals": { + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.outbound-links.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.outbound-links.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.local.outbound-links.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.local.manual.outbound-links.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.local.manual.outbound-links.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.local.manual.outbound-links.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.local.manual.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.local.manual.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.local.manual.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.local.manual.outbound-links.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.local.manual.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.local.manual.outbound-links.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.local.manual.outbound-links.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.local.manual.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.local.manual.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.local.manual.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.local.manual.outbound-links.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.local.manual.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.local.manual.outbound-links.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.local.outbound-links.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.local.outbound-links.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.local.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.local.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.local.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.local.outbound-links.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.local.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.local.outbound-links.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.local.outbound-links.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.local.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.local.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.local.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.local.outbound-links.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.local.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.local.outbound-links.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.manual.outbound-links.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.manual.outbound-links.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.manual.outbound-links.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.manual.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.manual.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.manual.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.manual.outbound-links.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.manual.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.manual.outbound-links.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.manual.outbound-links.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.manual.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.manual.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.manual.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.manual.outbound-links.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.manual.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.manual.outbound-links.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.outbound-links.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.outbound-links.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.outbound-links.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.outbound-links.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.outbound-links.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.outbound-links.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.outbound-links.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.local.outbound-links.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.local.manual.outbound-links.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.local.manual.outbound-links.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.local.manual.outbound-links.pageview-props.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.local.manual.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.local.manual.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.local.manual.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.local.manual.outbound-links.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.local.manual.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.local.manual.outbound-links.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.local.manual.outbound-links.pageview-props.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.local.manual.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.local.manual.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.local.manual.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.local.manual.outbound-links.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.local.manual.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.local.manual.outbound-links.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.local.outbound-links.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.local.outbound-links.pageview-props.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.local.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.local.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.local.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.local.outbound-links.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.local.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.local.outbound-links.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.local.outbound-links.pageview-props.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.local.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.local.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.local.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.local.outbound-links.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.local.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.local.outbound-links.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.manual.outbound-links.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.manual.outbound-links.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.manual.outbound-links.pageview-props.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.manual.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.manual.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.manual.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.manual.outbound-links.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.manual.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.manual.outbound-links.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.manual.outbound-links.pageview-props.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.manual.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.manual.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.manual.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.manual.outbound-links.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.manual.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.manual.outbound-links.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.outbound-links.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.outbound-links.pageview-props.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.outbound-links.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.outbound-links.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.outbound-links.pageview-props.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.outbound-links.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.outbound-links.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.outbound-links.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.local.outbound-links.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.local.manual.outbound-links.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.local.manual.outbound-links.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.local.manual.outbound-links.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.local.manual.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.local.manual.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.local.manual.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.local.manual.outbound-links.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.local.manual.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.local.manual.outbound-links.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.local.manual.outbound-links.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.local.manual.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.local.manual.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.local.manual.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.local.manual.outbound-links.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.local.manual.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.local.manual.outbound-links.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.local.outbound-links.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.local.outbound-links.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.local.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.local.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.local.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.local.outbound-links.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.local.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.local.outbound-links.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.local.outbound-links.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.local.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.local.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.local.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.local.outbound-links.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.local.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.local.outbound-links.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.manual.outbound-links.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.manual.outbound-links.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.manual.outbound-links.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.manual.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.manual.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.manual.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.manual.outbound-links.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.manual.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.manual.outbound-links.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.manual.outbound-links.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.manual.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.manual.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.manual.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.manual.outbound-links.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.manual.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.manual.outbound-links.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.outbound-links.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.outbound-links.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.outbound-links.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.outbound-links.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.outbound-links.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.outbound-links.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.outbound-links.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.local.outbound-links.js", + "globals": { + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.local.manual.outbound-links.js", + "globals": { + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.local.manual.outbound-links.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.local.manual.outbound-links.pageview-props.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.local.manual.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.local.manual.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.local.manual.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.local.manual.outbound-links.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.local.manual.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.local.manual.outbound-links.revenue.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.local.manual.outbound-links.pageview-props.js", + "globals": { + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.local.manual.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.local.manual.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.local.manual.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.local.manual.outbound-links.tagged-events.js", + "globals": { + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.local.manual.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.local.manual.outbound-links.revenue.js", + "globals": { + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.local.outbound-links.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.local.outbound-links.pageview-props.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.local.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.local.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.local.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.local.outbound-links.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.local.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.local.outbound-links.revenue.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.local.outbound-links.pageview-props.js", + "globals": { + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.local.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.local.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.local.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.local.outbound-links.tagged-events.js", + "globals": { + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.local.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.local.outbound-links.revenue.js", + "globals": { + "COMPILE_LOCAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.manual.outbound-links.js", + "globals": { + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.manual.outbound-links.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.manual.outbound-links.pageview-props.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.manual.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.manual.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.manual.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.manual.outbound-links.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.manual.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.manual.outbound-links.revenue.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.manual.outbound-links.pageview-props.js", + "globals": { + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.manual.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.manual.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.manual.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.manual.outbound-links.tagged-events.js", + "globals": { + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.manual.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.manual.outbound-links.revenue.js", + "globals": { + "COMPILE_MANUAL": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.outbound-links.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.outbound-links.pageview-props.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.outbound-links.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.outbound-links.revenue.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.outbound-links.pageview-props.js", + "globals": { + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.outbound-links.pageview-props.tagged-events.js", + "globals": { + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.outbound-links.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.outbound-links.pageview-props.revenue.js", + "globals": { + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.outbound-links.tagged-events.js", + "globals": { + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.outbound-links.revenue.tagged-events.js", + "globals": { + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.outbound-links.revenue.js", + "globals": { + "COMPILE_OUTBOUND_LINKS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.local.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.local.manual.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.local.manual.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.local.manual.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.local.manual.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.local.manual.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.local.manual.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.local.manual.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.local.manual.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.local.manual.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.local.manual.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.local.manual.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.local.manual.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.local.manual.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.local.manual.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.local.manual.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.local.manual.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.local.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.local.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.local.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.local.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.local.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.local.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.local.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.local.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.local.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.local.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.local.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.local.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.local.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.local.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.local.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.manual.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_MANUAL": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.manual.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.manual.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.manual.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.manual.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.manual.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.manual.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.manual.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.manual.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.manual.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.manual.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.manual.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.manual.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.manual.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_MANUAL": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.manual.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.manual.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.file-downloads.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.exclusions.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_EXCLUSIONS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.local.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.local.manual.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.local.manual.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.local.manual.pageview-props.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.local.manual.pageview-props.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.local.manual.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.local.manual.pageview-props.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.local.manual.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.local.manual.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.local.manual.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.local.manual.pageview-props.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.local.manual.pageview-props.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.local.manual.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.local.manual.pageview-props.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.local.manual.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.local.manual.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.local.manual.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.local.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.local.pageview-props.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.local.pageview-props.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.local.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.local.pageview-props.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.local.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.local.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.local.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.local.pageview-props.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.local.pageview-props.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.local.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.local.pageview-props.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.local.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.local.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.local.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_LOCAL": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.manual.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_MANUAL": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.manual.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.manual.pageview-props.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.manual.pageview-props.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.manual.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.manual.pageview-props.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.manual.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.manual.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.manual.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.manual.pageview-props.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.manual.pageview-props.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.manual.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.manual.pageview-props.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.manual.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_MANUAL": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.manual.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.manual.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.pageview-props.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.pageview-props.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.pageview-props.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.file-downloads.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.pageview-props.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.pageview-props.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.pageview-props.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.revenue.tagged-events.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.exclusions.revenue.js", + "globals": { + "COMPILE_EXCLUSIONS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.local.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_LOCAL": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.local.manual.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.local.manual.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.local.manual.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.local.manual.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.local.manual.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.local.manual.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.local.manual.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.local.manual.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.local.manual.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.local.manual.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.local.manual.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.local.manual.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.local.manual.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.local.manual.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.local.manual.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.local.manual.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.local.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.local.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.local.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.local.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.local.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.local.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.local.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.local.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.local.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.local.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.local.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.local.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.local.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_LOCAL": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.local.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_LOCAL": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.local.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_LOCAL": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.manual.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_MANUAL": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.manual.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.manual.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.manual.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.manual.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.manual.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.manual.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.manual.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.manual.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.manual.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.manual.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.manual.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.manual.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.manual.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_MANUAL": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.manual.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.manual.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.file-downloads.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.pageview-props.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.pageview-props.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.pageview-props.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.revenue.tagged-events.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.compat.revenue.js", + "globals": { + "COMPILE_COMPAT": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.local.js", + "globals": { + "COMPILE_LOCAL": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.local.manual.js", + "globals": { + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.local.manual.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.local.manual.pageview-props.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.local.manual.pageview-props.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.local.manual.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.local.manual.pageview-props.revenue.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.local.manual.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.local.manual.revenue.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.local.manual.revenue.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.local.manual.pageview-props.js", + "globals": { + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.local.manual.pageview-props.tagged-events.js", + "globals": { + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.local.manual.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.local.manual.pageview-props.revenue.js", + "globals": { + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.local.manual.tagged-events.js", + "globals": { + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.local.manual.revenue.tagged-events.js", + "globals": { + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.local.manual.revenue.js", + "globals": { + "COMPILE_LOCAL": true, + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.local.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.local.pageview-props.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.local.pageview-props.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.local.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.local.pageview-props.revenue.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.local.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.local.revenue.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.local.revenue.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_LOCAL": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.local.pageview-props.js", + "globals": { + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.local.pageview-props.tagged-events.js", + "globals": { + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.local.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.local.pageview-props.revenue.js", + "globals": { + "COMPILE_LOCAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.local.tagged-events.js", + "globals": { + "COMPILE_LOCAL": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.local.revenue.tagged-events.js", + "globals": { + "COMPILE_LOCAL": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.local.revenue.js", + "globals": { + "COMPILE_LOCAL": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.manual.js", + "globals": { + "COMPILE_MANUAL": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.manual.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.manual.pageview-props.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.manual.pageview-props.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.manual.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.manual.pageview-props.revenue.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.manual.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.manual.revenue.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.manual.revenue.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.manual.pageview-props.js", + "globals": { + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.manual.pageview-props.tagged-events.js", + "globals": { + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.manual.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.manual.pageview-props.revenue.js", + "globals": { + "COMPILE_MANUAL": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.manual.tagged-events.js", + "globals": { + "COMPILE_MANUAL": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.manual.revenue.tagged-events.js", + "globals": { + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.manual.revenue.js", + "globals": { + "COMPILE_MANUAL": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.pageview-props.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.pageview-props.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.pageview-props.revenue.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.revenue.tagged-events.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.file-downloads.revenue.js", + "globals": { + "COMPILE_FILE_DOWNLOADS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.pageview-props.js", + "globals": { + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.pageview-props.tagged-events.js", + "globals": { + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.pageview-props.revenue.tagged-events.js", + "globals": { + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.pageview-props.revenue.js", + "globals": { + "COMPILE_PAGEVIEW_PROPS": true, + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.tagged-events.js", + "globals": { + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.revenue.tagged-events.js", + "globals": { + "COMPILE_REVENUE": true, + "COMPILE_TAGGED_EVENTS": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + }, + { + "name": "plausible.revenue.js", + "globals": { + "COMPILE_REVENUE": true, + "COMPILE_PLAUSIBLE_LEGACY_VARIANT": true + } + } + ] +} diff --git a/tracker/compiler/worker-thread.js b/tracker/compiler/worker-thread.js new file mode 100644 index 0000000000000000000000000000000000000000..fdd339f867fdaccfccf6326db862bf65ac27b695 --- /dev/null +++ b/tracker/compiler/worker-thread.js @@ -0,0 +1,4 @@ +import { compileFile } from './index.js' +import { expose } from 'threads/worker' + +expose({ compileFile }) diff --git a/tracker/eslint.config.mjs b/tracker/eslint.config.mjs new file mode 100644 index 0000000000000000000000000000000000000000..7756e86e13456bc5ea5ae1e989f182430777bdd1 --- /dev/null +++ b/tracker/eslint.config.mjs @@ -0,0 +1,100 @@ +import globals from 'globals' +import { globalIgnores } from 'eslint/config' +import eslint from '@eslint/js' +import pluginPlaywright from 'eslint-plugin-playwright' +import prettierEslintInteroperabilityConfig from 'eslint-config-prettier/flat' +import tseslint from 'typescript-eslint' +import { DEFAULT_GLOBALS } from './compiler/index.js' + +const DEFAULT_BOOLEAN_SETTINGS = Object.fromEntries( + Object.entries(DEFAULT_GLOBALS).map(([global]) => [global, 'readonly']) +) + +export default tseslint.config([ + globalIgnores(['./npm_package/plausible.js']), + // shared config for all files + eslint.configs.recommended, + tseslint.configs.recommended, + { + rules: { + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_' + } + ], + '@typescript-eslint/no-unused-expressions': [ + 'error', + { + allowShortCircuit: true + } + ] + } + }, + // config for build scripts + { + files: [ + 'compile.js', + 'compiler/**/*.js', + 'playwright.config.js', + 'eslint.config.mjs', + 'playwright.config.ts', + 'test/support/server.js' + ], + languageOptions: { ecmaVersion: 'latest', globals: globals.node } + }, + // config for tracker script + { + files: ['src/**/*.js'], + languageOptions: { + ecmaVersion: 5, // must work also in older browsers + globals: { + ...globals.browser, + ...DEFAULT_BOOLEAN_SETTINGS, + plausible: 'writeable' + }, + sourceType: 'commonjs' + } + }, + // config for installation support scripts + { + files: ['installation_support/**/*.js'], + languageOptions: { + ecmaVersion: 'latest', // must work only in modern browsers + globals: { + ...globals.browser, + plausible: 'readable' + } + } + }, + // config for tests of tracker scripts and installation support scripts + { + files: ['test/**/*.js', 'test/**/*.ts'], + ...pluginPlaywright.configs['flat/recommended'], + languageOptions: { + ecmaVersion: 'latest', + globals: { + // available in test runner: + ...globals.node, + // available within the Page under test: + ...globals.browser, + plausible: 'readable' + } + }, + rules: { + ...pluginPlaywright.configs['flat/recommended'].rules, + 'playwright/expect-expect': [ + 'error', + { + assertFunctionNames: ['expectPlausibleInAction'] + } + ], + 'playwright/no-wait-for-timeout': 'off', // justification: it's necessary for engagement and scroll depth tests + 'playwright/no-conditional-in-test': 'off', // justification: it's necessary for generated tests + 'playwright/no-skipped-test': 'off' // justification: test skips are intentional, usually for flakiness on specific browsers + } + }, + prettierEslintInteroperabilityConfig +]) diff --git a/tracker/installation_support/autoconsent-rules/autoconsent/cookiebot.json b/tracker/installation_support/autoconsent-rules/autoconsent/cookiebot.json new file mode 100644 index 0000000000000000000000000000000000000000..a6c714e9a2c5cacd0d90cb234fcb8e4bfc491664 --- /dev/null +++ b/tracker/installation_support/autoconsent-rules/autoconsent/cookiebot.json @@ -0,0 +1,21 @@ +{ + "name": "cookiebot", + "prehideSelectors": [], + "detectCmp": [ + { + "exists": "#CybotCookiebotDialogBodyButtonAccept, #CybotCookiebotDialogBody, #CybotCookiebotDialogBodyLevelButtonPreferences, #cb-cookieoverlay, #CybotCookiebotDialog" + } + ], + "detectPopup": [ + { + "visible": "#CybotCookiebotDialogBodyButtonAccept, #CybotCookiebotDialogBody, #CybotCookiebotDialogBodyLevelButtonPreferences, #cb-cookieoverlay, #CybotCookiebotDialog, #cookiebanner" + } + ], + "optIn": [ + { + "waitForThenClick": "#CybotCookiebotDialogBodyLevelButtonLevelOptinAllowAll" + } + ], + "optOut": [], + "test": [] +} diff --git a/tracker/installation_support/autoconsent-to-cookies.js b/tracker/installation_support/autoconsent-to-cookies.js new file mode 100644 index 0000000000000000000000000000000000000000..57677e1960a69bfed1fe6e7dfe59a42fe9127357 --- /dev/null +++ b/tracker/installation_support/autoconsent-to-cookies.js @@ -0,0 +1,80 @@ +/** + * This module contains the code for starting a process + * that tries to detect Consent Management Platforms (CMPs) + * and if it finds any, to opt in. + * + * This is necessary because it's possible that Plausible script + * is conditional on the client opting in to tracking cookies. + */ + +// Note: these imports are using relative path syntax so rollup would bundle them +import AutoConsent from '../node_modules/@duckduckgo/autoconsent/dist/autoconsent.esm.js' +import usercentricsButton from '../node_modules/@duckduckgo/autoconsent/rules/autoconsent/usercentrics-button.json' +import iubenda from '../node_modules/@duckduckgo/autoconsent/rules/autoconsent/iubenda.json' +import quantcast from '../node_modules/@duckduckgo/autoconsent/rules/autoconsent/quantcast.json' +import { consentomatic } from '../node_modules/@duckduckgo/autoconsent/rules/consentomatic.json' +import cookiebotHandrolled from './autoconsent-rules/autoconsent/cookiebot.json' + +const autoconsent = [ + usercentricsButton, + iubenda, + cookiebotHandrolled, + quantcast +] + +export function initializeCookieConsentEngine({ + debug, + onConsentDone, + onLifecycleUpdate, + onConsentError +}) { + const onMessage = (message) => { + switch (message?.type) { + case 'autoconsentDone': + return onConsentDone(message.cmp) + case 'autoconsentError': + return onConsentError(message.details) + case 'report': + return onLifecycleUpdate(message.state.lifecycle) + default: + return + } + } + + try { + const engine = new AutoConsent( + onMessage, + { + enabled: true, + autoAction: 'optIn', + disabledCmps: [], + enablePrehide: false, + enableCosmeticRules: false, + enableGeneratedRules: false, + enableHeuristicDetection: false, + detectRetries: 2, + isMainWorld: false, + prehideTimeout: 0, + enableFilterList: false, + visualTest: false, + logs: { + lifecycle: debug, + rulesteps: debug, + detectionsteps: debug, + evals: debug, + errors: debug, + messages: debug, + waits: debug + } + }, + { autoconsent, consentomatic } + ) + + return { handled: null, engineLifecycle: engine.state.lifecycle } + } catch (_error) { + return { + handled: false, + error: { message: 'Error initializing cookie consent engine' } + } + } +} diff --git a/tracker/installation_support/check-disallowed-by-csp.js b/tracker/installation_support/check-disallowed-by-csp.js new file mode 100644 index 0000000000000000000000000000000000000000..a2c1cb8d6625dc5ccf04ddeb02e03045aec620dc --- /dev/null +++ b/tracker/installation_support/check-disallowed-by-csp.js @@ -0,0 +1,23 @@ +/** + * Checks if the CSP policy disallows the given host/domain. + * @param {Record} responseHeaders - Response headers with keys normalized to lowercase like { "x-foo": "bar" } + * @param {string} hostToCheck - Domain/host to check. Must be provided. + * @returns {boolean} + */ +export function checkDisallowedByCSP(responseHeaders, hostToCheck) { + if (!hostToCheck || typeof hostToCheck !== 'string') { + throw new Error('hostToCheck must be a non-empty string') + } + const policy = responseHeaders?.['content-security-policy'] + if (!policy) return false + + const directives = policy.split(';') + + const allowed = directives.some((directive) => { + const d = directive.trim() + // Check for the provided host/domain + return d.includes(hostToCheck) + }) + + return !allowed +} diff --git a/tracker/installation_support/check-gtm.js b/tracker/installation_support/check-gtm.js new file mode 100644 index 0000000000000000000000000000000000000000..b82e616d343a64a9d4b5d7c45bf661cae87a6538 --- /dev/null +++ b/tracker/installation_support/check-gtm.js @@ -0,0 +1,19 @@ +const GTM_SIGNATURES = ['googletagmanager.com/gtm.js'] + +function scanGTM(html) { + if (typeof html === 'string') { + return GTM_SIGNATURES.some((signature) => { + return html.includes(signature) + }) + } + + return false +} + +export function checkGTM(document) { + if (typeof document === 'object') { + return scanGTM(document.documentElement?.outerHTML) + } + + return false +} diff --git a/tracker/installation_support/check-npm.js b/tracker/installation_support/check-npm.js new file mode 100644 index 0000000000000000000000000000000000000000..b52ee9c42b9c36ed0373acdae763a2bf075a18a6 --- /dev/null +++ b/tracker/installation_support/check-npm.js @@ -0,0 +1,7 @@ +export function checkNPM(document) { + if (typeof document === 'object') { + return window.plausible?.s === 'npm' + } + + return false +} diff --git a/tracker/installation_support/check-wordpress.js b/tracker/installation_support/check-wordpress.js new file mode 100644 index 0000000000000000000000000000000000000000..09da51a3d7e32934e080e54c0351e4e9bba67a5d --- /dev/null +++ b/tracker/installation_support/check-wordpress.js @@ -0,0 +1,35 @@ +export const WORDPRESS_PLUGIN_VERSION_SELECTOR = + 'meta[name="plausible-analytics-version"]' + +const WORDPRESS_SIGNATURES = ['wp-content', 'wp-includes', 'wp-json'] + +function scanWpPlugin(document) { + if (typeof document.querySelector === 'function') { + const metaTag = document.querySelector(WORDPRESS_PLUGIN_VERSION_SELECTOR) + return metaTag !== null + } + + return false +} + +function scanWp(html) { + if (typeof html === 'string') { + return WORDPRESS_SIGNATURES.some((signature) => { + return html.includes(signature) + }) + } + + return false +} + +export function checkWordPress(document) { + if (typeof document === 'object') { + const wordpressPlugin = scanWpPlugin(document) + const wordpressLikely = + wordpressPlugin || scanWp(document.documentElement?.outerHTML) + + return { wordpressPlugin, wordpressLikely } + } + + return { wordpressPlugin: false, wordpressLikely: false } +} diff --git a/tracker/installation_support/detector.js b/tracker/installation_support/detector.js new file mode 100644 index 0000000000000000000000000000000000000000..fdce30a61d989061ab203826fb22a415614eace5 --- /dev/null +++ b/tracker/installation_support/detector.js @@ -0,0 +1,45 @@ +import { waitForPlausibleFunction } from './wait-for-plausible-function' +import { checkWordPress } from './check-wordpress' +import { checkGTM } from './check-gtm' +import { checkNPM } from './check-npm' + +window.scanPageBeforePlausibleInstallation = async function ({ + detectV1, + debug, + timeoutMs +}) { + function log(message) { + if (debug) console.log('[Plausible Verification]', message) + } + + let v1Detected = null + + if (detectV1) { + log('Waiting for Plausible function...') + const plausibleFound = await waitForPlausibleFunction(timeoutMs) + log(`plausibleFound: ${plausibleFound}`) + v1Detected = plausibleFound && typeof window.plausible.s === 'undefined' + log(`v1Detected: ${v1Detected}`) + } + + const { wordpressPlugin, wordpressLikely } = checkWordPress(document) + log(`wordpressPlugin: ${wordpressPlugin}`) + log(`wordpressLikely: ${wordpressLikely}`) + + const gtmLikely = checkGTM(document) + log(`gtmLikely: ${gtmLikely}`) + + const npm = checkNPM(document) + log(`npm: ${npm}`) + + return { + data: { + completed: true, + v1Detected: v1Detected, + wordpressPlugin: wordpressPlugin, + wordpressLikely: wordpressLikely, + gtmLikely: gtmLikely, + npm: npm + } + } +} diff --git a/tracker/installation_support/run-check.js b/tracker/installation_support/run-check.js new file mode 100644 index 0000000000000000000000000000000000000000..eb2e89275007783a98365c9e4c61f0549776479e --- /dev/null +++ b/tracker/installation_support/run-check.js @@ -0,0 +1,17 @@ +export async function runThrottledCheck(checkFn, { timeout, interval }) { + return runCheckRecursive(checkFn, timeout, interval, 1) +} + +async function runCheckRecursive(checkFn, timeout, interval, iteration) { + return new Promise((resolve) => { + if (iteration * interval >= timeout) { + resolve(checkFn({ timeout: true })) + } else if (checkFn({ timeout: false }) !== 'continue') { + resolve(checkFn({ timeout: false })) + } else { + setTimeout(() => { + resolve(runCheckRecursive(checkFn, timeout, interval, iteration + 1)) + }, interval) + } + }) +} diff --git a/tracker/installation_support/verifier.js b/tracker/installation_support/verifier.js new file mode 100644 index 0000000000000000000000000000000000000000..8d79f8d14d411c8a679548ad7ec1662eba48994a --- /dev/null +++ b/tracker/installation_support/verifier.js @@ -0,0 +1,314 @@ +/** @typedef {import('../test/support/types').VerifierArgs} VerifierArgs */ +/** @typedef {import('../test/support/types').VerifierResult} VerifierResult */ +import { initializeCookieConsentEngine } from './autoconsent-to-cookies' +import { checkDisallowedByCSP } from './check-disallowed-by-csp' + +/** + * Function that verifies if Plausible is installed correctly. + * @param {VerifierArgs} + * @returns {Promise} + */ + +const DEFAULT_TRACKER_SCRIPT_SELECTOR = 'script[src^="https://plausible.io/js"]' + +async function verifyPlausibleInstallation(options) { + const { + timeoutMs, + responseHeaders, + debug, + cspHostToCheck, + trackerScriptSelector + } = { + trackerScriptSelector: DEFAULT_TRACKER_SCRIPT_SELECTOR, + ...options + } + + function log(message) { + if (debug) console.log('[VERIFICATION]', message) + } + + const disallowedByCsp = checkDisallowedByCSP(responseHeaders, cspHostToCheck) + + forceIgnoreWebdriverCondition() + const { stopRecording, getInterceptedFetch } = startRecordingEventFetchCalls() + + const { + plausibleIsInitialized, + plausibleIsOnWindow, + plausibleVersion, + plausibleVariant, + testEvent, + cookiesConsentResult, + error: testPlausibleFunctionError + } = await testPlausibleFunction({ + timeoutMs, + debug + }) + const trackerIsInHtml = isInHtml(trackerScriptSelector) + + if (testPlausibleFunctionError) { + log( + `There was an error testing plausible function: ${testPlausibleFunctionError}` + ) + } + + stopRecording() + + let interceptedTestEvent = getInterceptedFetch('verification-agent-test') + + if (!interceptedTestEvent) { + log(`No test event request was among intercepted requests`) + } + + // this can be removed once most sites have migrated to v2 and WP plugin is migrated to v2 + if ( + !interceptedTestEvent && + [200, 202].includes(testEvent.callbackResult?.status) + ) { + log( + `The callback result indicates a successful request, assuming legacy .compat installation that uses XMLHttpRequest` + ) + const firstLegacySnippet = document.querySelector( + 'script[data-domain][src]' + ) + if (firstLegacySnippet) { + // legacy installations may list multiple domains in a comma-separated list + const domainString = firstLegacySnippet.getAttribute('data-domain') + const firstDomain = domainString && domainString.split(',').shift() + + interceptedTestEvent = { + request: { + normalizedBody: { + __legacyCompatInstallation: true, + domain: firstDomain + } + }, + response: { status: testEvent.callbackResult.status } + } + } + } + + const diagnostics = { + disallowedByCsp, + trackerIsInHtml, + plausibleIsOnWindow, + plausibleIsInitialized, + plausibleVersion, + plausibleVariant, + testEvent: { + ...testEvent, // callbackResult + testPlausibleFunctionError, + requestUrl: interceptedTestEvent?.request?.url, + normalizedBody: interceptedTestEvent?.request?.normalizedBody, + responseStatus: interceptedTestEvent?.response?.status, + error: interceptedTestEvent?.error + }, + cookiesConsentResult + } + + log({ + diagnostics + }) + + return { + data: { + completed: true, + ...diagnostics + } + } +} + +function getNormalizedPlausibleEventBody(fetchOptions) { + try { + const body = JSON.parse(fetchOptions.body ?? '{}') + + let name = null + let domain = null + let version = null + + if ( + fetchOptions.method === 'POST' && + (typeof body?.n === 'string' || typeof body?.name === 'string') && + (typeof body?.d === 'string' || typeof body?.domain === 'string') + ) { + name = body?.n || body?.name + domain = body?.d || body?.domain + version = body?.v || body?.version + } + return name && domain ? { name, domain, version } : null + } catch (_error) { + // ignore error + } +} + +function startRecordingEventFetchCalls() { + const interceptions = new Map() + + const originalFetch = window.fetch + window.fetch = function (url, options = {}) { + let identifier = null + + const normalizedEventBody = getNormalizedPlausibleEventBody(options) + if (normalizedEventBody) { + identifier = normalizedEventBody.name + interceptions.set(identifier, { + request: { url, normalizedBody: normalizedEventBody } + }) + } + + return originalFetch + .apply(this, arguments) + .then(async (response) => { + const eventRequest = interceptions.get(identifier) + if (eventRequest) { + const responseClone = response.clone() + const body = await responseClone.text() + eventRequest.response = { status: response.status, body } + } + return response + }) + .catch((error) => { + const eventRequest = interceptions.get(identifier) + if (eventRequest) { + eventRequest.error = { + message: error?.message || 'Unknown error during fetch' + } + } + throw error + }) + } + return { + getInterceptedFetch: (identifier) => interceptions.get(identifier), + stopRecording: () => { + window.fetch = originalFetch + } + } +} + +function isInHtml(selector) { + return document.querySelector(selector) !== null +} + +function isPlausibleOnWindow() { + return !!window.plausible +} + +function isPlausibleInitialized() { + return window.plausible?.l +} + +function getPlausibleVersion() { + return window.plausible?.v +} + +function getPlausibleVariant() { + return window.plausible?.s +} + +async function testPlausibleFunction({ timeoutMs, debug }) { + return new Promise((_resolve) => { + let plausibleIsOnWindow = isPlausibleOnWindow() + let plausibleIsInitialized = isPlausibleInitialized() + let plausibleVersion = getPlausibleVersion() + let plausibleVariant = getPlausibleVariant() + let testEvent = {} + let cookiesConsentResult = { + handled: null, + engineLifecycle: 'not-started' + } + let timeout = null + let plausibleOnWindowPollInterval = null + let plausibleInitializedPollInterval = null + let testEventPollInterval = null + + let resolved = false + + const resolve = (overrides) => { + clearTimeout(timeout) + clearInterval(plausibleOnWindowPollInterval) + clearInterval(plausibleInitializedPollInterval) + clearInterval(testEventPollInterval) + if (resolved) { + return + } + + resolved = true + _resolve({ + plausibleIsOnWindow, + plausibleIsInitialized, + plausibleVersion, + plausibleVariant, + testEvent, + cookiesConsentResult, + ...overrides + }) + } + + timeout = setTimeout(() => { + resolve({ + error: 'Test Plausible function timeout exceeded' + }) + }, timeoutMs) + + plausibleOnWindowPollInterval = setInterval( + () => + plausibleIsOnWindow + ? clearInterval(plausibleOnWindowPollInterval) + : (plausibleIsOnWindow = isPlausibleOnWindow()), + 10 + ) + + plausibleInitializedPollInterval = setInterval(() => { + if (plausibleIsInitialized) { + plausibleVersion = getPlausibleVersion() + plausibleVariant = getPlausibleVariant() + clearInterval(plausibleInitializedPollInterval) + } else { + plausibleIsInitialized = isPlausibleInitialized() + } + }, 10) + + testEventPollInterval = setInterval(() => { + if (plausibleIsOnWindow && plausibleIsInitialized) { + window.plausible('verification-agent-test', { + callback: (testEventCallbackResult) => { + resolve({ + testEvent: { + callbackResult: testEventCallbackResult ?? 'undefined or null' + } + }) + } + }) + clearInterval(testEventPollInterval) + } + }, 10) + + cookiesConsentResult = initializeCookieConsentEngine({ + debug, + onConsentDone: (cmp) => { + if (resolved) return + cookiesConsentResult = { handled: true, cmp } + }, + onConsentError: (err) => { + if (resolved) return + cookiesConsentResult = { handled: false, error: err } + }, + onLifecycleUpdate: (lifecycle) => { + if (resolved) return + // skips messages that might override consent success or error + if (cookiesConsentResult.handled !== null) return + if (lifecycle === 'done') { + cookiesConsentResult = { handled: true } + } else { + cookiesConsentResult.engineLifecycle = lifecycle + } + } + }) + }) +} + +function forceIgnoreWebdriverCondition() { + window.__plausible = true +} + +window.verifyPlausibleInstallation = verifyPlausibleInstallation diff --git a/tracker/installation_support/wait-for-plausible-function.js b/tracker/installation_support/wait-for-plausible-function.js new file mode 100644 index 0000000000000000000000000000000000000000..6403f4863c0431f49f8a05869b085ceea6c6f3c8 --- /dev/null +++ b/tracker/installation_support/wait-for-plausible-function.js @@ -0,0 +1,14 @@ +import { runThrottledCheck } from './run-check' + +export async function waitForPlausibleFunction(timeout = 5000) { + const checkFn = (opts) => { + if (window.plausible?.l) { + return true + } + if (opts.timeout) { + return false + } + return 'continue' + } + return await runThrottledCheck(checkFn, { timeout: timeout, interval: 100 }) +} diff --git a/tracker/npm_package/CHANGELOG.md b/tracker/npm_package/CHANGELOG.md new file mode 100644 index 0000000000000000000000000000000000000000..67463f87277d3f983674726ed18481a8b5515167 --- /dev/null +++ b/tracker/npm_package/CHANGELOG.md @@ -0,0 +1,72 @@ +# Changelog + +All notable changes to this npm library will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## Unreleased + +## [0.4.5] - 2026-05-05 + +- Use ResizeObserver over polling for getting scroll metrics + +## [0.4.4] - 2025-10-31 + +- Convert all TypeScript definition comments from `//` style to JSDoc `/** */` style for better IDE integration and TypeScript tooling support + +## [0.4.3] - 2025-09-15 + +- Fix formatting issues. + +## [0.4.2] - 2025-09-04 + +- Remove redeclared variables from the tracker. + +## [0.4.1] - 2025-09-01 + +- Minor update: Allow setting `lib` option for other tools integrating with Plausible. + +## [0.4.0] - 2025-08-12 + +- Move package under `@plausible-analytics/tracker` + +## [0.3.6] - 2025-08-04 + +- Fix issue with a second init() call changing the config option unexpectedly. + +## [0.3.5] - 2025-08-04 + +- Fix issue with link tracking features (tagged events, file downloads, outbound links) causing errors on the page when clicking on `a` tags within `svg` tags. + +## [0.3.4] - 2025-07-23 + +- Plausible loaded indicator `window.plausible.l = true` is set last in initialisation functions + +## [0.3.3] - 2025-07-22 + +- Bind the `track` function into `window.plausible`. This makes it possible for the Plausible verification agent to verify a successful installation. Can be disabled setting the `bindToWindow` config option to `false`. + +## [0.3.2] - 2025-07-14 + +- "Form: Submission" event payload does not need to contain props.path any more: it is saved to be the same as the pathname of the event + +## [0.3.1] - 2025-07-08 + +- Do not send "Form: Submission" event if the form is tagged + +## [0.3.0] - 2025-06-27 + +- Remove now unnecessary navigation delays on link clicks and form submissions. + +## [0.2.4] - 2025-06-19 + +- Add `logging` option +- Improve `callback` option +- Improve `fileDownloads` typing, export `DEFAULT_FILE_TYPES` + +## [0.2.2] - 2025-06-16 + +- Support for `config.transformRequest` +- Support for passing `url` as option when calling `track` +- Drop support for `meta` argument. diff --git a/tracker/npm_package/LICENSE.md b/tracker/npm_package/LICENSE.md new file mode 100644 index 0000000000000000000000000000000000000000..addb30cee09b39abc1bf1ffd0699ac8247324f0d --- /dev/null +++ b/tracker/npm_package/LICENSE.md @@ -0,0 +1,9 @@ +MIT License + +Copyright 2020 Plausible Insights OÜ + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/tracker/npm_package/README.md b/tracker/npm_package/README.md new file mode 100644 index 0000000000000000000000000000000000000000..86ead9162621d44738cacfc740102d02cbbb070a --- /dev/null +++ b/tracker/npm_package/README.md @@ -0,0 +1,130 @@ +# Plausible Analytics tracker + +[![NPM](https://flat.badgen.net/npm/v/@plausible-analytics/tracker)](https://www.npmjs.com/package/@plausible-analytics/tracker) +[![MIT License](https://img.shields.io/badge/License-MIT-red.svg?style=flat-square)](https://opensource.org/licenses/MIT) + +Add [Plausible Analytics](https://plausible.io/) to your website. + +## Features + +- Small package size +- Same features and codebase as the official script, but as an NPM module +- Automatically track page views in your SPA apps +- Track goals and custom events +- Provide manual values that will be bound to the event +- Full typescript support + +> **Important:** This library only works in browser environments. When using server-side rendering (SSR), the `init` and `track` functions will not work as they rely on browser APIs. Make sure to only initialize and track events on the client side. + +## Package Installation + +With npm: + +```bash +npm install @plausible-analytics/tracker +``` + +## Usage + +To begin tracking events, you must initialize the tracker: + +```javascript +import { init } from '@plausible-analytics/tracker' + +init({ + domain: 'my-app.com' +}) +``` + +### Configuration options + +See also [plausible.d.ts](https://github.com/plausible/analytics/blob/master/tracker/npm_package/plausible.d.ts) for typescript types. + +| Option | Description | Default | +| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------- | +| `domain` | **Required** Your site's domain, as declared by you in Plausible's settings. | | +| `endpoint` | The URL of the Plausible API endpoint. See proxying guide at https://plausible.io/docs/proxy/introduction | `"https://plausible.io/api/event"` | +| `autoCapturePageviews` | Whether to automatically capture pageviews. | `true` | +| `hashBasedRouting` | Whether the page uses hash based routing. Read more at https://plausible.io/docs/hash-based-routing | `false` | +| `outboundLinks` | Whether to track outbound link clicks. | `false` | +| `fileDownloads` | Whether to track file downloads. | `false` | +| `formSubmissions` | Whether to track form submissions. | `false` | +| `captureOnLocalhost` | Whether to capture events on localhost. | `false` | +| `logging` | Whether to log on ignored events. | `true` | +| `customProperties` | Object or function that returns custom properties for a given event. | `{}` | +| `transformRequest` | Function that allows transforming or ignoring requests | | +| `bindToWindow` | Binds `track` to `window.plausible` which is used by Plausible installation verification tool to detect whether Plausible has been installed correctly. If `bindToWindow` is set to false, the installation verification tool won't be able to automatically detect it on your site. | `true` | + +#### Using `customProperties` + +To track a custom property with every page view, you can use the `customProperties` configuration option: + +```javascript +init({ + domain: 'my-app.com', + customProperties: { content_category: 'news' } +}) +``` + +`customProperties` can also be a dynamic function: + +```javascript +init({ + domain: 'my-app.com', + customProperties: (eventName) => ({ title: document.title }) +}) +``` + +### Tracking custom events + +To track a custom event, call `track` and give it the name of the event. Custom properties can be passed as a second argument: + +```javascript +import { track } from '@plausible-analytics/tracker' + +track('signup', { props: { tier: 'startup' } }) +``` + +To mark an event as non-interactive so it would not be counted towards bounce rate calculations, set `interactive` option: + +```javascript +track('autoplay', { interactive: false }) +``` + +### Revenue tracking + +To track an event with revenue information, do: + +```javascript +import { track } from '@plausible-analytics/tracker' + +track('Purchase', { revenue: { amount: 15.99, currency: 'USD' } }) +``` + +More information can be found in [ecommerce revenue tracking docs](https://plausible.io/docs/ecommerce-revenue-tracking) + +### Callbacks + +When calling `track`, you can pass in a custom callback. + +```javascript +import { track } from '@plausible-analytics/tracker' + +track('some-event', { + callback: (result) => { + if (result?.status) { + console.debug('Request to plausible done. Status:', result.status) + } else if (result?.error) { + console.log('Error handling request:', result.error) + } else { + console.log('Request was ignored') + } + } +}) +``` + +### Opt out and exclude yourself from the analytics + +Since plausible-tracker is bundled with your application code, using an ad-blocker to exclude your visits isn't an option. Fortunately Plausible has an alternative for this scenario: plausible-tracker will not send events if `localStorage.plausible_ignore` is set to `"true"`. + +More information about this method can be found in the [Plausible documentation](https://plausible.io/docs/excluding-localstorage). diff --git a/tracker/npm_package/package.json b/tracker/npm_package/package.json new file mode 100644 index 0000000000000000000000000000000000000000..9caf8343029b4f951a9ae21aa569c59bdc98e6b1 --- /dev/null +++ b/tracker/npm_package/package.json @@ -0,0 +1,20 @@ +{ + "name": "@plausible-analytics/tracker", + "version": "0.4.5", + "description": "Plausible Analytics official frontend tracking library", + "scripts": { + "test": "echo \"Error: Testing done in the tracker folder\" && exit 1" + }, + "repository": "https://github.com/plausible/plausible", + "keywords": [ + "web-analytics", + "website-analytics", + "analytics" + ], + "author": "Plausible Analytics", + "license": "MIT", + "homepage": "https://plausible.io", + "type": "module", + "module": "./plausible.js", + "types": "./plausible.d.ts" +} diff --git a/tracker/npm_package/plausible.d.ts b/tracker/npm_package/plausible.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..f29a401644acad6fbb6c3fba368be7e76aaee624 --- /dev/null +++ b/tracker/npm_package/plausible.d.ts @@ -0,0 +1,130 @@ +/** Sets up the tracking library. Can be called once. */ +export function init(config: PlausibleConfig): void + +/** Tracks an event, requires `init` to be called first. */ +export function track(eventName: string, options: PlausibleEventOptions): void + +export interface PlausibleConfig { + /** Your site's domain, as declared by you in Plausible's settings. */ + domain: string + + /** + * The URL of the Plausible API endpoint. Defaults to https://plausible.io/api/event + * See proxying guide at https://plausible.io/docs/proxy/introduction + */ + endpoint?: string + + /** Whether to automatically capture pageviews. Defaults to true. */ + autoCapturePageviews?: boolean + + /** + * Whether the page uses hash based routing. Defaults to false. + * Read more at https://plausible.io/docs/hash-based-routing + */ + hashBasedRouting?: boolean + + /** Whether to track outbound link clicks. Defaults to false. */ + outboundLinks?: boolean + + /** Whether to track file downloads. Defaults to false. */ + fileDownloads?: boolean | { fileExtensions: string[] } + + /** Whether to track form submissions. Defaults to false. */ + formSubmissions?: boolean + + /** Whether to capture events on localhost. Defaults to false. */ + captureOnLocalhost?: boolean + + /** Whether to log on ignored events. Defaults to true. */ + logging?: boolean + + /** + * Custom properties to add to all events tracked. + * If passed as a function, it will be called when `track` is called. + */ + customProperties?: + | CustomProperties + | ((eventName: string) => CustomProperties) + + /** + * A function that can be used to transform the payload before it is sent to the API. + * If the function returns null or any other falsy value, the event will be ignored. + * + * This can be used to avoid sending certain types of events, or modifying any event + * parameters, e.g. to clean URLs of values that should not be recorded. + */ + transformRequest?: ( + payload: PlausibleRequestPayload + ) => PlausibleRequestPayload | null + + /** + * If enabled (the default), the script will set `window.plausible` after `init` is called. + * This is used by the verifier to detect if the script is loaded from npm package. + */ + bindToWindow?: boolean +} + +export interface PlausibleEventOptions { + /** + * Custom properties to add to the event. + * Read more at https://plausible.io/docs/custom-props/introduction + */ + props?: CustomProperties + + /** + * Whether the tracked event is interactive. Defaults to true. + * By marking a custom event as non-interactive, it will not be counted towards bounce rate calculations. + */ + interactive?: boolean + + /** + * Revenue data to add to the event. + * Read more at https://plausible.io/docs/ecommerce-revenue-tracking + */ + revenue?: PlausibleEventRevenue + + /** + * Called when request to `endpoint` completes or is ignored. + * When request is ignored, the result will be undefined. + * When request was delivered, the result will be an object with the response status code of the request. + * When there was a network error, the result will be an object with the error object. + */ + callback?: ( + result?: { status: number } | { error: unknown } | undefined + ) => void + + /** + * Overrides the URL of the page that the event is being tracked on. + * If not provided, `location.href` will be used. + */ + url?: string +} + +export type CustomProperties = Record + +export type PlausibleEventRevenue = { + /** Revenue amount in `currency` */ + amount: number | string + /** Currency is an ISO 4217 string representing the currency code, e.g. "USD" or "EUR" */ + currency: string +} + +export type PlausibleRequestPayload = { + /** Event name */ + n: string + /** URL of the event */ + u: string + /** Domain of the event */ + d: string + /** Referrer */ + r?: string | null + /** Custom properties */ + p?: CustomProperties + /** Revenue information */ + $?: PlausibleEventRevenue + /** Whether the event is interactive */ + i?: boolean +} & Record + +/** Default file types that are tracked when `fileDownloads` is enabled. */ +export const DEFAULT_FILE_TYPES: string[] diff --git a/tracker/package-lock.json b/tracker/package-lock.json new file mode 100644 index 0000000000000000000000000000000000000000..29897cf75ee2bfb6b4c69e61ac31505e0005f146 --- /dev/null +++ b/tracker/package-lock.json @@ -0,0 +1,3495 @@ +{ + "name": "tracker", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "license": "MIT", + "dependencies": { + "@duckduckgo/autoconsent": "^14.16.0", + "@rollup/plugin-json": "^6.1.0", + "@swc/core": "^1.11.24", + "chokidar": "^4.0.3", + "cli-progress": "^3.12.0", + "generatorics": "^1.1.0", + "rollup": "^4.41.1", + "threads": "^1.7.0" + }, + "devDependencies": { + "@eslint/js": "^9.34.0", + "@playwright/test": "^1.56.1", + "@types/node": "^22.13.4", + "eslint": "^9.34.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-playwright": "^2.2.2", + "express": "^4.21.2", + "globals": "^16.3.0", + "markdown-table": "^3.0.4", + "prettier": "^3.3.3", + "typescript": "^5.9.2", + "typescript-eslint": "^8.41.0" + } + }, + "node_modules/@aashutoshrathi/word-wrap": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", + "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@duckduckgo/autoconsent": { + "version": "14.16.0", + "resolved": "https://registry.npmjs.org/@duckduckgo/autoconsent/-/autoconsent-14.16.0.tgz", + "integrity": "sha512-BSSiuv7tLrjY42EYKBbkZv7N8xLxJJS4FJqvrDMOQq6JVyWiaPKM+Yt4wxThyrAMnCEiSdMNk7UQJUWWhuwAiQ==", + "license": "MPL-2.0", + "dependencies": { + "@ghostery/adblocker": "^2.0.4", + "@ghostery/adblocker-content": "^2.0.4", + "tldts-experimental": "^7.0.4" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", + "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz", + "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", + "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.34.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.34.0.tgz", + "integrity": "sha512-EoyvqQnBNsV1CWaEJ559rxXL4c8V92gxirbawSmVUOWXlsRxxQXl6LmCpdUblgxgSkDIqKnhzba2SjRTI/A5Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", + "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.15.2", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@ghostery/adblocker": { + "version": "2.11.5", + "resolved": "https://registry.npmjs.org/@ghostery/adblocker/-/adblocker-2.11.5.tgz", + "integrity": "sha512-rJ3N+3XPy1SEeEaeQKowYT/R/Tt1ac9cqYSin1igLY7XqIaGdCYq3tQjBQQ4Vx6ZM2ic412aM84hAKn+D/EDvA==", + "license": "MPL-2.0", + "dependencies": { + "@ghostery/adblocker-content": "^2.11.5", + "@ghostery/adblocker-extended-selectors": "^2.11.5", + "@ghostery/url-parser": "^1.3.0", + "@remusao/guess-url-type": "^2.1.0", + "@remusao/small": "^2.1.0", + "@remusao/smaz": "^2.2.0", + "tldts-experimental": "^7.0.12" + } + }, + "node_modules/@ghostery/adblocker-content": { + "version": "2.11.5", + "resolved": "https://registry.npmjs.org/@ghostery/adblocker-content/-/adblocker-content-2.11.5.tgz", + "integrity": "sha512-7mDOic2lJGxHtmqcbME/oi4XnygYbJ0vgCj/7KedHzhTR8/w/HLpkEQRFiEAKRxQMrEfLVXy+m171Yeia3TaRw==", + "license": "MPL-2.0", + "dependencies": { + "@ghostery/adblocker-extended-selectors": "^2.11.5" + } + }, + "node_modules/@ghostery/adblocker-extended-selectors": { + "version": "2.11.5", + "resolved": "https://registry.npmjs.org/@ghostery/adblocker-extended-selectors/-/adblocker-extended-selectors-2.11.5.tgz", + "integrity": "sha512-O0Diq8pgFeC+IUnS8Ud00aHLHv87djFiXwfUPBiwmwmS8eNRj0FnaRYFd/Du4Xu4pgNe8lkD+OVsBMtG1DGwSw==", + "license": "MPL-2.0" + }, + "node_modules/@ghostery/url-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ghostery/url-parser/-/url-parser-1.3.0.tgz", + "integrity": "sha512-FEzdSeiva0Mt3bR4xePFzthhjT4IzvA5QTvS1xXkNyLpMGeq40mb3V2fSs0ZItRaP9IybZthDfHUSbQ1HLdx4Q==", + "license": "MPL-2.0", + "dependencies": { + "tldts-experimental": "^7.0.8" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.6", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", + "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "dev": true, + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.3.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "dev": true, + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.2.tgz", + "integrity": "sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ==", + "dev": true, + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@playwright/test": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz", + "integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.56.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@remusao/guess-url-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@remusao/guess-url-type/-/guess-url-type-2.1.0.tgz", + "integrity": "sha512-zI3dlTUxpjvx2GCxp9nLOSK5yEIqDCpxlAVGwb2Y49RKkS72oeNaxxo+VWS5+XQ5+Mf8Zfp9ZXIlk+G5eoEN8A==", + "license": "MPL-2.0" + }, + "node_modules/@remusao/small": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@remusao/small/-/small-2.1.0.tgz", + "integrity": "sha512-Y1kyjZp7JU7dXdyOdxHVNfoTr1XLZJTyQP36/esZUU/WRWq9XY0PV2HsE3CsIHuaTf4pvgWv2pvzvnZ//UHIJQ==", + "license": "MPL-2.0" + }, + "node_modules/@remusao/smaz": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@remusao/smaz/-/smaz-2.2.0.tgz", + "integrity": "sha512-eSd3Qs0ELP/e7tU1SI5RWXcCn9KjDgvBY+KtWbL4i2QvvHhJOfdIt4v0AA3S5BbLWAr5dCEC7C4LUfogDm6q/Q==", + "license": "MPL-2.0", + "dependencies": { + "@remusao/smaz-compress": "^2.2.0", + "@remusao/smaz-decompress": "^2.2.0" + } + }, + "node_modules/@remusao/smaz-compress": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@remusao/smaz-compress/-/smaz-compress-2.2.0.tgz", + "integrity": "sha512-TXpTPgILRUYOt2rEe0+9PC12xULPvBqeMpmipzB9A7oM4fa9Ztvy9lLYzPTd7tiQEeoNa1pmxihpKfJtsxnM/w==", + "license": "MPL-2.0", + "dependencies": { + "@remusao/trie": "^2.1.0" + } + }, + "node_modules/@remusao/smaz-decompress": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@remusao/smaz-decompress/-/smaz-decompress-2.2.0.tgz", + "integrity": "sha512-ERAPwxPaA0/yg4hkNU7T2S+lnp9jj1sApcQMtOyROvOQyo+Zuh6Hn/oRcXr8mmjlYzyRaC7E6r3mT1nrdHR6pg==", + "license": "MPL-2.0" + }, + "node_modules/@remusao/trie": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@remusao/trie/-/trie-2.1.0.tgz", + "integrity": "sha512-Er3Q8q0/2OcCJPQYJOPLmCuqO0wu7cav3SPtpjlxSbjFi1x+A1pZkkLD6c9q2rGEkGW/tkrRzfrhNMt8VQjzXg==", + "license": "MPL-2.0" + }, + "node_modules/@rollup/plugin-json": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", + "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==", + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.2.0.tgz", + "integrity": "sha512-qWJ2ZTbmumwiLFomfzTyt5Kng4hwPi9rwCYN4SHb6eaRU1KNO4ccxINHr/VhH4GgPlt1XfSTLX2LBTme8ne4Zw==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.41.1.tgz", + "integrity": "sha512-NELNvyEWZ6R9QMkiytB4/L4zSEaBC03KIXEghptLGLZWJ6VPrL63ooZQCOnlx36aQPGhzuOMwDerC1Eb2VmrLw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.41.1.tgz", + "integrity": "sha512-DXdQe1BJ6TK47ukAoZLehRHhfKnKg9BjnQYUu9gzhI8Mwa1d2fzxA1aw2JixHVl403bwp1+/o/NhhHtxWJBgEA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.41.1.tgz", + "integrity": "sha512-5afxvwszzdulsU2w8JKWwY8/sJOLPzf0e1bFuvcW5h9zsEg+RQAojdW0ux2zyYAz7R8HvvzKCjLNJhVq965U7w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.41.1.tgz", + "integrity": "sha512-egpJACny8QOdHNNMZKf8xY0Is6gIMz+tuqXlusxquWu3F833DcMwmGM7WlvCO9sB3OsPjdC4U0wHw5FabzCGZg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.41.1.tgz", + "integrity": "sha512-DBVMZH5vbjgRk3r0OzgjS38z+atlupJ7xfKIDJdZZL6sM6wjfDNo64aowcLPKIx7LMQi8vybB56uh1Ftck/Atg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.41.1.tgz", + "integrity": "sha512-3FkydeohozEskBxNWEIbPfOE0aqQgB6ttTkJ159uWOFn42VLyfAiyD9UK5mhu+ItWzft60DycIN1Xdgiy8o/SA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.41.1.tgz", + "integrity": "sha512-wC53ZNDgt0pqx5xCAgNunkTzFE8GTgdZ9EwYGVcg+jEjJdZGtq9xPjDnFgfFozQI/Xm1mh+D9YlYtl+ueswNEg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.41.1.tgz", + "integrity": "sha512-jwKCca1gbZkZLhLRtsrka5N8sFAaxrGz/7wRJ8Wwvq3jug7toO21vWlViihG85ei7uJTpzbXZRcORotE+xyrLA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.41.1.tgz", + "integrity": "sha512-g0UBcNknsmmNQ8V2d/zD2P7WWfJKU0F1nu0k5pW4rvdb+BIqMm8ToluW/eeRmxCared5dD76lS04uL4UaNgpNA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.41.1.tgz", + "integrity": "sha512-XZpeGB5TKEZWzIrj7sXr+BEaSgo/ma/kCgrZgL0oo5qdB1JlTzIYQKel/RmhT6vMAvOdM2teYlAaOGJpJ9lahg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.41.1.tgz", + "integrity": "sha512-bkCfDJ4qzWfFRCNt5RVV4DOw6KEgFTUZi2r2RuYhGWC8WhCA8lCAJhDeAmrM/fdiAH54m0mA0Vk2FGRPyzI+tw==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.41.1.tgz", + "integrity": "sha512-3mr3Xm+gvMX+/8EKogIZSIEF0WUu0HL9di+YWlJpO8CQBnoLAEL/roTCxuLncEdgcfJcvA4UMOf+2dnjl4Ut1A==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.41.1.tgz", + "integrity": "sha512-3rwCIh6MQ1LGrvKJitQjZFuQnT2wxfU+ivhNBzmxXTXPllewOF7JR1s2vMX/tWtUYFgphygxjqMl76q4aMotGw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.41.1.tgz", + "integrity": "sha512-LdIUOb3gvfmpkgFZuccNa2uYiqtgZAz3PTzjuM5bH3nvuy9ty6RGc/Q0+HDFrHrizJGVpjnTZ1yS5TNNjFlklw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.41.1.tgz", + "integrity": "sha512-oIE6M8WC9ma6xYqjvPhzZYk6NbobIURvP/lEbh7FWplcMO6gn7MM2yHKA1eC/GvYwzNKK/1LYgqzdkZ8YFxR8g==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.41.1.tgz", + "integrity": "sha512-cWBOvayNvA+SyeQMp79BHPK8ws6sHSsYnK5zDcsC3Hsxr1dgTABKjMnMslPq1DvZIp6uO7kIWhiGwaTdR4Og9A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.41.1.tgz", + "integrity": "sha512-y5CbN44M+pUCdGDlZFzGGBSKCA4A/J2ZH4edTYSSxFg7ce1Xt3GtydbVKWLlzL+INfFIZAEg1ZV6hh9+QQf9YQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.41.1.tgz", + "integrity": "sha512-lZkCxIrjlJlMt1dLO/FbpZbzt6J/A8p4DnqzSa4PWqPEUUUnzXLeki/iyPLfV0BmHItlYgHUqJe+3KiyydmiNQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.41.1.tgz", + "integrity": "sha512-+psFT9+pIh2iuGsxFYYa/LhS5MFKmuivRsx9iPJWNSGbh2XVEjk90fmpUEjCnILPEPJnikAU6SFDiEUyOv90Pg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.41.1.tgz", + "integrity": "sha512-Wq2zpapRYLfi4aKxf2Xff0tN+7slj2d4R87WEzqw7ZLsVvO5zwYCIuEGSZYiK41+GlwUo1HiR+GdkLEJnCKTCw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@swc/core": { + "version": "1.11.24", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.11.24.tgz", + "integrity": "sha512-MaQEIpfcEMzx3VWWopbofKJvaraqmL6HbLlw2bFZ7qYqYw3rkhM0cQVEgyzbHtTWwCwPMFZSC2DUbhlZgrMfLg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "@swc/types": "^0.1.21" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/swc" + }, + "optionalDependencies": { + "@swc/core-darwin-arm64": "1.11.24", + "@swc/core-darwin-x64": "1.11.24", + "@swc/core-linux-arm-gnueabihf": "1.11.24", + "@swc/core-linux-arm64-gnu": "1.11.24", + "@swc/core-linux-arm64-musl": "1.11.24", + "@swc/core-linux-x64-gnu": "1.11.24", + "@swc/core-linux-x64-musl": "1.11.24", + "@swc/core-win32-arm64-msvc": "1.11.24", + "@swc/core-win32-ia32-msvc": "1.11.24", + "@swc/core-win32-x64-msvc": "1.11.24" + }, + "peerDependencies": { + "@swc/helpers": ">=0.5.17" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, + "node_modules/@swc/core-darwin-arm64": { + "version": "1.11.24", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.11.24.tgz", + "integrity": "sha512-dhtVj0PC1APOF4fl5qT2neGjRLgHAAYfiVP8poJelhzhB/318bO+QCFWAiimcDoyMgpCXOhTp757gnoJJrheWA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-darwin-x64": { + "version": "1.11.24", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.11.24.tgz", + "integrity": "sha512-H/3cPs8uxcj2Fe3SoLlofN5JG6Ny5bl8DuZ6Yc2wr7gQFBmyBkbZEz+sPVgsID7IXuz7vTP95kMm1VL74SO5AQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm-gnueabihf": { + "version": "1.11.24", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.11.24.tgz", + "integrity": "sha512-PHJgWEpCsLo/NGj+A2lXZ2mgGjsr96ULNW3+T3Bj2KTc8XtMUkE8tmY2Da20ItZOvPNC/69KroU7edyo1Flfbw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-gnu": { + "version": "1.11.24", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.11.24.tgz", + "integrity": "sha512-C2FJb08+n5SD4CYWCTZx1uR88BN41ZieoHvI8A55hfVf2woT8+6ZiBzt74qW2g+ntZ535Jts5VwXAKdu41HpBg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-musl": { + "version": "1.11.24", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.11.24.tgz", + "integrity": "sha512-ypXLIdszRo0re7PNNaXN0+2lD454G8l9LPK/rbfRXnhLWDBPURxzKlLlU/YGd2zP98wPcVooMmegRSNOKfvErw==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-gnu": { + "version": "1.11.24", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.11.24.tgz", + "integrity": "sha512-IM7d+STVZD48zxcgo69L0yYptfhaaE9cMZ+9OoMxirNafhKKXwoZuufol1+alEFKc+Wbwp+aUPe/DeWC/Lh3dg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-musl": { + "version": "1.11.24", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.11.24.tgz", + "integrity": "sha512-DZByJaMVzSfjQKKQn3cqSeqwy6lpMaQDQQ4HPlch9FWtDx/dLcpdIhxssqZXcR2rhaQVIaRQsCqwV6orSDGAGw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.11.24", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.11.24.tgz", + "integrity": "sha512-Q64Ytn23y9aVDKN5iryFi8mRgyHw3/kyjTjT4qFCa8AEb5sGUuSj//AUZ6c0J7hQKMHlg9do5Etvoe61V98/JQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.11.24", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.11.24.tgz", + "integrity": "sha512-9pKLIisE/Hh2vJhGIPvSoTK4uBSPxNVyXHmOrtdDot4E1FUUI74Vi8tFdlwNbaj8/vusVnb8xPXsxF1uB0VgiQ==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.11.24", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.11.24.tgz", + "integrity": "sha512-sybnXtOsdB+XvzVFlBVGgRHLqp3yRpHK7CrmpuDKszhj/QhmsaZzY/GHSeALlMtLup13M0gqbcQvsTNlAHTg3w==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "license": "Apache-2.0" + }, + "node_modules/@swc/types": { + "version": "0.1.21", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.21.tgz", + "integrity": "sha512-2YEtj5HJVbKivud9N4bpPBAyZhj4S2Ipe5LkUG94alTpr7in/GU/EARgPAd3BwU+YOmFVJC2+kjqhGRi3r0ZpQ==", + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3" + } + }, + "node_modules/@types/estree": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", + "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.13.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.4.tgz", + "integrity": "sha512-ywP2X0DYtX3y08eFVx5fNIw7/uIv8hYUKgXoK8oayJlLnKcRfEYCxWMVE1XagUdVtCJlZT1AU4LXEABW+L1Peg==", + "dev": true, + "dependencies": { + "undici-types": "~6.20.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.41.0.tgz", + "integrity": "sha512-8fz6oa6wEKZrhXWro/S3n2eRJqlRcIa6SlDh59FXJ5Wp5XRZ8B9ixpJDcjadHq47hMx0u+HW6SNa6LjJQ6NLtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.41.0", + "@typescript-eslint/type-utils": "8.41.0", + "@typescript-eslint/utils": "8.41.0", + "@typescript-eslint/visitor-keys": "8.41.0", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.41.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.41.0.tgz", + "integrity": "sha512-gTtSdWX9xiMPA/7MV9STjJOOYtWwIJIYxkQxnSV1U3xcE+mnJSH3f6zI0RYP+ew66WSlZ5ed+h0VCxsvdC1jJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.41.0", + "@typescript-eslint/types": "8.41.0", + "@typescript-eslint/typescript-estree": "8.41.0", + "@typescript-eslint/visitor-keys": "8.41.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.41.0.tgz", + "integrity": "sha512-b8V9SdGBQzQdjJ/IO3eDifGpDBJfvrNTp2QD9P2BeqWTGrRibgfgIlBSw6z3b6R7dPzg752tOs4u/7yCLxksSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.41.0", + "@typescript-eslint/types": "^8.41.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.41.0.tgz", + "integrity": "sha512-n6m05bXn/Cd6DZDGyrpXrELCPVaTnLdPToyhBoFkLIMznRUQUEQdSp96s/pcWSQdqOhrgR1mzJ+yItK7T+WPMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.41.0", + "@typescript-eslint/visitor-keys": "8.41.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.41.0.tgz", + "integrity": "sha512-TDhxYFPUYRFxFhuU5hTIJk+auzM/wKvWgoNYOPcOf6i4ReYlOoYN8q1dV5kOTjNQNJgzWN3TUUQMtlLOcUgdUw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.41.0.tgz", + "integrity": "sha512-63qt1h91vg3KsjVVonFJWjgSK7pZHSQFKH6uwqxAH9bBrsyRhO6ONoKyXxyVBzG1lJnFAJcKAcxLS54N1ee1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.41.0", + "@typescript-eslint/typescript-estree": "8.41.0", + "@typescript-eslint/utils": "8.41.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.41.0.tgz", + "integrity": "sha512-9EwxsWdVqh42afLbHP90n2VdHaWU/oWgbH2P0CfcNfdKL7CuKpwMQGjwev56vWu9cSKU7FWSu6r9zck6CVfnag==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.41.0.tgz", + "integrity": "sha512-D43UwUYJmGhuwHfY7MtNKRZMmfd8+p/eNSfFe6tH5mbVDto+VQCayeAt35rOx3Cs6wxD16DQtIKw/YXxt5E0UQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.41.0", + "@typescript-eslint/tsconfig-utils": "8.41.0", + "@typescript-eslint/types": "8.41.0", + "@typescript-eslint/visitor-keys": "8.41.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.41.0.tgz", + "integrity": "sha512-udbCVstxZ5jiPIXrdH+BZWnPatjlYwJuJkDA4Tbo3WyYLh8NvB+h/bKeSZHDOFKfphsZYJQqaFtLeXEqurQn1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.41.0", + "@typescript-eslint/types": "8.41.0", + "@typescript-eslint/typescript-estree": "8.41.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.41.0.tgz", + "integrity": "sha512-+GeGMebMCy0elMNg67LRNoVnUFPIm37iu5CmHESVx56/9Jsfdpsvbv605DQ81Pi/x11IdKUsS5nzgTYbCQU9fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.41.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dev": true, + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "dev": true + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "dev": true, + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.3.tgz", + "integrity": "sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==", + "dev": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/cli-progress": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/cli-progress/-/cli-progress-3.12.0.tgz", + "integrity": "sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A==", + "license": "MIT", + "dependencies": { + "string-width": "^4.2.3" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dev": true, + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "dev": true + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "dev": true, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.34.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.34.0.tgz", + "integrity": "sha512-RNCHRX5EwdrESy3Jc9o8ie8Bog+PeYvvSR8sDGoZxNFTvZ4dlxUB3WzQ3bQMztFrSRODGrLLj8g6OFuGY/aiQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.3.1", + "@eslint/core": "^0.15.2", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.34.0", + "@eslint/plugin-kit": "^0.3.5", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-playwright": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-playwright/-/eslint-plugin-playwright-2.2.2.tgz", + "integrity": "sha512-j0jKpndIPOXRRP9uMkwb9l/nSmModOU3452nrFdgFJoEv/435J1onk8+aITzjDW8DfypxgmVaDMdmVIa6F7I0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "globals": "^13.23.0" + }, + "engines": { + "node": ">=16.6.0" + }, + "peerDependencies": { + "eslint": ">=8.40.0" + } + }, + "node_modules/eslint-plugin-playwright/node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esm": { + "version": "3.2.25", + "resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz", + "integrity": "sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "dev": true, + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.12", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "dev": true, + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generatorics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/generatorics/-/generatorics-1.1.0.tgz", + "integrity": "sha512-LuYDCS1DbKQsvChP1xHmAzHnGdd0z0K1XMebmbNbFzGZI62KODnV2CXA7zOqebiDzlK2sxXrPGfwlDzSm9aP4g==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.7.tgz", + "integrity": "sha512-VW6Pxhsrk0KAOqs3WEd0klDiF/+V7gQOpAvY1jVU/LHmaD/kQO4523aiJuikX/QAKYiW6x8Jh+RJej1almdtCA==", + "dev": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "function-bind": "^1.1.2", + "get-proto": "^1.0.0", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.3.0.tgz", + "integrity": "sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dev": true, + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-observable": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-observable/-/is-observable-2.1.0.tgz", + "integrity": "sha512-DailKdLb0WU+xX8K5w7VsJhapwHLZ9jjmazqCJq4X12CTgqq73TKnbRcnSLuXYPOoLQgV5IrD7ePiX/h1vnkBw==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/observable-fns": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/observable-fns/-/observable-fns-0.6.1.tgz", + "integrity": "sha512-9gRK4+sRWzeN6AOewNBTLXir7Zl/i3GB6Yl26gK4flxz8BXVpD3kt8amREmWNb0mxYOGDotvE5a4N+PtGGKdkg==", + "license": "MIT" + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/optionator": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", + "integrity": "sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==", + "dev": true, + "dependencies": { + "@aashutoshrathi/word-wrap": "^1.2.3", + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate/node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "dev": true + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz", + "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.56.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz", + "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "dev": true, + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "dev": true, + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.41.1.tgz", + "integrity": "sha512-cPmwD3FnFv8rKMBc1MxWCwVQFxwf1JEmSX3iQXrRVVG15zerAIXRjMFVWnd5Q5QvgKF7Aj+5ykXFhUl+QGnyOw==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.7" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.41.1", + "@rollup/rollup-android-arm64": "4.41.1", + "@rollup/rollup-darwin-arm64": "4.41.1", + "@rollup/rollup-darwin-x64": "4.41.1", + "@rollup/rollup-freebsd-arm64": "4.41.1", + "@rollup/rollup-freebsd-x64": "4.41.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.41.1", + "@rollup/rollup-linux-arm-musleabihf": "4.41.1", + "@rollup/rollup-linux-arm64-gnu": "4.41.1", + "@rollup/rollup-linux-arm64-musl": "4.41.1", + "@rollup/rollup-linux-loongarch64-gnu": "4.41.1", + "@rollup/rollup-linux-powerpc64le-gnu": "4.41.1", + "@rollup/rollup-linux-riscv64-gnu": "4.41.1", + "@rollup/rollup-linux-riscv64-musl": "4.41.1", + "@rollup/rollup-linux-s390x-gnu": "4.41.1", + "@rollup/rollup-linux-x64-gnu": "4.41.1", + "@rollup/rollup-linux-x64-musl": "4.41.1", + "@rollup/rollup-win32-arm64-msvc": "4.41.1", + "@rollup/rollup-win32-ia32-msvc": "4.41.1", + "@rollup/rollup-win32-x64-msvc": "4.41.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true + }, + "node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "dev": true, + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "dev": true, + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/threads": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/threads/-/threads-1.7.0.tgz", + "integrity": "sha512-Mx5NBSHX3sQYR6iI9VYbgHKBLisyB+xROCBGjjWm1O9wb9vfLxdaGtmT/KCjUqMsSNW6nERzCW3T6H43LqjDZQ==", + "license": "MIT", + "dependencies": { + "callsites": "^3.1.0", + "debug": "^4.2.0", + "is-observable": "^2.1.0", + "observable-fns": "^0.6.1" + }, + "funding": { + "url": "https://github.com/andywer/threads.js?sponsor=1" + }, + "optionalDependencies": { + "tiny-worker": ">= 2" + } + }, + "node_modules/tiny-worker": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tiny-worker/-/tiny-worker-2.3.0.tgz", + "integrity": "sha512-pJ70wq5EAqTAEl9IkGzA+fN0836rycEuz2Cn6yeZ6FRzlVS5IDOkFHpIoEsksPRQV34GDqXm65+OlnZqUSyK2g==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "esm": "^3.2.25" + } + }, + "node_modules/tldts-core": { + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.12.tgz", + "integrity": "sha512-3K76aXywJFduGRsOYoY5JzINLs/WMlOkeDwPL+8OCPq2Rh39gkSDtWAxdJQlWjpun/xF/LHf29yqCi6VC/rHDA==", + "license": "MIT" + }, + "node_modules/tldts-experimental": { + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/tldts-experimental/-/tldts-experimental-7.0.12.tgz", + "integrity": "sha512-riDDp/JrJJuh/2GV1Tgg6OnOwcWlgleBKi4/xkhd0DW7tvvmNjmWXCh2nj7Z2G1k9S/AGN4RiiPRbvhGJUCxeg==", + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.12" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "engines": { + "node": ">=0.6" + } + }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dev": true, + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.41.0.tgz", + "integrity": "sha512-n66rzs5OBXW3SFSnZHr2T685q1i4ODm2nulFJhMZBotaTavsS8TrI3d7bDlRSs9yWo7HmyWrN9qDu14Qv7Y0Dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.41.0", + "@typescript-eslint/parser": "8.41.0", + "@typescript-eslint/typescript-estree": "8.41.0", + "@typescript-eslint/utils": "8.41.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/undici-types": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "dev": true + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "dev": true, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + } + } +} diff --git a/tracker/package.json b/tracker/package.json new file mode 100644 index 0000000000000000000000000000000000000000..8e15e9d138a49769713b4e2a55e98be4939b6d2f --- /dev/null +++ b/tracker/package.json @@ -0,0 +1,42 @@ +{ + "tracker_script_version": 34, + "type": "module", + "scripts": { + "lint": "eslint", + "check-format": "prettier --check \"**/*.{js,mjs,ts,html,json,md}\"", + "deploy": "node compile.js", + "test": "npx playwright test", + "test:local": "npx playwright test", + "start": "node test/support/server.js", + "npm:prepare_release:patch": "npm run npm:bump_version patch && npm run npm:update_code_and_changelog", + "npm:prepare_release:minor": "npm run npm:bump_version minor && npm run npm:update_code_and_changelog", + "npm:prepare_release:major": "npm run npm:bump_version major && npm run npm:update_code_and_changelog", + "npm:bump_version": "npm --prefix npm_package version --no-git-tag-version --", + "npm:update_code_and_changelog": "node compile.js && ./release-update-changelog.sh" + }, + "license": "MIT", + "dependencies": { + "@duckduckgo/autoconsent": "^14.16.0", + "@rollup/plugin-json": "^6.1.0", + "@swc/core": "^1.11.24", + "chokidar": "^4.0.3", + "cli-progress": "^3.12.0", + "generatorics": "^1.1.0", + "rollup": "^4.41.1", + "threads": "^1.7.0" + }, + "devDependencies": { + "@eslint/js": "^9.34.0", + "@playwright/test": "^1.56.1", + "@types/node": "^22.13.4", + "eslint": "^9.34.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-playwright": "^2.2.2", + "express": "^4.21.2", + "globals": "^16.3.0", + "markdown-table": "^3.0.4", + "prettier": "^3.3.3", + "typescript": "^5.9.2", + "typescript-eslint": "^8.41.0" + } +} diff --git a/tracker/playwright.config.ts b/tracker/playwright.config.ts new file mode 100644 index 0000000000000000000000000000000000000000..6f2212807343a40f2cba13124289355a6fef6c21 --- /dev/null +++ b/tracker/playwright.config.ts @@ -0,0 +1,49 @@ +import { defineConfig, devices } from '@playwright/test' + +/** + * @see https://playwright.dev/docs/test-configuration + */ +export default defineConfig({ + testDir: './test', + /* Can be overridden in specific tests with test('a longer running test', async () => { test.setTimeout(); // test content... }) */ + timeout: 10 * 1000, + fullyParallel: true, + /* Optimize workers for CI vs local development */ + workers: process.env.CI ? 4 : undefined, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'list', + /* Limit the number of failures on CI to save resources */ + maxFailures: process.env.CI ? 10 : undefined, + /* + Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. + NOTE: We run the installation support tests on Chrome only because the Browserless /function API + runs a Chromium-based browser environment. + */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] } + }, + + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + testIgnore: 'test/installation_support/**' + }, + + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + testIgnore: 'test/installation_support/**' + } + ], + webServer: { + command: 'npm run start', + port: 3000, + reuseExistingServer: !process.env.CI + } +}) diff --git a/tracker/release-update-changelog.sh b/tracker/release-update-changelog.sh new file mode 100644 index 0000000000000000000000000000000000000000..e4d2678d1e9c11660b7f174ecc914790c11ff21e --- /dev/null +++ b/tracker/release-update-changelog.sh @@ -0,0 +1,24 @@ +#!/bin/bash + +# Ensure we're in the correct directory for relative paths to work +script_dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +cd "$script_dir" + +version=$(jq -r .version npm_package/package.json) +release_date=$(date +%Y-%m-%d) + +temp_file=$(mktemp) + +# Copy header and add new unreleased section +head -n 6 CHANGELOG.md > "$temp_file" +cat >> "$temp_file" << EOF + +## Unreleased + +EOF + +# Replace the old unreleased section with new version +sed "s/## Unreleased/## [$version] - $release_date/" CHANGELOG.md | \ + tail -n +8 >> "$temp_file" + +mv "$temp_file" npm_package/CHANGELOG.md diff --git a/tracker/src/autocapture.js b/tracker/src/autocapture.js new file mode 100644 index 0000000000000000000000000000000000000000..2d69a9970448fe9216a15943afe89eca466fef76 --- /dev/null +++ b/tracker/src/autocapture.js @@ -0,0 +1,54 @@ +import { config } from './config' + +export function init(track) { + var lastPage + + function page(isSPANavigation) { + if (!(COMPILE_HASH && (!COMPILE_CONFIG || config.hashBasedRouting))) { + if (isSPANavigation && lastPage === location.pathname) return + } + + lastPage = location.pathname + track('pageview') + } + + var onSPANavigation = function () { + page(true) + } + + if (COMPILE_HASH && (!COMPILE_CONFIG || config.hashBasedRouting)) { + window.addEventListener('hashchange', onSPANavigation) + } else { + var his = window.history + if (his.pushState) { + var originalPushState = his['pushState'] + his.pushState = function () { + originalPushState.apply(this, arguments) + onSPANavigation() + } + window.addEventListener('popstate', onSPANavigation) + } + } + + function handleVisibilityChange() { + if (!lastPage && document.visibilityState === 'visible') { + page() + } + } + + if ( + document.visibilityState === 'hidden' || + document.visibilityState === 'prerender' + ) { + document.addEventListener('visibilitychange', handleVisibilityChange) + } else { + page() + } + + window.addEventListener('pageshow', function (event) { + if (event.persisted) { + // Page was restored from bfcache - track a pageview + page() + } + }) +} diff --git a/tracker/src/config.js b/tracker/src/config.js new file mode 100644 index 0000000000000000000000000000000000000000..3577e729152605e5045f401c6726bd54e04af0fb --- /dev/null +++ b/tracker/src/config.js @@ -0,0 +1,66 @@ +if (COMPILE_COMPAT) { + var scriptEl = document.getElementById('plausible') +} else if (COMPILE_PLAUSIBLE_LEGACY_VARIANT) { + // eslint-disable-next-line no-redeclare + var scriptEl = document.currentScript +} + +var config = {} + +function defaultEndpoint() { + if (COMPILE_COMPAT) { + var pathArray = scriptEl.src.split('/') + var protocol = pathArray[0] + var host = pathArray[2] + return protocol + '//' + host + '/api/event' + } else { + return new URL(scriptEl.src).origin + '/api/event' + } +} + +export function getOptionsWithDefaults(initOptions) { + if (COMPILE_PLAUSIBLE_WEB) { + return Object.assign(initOptions, { + autoCapturePageviews: initOptions.autoCapturePageviews !== false, + logging: initOptions.logging !== false, + lib: initOptions.lib || 'web' + }) + } + if (COMPILE_PLAUSIBLE_NPM) { + return Object.assign(initOptions, { + autoCapturePageviews: initOptions.autoCapturePageviews !== false, + logging: initOptions.logging !== false, + bindToWindow: initOptions.bindToWindow !== false + }) + } +} + +export function init(options) { + if (COMPILE_PLAUSIBLE_WEB) { + // This will be dynamically replaced by a config json object in the script serving endpoint + config = '<%= @config_js %>' + Object.assign(config, options, { + // Explicitly set domain after other options are applied as `plausible-web` does not support overriding it, except by transformRequest + domain: config.domain + }) + } else if (COMPILE_PLAUSIBLE_NPM) { + if (config.isInitialized) { + throw new Error('plausible.init() can only be called once') + } + if (!options || !options.domain) { + throw new Error('plausible.init(): domain argument is required') + } + if (!options.endpoint) { + options.endpoint = 'https://plausible.io/api/event' + } + Object.assign(config, options) + config.isInitialized = true + } else { + // Legacy variant + config.endpoint = scriptEl.getAttribute('data-api') || defaultEndpoint() + config.domain = scriptEl.getAttribute('data-domain') + config.logging = true + } +} + +export { config, scriptEl } diff --git a/tracker/src/custom-events.js b/tracker/src/custom-events.js new file mode 100644 index 0000000000000000000000000000000000000000..e159dc23b99aede7a942528f1502896a69d1e268 --- /dev/null +++ b/tracker/src/custom-events.js @@ -0,0 +1,373 @@ +// This file has the logic for tracking tagged events, form submissions, file downloads and outbound links. + +import { config, scriptEl } from './config' +import { track } from './track' + +export var DEFAULT_FILE_TYPES = [ + 'pdf', + 'xlsx', + 'docx', + 'txt', + 'rtf', + 'csv', + 'exe', + 'key', + 'pps', + 'ppt', + 'pptx', + '7z', + 'pkg', + 'rar', + 'gz', + 'zip', + 'avi', + 'mov', + 'mp4', + 'mpeg', + 'wmv', + 'midi', + 'mp3', + 'wav', + 'wma', + 'dmg' +] + +var MIDDLE_MOUSE_BUTTON = 1 +var PARENTS_TO_SEARCH_LIMIT = 3 +var fileTypesToTrack = DEFAULT_FILE_TYPES + +function getLinkEl(link) { + while ( + link && + (typeof link.tagName === 'undefined' || !isLink(link) || !link.href) + ) { + link = link.parentNode + } + return link +} + +function isLink(element) { + return element && element.tagName && element.tagName.toLowerCase() === 'a' +} + +function shouldInterceptNavigation(event, link) { + // If default has been prevented by an external script, Plausible should not intercept navigation. + if (event.defaultPrevented) return false + var target = link.target + // If the link directs to open the link in a different context, or we're not sure, do not intercept navigation + if ( + target && + (typeof target !== 'string' || !target.match(/^_(self|parent|top)$/i)) + ) + return false + // If the click is not a regular click (e.g. ctrl, meta, shift, or not a click event), do not intercept navigation + if ( + event.ctrlKey || + event.metaKey || + event.shiftKey || + event.type !== 'click' + ) + return false + + return true +} + +function handleLinkClickEvent(event) { + if (event.type === 'auxclick' && event.button !== MIDDLE_MOUSE_BUTTON) { + return + } + + var link = getLinkEl(event.target) + var hrefWithoutQuery = + link && typeof link.href === 'string' && link.href.split('?')[0] + + if (COMPILE_TAGGED_EVENTS) { + if (isElementOrParentTagged(link, 0)) { + // Return to prevent sending multiple events with the same action. + // Clicks on tagged links are handled by another function. + return + } + } + + if (COMPILE_OUTBOUND_LINKS && (!COMPILE_CONFIG || config.outboundLinks)) { + if (isOutboundLink(link)) { + return sendLinkClickEvent(event, link, { + name: 'Outbound Link: Click', + props: { url: link.href } + }) + } + } + + if (COMPILE_FILE_DOWNLOADS && (!COMPILE_CONFIG || config.fileDownloads)) { + if (isDownloadToTrack(hrefWithoutQuery)) { + return sendLinkClickEvent(event, link, { + name: 'File Download', + props: { url: hrefWithoutQuery } + }) + } + } +} + +function sendLinkClickEvent(event, link, eventAttrs) { + // In some legacy variants, this block delays opening the link up to 5 seconds, + // or until analytics request finishes, otherwise navigation could prevent the analytics event from being sent. + var attrs + if (COMPILE_COMPAT) { + var followedLink = false + function followLink() { + if (!followedLink) { + followedLink = true + window.location = link.href + } + } + + if (shouldInterceptNavigation(event, link)) { + attrs = { props: eventAttrs.props, callback: followLink } + if (COMPILE_REVENUE) { + attrs.revenue = eventAttrs.revenue + } + track(eventAttrs.name, attrs) + setTimeout(followLink, 5000) + event.preventDefault() + } else { + attrs = { props: eventAttrs.props } + if (COMPILE_REVENUE) { + attrs.revenue = eventAttrs.revenue + } + track(eventAttrs.name, attrs) + } + } else { + attrs = { props: eventAttrs.props } + if (COMPILE_REVENUE) { + attrs.revenue = eventAttrs.revenue + } + track(eventAttrs.name, attrs) + } +} + +function isOutboundLink(link) { + return ( + link && + typeof link.href === 'string' && + link.host && + link.host !== location.host + ) +} + +function isDownloadToTrack(url) { + if (!url) { + return false + } + + var fileType = url.split('.').pop() + return fileTypesToTrack.some(function (fileTypeToTrack) { + return fileTypeToTrack === fileType + }) +} + +function isTagged(element) { + var classList = element && element.classList + if (classList) { + for (var i = 0; i < classList.length; i++) { + if (classList.item(i).match(/plausible-event-name(=|--)(.+)/)) { + return true + } + } + } + return false +} + +function isElementOrParentTagged(element, parentsChecked) { + if (!element || parentsChecked > PARENTS_TO_SEARCH_LIMIT) { + return false + } + if (isTagged(element)) { + return true + } + return isElementOrParentTagged(element.parentNode, parentsChecked + 1) +} + +// Finds event attributes by iterating over the given element's (or its +// parent's) classList. Returns an object with `name` and `props` keys. +function getTaggedEventAttributes(htmlElement) { + var taggedElement = isTagged(htmlElement) + ? htmlElement + : htmlElement && htmlElement.parentNode + var eventAttrs = { name: null, props: {} } + if (COMPILE_REVENUE) { + eventAttrs.revenue = {} + } + + var classList = taggedElement && taggedElement.classList + if (!classList) { + return eventAttrs + } + + for (var i = 0; i < classList.length; i++) { + var className = classList.item(i) + + var key + var value + var matchList = className.match(/plausible-event-(.+)(=|--)(.+)/) + if (matchList) { + key = matchList[1] + value = matchList[3].replace(/\+/g, ' ') + + if (key.toLowerCase() == 'name') { + eventAttrs.name = value + } else { + eventAttrs.props[key] = value + } + } + + if (COMPILE_REVENUE) { + var revenueMatchList = className.match(/plausible-revenue-(.+)(=|--)(.+)/) + if (revenueMatchList) { + key = revenueMatchList[1] + value = revenueMatchList[3] + eventAttrs.revenue[key] = value + } + } + } + + return eventAttrs +} + +export function init() { + document.addEventListener('click', handleLinkClickEvent) + document.addEventListener('auxclick', handleLinkClickEvent) + + if (COMPILE_FILE_DOWNLOADS && (!COMPILE_CONFIG || config.fileDownloads)) { + if (COMPILE_CONFIG) { + if ( + typeof config.fileDownloads === 'object' && + Array.isArray(config.fileDownloads.fileExtensions) + ) { + fileTypesToTrack = config.fileDownloads.fileExtensions + } + } else { + var fileTypesAttr = scriptEl.getAttribute('file-types') + var addFileTypesAttr = scriptEl.getAttribute('add-file-types') + + if (fileTypesAttr) { + fileTypesToTrack = fileTypesAttr.split(',') + } + if (addFileTypesAttr) { + fileTypesToTrack = addFileTypesAttr + .split(',') + .concat(DEFAULT_FILE_TYPES) + } + } + } + + if (COMPILE_CONFIG && config.formSubmissions) { + function trackFormSubmission(e) { + if (e.target.hasAttribute('novalidate') || e.target.checkValidity()) { + if (COMPILE_TAGGED_EVENTS && isElementOrParentTagged(e.target, 0)) { + // If the form is tagged, we don't track it as a generic form submission. + return + } + track('Form: Submission') + } + } + + document.addEventListener('submit', trackFormSubmission, true) + } + + if (COMPILE_TAGGED_EVENTS) { + function handleTaggedFormSubmitEvent(event) { + var form = event.target + var eventAttrs = getTaggedEventAttributes(form) + if (!eventAttrs.name) { + return + } + + var attrs + // In some legacy variants, this block delays submitting the form for up to 5 seconds, + // or until analytics request finishes, otherwise form-related navigation could prevent the analytics event from being sent. + if (COMPILE_COMPAT) { + event.preventDefault() + var formSubmitted = false + + function submitForm() { + if (!formSubmitted) { + formSubmitted = true + form.submit() + } + } + + setTimeout(submitForm, 5000) + + attrs = { props: eventAttrs.props, callback: submitForm } + if (COMPILE_REVENUE) { + attrs.revenue = eventAttrs.revenue + } + track(eventAttrs.name, attrs) + } else { + attrs = { props: eventAttrs.props } + if (COMPILE_REVENUE) { + attrs.revenue = eventAttrs.revenue + } + track(eventAttrs.name, attrs) + } + } + + function isForm(element) { + return ( + element && element.tagName && element.tagName.toLowerCase() === 'form' + ) + } + + function handleTaggedElementClickEvent(event) { + if (event.type === 'auxclick' && event.button !== MIDDLE_MOUSE_BUTTON) { + return + } + + var clicked = event.target + + var clickedLink + var taggedElement + // Iterate over parents to find the tagged element. Also search for + // a link element to call for different tracking behavior if found. + for (var i = 0; i <= PARENTS_TO_SEARCH_LIMIT; i++) { + if (!clicked) { + break + } + + // Clicks inside forms are not tracked. Only form submits are. + if (isForm(clicked)) { + return + } + if (isLink(clicked)) { + clickedLink = clicked + } + if (isTagged(clicked)) { + taggedElement = clicked + } + clicked = clicked.parentNode + } + + if (taggedElement) { + var eventAttrs = getTaggedEventAttributes(taggedElement) + + if (clickedLink) { + // if the clicked tagged element is a link, we attach the `url` property + // automatically for user convenience + eventAttrs.props.url = clickedLink.href + sendLinkClickEvent(event, clickedLink, eventAttrs) + } else { + var attrs = {} + attrs.props = eventAttrs.props + if (COMPILE_REVENUE) { + attrs.revenue = eventAttrs.revenue + } + track(eventAttrs.name, attrs) + } + } + } + + document.addEventListener('submit', handleTaggedFormSubmitEvent) + document.addEventListener('click', handleTaggedElementClickEvent) + document.addEventListener('auxclick', handleTaggedElementClickEvent) + } +} diff --git a/tracker/src/engagement.js b/tracker/src/engagement.js new file mode 100644 index 0000000000000000000000000000000000000000..c4a6ab9e55c870901eacd2c343621f7ae7dfc772 --- /dev/null +++ b/tracker/src/engagement.js @@ -0,0 +1,186 @@ +import { config } from './config' +import { sendRequest } from './networking' + +/* Engagement tracking variables and functions */ + +// Multiple pageviews might be sent by the same script when the page +// uses client-side routing (e.g. hash or history-based). This flag +// prevents registering multiple listeners in those cases. +var listeningOnEngagement = false + +var currentEngagementIgnored +var currentEngagementURL = location.href +var currentEngagementProps = {} +var currentEngagementMaxScrollDepth = -1 + +// Timestamp indicating when this particular page last became visible. +// Reset during pageviews, set to 0 when page is closed. +var runningEngagementStart = 0 + +// When page is hidden, this 'engaged' time is saved to this variable +var currentEngagementTime = 0 + +export function prePageviewTrack() { + if (listeningOnEngagement) { + // If we're listening on engagement already, at least one pageview + // has been sent by the current script (i.e. it's most likely a SPA). + // Trigger an engagement marking the "exit from the previous page". + triggerEngagement() + currentDocumentHeight = getDocumentHeight() + maxScrollDepthPx = getCurrentScrollDepthPx() + } +} + +export function postPageviewTrack(payload) { + currentEngagementIgnored = false + currentEngagementURL = payload.u + currentEngagementProps = payload.p + currentEngagementMaxScrollDepth = -1 + currentEngagementTime = 0 + runningEngagementStart = Date.now() + registerEngagementListener() +} + +export function onPageviewIgnored() { + currentEngagementIgnored = true +} + +function triggerEngagement() { + var engagementTime = getEngagementTime() + + /* + We send engagements if there's new relevant engagement information to share: + - If the user has scrolled more than the previously sent max scroll depth. + - If the user has been engaged for more than 3 seconds since the last engagement event. + + The first engagement event is always sent due to containing at least the initial scroll depth. + + Also, we don't send engagements if the current pageview is ignored (onIgnoredEvent) + */ + if ( + !currentEngagementIgnored && + (currentEngagementMaxScrollDepth < maxScrollDepthPx || + engagementTime >= 3000) + ) { + currentEngagementMaxScrollDepth = maxScrollDepthPx + + var payload = { + n: 'engagement', + sd: Math.round((maxScrollDepthPx / currentDocumentHeight) * 100), + d: config.domain, + u: currentEngagementURL, + p: currentEngagementProps, + e: engagementTime, + v: COMPILE_TRACKER_SCRIPT_VERSION + } + + // Reset current engagement time metrics. They will restart upon when page becomes visible or the next SPA pageview + runningEngagementStart = 0 + currentEngagementTime = 0 + + if (COMPILE_HASH && (!COMPILE_CONFIG || config.hashBasedRouting)) { + payload.h = 1 + } + + sendRequest(config.endpoint, payload) + } +} + +function onVisibilityChange() { + if ( + document.visibilityState === 'visible' && + document.hasFocus() && + runningEngagementStart === 0 + ) { + runningEngagementStart = Date.now() + } else if (document.visibilityState === 'hidden' || !document.hasFocus()) { + // Tab went back to background or lost focus. Save the engaged time so far + currentEngagementTime = getEngagementTime() + runningEngagementStart = 0 + + triggerEngagement() + } +} + +function registerEngagementListener() { + if (!listeningOnEngagement) { + // Only register visibilitychange listener only after initial page load and pageview + document.addEventListener('visibilitychange', onVisibilityChange) + window.addEventListener('blur', onVisibilityChange) + window.addEventListener('focus', onVisibilityChange) + listeningOnEngagement = true + } +} + +function getEngagementTime() { + if (runningEngagementStart) { + return currentEngagementTime + (Date.now() - runningEngagementStart) + } else { + return currentEngagementTime + } +} + +var currentDocumentHeight +var maxScrollDepthPx + +function getDocumentHeight() { + var body = document.body || {} + var el = document.documentElement || {} + return Math.max( + body.scrollHeight || 0, + body.offsetHeight || 0, + body.clientHeight || 0, + el.scrollHeight || 0, + el.offsetHeight || 0, + el.clientHeight || 0 + ) +} + +function getCurrentScrollDepthPx() { + var viewportHeight, scrollTop + if (COMPILE_COMPAT) { + var el = document.documentElement || {} + var body = document.body || {} + viewportHeight = window.innerHeight || el.clientHeight || 0 + scrollTop = + window.scrollY || + window.pageYOffset || + el.scrollTop || + body.scrollTop || + 0 + } else { + viewportHeight = window.innerHeight + scrollTop = window.scrollY + } + return currentDocumentHeight <= viewportHeight + ? currentDocumentHeight + : scrollTop + viewportHeight +} + +export function init() { + currentDocumentHeight = getDocumentHeight() + maxScrollDepthPx = getCurrentScrollDepthPx() + + if (COMPILE_COMPAT) { + window.addEventListener('load', function () { + currentDocumentHeight = getDocumentHeight() + var count = 0 + var interval = setInterval(function () { + currentDocumentHeight = getDocumentHeight() + if (++count === 15) clearInterval(interval) + }, 200) + }) + } else { + new ResizeObserver(function () { + currentDocumentHeight = getDocumentHeight() + }).observe(document.documentElement) + } + + document.addEventListener('scroll', function () { + var currentScrollDepthPx = getCurrentScrollDepthPx() + + if (currentScrollDepthPx > maxScrollDepthPx) { + maxScrollDepthPx = currentScrollDepthPx + } + }) +} diff --git a/tracker/src/networking.js b/tracker/src/networking.js new file mode 100644 index 0000000000000000000000000000000000000000..51797b393b48516ceda8e3081f53169ee5b935b0 --- /dev/null +++ b/tracker/src/networking.js @@ -0,0 +1,42 @@ +export function sendRequest(endpoint, payload, options) { + if (COMPILE_COMPAT) { + var request = new XMLHttpRequest() + request.open('POST', endpoint, true) + request.setRequestHeader('Content-Type', 'text/plain') + + request.send(JSON.stringify(payload)) + + request.onreadystatechange = function () { + if (request.readyState === 4) { + if (request.status === 0) { + options && + options.callback && + options.callback({ error: new Error('Network error') }) + } else { + options && + options.callback && + options.callback({ status: request.status }) + } + } + } + } else { + if (window.fetch) { + fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'text/plain' + }, + keepalive: true, + body: JSON.stringify(payload) + }) + .then(function (response) { + options && + options.callback && + options.callback({ status: response.status }) + }) + .catch(function (error) { + options && options.callback && options.callback({ error }) + }) + } + } +} diff --git a/tracker/src/plausible.js b/tracker/src/plausible.js new file mode 100644 index 0000000000000000000000000000000000000000..f8b669c7c2a834202dab71d21261659ca9faa096 --- /dev/null +++ b/tracker/src/plausible.js @@ -0,0 +1,72 @@ +import { init as initEngagementTracking } from './engagement' +import { init as initConfig, getOptionsWithDefaults, config } from './config' +import { init as initCustomEvents } from './custom-events' +import { init as initAutocapture } from './autocapture' +import { track } from './track' + +function init(overrides) { + const options = getOptionsWithDefaults(overrides || {}) + + if (COMPILE_PLAUSIBLE_WEB && window.plausible && window.plausible.l) { + if (options.logging) { + console.warn('Plausible analytics script was already initialized, skipping init') + } + return + } + + initConfig(options) + + initEngagementTracking() + + if (!COMPILE_MANUAL || (COMPILE_CONFIG && config.autoCapturePageviews)) { + initAutocapture(track) + } + + if (COMPILE_PLAUSIBLE_WEB || COMPILE_PLAUSIBLE_NPM || COMPILE_OUTBOUND_LINKS || COMPILE_FILE_DOWNLOADS || COMPILE_TAGGED_EVENTS) { + initCustomEvents() + } + + + if (COMPILE_PLAUSIBLE_WEB || COMPILE_PLAUSIBLE_LEGACY_VARIANT) { + // Call `track` for any events that were queued via plausible('event') before `init` was called + var queue = (window.plausible && window.plausible.q) || [] + for (var i = 0; i < queue.length; i++) { + track.apply(this, queue[i]) + } + + window.plausible = track + window.plausible.init = init + window.plausible.v = COMPILE_TRACKER_SCRIPT_VERSION + + if (COMPILE_PLAUSIBLE_WEB) { + window.plausible.s = config.lib + } + + window.plausible.l = true + } + + // Bind to window to be detectable by the verifier tool + // This is done in a 'safe' way to avoid breaking the page if window is frozen or running without window + if (COMPILE_PLAUSIBLE_NPM && config.bindToWindow && typeof window !== 'undefined') { + window.plausible = track + window.plausible.s = 'npm' + window.plausible.v = COMPILE_TRACKER_SCRIPT_VERSION + window.plausible.l = true + } +} + +if (COMPILE_PLAUSIBLE_WEB) { + window.plausible = (window.plausible || {}) + + if (plausible.o) { + init(plausible.o) + } + + plausible.init = init +} else if (COMPILE_PLAUSIBLE_LEGACY_VARIANT) { + // Legacy variants automatically initialize based compile variables + init() +} + +// In npm module, we export the init and track functions +// export { init, track, DEFAULT_FILE_TYPES } diff --git a/tracker/src/track.js b/tracker/src/track.js new file mode 100644 index 0000000000000000000000000000000000000000..1b2b0d4ed220ae8ebe9314bdc27b0ce826a48df8 --- /dev/null +++ b/tracker/src/track.js @@ -0,0 +1,176 @@ +import { sendRequest } from './networking' +import { + prePageviewTrack, + postPageviewTrack, + onPageviewIgnored +} from './engagement' +import { config, scriptEl } from './config' + +export function track(eventName, options) { + if (COMPILE_PLAUSIBLE_NPM && !config.isInitialized) { + throw new Error( + 'plausible.track() can only be called after plausible.init()' + ) + } + + var isPageview = eventName === 'pageview' + + if (isPageview) { + prePageviewTrack() + } + + if (!(COMPILE_LOCAL && (!COMPILE_CONFIG || config.captureOnLocalhost))) { + if ( + /^localhost$|^127(\.[0-9]+){0,2}\.[0-9]+$|^\[::1?\]$/.test( + location.hostname + ) || + location.protocol === 'file:' + ) { + return onIgnoredEvent(eventName, options, 'localhost') + } + if ( + (window._phantom || + window.__nightmare || + window.navigator.webdriver || + window.Cypress) && + !window.__plausible + ) { + return onIgnoredEvent(eventName, options) + } + } + try { + if (window.localStorage.plausible_ignore === 'true') { + return onIgnoredEvent(eventName, options, 'localStorage flag') + } + } catch (_error) { + // ignore error + } + + if (COMPILE_EXCLUSIONS) { + var dataIncludeAttr = scriptEl && scriptEl.getAttribute('data-include') + var dataExcludeAttr = scriptEl && scriptEl.getAttribute('data-exclude') + + function pathMatches(wildcardPath) { + var actualPath = location.pathname + + if (COMPILE_HASH && (!COMPILE_CONFIG || config.hashBasedRouting)) { + actualPath += location.hash + } + + return actualPath.match( + new RegExp( + '^' + + wildcardPath + .trim() + .replace(/\*\*/g, '.*') + // eslint-disable-next-line no-useless-escape + .replace(/([^\.])\*/g, '$1[^\\s\/]*') + + // eslint-disable-next-line no-useless-escape + '\/?$' + ) + ) + } + + if (isPageview) { + var isIncluded = + !dataIncludeAttr || + (dataIncludeAttr && dataIncludeAttr.split(',').some(pathMatches)) + var isExcluded = + dataExcludeAttr && dataExcludeAttr.split(',').some(pathMatches) + + if (!isIncluded || isExcluded) + return onIgnoredEvent(eventName, options, 'exclusion rule') + } + } + + var payload = {} + payload.n = eventName + payload.v = COMPILE_TRACKER_SCRIPT_VERSION + + if (COMPILE_MANUAL) { + var customURL = options && (options.u || options.url) + + payload.u = customURL ? customURL : location.href + } else { + payload.u = location.href + } + + payload.d = config.domain + payload.r = document.referrer || null + if (COMPILE_PLAUSIBLE_LEGACY_VARIANT && options && options.meta) { + payload.m = JSON.stringify(options.meta) + } + if (options && options.props) { + payload.p = options.props + } + if (options && options.interactive === false) { + payload.i = false + } + if (COMPILE_REVENUE) { + if (options && options.revenue) { + payload.$ = options.revenue + } + } + + if (COMPILE_PAGEVIEW_PROPS) { + var propAttributes = scriptEl.getAttributeNames().filter(function (name) { + return name.substring(0, 6) === 'event-' + }) + + var props = payload.p || {} + + propAttributes.forEach(function (attribute) { + var propKey = attribute.replace('event-', '') + var propValue = scriptEl.getAttribute(attribute) + props[propKey] = props[propKey] || propValue + }) + + payload.p = props + } + + // Track custom properties for pageviews and other events + if (COMPILE_CUSTOM_PROPERTIES && config.customProperties) { + // eslint-disable-next-line no-redeclare + var props = config.customProperties + if (typeof props === 'function') { + props = config.customProperties(eventName) + } + + if (typeof props === 'object') { + payload.p = Object.assign({}, props, payload.p) + } + } + + if (COMPILE_HASH && (!COMPILE_CONFIG || config.hashBasedRouting)) { + payload.h = 1 + } + + if ( + (COMPILE_PLAUSIBLE_WEB || COMPILE_PLAUSIBLE_NPM) && + typeof config.transformRequest === 'function' + ) { + payload = config.transformRequest(payload) + + if (!payload) { + return onIgnoredEvent(eventName, options, 'transformRequest') + } + } + + if (isPageview) { + postPageviewTrack(payload) + } + + sendRequest(config.endpoint, payload, options) +} + +function onIgnoredEvent(eventName, options, reason) { + if (reason && config.logging) { + console.warn('Ignoring Event: ' + reason) + } + + options && options.callback && options.callback() + + if (eventName === 'pageview') { + onPageviewIgnored() + } +} diff --git a/tracker/src/web-snippet.js b/tracker/src/web-snippet.js new file mode 100644 index 0000000000000000000000000000000000000000..1eb57011800db0bd2499b52f1fbcb72d11cad330 --- /dev/null +++ b/tracker/src/web-snippet.js @@ -0,0 +1,16 @@ +// This snippet is shown to users to install plausible tracker script + +// diff --git a/tracker/test/callbacks.spec.ts b/tracker/test/callbacks.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..383e07865443937079703dc9f7c04ffc07d1335f --- /dev/null +++ b/tracker/test/callbacks.spec.ts @@ -0,0 +1,94 @@ +import { test, expect } from '@playwright/test' +import { initializePageDynamically } from './support/initialize-page-dynamically' +import { mockManyRequests } from './support/mock-many-requests' +import { switchByMode } from './support/test-utils' + +const DOMAIN = 'example.com' + +for (const mode of ['web', 'esm', 'legacy']) { + test.describe(`callback results (${mode})`, () => { + for (const { + name, + captureOnLocalhost, + apiPath, + mockPath, + fulfill, + expectedResult + } of [ + { + name: 'on successful request', + captureOnLocalhost: true, + apiPath: '/api/event', + mockPath: `/api/event`, + fulfill: { status: 202 }, + expectedResult: { status: 202 } + }, + { + name: 'on 404', + captureOnLocalhost: true, + apiPath: '/api/event', + mockPath: `/api/event`, + fulfill: { status: 404 }, + expectedResult: { status: 404 } + }, + { + name: 'on network error', + captureOnLocalhost: true, + apiPath: 'h://no-exist', + mockPath: `/api/event`, + fulfill: { status: 202 }, + expectedResult: { error: expect.any(Error) } + }, + { + name: 'on ignored request (because of not having capturing events on localhost)', + captureOnLocalhost: false, + apiPath: '/api/event', + mockPath: `/api/event`, + fulfill: { status: 202 }, + expectedResult: undefined + } + ]) { + test(`${name}`, async ({ page }, { testId }) => { + const config = { domain: DOMAIN, endpoint: apiPath, captureOnLocalhost } + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + legacy: ``, + web: { domain: DOMAIN, endpoint: apiPath, captureOnLocalhost }, + esm: `` + }, + mode + ), + bodyContent: '' + }) + + await mockManyRequests({ + page, + path: mockPath, + fulfill, + awaitedRequestCount: 1, + mockRequestTimeout: 2000 + }) + await page.goto(url) + await page.waitForFunction(() => window.plausible?.l) + const callbackResult = await page.evaluate( + () => + new Promise((resolve) => + // @ts-expect-error - window.plausible is defined + window.plausible('Purchase', { + callback: (result) => resolve(result) + }) + ) + ) + expect(callbackResult).toEqual(expectedResult) + }) + } + }) +} diff --git a/tracker/test/custom-properties.spec.ts b/tracker/test/custom-properties.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..1b49a0bbb0a90bf7a57545df4375db07a17d27e2 --- /dev/null +++ b/tracker/test/custom-properties.spec.ts @@ -0,0 +1,292 @@ +import { + initializePageDynamically, + serializeWithFunctions +} from './support/initialize-page-dynamically' +import { + e, + expectPlausibleInAction, + hideAndShowCurrentTab, + isEngagementEvent, + switchByMode +} from './support/test-utils' +import { test } from '@playwright/test' +import { ScriptConfig } from './support/types' +import { LOCAL_SERVER_ADDR } from './support/server' +const DEFAULT_CONFIG: ScriptConfig = { + domain: 'example.com', + endpoint: `${LOCAL_SERVER_ADDR}/api/event`, + captureOnLocalhost: true +} + +for (const mode of ['web', 'esm']) { + test.describe(`respects "customProperties" config option (${mode})`, () => { + test('if "customProperties" is not set, pageviews and engagement events are sent without "p" parameter', async ({ + page + }, { testId }) => { + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: DEFAULT_CONFIG, + esm: `` + }, + mode + ), + bodyContent: '' + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(url) + await hideAndShowCurrentTab(page, { delay: 200 }) + }, + expectedRequests: [ + { + n: 'pageview', + p: e.toBeUndefined() + }, + { n: 'engagement', p: e.toBeUndefined() } + ] + }) + }) + + test('if "customProperties" is set to a fixed value, pageviews and engagement events are sent with "p" parameter', async ({ + page + }, { testId }) => { + const config = { + ...DEFAULT_CONFIG, + customProperties: { author: 'John Smith' } + } + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: config, + esm: `` + }, + mode + ), + bodyContent: '' + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(url) + await hideAndShowCurrentTab(page, { delay: 200 }) + }, + expectedRequests: [ + { + n: 'pageview', + p: config.customProperties + }, + { n: 'engagement', p: config.customProperties } + ] + }) + }) + + test('if "customProperties" is set to be a function, pageviews and engagement events are sent with "p" parameter', async ({ + page + }, { testId }) => { + const config = { + ...DEFAULT_CONFIG, + customProperties: (eventName) => ({ + author: 'John Smith', + eventName: eventName, + documentTitle: document.title + }) + } + + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: config, + esm: `` + }, + mode + ), + bodyContent: '' + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(url) + await hideAndShowCurrentTab(page, { delay: 200 }) + }, + expectedRequests: [ + { + n: 'pageview', + p: { + author: 'John Smith', + eventName: 'pageview', + documentTitle: 'Plausible Playwright tests' + } + }, + { + n: 'engagement', + // Engagement event is sent with the same custom properties as the pageview event, customProperties function does not get called for these! + p: { + author: 'John Smith', + eventName: 'pageview', + documentTitle: 'Plausible Playwright tests' + } + } + ] + }) + }) + + test('specificity: props given in "track" call override any custom properties set in "customProperties"', async ({ + page + }, { testId }) => { + const config = { + ...DEFAULT_CONFIG, + customProperties: () => ({ + author: 'John Smith', + title: document.title + }) + } + + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: config, + esm: `` + }, + mode + ), + bodyContent: /* HTML */ `` + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(url) + await page.click('button') + }, + shouldIgnoreRequest: [isEngagementEvent], + expectedRequests: [ + { + n: 'pageview', + p: { + author: 'John Smith', + title: 'Plausible Playwright tests' + } + }, + { + n: 'subscribed from blog', + p: { + author: 'John Smith', + title: 'A blog post title' + } + } + ] + }) + }) + + test('if "customProperties" is defined as not an object or function, it is ignored', async ({ + page + }, { testId }) => { + const config = { + ...DEFAULT_CONFIG, + customProperties: 123 + } + + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: config, + esm: `` + }, + mode + ), + bodyContent: /* HTML */ `` + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(url) + await page.click('button') + }, + shouldIgnoreRequest: [isEngagementEvent], + expectedRequests: [ + { + n: 'pageview', + p: e.toBeUndefined() + }, + { + n: 'subscribed from blog', + p: { + title: 'A blog post title' + } + } + ] + }) + }) + + test('if "customProperties" is defined to be a function that does not return an object, its output is ignored', async ({ + page + }, { testId }) => { + const config = { + ...DEFAULT_CONFIG, + customProperties: () => 'not an object' + } + + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: config, + esm: `` + }, + mode + ), + bodyContent: /* HTML */ `` + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(url) + await page.click('button') + }, + shouldIgnoreRequest: [isEngagementEvent], + expectedRequests: [ + { + n: 'pageview', + p: e.toBeUndefined() + }, + { + n: 'subscribed from blog', + p: { + title: 'A blog post title' + } + } + ] + }) + }) + }) +} diff --git a/tracker/test/engagement.spec.js b/tracker/test/engagement.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..af325f7ac450565295c15048946a42ac844b6a80 --- /dev/null +++ b/tracker/test/engagement.spec.js @@ -0,0 +1,472 @@ +import { expect } from '@playwright/test' +import { + expectPlausibleInAction, + hideAndShowCurrentTab, + focus, + blur, + blurAndFocusPage, + tracker_script_version +} from './support/test-utils' +import { test } from '@playwright/test' +import { LOCAL_SERVER_ADDR } from './support/server' + +test.describe('engagement events', () => { + test('sends an engagement event with time measurement when navigating to the next page', async ({ + page + }) => { + await expectPlausibleInAction(page, { + action: () => page.goto('/engagement.html'), + expectedRequests: [{ n: 'pageview' }] + }) + + await page.waitForTimeout(1000) + + const [request] = await expectPlausibleInAction(page, { + action: () => page.click('#navigate-away'), + expectedRequests: [ + { + n: 'engagement', + u: `${LOCAL_SERVER_ADDR}/engagement.html`, + v: tracker_script_version + } + ] + }) + + expect(request.e).toBeGreaterThan(1000) + expect(request.e).toBeLessThan(1500) + }) + + test('sends an event and a pageview on hash-based SPA navigation', async ({ + page + }) => { + await expectPlausibleInAction(page, { + action: () => page.goto('/engagement-hash.html'), + expectedRequests: [{ n: 'pageview' }] + }) + + await page.waitForTimeout(1000) + + const [request] = await expectPlausibleInAction(page, { + action: () => page.click('#hash-nav'), + expectedRequests: [ + { n: 'engagement', u: `${LOCAL_SERVER_ADDR}/engagement-hash.html` }, + { + n: 'pageview', + u: `${LOCAL_SERVER_ADDR}/engagement-hash.html#some-hash` + } + ] + }) + + expect(request.e).toBeGreaterThan(1000) + expect(request.e).toBeLessThan(1500) + }) + + test('sends an event and a pageview on history-based SPA navigation', async ({ + page + }) => { + await expectPlausibleInAction(page, { + action: () => page.goto('/engagement.html'), + expectedRequests: [{ n: 'pageview' }] + }) + + await page.waitForTimeout(1000) + + const [request] = await expectPlausibleInAction(page, { + action: () => page.click('#history-nav'), + expectedRequests: [ + { n: 'engagement', u: `${LOCAL_SERVER_ADDR}/engagement.html` }, + { n: 'pageview', u: `${LOCAL_SERVER_ADDR}/another-page` } + ] + }) + + expect(request.e).toBeGreaterThan(1000) + expect(request.e).toBeLessThan(1500) + }) + + test('sends engagements when pageviews are triggered manually on a SPA', async ({ + page + }) => { + await expectPlausibleInAction(page, { + action: () => page.goto('/engagement-hash-manual.html'), + expectedRequests: [{ n: 'pageview' }] + }) + + await page.waitForTimeout(1000) + + const [request] = await expectPlausibleInAction(page, { + action: () => page.click('#about-us-hash-link'), + expectedRequests: [ + { n: 'engagement', u: `${LOCAL_SERVER_ADDR}/#home` }, + { n: 'pageview', u: `${LOCAL_SERVER_ADDR}/#about-us` } + ] + }) + + expect(request.e).toBeGreaterThan(1000) + expect(request.e).toBeLessThan(1500) + }) + + test('sends an event with the manually overridden URL', async ({ page }) => { + await page.goto('/engagement-manual.html') + + await expectPlausibleInAction(page, { + action: () => page.click('#pageview-trigger-custom-url'), + expectedRequests: [ + { n: 'pageview', u: 'https://example.com/custom/location' } + ] + }) + + await expectPlausibleInAction(page, { + action: () => page.click('#navigate-away'), + expectedRequests: [ + { n: 'engagement', u: 'https://example.com/custom/location' } + ] + }) + }) + + test('does not send an event when pageview was not sent in manual mode', async ({ + page + }) => { + await page.goto('/engagement-manual.html') + + await expectPlausibleInAction(page, { + action: () => page.click('#navigate-away'), + refutedRequests: [{ n: 'engagement' }] + }) + }) + + test('script.exclusions.hash.js sends an event only from URLs where a pageview was sent', async ({ + page + }) => { + const pageBaseURL = `${LOCAL_SERVER_ADDR}/engagement-hash-exclusions.html` + + await expectPlausibleInAction(page, { + action: () => page.goto('/engagement-hash-exclusions.html'), + expectedRequests: [{ n: 'pageview' }] + }) + + // After the initial pageview is sent, navigate to ignored page -> + // engagement event is sent from the initial page URL + await expectPlausibleInAction(page, { + action: () => page.click('#ignored-hash-link'), + expectedRequests: [{ n: 'engagement', u: pageBaseURL, h: 1 }] + }) + + // Navigate from ignored page to a tracked page -> + // no engagement from the current page, pageview on the next page + await expectPlausibleInAction(page, { + action: () => page.click('#hash-link-1'), + expectedRequests: [{ n: 'pageview', u: `${pageBaseURL}#hash1`, h: 1 }], + refutedRequests: [{ n: 'engagement' }] + }) + + // Navigate from a tracked page to another tracked page -> + // engagement with the last page URL, pageview with the new URL + await expectPlausibleInAction(page, { + action: () => page.click('#hash-link-2'), + expectedRequests: [ + { n: 'engagement', u: `${pageBaseURL}#hash1`, h: 1 }, + { n: 'pageview', u: `${pageBaseURL}#hash2`, h: 1 } + ] + }) + }) + + test('sends an event with the same props as pageview (manual extension)', async ({ + page + }) => { + await page.goto('/engagement-manual.html') + + await expectPlausibleInAction(page, { + action: () => page.click('#pageview-trigger-custom-props'), + expectedRequests: [{ n: 'pageview', p: { author: 'John' } }] + }) + + await expectPlausibleInAction(page, { + action: () => page.click('#navigate-away'), + expectedRequests: [{ n: 'engagement', p: { author: 'John' } }] + }) + }) + + test('sends an event with the same props as pageview (pageview-props extension)', async ({ + page + }) => { + await expectPlausibleInAction(page, { + action: () => page.goto('/engagement-pageview-props.html'), + expectedRequests: [{ n: 'pageview', p: { author: 'John', index: '0' } }] + }) + + await expectPlausibleInAction(page, { + action: () => page.click('#navigate-away'), + expectedRequests: [{ n: 'engagement', p: { author: 'John', index: '0' } }] + }) + }) + + test('pageview props with custom events and values changing mid-view (pageview-props extension)', async ({ + page + }) => { + await expectPlausibleInAction(page, { + action: () => page.goto('/engagement-pageview-props.html'), + expectedRequests: [{ n: 'pageview', p: { author: 'John', index: '0' } }] + }) + + await page.click('#increment-event-index') + + await expectPlausibleInAction(page, { + action: () => page.click('#custom-event-button'), + expectedRequests: [ + { n: 'Custom event', p: { author: 'Karl', index: '1' } } + ] + }) + + await expectPlausibleInAction(page, { + action: () => page.click('#navigate-away'), + expectedRequests: [{ n: 'engagement', p: { author: 'John', index: '0' } }] + }) + }) + + test('sends an event with the same props as pageview (hash navigation / pageview-props extension)', async ({ + page + }) => { + await expectPlausibleInAction(page, { + action: () => page.goto('/engagement-hash-pageview-props.html'), + expectedRequests: [{ n: 'pageview', p: {} }] + }) + + await expectPlausibleInAction(page, { + action: () => page.click('#john-post'), + expectedRequests: [ + { n: 'engagement', p: {} }, + { n: 'pageview', p: { author: 'john' } } + ] + }) + + await expectPlausibleInAction(page, { + action: () => page.click('#jane-post'), + expectedRequests: [ + { n: 'engagement', p: { author: 'john' } }, + { n: 'pageview', p: { author: 'jane' } } + ] + }) + + await expectPlausibleInAction(page, { + action: () => page.click('#home'), + expectedRequests: [ + { n: 'engagement', p: { author: 'jane' } }, + { n: 'pageview', p: {} } + ] + }) + }) + + test('sends an event when plausible API is slow and user navigates away before response is received', async ({ + page, + browserName + }) => { + test.skip(browserName === 'chromium', 'flaky') + + await expectPlausibleInAction(page, { + action: () => page.goto('/engagement.html'), + expectedRequests: [ + { n: 'pageview', u: `${LOCAL_SERVER_ADDR}/engagement.html` } + ] + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.click('#to-pageleave-pageview-props') + await page.waitForTimeout(500) + await page.click('#back-button-trigger') + }, + expectedRequests: [ + { n: 'engagement', u: `${LOCAL_SERVER_ADDR}/engagement.html` }, + { + n: 'pageview', + u: `${LOCAL_SERVER_ADDR}/engagement-pageview-props.html`, + p: { author: 'John', index: '0' } + }, + { + n: 'engagement', + u: `${LOCAL_SERVER_ADDR}/engagement-pageview-props.html`, + p: { author: 'John', index: '0' } + }, + { n: 'pageview', u: `${LOCAL_SERVER_ADDR}/engagement.html` } + ], + responseDelay: 1000 + }) + }) + + test('sends engagement events when tab toggles between foreground and background', async ({ + page + }) => { + await expectPlausibleInAction(page, { + action: () => page.goto('/engagement.html'), + expectedRequests: [{ n: 'pageview' }] + }) + + const [request1] = await expectPlausibleInAction(page, { + action: () => hideAndShowCurrentTab(page, { delay: 2000 }), + expectedRequests: [ + { n: 'engagement', u: `${LOCAL_SERVER_ADDR}/engagement.html` } + ] + }) + expect(request1.e).toBeLessThan(500) + + await page.waitForTimeout(3000) + + const [request2] = await expectPlausibleInAction(page, { + action: () => hideAndShowCurrentTab(page, { delay: 2000 }), + expectedRequests: [ + { n: 'engagement', u: `${LOCAL_SERVER_ADDR}/engagement.html` } + ] + }) + + expect(request2.e).toBeGreaterThan(3000) + expect(request2.e).toBeLessThan(3500) + }) + + test('does not send engagement events when tab is only open for a short time until over 3000ms has passed', async ({ + page + }) => { + await expectPlausibleInAction(page, { + action: () => page.goto('/engagement.html'), + expectedRequests: [{ n: 'pageview' }] + }) + + const [request1] = await expectPlausibleInAction(page, { + action: () => hideAndShowCurrentTab(page), + expectedRequests: [ + { n: 'engagement', u: `${LOCAL_SERVER_ADDR}/engagement.html` } + ] + }) + expect(request1.e).toBeLessThan(500) + + await page.waitForTimeout(500) + + await expectPlausibleInAction(page, { + action: () => hideAndShowCurrentTab(page), + refutedRequests: [{ n: 'engagement' }], + mockRequestTimeout: 100 + }) + + await page.waitForTimeout(2500) + + const [request2] = await expectPlausibleInAction(page, { + action: () => hideAndShowCurrentTab(page, { delay: 3000 }), + expectedRequests: [ + { n: 'engagement', u: `${LOCAL_SERVER_ADDR}/engagement.html` } + ] + }) + + // Sum of both visibility times + expect(request2.e).toBeGreaterThan(3000) + expect(request2.e).toBeLessThan(3500) + }) + + test('tracks engagement time properly in a SPA', async ({ page }) => { + await expectPlausibleInAction(page, { + action: () => page.goto('/engagement-hash.html'), + expectedRequests: [{ n: 'pageview' }] + }) + + await page.waitForTimeout(1000) + + const [request] = await expectPlausibleInAction(page, { + action: () => page.click('#hash-nav'), + expectedRequests: [ + { n: 'engagement', u: `${LOCAL_SERVER_ADDR}/engagement-hash.html` }, + { + n: 'pageview', + u: `${LOCAL_SERVER_ADDR}/engagement-hash.html#some-hash` + } + ] + }) + + expect(request.e).toBeGreaterThan(1000) + expect(request.e).toBeLessThan(1500) + + const [request2] = await expectPlausibleInAction(page, { + action: () => page.click('#hash-nav-2'), + expectedRequests: [ + { + n: 'engagement', + u: `${LOCAL_SERVER_ADDR}/engagement-hash.html#some-hash` + }, + { + n: 'pageview', + u: `${LOCAL_SERVER_ADDR}/engagement-hash.html#another-hash` + } + ] + }) + + expect(request2.e).toBeLessThan(200) + + await page.waitForTimeout(3000) + + const [request3] = await expectPlausibleInAction(page, { + action: () => hideAndShowCurrentTab(page), + expectedRequests: [ + { + n: 'engagement', + u: `${LOCAL_SERVER_ADDR}/engagement-hash.html#another-hash` + } + ] + }) + + expect(request3.e).toBeGreaterThan(3000) + expect(request3.e).toBeLessThan(3500) + + await page.waitForTimeout(3000) + const [request4] = await expectPlausibleInAction(page, { + action: () => page.click('#hash-nav'), + expectedRequests: [ + { + n: 'engagement', + u: `${LOCAL_SERVER_ADDR}/engagement-hash.html#another-hash` + }, + { + n: 'pageview', + u: `${LOCAL_SERVER_ADDR}/engagement-hash.html#some-hash` + } + ] + }) + + expect(request4.e).toBeGreaterThan(3000) + expect(request4.e).toBeLessThan(3500) + }) + + test('tracks engagement time whilst tab gains and loses focus', async ({ + page + }) => { + await expectPlausibleInAction(page, { + action: () => page.goto('/engagement.html'), + expectedRequests: [{ n: 'pageview' }] + }) + + const [request1] = await expectPlausibleInAction(page, { + action: () => blur(page), + expectedRequests: [ + { n: 'engagement', u: `${LOCAL_SERVER_ADDR}/engagement.html` } + ] + }) + expect(request1.e).toBeLessThan(500) + + await focus(page) + await page.waitForTimeout(1000) + + await expectPlausibleInAction(page, { + action: () => blurAndFocusPage(page, { delay: 3000 }), + refutedRequests: [{ n: 'engagement' }], + mockRequestTimeout: 100 + }) + + await page.waitForTimeout(2500) + + const [request2] = await expectPlausibleInAction(page, { + action: () => blur(page), + expectedRequests: [ + { n: 'engagement', u: `${LOCAL_SERVER_ADDR}/engagement.html` } + ] + }) + + expect(request2.e).toBeGreaterThan(3500) + expect(request2.e).toBeLessThan(4000) + }) +}) diff --git a/tracker/test/features-hierarchy-on-overlap.spec.ts b/tracker/test/features-hierarchy-on-overlap.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..63ea5b1c8a47f8f53c0517c1ab66c8ead8b383d2 --- /dev/null +++ b/tracker/test/features-hierarchy-on-overlap.spec.ts @@ -0,0 +1,262 @@ +import { + ensurePlausibleInitialized, + expectPlausibleInAction, + isEngagementEvent, + isPageviewEvent, + switchByMode +} from './support/test-utils' +import { test, expect } from '@playwright/test' +import { LOCAL_SERVER_ADDR } from './support/server' +import { initializePageDynamically } from './support/initialize-page-dynamically' +import { mockManyRequests } from './support/mock-many-requests' +import { ScriptConfig } from './support/types' +import { customSubmitHandlerStub } from './support/html-fixtures' + +const DEFAULT_CONFIG: ScriptConfig = { + domain: 'example.com', + endpoint: `${LOCAL_SERVER_ADDR}/api/event`, + captureOnLocalhost: true +} + +for (const mode of ['legacy', 'web'] as const) { + test.describe(`outbound links, file downloads, tagged events features hierarchy on overlap legacy/v2 parity (${mode})`, () => { + test('sends only tagged event if the link is a tagged outbound download link', async ({ + page + }, { testId }) => { + const downloadUrl = 'https://files.example.com/file.pdf' + const downloadMock = await mockManyRequests({ + page, + path: downloadUrl, + fulfill: { + status: 200, + contentType: 'application/pdf', + body: 'mocked pdf content' + }, + awaitedRequestCount: 1 + }) + + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: { + ...DEFAULT_CONFIG, + autoCapturePageviews: false, + outboundLinks: true, + fileDownloads: true + }, + legacy: + '' + }, + mode + ), + bodyContent: /* HTML */ `Outbound Download` + }) + await page.goto(url) + + await expectPlausibleInAction(page, { + action: () => page.click('a'), + expectedRequests: [{ n: 'Custom Event', p: { url: downloadUrl } }], + awaitedRequestCount: 2 + }) + + await expect(downloadMock.getRequestList()).resolves.toHaveLength(1) + }) + + test('sends only outbound link event if the link is an outbound download link', async ({ + page + }, { testId }) => { + const downloadUrl = 'https://files.example.com/file.pdf' + const downloadMock = await mockManyRequests({ + page, + path: downloadUrl, + fulfill: { + status: 200, + contentType: 'application/pdf', + body: 'mocked pdf content' + }, + awaitedRequestCount: 1 + }) + + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: { + ...DEFAULT_CONFIG, + autoCapturePageviews: false, + outboundLinks: true, + fileDownloads: true + }, + legacy: + '' + }, + mode + ), + bodyContent: /* HTML */ `Get file` + }) + await page.goto(url) + + await expectPlausibleInAction(page, { + action: () => page.click('a'), + expectedRequests: [ + { n: 'Outbound Link: Click', p: { url: downloadUrl } } + ], + awaitedRequestCount: 2 + }) + await expect(downloadMock.getRequestList()).resolves.toHaveLength(1) + }) + + test('sends file download event when local download link is clicked', async ({ + page + }, { testId }) => { + const downloadUrl = '/file.pdf' + const downloadMock = await mockManyRequests({ + page, + path: downloadUrl, + fulfill: { + status: 200, + contentType: 'application/pdf', + body: 'mocked pdf content' + }, + awaitedRequestCount: 1 + }) + + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: { + ...DEFAULT_CONFIG, + autoCapturePageviews: false, + outboundLinks: true, + fileDownloads: true + }, + legacy: + '' + }, + mode + ), + bodyContent: /* HTML */ `Get file` + }) + await page.goto(url) + + await expectPlausibleInAction(page, { + action: () => page.click('a'), + expectedRequests: [ + { + n: 'File Download', + p: { url: `${LOCAL_SERVER_ADDR}${downloadUrl}` } + } + ], + awaitedRequestCount: 2 + }) + + await expect(downloadMock.getRequestList()).resolves.toHaveLength(1) + }) + }) +} + +for (const mode of ['web', 'esm'] as const) { + test.describe(`form submissions and tagged events features hierarchy on overlap v2-specific (${mode})`, () => { + test('sends only tagged event if a form is tagged', async ({ page }, { + testId + }) => { + const config = { ...DEFAULT_CONFIG, formSubmissions: true } + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: config, + esm: ` + + ` + }, + mode + ), + bodyContent: /* HTML */ ` +
+ +
+ ` + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(url) + await ensurePlausibleInitialized(page) + await page.fill('input[type="text"]', 'Any Name') + await page.click('input[type="submit"]') + }, + shouldIgnoreRequest: [isPageviewEvent, isEngagementEvent], + expectedRequests: [ + { + n: 'A Tagged Form', + u: `${LOCAL_SERVER_ADDR}${url}` + } + ], + refutedRequests: [{ n: 'Form: Submission' }], + mockRequestTimeout: 1000 + }) + }) + + test('sends only tagged event if the form parent is tagged', async ({ + page + }, { testId }) => { + const config = { ...DEFAULT_CONFIG, formSubmissions: true } + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: config, + esm: ` + + ` + }, + mode + ), + bodyContent: /* HTML */ ` +
+
+ +
+
+ ` + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(url) + await ensurePlausibleInitialized(page) + await page.fill('input[type="email"]', 'any@example.com') + await page.press('input[type="email"]', 'Enter') + }, + shouldIgnoreRequest: [isPageviewEvent, isEngagementEvent], + expectedRequests: [ + { n: 'A Tagged Form', u: `${LOCAL_SERVER_ADDR}${url}` } + ], + refutedRequests: [ + { + n: 'Form: Submission' + } + ], + mockRequestTimeout: 1000 + }) + }) + }) +} diff --git a/tracker/test/file-downloads.spec.js b/tracker/test/file-downloads.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/tracker/test/file-downloads.spec.ts b/tracker/test/file-downloads.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..757f76757f8544e66bbc58b2c8235c30aed2e843 --- /dev/null +++ b/tracker/test/file-downloads.spec.ts @@ -0,0 +1,744 @@ +import { + expectPlausibleInAction, + isPageviewEvent, + isEngagementEvent, + switchByMode +} from './support/test-utils' +import { expect, test } from '@playwright/test' +import { LOCAL_SERVER_ADDR } from './support/server' +import { initializePageDynamically } from './support/initialize-page-dynamically' +import { ScriptConfig } from './support/types' +import { + mockManyRequests, + resolveWithTimestamps +} from './support/mock-many-requests' + +const DEFAULT_CONFIG: ScriptConfig = { + domain: 'example.com', + endpoint: `${LOCAL_SERVER_ADDR}/api/event`, + captureOnLocalhost: true +} + +for (const mode of ['web', 'esm']) { + test.describe(`respects "fileDownloads" v2 config option (${mode})`, () => { + test('does not track file downloads when `fileDownloads: false`', async ({ + page + }, { testId }) => { + const filePath = '/file.csv' + const downloadMock = await mockManyRequests({ + page, + path: `${LOCAL_SERVER_ADDR}${filePath}`, + fulfill: { + contentType: 'text/csv' + }, + awaitedRequestCount: 1 + }) + const config = { ...DEFAULT_CONFIG, fileDownloads: false } + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: config, + esm: `` + }, + mode + ), + bodyContent: /* HTML */ `📥` + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(url) + await page.click('a') + }, + expectedRequests: [ + { + n: 'pageview', + d: DEFAULT_CONFIG.domain, + u: `${LOCAL_SERVER_ADDR}${url}` + } + ], + refutedRequests: [{ n: 'File Download' }], + shouldIgnoreRequest: isEngagementEvent + }) + await expect(downloadMock.getRequestList()).resolves.toHaveLength(1) + }) + + test('tracks file downloads when `fileDownloads: true`', async ({ page }, { + testId + }) => { + const filePath = '/file.csv' + const downloadMock = await mockManyRequests({ + page, + path: `${LOCAL_SERVER_ADDR}${filePath}`, + fulfill: { + contentType: 'text/csv' + }, + awaitedRequestCount: 1 + }) + const config = { ...DEFAULT_CONFIG, fileDownloads: true } + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: config, + esm: `` + }, + mode + ), + bodyContent: /* HTML */ `📥` + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(url) + await page.click('a') + }, + expectedRequests: [ + { + n: 'pageview', + d: DEFAULT_CONFIG.domain, + u: `${LOCAL_SERVER_ADDR}${url}` + }, + { n: 'File Download', p: { url: `${LOCAL_SERVER_ADDR}${filePath}` } } + ], + shouldIgnoreRequest: isEngagementEvent + }) + await expect(downloadMock.getRequestList()).resolves.toHaveLength(1) + }) + + test('malformed `fileDownloads: "iso"` option enables the feature with default file types', async ({ + page + }, { testId }) => { + const csvFileURL = `https://example.com/file.csv` + const isoFileURL = `https://example.com/file.iso` + + const csvMock = await mockManyRequests({ + page, + path: csvFileURL, + fulfill: { + contentType: 'text/csv' + }, + awaitedRequestCount: 1 + }) + const isoMock = await mockManyRequests({ + page, + path: isoFileURL, + fulfill: { + contentType: 'application/octet-stream' + }, + awaitedRequestCount: 1 + }) + + const config = { + ...DEFAULT_CONFIG, + fileDownloads: 'iso' + } + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: config, + esm: `` + }, + mode + ), + bodyContent: /* HTML */ `📥📥` + }) + await page.goto(url) + await expectPlausibleInAction(page, { + action: () => page.click(`a[href="${csvFileURL}"]`), + expectedRequests: [{ n: 'File Download', p: { url: csvFileURL } }], + shouldIgnoreRequest: [isPageviewEvent, isEngagementEvent] + }) + await expect(csvMock.getRequestList()).resolves.toHaveLength(1) + + await expectPlausibleInAction(page, { + action: () => page.click(`a[href="${isoFileURL}"]`), + refutedRequests: [{ n: 'File Download' }], + shouldIgnoreRequest: [isPageviewEvent, isEngagementEvent] + }) + await expect(isoMock.getRequestList()).resolves.toHaveLength(1) + }) + }) +} + +for (const mode of ['legacy', 'web']) { + test.describe(`file downloads feature legacy/v2 parity (${mode})`, () => { + test('tracks download when link opens in same tab', async ({ page }, { + testId + }) => { + const filePath = '/file.csv' + const { getRequestList } = await mockManyRequests({ + page, + path: `${LOCAL_SERVER_ADDR}${filePath}`, + fulfill: { + contentType: 'text/csv' + }, + awaitedRequestCount: 1 + }) + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: { ...DEFAULT_CONFIG, fileDownloads: true }, + legacy: + '' + }, + mode + ), + bodyContent: /* HTML */ `📥` + }) + await page.goto(url) + + await expectPlausibleInAction(page, { + action: () => page.click('a'), + expectedRequests: [ + { n: 'File Download', p: { url: `${LOCAL_SERVER_ADDR}${filePath}` } } + ], + shouldIgnoreRequest: [isPageviewEvent, isEngagementEvent] + }) + + await expect(getRequestList()).resolves.toHaveLength(1) + }) + + test('tracks download when link opens in new tab (target="_blank")', async ({ + page + }, { testId }) => { + const pdfUrl = 'https://example.com/downloads/file.pdf' + const pdfMock = await mockManyRequests({ + page, + path: pdfUrl, + fulfill: { + contentType: 'application/pdf' + }, + awaitedRequestCount: 1 + }) + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: { ...DEFAULT_CONFIG, fileDownloads: true }, + legacy: + '' + }, + mode + ), + bodyContent: /* HTML */ `📥` + }) + await page.goto(url) + + await expectPlausibleInAction(page, { + action: () => page.click('a'), + expectedRequests: [{ n: 'File Download', p: { url: pdfUrl } }], + shouldIgnoreRequest: [isPageviewEvent, isEngagementEvent] + }) + + await expect(pdfMock.getRequestList()).resolves.toHaveLength(1) + }) + + test('tracks download when link opens in new tab (ControlOrMeta + click)', async ({ + page, + browserName + }, { testId }) => { + test.skip( + browserName === 'webkit', + 'does not start downloads properly with such clicks (works when testing manually in macOS Safari)' + ) + const pdfUrl = 'https://example.com/downloads/file.pdf' + const pdfMock = await mockManyRequests({ + page, + path: pdfUrl, + fulfill: { + contentType: 'application/pdf' + }, + awaitedRequestCount: 1 + }) + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: { ...DEFAULT_CONFIG, fileDownloads: true }, + legacy: + '' + }, + mode + ), + bodyContent: /* HTML */ `📥` + }) + await page.goto(url) + + await expectPlausibleInAction(page, { + action: () => page.click('a', { modifiers: ['ControlOrMeta'] }), + expectedRequests: [{ n: 'File Download', p: { url: pdfUrl } }], + shouldIgnoreRequest: [isPageviewEvent, isEngagementEvent] + }) + + await expect(pdfMock.getRequestList()).resolves.toHaveLength(1) + }) + + test('tracks download when link child element is clicked', async ({ + page + }, { testId }) => { + const pdfUrl = 'https://example.com/downloads/file.pdf' + const pdfMock = await mockManyRequests({ + page, + path: pdfUrl, + fulfill: { + contentType: 'application/pdf' + }, + awaitedRequestCount: 1 + }) + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: { ...DEFAULT_CONFIG, fileDownloads: true }, + legacy: + '' + }, + mode + ), + bodyContent: /* HTML */ `
📥
` + }) + await page.goto(url) + + await expectPlausibleInAction(page, { + action: () => page.click('span'), + expectedRequests: [{ n: 'File Download', p: { url: pdfUrl } }], + shouldIgnoreRequest: [isPageviewEvent, isEngagementEvent] + }) + + await expect(pdfMock.getRequestList()).resolves.toHaveLength(1) + }) + + test('tracks download without delaying navigation, relying on fetch options.keepalive to deliver tracking events', async ({ + page + }, { testId }) => { + const eventsApiMock = await mockManyRequests({ + page, + path: '**/api/event', + awaitedRequestCount: 1, + responseDelay: 500 + }) + const pdfUrl = 'https://example.com/downloads/file.pdf' + const pdfMock = await mockManyRequests({ + page, + path: pdfUrl, + fulfill: { + contentType: 'application/pdf' + }, + awaitedRequestCount: 1 + }) + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: { + ...DEFAULT_CONFIG, + fileDownloads: true, + autoCapturePageviews: false + }, + legacy: + '' + }, + mode + ), + bodyContent: /* HTML */ `Download` + }) + await page.goto(url) + await page.click('a') + const [ + [trackingRequestList, trackingResponseTime], + [downloadMockRequestList, downloadRequestTime] + ] = await resolveWithTimestamps([ + eventsApiMock.getRequestList(), + pdfMock.getRequestList() + ]) + + expect(downloadRequestTime).toBeLessThan(trackingResponseTime) + expect(downloadMockRequestList).toHaveLength(1) + expect(trackingRequestList).toEqual([ + expect.objectContaining({ + n: 'File Download', + p: { + url: pdfUrl + } + }) + ]) + }) + + test('event.props.url is stripped of query parameters', async ({ page }, { + testId + }) => { + const pdfUrl = 'https://example.com/downloads/file.pdf' + const pdfMock = await mockManyRequests({ + page, + path: `${pdfUrl}*`, + fulfill: { + contentType: 'application/pdf' + }, + awaitedRequestCount: 1 + }) + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: { ...DEFAULT_CONFIG, fileDownloads: true }, + legacy: + '' + }, + mode + ), + bodyContent: /* HTML */ `Download PDF` + }) + await page.goto(url) + + await expectPlausibleInAction(page, { + action: () => page.click('a'), + expectedRequests: [{ n: 'File Download', p: { url: pdfUrl } }], + shouldIgnoreRequest: [isPageviewEvent, isEngagementEvent] + }) + + await expect(pdfMock.getRequestList()).resolves.toHaveLength(1) + }) + + test('does not track iso files by default', async ({ page }, { + testId + }) => { + await mockManyRequests({ + page, + path: 'https://example.com/file.iso', + fulfill: { + contentType: 'application/octet-stream' + }, + awaitedRequestCount: 1 + }) + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: { ...DEFAULT_CONFIG, fileDownloads: true }, + legacy: + '' + }, + mode + ), + bodyContent: /* HTML */ `📥` + }) + await page.goto(url) + + await expectPlausibleInAction(page, { + action: () => page.click('a'), + refutedRequests: [{ n: 'File Download' }], + shouldIgnoreRequest: [isPageviewEvent, isEngagementEvent] + }) + }) + + test('respects file download whitelist ["iso"]', async ({ page }, { + testId + }) => { + const csvFileURL = `https://example.com/file.csv` + const isoFileURL = `https://example.com/file.iso` + const csvMock = await mockManyRequests({ + page, + path: csvFileURL, + fulfill: { + contentType: 'text/csv' + }, + awaitedRequestCount: 1 + }) + const isoMock = await mockManyRequests({ + page, + path: isoFileURL, + fulfill: { + contentType: 'application/octet-stream' + }, + awaitedRequestCount: 1 + }) + + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: { + ...DEFAULT_CONFIG, + fileDownloads: { fileExtensions: ['iso'] } + }, + legacy: + '' + }, + mode + ), + bodyContent: /* HTML */ `📥📥` + }) + await page.goto(url) + await expectPlausibleInAction(page, { + action: () => page.click(`a[href="${csvFileURL}"]`), + refutedRequests: [{ n: 'File Download' }], + shouldIgnoreRequest: [isPageviewEvent, isEngagementEvent] + }) + await expect(csvMock.getRequestList()).resolves.toHaveLength(1) + + await expectPlausibleInAction(page, { + action: () => page.click(`a[href="${isoFileURL}"]`), + expectedRequests: [{ n: 'File Download', p: { url: isoFileURL } }], + shouldIgnoreRequest: [isPageviewEvent, isEngagementEvent] + }) + await expect(isoMock.getRequestList()).resolves.toHaveLength(1) + }) + + test('limitation: does track downloads of links within svg elements', async ({ + page + }, { testId }) => { + const csvFileURL = `https://example.com/file.csv` + const csvMock = await mockManyRequests({ + page, + path: csvFileURL, + fulfill: { + contentType: 'text/csv' + }, + awaitedRequestCount: 1 + }) + + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: { ...DEFAULT_CONFIG, fileDownloads: true }, + legacy: + '' + }, + mode + ), + bodyContent: /* HTML */ ` + + + + ` + }) + + const pageErrors: Error[] = [] + page.on('pageerror', (err) => pageErrors.push(err)) + + await page.goto(url) + + await expectPlausibleInAction(page, { + action: () => page.click('a'), + refutedRequests: [{ n: 'File Download' }], + shouldIgnoreRequest: [isPageviewEvent, isEngagementEvent] + }) + + expect(pageErrors).toHaveLength(0) + await expect(csvMock.getRequestList()).resolves.toHaveLength(1) + }) + }) +} + +test.describe('file downloads feature when using legacy .compat extension', () => { + for (const { clickName, linkAttributes, click, expected, skip } of [ + { + clickName: 'when left clicking on link', + linkAttributes: '', + click: { element: 'a' }, + expected: { downloadsOnSamePage: 1, downloadsOnOtherPages: 0 } + }, + { + clickName: 'when left clicking on link with Ctrl or Meta key', + linkAttributes: '', + click: { element: 'a', modifiers: ['ControlOrMeta' as const] }, + expected: { downloadsOnSamePage: 0, downloadsOnOtherPages: 1 }, + skip: (browserName) => + test.skip( + browserName === 'webkit', + 'does not open links with such clicks (works when testing manually in macOS Safari)' + ) + }, + { + clickName: 'when left clicking on child element of target="_blank" link', + linkAttributes: 'target="_blank"', + click: { element: 'a[target="_blank"] > h1' }, + expected: { downloadsOnSamePage: 0, downloadsOnOtherPages: 1 } + } + ]) { + test(`tracks and starts exactly one download ${clickName}`, async ({ + page, + browserName + }, { testId }) => { + if (skip) { + skip(browserName) + } + + const eventsApiMock = await mockManyRequests({ + page, + path: '**/api/event', + awaitedRequestCount: 2, + mockRequestTimeout: 2000 + }) + const filePath = '/file.csv' + const downloadMockOptions = { + page, + path: `${LOCAL_SERVER_ADDR}${filePath}`, + fulfill: { + contentType: 'text/csv' + }, + awaitedRequestCount: 2, + mockRequestTimeout: 2000 + } + + const downloadMockForOtherPages = await mockManyRequests({ + ...downloadMockOptions, + scopeMockToPage: false + }) + const downloadMockForSamePage = await mockManyRequests({ + ...downloadMockOptions, + scopeMockToPage: true + }) + + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: + '', + bodyContent: /* HTML */ `

📥

` + }) + await page.goto(url) + await page.click(click.element, { modifiers: click.modifiers }) + const [downloadsOnSamePage, downloadsOnOtherPages, eventsApiRequests] = + await Promise.all([ + downloadMockForSamePage.getRequestList().then((d) => d.length), + downloadMockForOtherPages.getRequestList().then((d) => d.length), + eventsApiMock.getRequestList() + ]) + expect({ + downloadsOnSamePage, + downloadsOnOtherPages + }).toEqual(expected) + expect(eventsApiRequests).toEqual([ + expect.objectContaining({ + n: 'File Download', + p: { + url: `${LOCAL_SERVER_ADDR}${filePath}` + } + }) + ]) + }) + } + + for (const { fulfill } of [ + { + fulfill: { + status: 202, + contentType: 'text/plain', + body: 'ok' + } + }, + { + fulfill: { + status: 400, + contentType: 'text/plain', + body: 'Bad Request' + } + } + ]) { + test(`tracking delays navigation until the tracking request has finished (with status: ${fulfill.status})`, async ({ + page + }, { testId }) => { + const eventsApiMock = await mockManyRequests({ + page, + path: '**/api/event', + fulfill, + awaitedRequestCount: 1, + responseDelay: 1000 + }) + const filePath = '/file.csv' + const downloadableFileMock = await mockManyRequests({ + page, + path: `${LOCAL_SERVER_ADDR}${filePath}`, + fulfill: { + contentType: 'text/csv' + }, + awaitedRequestCount: 1, + mockRequestTimeout: 1000 + }) + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: + '', + bodyContent: /* HTML */ `📥` + }) + await page.goto(url) + + const navigationPromise = page.waitForRequest(filePath, { + timeout: 2000 + }) + const trackingPromise = page.waitForResponse('**/api/event', { + timeout: 2000 + }) + + await page.click('a') + const [[, trackingResponseTime], [, navigationTime]] = + await resolveWithTimestamps([trackingPromise, navigationPromise]) + await expect(downloadableFileMock.getRequestList()).resolves.toHaveLength( + 1 + ) + expect(trackingResponseTime).toBeLessThanOrEqual(navigationTime) + await expect(eventsApiMock.getRequestList()).resolves.toEqual([ + expect.objectContaining({ + n: 'File Download', + p: { + url: `${LOCAL_SERVER_ADDR}${filePath}` + } + }) + ]) + }) + } + + test('if the tracking requests delays navigation for more than 5s, it navigates anyway, without waiting for the request to resolve', async ({ + page + }, { testId }) => { + test.setTimeout(20000) + await mockManyRequests({ + page, + path: '**/api/event', + awaitedRequestCount: 1, + responseDelay: 6000 + }) + const filePath = '/file.csv' + const downloadableFileMock = await mockManyRequests({ + page, + path: `${LOCAL_SERVER_ADDR}${filePath}`, + fulfill: { + contentType: 'text/csv' + }, + awaitedRequestCount: 1, + mockRequestTimeout: 1000 + }) + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: + '', + bodyContent: /* HTML */ `📥` + }) + await page.goto(url) + const navigationPromise = page.waitForRequest(filePath, { + timeout: 6000 + }) + await page.click('a') + await navigationPromise + await expect(downloadableFileMock.getRequestList()).resolves.toHaveLength(1) + }) +}) diff --git a/tracker/test/fixtures/cookies-cookiebot.html b/tracker/test/fixtures/cookies-cookiebot.html new file mode 100644 index 0000000000000000000000000000000000000000..92c9080d8c69fbd8fa1be87b1baa955e9ed8e03a --- /dev/null +++ b/tracker/test/fixtures/cookies-cookiebot.html @@ -0,0 +1,4789 @@ + + + + + Plausible Playwright tests + + + + + cookiebot fixture + + + diff --git a/tracker/test/fixtures/cookies-iubenda.html b/tracker/test/fixtures/cookies-iubenda.html new file mode 100644 index 0000000000000000000000000000000000000000..36f279fcc4d5fb65fce4e0fcee60a3694f5da452 --- /dev/null +++ b/tracker/test/fixtures/cookies-iubenda.html @@ -0,0 +1,117 @@ + + + + + Plausible Playwright tests + + + + + iubenda fixture +
+
+
+
+
+ logo +
+ +
+

Notice

+
+

+ We and selected third parties use cookies or similar + technologies for technical purposes and, with your consent, + for other purposes as specified in the + cookie policy. +

+

+

+ Use the “Accept” button to consent. Use the “Reject” button or + close this notice to continue without accepting. +

+
+
+ +
+
+ +
+ +
+
+
+ +
+
+ + diff --git a/tracker/test/fixtures/cookies-onetrust.html b/tracker/test/fixtures/cookies-onetrust.html new file mode 100644 index 0000000000000000000000000000000000000000..4ed7275e3ed490f96e5e30fb37dc48450aa1ec83 --- /dev/null +++ b/tracker/test/fixtures/cookies-onetrust.html @@ -0,0 +1,2547 @@ + + + + + + + + + onetrust cookie banner + + + diff --git a/tracker/test/fixtures/cookies-quantcast.html b/tracker/test/fixtures/cookies-quantcast.html new file mode 100644 index 0000000000000000000000000000000000000000..77de8238c17c09759791b41dc6fcda68728573f8 --- /dev/null +++ b/tracker/test/fixtures/cookies-quantcast.html @@ -0,0 +1,111 @@ + + + + + Plausible Playwright tests + + + + + quantcast fixture +
+
+
+ +
+
+
+ + diff --git a/tracker/test/fixtures/engagement-hash-exclusions.html b/tracker/test/fixtures/engagement-hash-exclusions.html new file mode 100644 index 0000000000000000000000000000000000000000..38c15680064e166ccbf9f6c3e55c8c071788dac8 --- /dev/null +++ b/tracker/test/fixtures/engagement-hash-exclusions.html @@ -0,0 +1,22 @@ + + + + + + + Plausible Playwright tests + + + + + Ignored Hash Link + Hash Link 1 + Hash Link 2 + + diff --git a/tracker/test/fixtures/engagement-hash-manual.html b/tracker/test/fixtures/engagement-hash-manual.html new file mode 100644 index 0000000000000000000000000000000000000000..a0d0aa6a784f26d988d6c76323410def4f0c589a --- /dev/null +++ b/tracker/test/fixtures/engagement-hash-manual.html @@ -0,0 +1,43 @@ + + + + + + + Plausible Playwright tests + + + + + + About us + Home + +
+

Home

+
+ + + + diff --git a/tracker/test/fixtures/engagement-hash-pageview-props.html b/tracker/test/fixtures/engagement-hash-pageview-props.html new file mode 100644 index 0000000000000000000000000000000000000000..b21777098eced41357650b1649192c9822ad3db6 --- /dev/null +++ b/tracker/test/fixtures/engagement-hash-pageview-props.html @@ -0,0 +1,47 @@ + + + + + + + Plausible Playwright tests + + + + +

Blog

+ + + + + +
+ + + + diff --git a/tracker/test/fixtures/engagement-hash.html b/tracker/test/fixtures/engagement-hash.html new file mode 100644 index 0000000000000000000000000000000000000000..b6a3cf049e4a9e47162049b56bbfff77e6ebd685 --- /dev/null +++ b/tracker/test/fixtures/engagement-hash.html @@ -0,0 +1,16 @@ + + + + + + + Plausible Playwright tests + + + + + + Hash link + Another hash link + + diff --git a/tracker/test/fixtures/engagement-manual.html b/tracker/test/fixtures/engagement-manual.html new file mode 100644 index 0000000000000000000000000000000000000000..3d64b8ffcc38147a8acbf853ceea9c216f3a57d3 --- /dev/null +++ b/tracker/test/fixtures/engagement-manual.html @@ -0,0 +1,40 @@ + + + + + + + Plausible Playwright tests + + + + + + Navigate away + + + + + + + diff --git a/tracker/test/fixtures/engagement-pageview-props.html b/tracker/test/fixtures/engagement-pageview-props.html new file mode 100644 index 0000000000000000000000000000000000000000..cdea2320ec3fc6a09730a9ff03e51bc7f5d0ff23 --- /dev/null +++ b/tracker/test/fixtures/engagement-pageview-props.html @@ -0,0 +1,42 @@ + + + + + + + Plausible Playwright tests + + + + + Navigate away + + + + + + + diff --git a/tracker/test/fixtures/engagement.html b/tracker/test/fixtures/engagement.html new file mode 100644 index 0000000000000000000000000000000000000000..148af9cb9b964355086cce63368dfe9e5069d2f8 --- /dev/null +++ b/tracker/test/fixtures/engagement.html @@ -0,0 +1,25 @@ + + + + + + + Plausible Playwright tests + + + + + Navigate away + Navigate to pageleave-pageview props + + + + + + diff --git a/tracker/test/fixtures/file-download-plausible-web.html b/tracker/test/fixtures/file-download-plausible-web.html new file mode 100644 index 0000000000000000000000000000000000000000..0e929c69a730e99aab7856c347d68f454fc2f1d9 --- /dev/null +++ b/tracker/test/fixtures/file-download-plausible-web.html @@ -0,0 +1,36 @@ + + + + + + + plausible-web.js tests + + + + Download + Download ISO + + + + diff --git a/tracker/test/fixtures/img/black3x3000.png b/tracker/test/fixtures/img/black3x3000.png new file mode 100644 index 0000000000000000000000000000000000000000..2c1f173db74880005bb8a28d41f8b658bddb2e02 Binary files /dev/null and b/tracker/test/fixtures/img/black3x3000.png differ diff --git a/tracker/test/fixtures/legacy-custom-properties.html b/tracker/test/fixtures/legacy-custom-properties.html new file mode 100644 index 0000000000000000000000000000000000000000..479bf4a2a810a67f304da5accb3eefd583bfefc0 --- /dev/null +++ b/tracker/test/fixtures/legacy-custom-properties.html @@ -0,0 +1,25 @@ + + + + + + + Plausible Playwright tests + + + + + + + + diff --git a/tracker/test/fixtures/legacy-pageview-properties.html b/tracker/test/fixtures/legacy-pageview-properties.html new file mode 100644 index 0000000000000000000000000000000000000000..140168d250133339c255b31616cf3f2c928e0d7e --- /dev/null +++ b/tracker/test/fixtures/legacy-pageview-properties.html @@ -0,0 +1,24 @@ + + + + + + + Plausible Playwright tests + + + + + + + diff --git a/tracker/test/fixtures/manual.html b/tracker/test/fixtures/manual.html new file mode 100644 index 0000000000000000000000000000000000000000..ae363402629b7a38318edaf0d726a29764b1b280 --- /dev/null +++ b/tracker/test/fixtures/manual.html @@ -0,0 +1,46 @@ + + + + + + + Plausible Playwright tests + + + + + + + + + + + + diff --git a/tracker/test/fixtures/no-async.html b/tracker/test/fixtures/no-async.html new file mode 100644 index 0000000000000000000000000000000000000000..96c566410d8d02a6cd877fdf2237f231d565162c --- /dev/null +++ b/tracker/test/fixtures/no-async.html @@ -0,0 +1,14 @@ + + + + + + + Plausible Playwright tests + + + + + HELLO! + + diff --git a/tracker/test/fixtures/revenue.html b/tracker/test/fixtures/revenue.html new file mode 100644 index 0000000000000000000000000000000000000000..d586c815e08ecf49d7275459549730dfcb31215c --- /dev/null +++ b/tracker/test/fixtures/revenue.html @@ -0,0 +1,30 @@ + + + + + + + Plausible Playwright tests + + + + + + + + + + diff --git a/tracker/test/fixtures/scroll-depth-content-onscroll.html b/tracker/test/fixtures/scroll-depth-content-onscroll.html new file mode 100644 index 0000000000000000000000000000000000000000..21f3a9066db36ffb1b0ebc2c8a390c11786462b0 --- /dev/null +++ b/tracker/test/fixtures/scroll-depth-content-onscroll.html @@ -0,0 +1,40 @@ + + + + + + + Plausible Playwright tests + + + + + + + + + diff --git a/tracker/test/fixtures/scroll-depth-dynamic-content-load.html b/tracker/test/fixtures/scroll-depth-dynamic-content-load.html new file mode 100644 index 0000000000000000000000000000000000000000..bd0f3651e4462378281d48fee1793c6c626d0b11 --- /dev/null +++ b/tracker/test/fixtures/scroll-depth-dynamic-content-load.html @@ -0,0 +1,24 @@ + + + + + + Document + + + +
+
+ + + diff --git a/tracker/test/fixtures/scroll-depth-hash.html b/tracker/test/fixtures/scroll-depth-hash.html new file mode 100644 index 0000000000000000000000000000000000000000..4762883df941fcf1026297d45e9dd581d3950023 --- /dev/null +++ b/tracker/test/fixtures/scroll-depth-hash.html @@ -0,0 +1,36 @@ + + + + + + + Plausible Playwright tests + + + + + + +
+ + + + diff --git a/tracker/test/fixtures/scroll-depth-slow-window-load.html b/tracker/test/fixtures/scroll-depth-slow-window-load.html new file mode 100644 index 0000000000000000000000000000000000000000..ed81e705ba6f4f66479ed91a29620cc24c8879df --- /dev/null +++ b/tracker/test/fixtures/scroll-depth-slow-window-load.html @@ -0,0 +1,14 @@ + + + + + + Document + + + + Navigate away +
+ slow image + + diff --git a/tracker/test/fixtures/scroll-depth.html b/tracker/test/fixtures/scroll-depth.html new file mode 100644 index 0000000000000000000000000000000000000000..ef6923abca356e2fa5ff07a2833944eea31231c8 --- /dev/null +++ b/tracker/test/fixtures/scroll-depth.html @@ -0,0 +1,19 @@ + + + + + + + Document + + + + + diff --git a/tracker/test/form-submissions.spec.ts b/tracker/test/form-submissions.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..24c5c66d71154b7647c2a53763507294ca5ce296 --- /dev/null +++ b/tracker/test/form-submissions.spec.ts @@ -0,0 +1,301 @@ +import { test } from '@playwright/test' +import { LOCAL_SERVER_ADDR } from './support/server' +import { + e, + ensurePlausibleInitialized, + expectPlausibleInAction, + isEngagementEvent, + isPageviewEvent +} from './support/test-utils' +import { initializePageDynamically } from './support/initialize-page-dynamically' +import { ScriptConfig } from './support/types' +import { customSubmitHandlerStub } from './support/html-fixtures' + +const DEFAULT_CONFIG: ScriptConfig = { + domain: 'example.com', + endpoint: `${LOCAL_SERVER_ADDR}/api/event`, + captureOnLocalhost: true +} + +test('does not track form submissions when the feature is disabled', async ({ + page +}, { testId }) => { + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: DEFAULT_CONFIG, + bodyContent: /* HTML */ ` +
+ ` + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(url) + await page.click('input[type="submit"]') + }, + shouldIgnoreRequest: isEngagementEvent, + expectedRequests: [{ n: 'pageview' }], + refutedRequests: [ + { + n: 'Form: Submission' + } + ] + }) +}) + +test.describe('form submissions feature is enabled', () => { + test('tracks forms that use GET method', async ({ page }, { testId }) => { + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: { ...DEFAULT_CONFIG, formSubmissions: true }, + bodyContent: /* HTML */ ` +
+ +
+ ` + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(url) + await ensurePlausibleInitialized(page) + await page.fill('input[type="text"]', 'Any Name') + await page.click('input[type="submit"]') + }, + shouldIgnoreRequest: [isPageviewEvent, isEngagementEvent], + expectedRequests: [ + { + n: 'Form: Submission', + u: `${LOCAL_SERVER_ADDR}${url}`, + p: e.toBeUndefined() + } + ] + }) + }) + + test('tracks form submissions triggered with submit button with custom onsubmit', async ({ + page + }, { testId }) => { + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: { ...DEFAULT_CONFIG, formSubmissions: true }, + bodyContent: /* HTML */ ` +
+ +
+ ` + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(url) + await ensurePlausibleInitialized(page) + await page.click('input[type="submit"]') + }, + shouldIgnoreRequest: [isPageviewEvent, isEngagementEvent], + expectedRequests: [ + { + n: 'Form: Submission', + u: `${LOCAL_SERVER_ADDR}${url}`, + p: e.toBeUndefined() + } + ] + }) + }) + + test('tracks dynamically inserted forms', async ({ page }, { testId }) => { + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: { ...DEFAULT_CONFIG, formSubmissions: true }, + bodyContent: /* HTML */ ` +
+ + +
+ ` + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(url) + await ensurePlausibleInitialized(page) + await page.click('button#dynamically-insert-form') + await page.click('input[type="submit"]') + }, + shouldIgnoreRequest: [isPageviewEvent, isEngagementEvent], + expectedRequests: [ + { + n: 'Form: Submission', + u: `${LOCAL_SERVER_ADDR}${url}`, + p: e.toBeUndefined() + } + ] + }) + }) + + test('tracks form submissions that do not pass checkValidity if the form has novalidate attribute', async ({ + page + }, { testId }) => { + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: { ...DEFAULT_CONFIG, formSubmissions: true }, + bodyContent: /* HTML */ ` +
+ + +
+ ` + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(url) + await ensurePlausibleInitialized(page) + + await page.fill('input[type="email"]', 'invalid email') + await page.click('input[type="submit"]') + }, + shouldIgnoreRequest: [isPageviewEvent, isEngagementEvent], + expectedRequests: [ + { + n: 'Form: Submission', + u: `${LOCAL_SERVER_ADDR}${url}`, + p: e.toBeUndefined() + } + ] + }) + }) + + test('does not track form submissions that do not pass checkValidity', async ({ + page + }, { testId }) => { + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: { ...DEFAULT_CONFIG, formSubmissions: true }, + bodyContent: /* HTML */ ` +
+ + +
+ ` + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(url) + await ensurePlausibleInitialized(page) + await page.fill('input[type="email"]', 'invalid email') + await page.click('input[type="submit"]') + }, + shouldIgnoreRequest: isEngagementEvent, + expectedRequests: [{ n: 'pageview' }], + refutedRequests: [ + { + n: 'Form: Submission' + } + ] + }) + }) + + test('limitation: does not detect forms submitted using FormElement.submit() method', async ({ + page + }, { testId }) => { + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: { ...DEFAULT_CONFIG, formSubmissions: true }, + bodyContent: /* HTML */ ` +
+ +
+ + ` + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(url) + await ensurePlausibleInitialized(page) + + await page.click('button#trigger-FormElement-submit') + }, + shouldIgnoreRequest: isEngagementEvent, + expectedRequests: [{ n: 'pageview' }], + refutedRequests: [ + { + n: 'Form: Submission' + } + ] + }) + }) + + test('limitation: tracks _all_ forms on the same page, but _records them indistinguishably_', async ({ + page + }, { testId }) => { + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: { ...DEFAULT_CONFIG, formSubmissions: true }, + bodyContent: /* HTML */ ` +
+

Form 1

+ +
+
+

Form 2

+ +
+ ` + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(url) + await ensurePlausibleInitialized(page) + await page.click('input[type="submit"]') + }, + shouldIgnoreRequest: [isPageviewEvent, isEngagementEvent], + expectedRequests: [ + { + n: 'Form: Submission', + u: `${LOCAL_SERVER_ADDR}${url}`, + p: e.toBeUndefined() + } + ] + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.fill('input[type="email"]', 'customer@example.com') + await page.keyboard.press('Enter') + }, + shouldIgnoreRequest: [isPageviewEvent, isEngagementEvent], + expectedRequests: [ + { + n: 'Form: Submission', + u: `${LOCAL_SERVER_ADDR}${url}`, + p: e.toBeUndefined() + } + ] + }) + }) +}) diff --git a/tracker/test/hash-based-routing.spec.ts b/tracker/test/hash-based-routing.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..b6ab29a121d070ac63a9aec8c1cbe2246aa52bb7 --- /dev/null +++ b/tracker/test/hash-based-routing.spec.ts @@ -0,0 +1,125 @@ +import { initializePageDynamically } from './support/initialize-page-dynamically' +import { + e, + expectPlausibleInAction, + hideAndShowCurrentTab, + switchByMode +} from './support/test-utils' +import { test } from '@playwright/test' +import { ScriptConfig } from './support/types' +import { LOCAL_SERVER_ADDR } from './support/server' + +const DEFAULT_CONFIG: ScriptConfig = { + domain: 'example.com', + endpoint: `${LOCAL_SERVER_ADDR}/api/event`, + captureOnLocalhost: true +} + +for (const mode of ['web', 'esm']) { + test.describe(`respects hashBasedRouting config option (${mode})`, () => { + test('pageviews and engagement events are sent without "h" parameter if hashBasedRouting is not set', async ({ + page + }, { testId }) => { + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: DEFAULT_CONFIG, + esm: `` + }, + mode + ), + bodyContent: '' + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(url) + await hideAndShowCurrentTab(page, { delay: 200 }) + }, + expectedRequests: [ + { + n: 'pageview', + h: e.toBeUndefined() + }, + { n: 'engagement', h: e.toBeUndefined() } + ] + }) + }) + + test('pageviews and engagement events are sent with "h:1" parameter if hashBasedRouting is set to true', async ({ + page + }, { testId }) => { + const config = { ...DEFAULT_CONFIG, hashBasedRouting: true } + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: config, + esm: `` + }, + mode + ), + bodyContent: '' + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(`${url}#page1`) + await hideAndShowCurrentTab(page, { delay: 200 }) + }, + expectedRequests: [ + { + n: 'pageview', + h: 1, + u: `${LOCAL_SERVER_ADDR}${url}#page1` + }, + { + n: 'engagement', + h: 1, + u: `${LOCAL_SERVER_ADDR}${url}#page1` + } + ] + }) + }) + }) +} + +test.describe('hash-based routing (legacy)', () => { + test('pageviews and engagement events are sent with "h:1" parameter if using the hash extension', async ({ + page + }, { testId }) => { + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: /* HTML */ ``, + bodyContent: '' + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(`${url}#page1`) + await hideAndShowCurrentTab(page, { delay: 200 }) + }, + expectedRequests: [ + { + n: 'pageview', + h: 1, + u: `${LOCAL_SERVER_ADDR}${url}#page1` + }, + { + n: 'engagement', + h: 1, + u: `${LOCAL_SERVER_ADDR}${url}#page1` + } + ] + }) + }) +}) diff --git a/tracker/test/hash-exclusions.spec.ts b/tracker/test/hash-exclusions.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..288f1253b7ea3b5da344c72896835c876023e06f --- /dev/null +++ b/tracker/test/hash-exclusions.spec.ts @@ -0,0 +1,22 @@ +import { initializePageDynamically } from './support/initialize-page-dynamically' +import { expectPlausibleInAction } from './support/test-utils' +import { test } from '@playwright/test' + +test.describe('combination of hash and exclusions script extensions', () => { + test('excludes by hash part of the URL', async ({ page }, { testId }) => { + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: /* HTML */ ``, + bodyContent: '' + }) + await expectPlausibleInAction(page, { + action: () => page.goto(`${url}#this/hash/should/be/ignored`), + expectedRequests: [], + awaitedRequestCount: 1 + }) + }) +}) diff --git a/tracker/test/installation_support/check-disallowed-by-csp.spec.js b/tracker/test/installation_support/check-disallowed-by-csp.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..ac15b04aeff2a9d0da7b26f04a8b983a8ba43691 --- /dev/null +++ b/tracker/test/installation_support/check-disallowed-by-csp.spec.js @@ -0,0 +1,54 @@ +import { test, expect } from '@playwright/test' +import { checkDisallowedByCSP } from '../../installation_support/check-disallowed-by-csp' + +const HOST_TO_CHECK = 'plausible.io' + +test.describe('checkDisallowedByCSP', () => { + test('returns false if no CSP header', () => { + expect(checkDisallowedByCSP({}, HOST_TO_CHECK)).toBe(false) + expect(checkDisallowedByCSP({ foo: 'bar' }, HOST_TO_CHECK)).toBe(false) + }) + + test('returns false if CSP header is empty', () => { + expect( + checkDisallowedByCSP({ 'content-security-policy': '' }, HOST_TO_CHECK) + ).toBe(false) + }) + + test('returns true if plausible.io is not allowed', () => { + const headers = { + 'content-security-policy': "default-src 'self' foo.local; example.com" + } + expect(checkDisallowedByCSP(headers, HOST_TO_CHECK)).toBe(true) + }) + + test('returns false if plausible.io is allowed', () => { + const headers = { + 'content-security-policy': "default-src 'self' plausible.io; example.com" + } + expect(checkDisallowedByCSP(headers, HOST_TO_CHECK)).toBe(false) + }) + + test('returns false if plausible.io subdomain is allowed', () => { + const headers = { + 'content-security-policy': + "default-src 'self' staging.plausible.io; example.com" + } + expect(checkDisallowedByCSP(headers, HOST_TO_CHECK)).toBe(false) + }) + + test('returns false if plausible.io is allowed with https', () => { + const headers = { + 'content-security-policy': + "default-src 'self' https://plausible.io; example.com" + } + expect(checkDisallowedByCSP(headers, HOST_TO_CHECK)).toBe(false) + }) + + test('returns true if plausible.io is not present in any directive', () => { + const headers = { + 'content-security-policy': "default-src 'self' foo.com; bar.com" + } + expect(checkDisallowedByCSP(headers, HOST_TO_CHECK)).toBe(true) + }) +}) diff --git a/tracker/test/installation_support/check-gtm.spec.js b/tracker/test/installation_support/check-gtm.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..d684dd9796158aefdedf384544b76b079b25e433 --- /dev/null +++ b/tracker/test/installation_support/check-gtm.spec.js @@ -0,0 +1,31 @@ +import { test, expect } from '@playwright/test' +import { checkGTM } from '../../installation_support/check-gtm' + +function mockDocument(html) { + return { + documentElement: { outerHTML: `${html}` } + } +} + +test.describe('checkGTM (gtmLikely)', () => { + test('handles document undefined', () => { + expect(checkGTM(undefined)).toBe(false) + }) + + test('handles document.documentElement undefined', () => { + const document = { documentElement: undefined } + expect(checkGTM(document)).toBe(false) + }) + + test('false when no GTM indicators present', () => { + const document = mockDocument('') + expect(checkGTM(document)).toBe(false) + }) + + test('detects gtmLikely by googletagmanager.com/gtm.js signature', () => { + const document = mockDocument( + '' + ) + expect(checkGTM(document)).toBe(true) + }) +}) diff --git a/tracker/test/installation_support/check-wp.spec.js b/tracker/test/installation_support/check-wp.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..2e555439ff6febcba45f92b2f940fe5f5b11f180 --- /dev/null +++ b/tracker/test/installation_support/check-wp.spec.js @@ -0,0 +1,110 @@ +import { test, expect } from '@playwright/test' +import { + checkWordPress, + WORDPRESS_PLUGIN_VERSION_SELECTOR +} from '../../installation_support/check-wordpress' + +function mockDocument(html, hasMetaTag) { + return { + documentElement: { outerHTML: `${html}` }, + querySelector: (selector) => { + if (selector === WORDPRESS_PLUGIN_VERSION_SELECTOR) { + return hasMetaTag ? {} : null + } + return null + } + } +} + +test.describe('checkWordPress (wordpressPlugin, wordPressLikely)', () => { + test('handles document undefined', () => { + const result = checkWordPress(undefined) + + expect(result.wordpressPlugin).toBe(false) + expect(result.wordpressLikely).toBe(false) + }) + + test('handles document.querySelector undefined', () => { + const document = { documentElement: { outerHTML: '' } } + const result = checkWordPress(document) + + expect(result.wordpressPlugin).toBe(false) + expect(result.wordpressLikely).toBe(false) + }) + + test('handles document.documentElement undefined', () => { + const document = { documentElement: undefined, querySelector: (_) => null } + const result = checkWordPress(document) + + expect(result.wordpressPlugin).toBe(false) + expect(result.wordpressLikely).toBe(false) + }) + + test('both false when no WordPress indicators present', () => { + const document = mockDocument('', false) + const result = checkWordPress(document) + + expect(result.wordpressPlugin).toBe(false) + expect(result.wordpressLikely).toBe(false) + }) + + test('both true if WordPress plugin version meta tag detected', () => { + const document = mockDocument('', true) + const result = checkWordPress(document) + + expect(result.wordpressPlugin).toBe(true) + expect(result.wordpressLikely).toBe(true) + }) + + test('detects wordpressLikely by wp-content signature', () => { + const document = mockDocument( + '', + false + ) + const result = checkWordPress(document) + + expect(result.wordpressPlugin).toBe(false) + expect(result.wordpressLikely).toBe(true) + }) + + test('detects wordpressLikely by wp-includes signature', () => { + const document = mockDocument( + '', + false + ) + const result = checkWordPress(document) + + expect(result.wordpressPlugin).toBe(false) + expect(result.wordpressLikely).toBe(true) + }) + + test('detects wordpressLikely by wp-json signature', () => { + const document = mockDocument( + '', + false + ) + const result = checkWordPress(document) + + expect(result.wordpressPlugin).toBe(false) + expect(result.wordpressLikely).toBe(true) + }) + + test('detects wordpressLikely by multiple signatures', () => { + const document = mockDocument( + ` + + + + + + + `, + false + ) + + const result = checkWordPress(document) + + expect(result.wordpressPlugin).toBe(false) + expect(result.wordpressLikely).toBe(true) + }) +}) diff --git a/tracker/test/installation_support/detector.spec.js b/tracker/test/installation_support/detector.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..d980be1bfd7835e5cdf160cdfcba93144003a77a --- /dev/null +++ b/tracker/test/installation_support/detector.spec.js @@ -0,0 +1,183 @@ +import { test, expect } from '@playwright/test' +import { detect } from '../support/installation-support-playwright-wrappers' +import { initializePageDynamically } from '../support/initialize-page-dynamically' + +test.describe('detector.js (tech recognition)', () => { + test('skips v1 snippet detection by default', async ({ page }, { + testId + }) => { + const { url } = await initializePageDynamically(page, { + testId, + response: /* HTML */ ` + + + + + + ` + }) + + const result = await detect(page, { + url: url, + detectV1: false, + timeoutMs: 1000 + }) + + expect(result.data.v1Detected).toBe(null) + }) + + test('detects WP plugin, WP and GTM', async ({ page }, { testId }) => { + const { url } = await initializePageDynamically(page, { + testId, + response: /* HTML */ ` + + + + + + + + ` + }) + + const result = await detect(page, { + url: url, + detectV1: false, + timeoutMs: 1000 + }) + + expect(result.data.wordpressPlugin).toBe(true) + expect(result.data.wordpressLikely).toBe(true) + expect(result.data.gtmLikely).toBe(true) + }) + + test('No WP plugin, WP or GTM', async ({ page }, { testId }) => { + const { url } = await initializePageDynamically(page, { + testId, + response: /* HTML */ ` + + ` + }) + + const result = await detect(page, { + url: url, + detectV1: false, + timeoutMs: 1000 + }) + + expect(result.data.wordpressPlugin).toBe(false) + expect(result.data.wordpressLikely).toBe(false) + expect(result.data.gtmLikely).toBe(false) + expect(result.data.npm).toBe(false) + }) + + test('npm is reported correctly', async ({ page }, { testId }) => { + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: /* HTML */ `` + }) + + const result = await detect(page, { url: url, detectV1: false }) + + expect(result.data.wordpressPlugin).toBe(false) + expect(result.data.wordpressLikely).toBe(false) + expect(result.data.gtmLikely).toBe(false) + expect(result.data.npm).toBe(true) + }) +}) + +test.describe('detector.js (v1 detection)', () => { + test('v1Detected is true when v1 plausible exists + detects WP plugin, WP and GTM', async ({ + page + }, { testId }) => { + const { url } = await initializePageDynamically(page, { + testId, + response: /* HTML */ ` + + + + + + + + + ` + }) + + const result = await detect(page, { + url: url, + detectV1: true, + timeoutMs: 1000 + }) + + expect(result.data.v1Detected).toBe(true) + expect(result.data.wordpressPlugin).toBe(true) + expect(result.data.wordpressLikely).toBe(true) + expect(result.data.gtmLikely).toBe(true) + expect(result.data.npm).toBe(false) + }) + + test('v1Detected is false when plausible function does not exist', async ({ + page + }, { testId }) => { + const { url } = await initializePageDynamically(page, { + testId, + response: /* HTML */ ` + + ` + }) + + const result = await detect(page, { + url: url, + detectV1: true, + timeoutMs: 1000 + }) + + expect(result.data.v1Detected).toBe(false) + expect(result.data.wordpressPlugin).toBe(false) + expect(result.data.wordpressLikely).toBe(false) + expect(result.data.gtmLikely).toBe(false) + expect(result.data.npm).toBe(false) + }) + + test('v1Detected is false when v2 plausible installed', async ({ page }, { + testId + }) => { + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: { + domain: 'abc.de', + captureOnLocalhost: false + } + }) + + const result = await detect(page, { + url: url, + detectV1: true, + timeoutMs: 1000 + }) + + expect(result.data.v1Detected).toBe(false) + }) +}) diff --git a/tracker/test/installation_support/verifier.spec.ts b/tracker/test/installation_support/verifier.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..a9eee4e19808ab7a39309626f9b102f157b31e85 --- /dev/null +++ b/tracker/test/installation_support/verifier.spec.ts @@ -0,0 +1,945 @@ +import { test, expect } from '@playwright/test' +import { executeVerifier } from '../support/installation-support-playwright-wrappers' +import { initializePageDynamically } from '../support/initialize-page-dynamically' +import { mockManyRequests } from '../support/mock-many-requests' +import { LOCAL_SERVER_ADDR } from '../support/server' +import { tracker_script_version as version } from '../support/test-utils' + +const DEFAULT_VERIFICATION_OPTIONS = { + responseHeaders: {}, + debug: true, + timeoutMs: 1000, + cspHostToCheck: 'plausible.io', + maxAttempts: 2, + timeoutBetweenAttemptsMs: 500, + trackerScriptSelector: `script[src^="/tracker/js/plausible-web.js"]` +} + +const incompleteCookiesConsentResult = { + engineLifecycle: expect.stringMatching(/started|initialized/), + handled: null +} + +test.describe('installed plausible web variant', () => { + test('using provided snippet', async ({ page }, { testId }) => { + await mockManyRequests({ + page, + path: `https://plausible.io/api/event`, + awaitedRequestCount: 1, + fulfill: { + status: 202, + contentType: 'text/plain', + body: 'ok' + } + }) + + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: { + domain: 'example.com', + endpoint: `https://plausible.io/api/event`, + captureOnLocalhost: true + }, + bodyContent: '' + }) + + const response = await page.goto(url) + const responseHeaders = response?.headers() ?? {} + + const result = await executeVerifier(page, { + ...DEFAULT_VERIFICATION_OPTIONS, + responseHeaders + }) + + expect(result).toEqual({ + data: { + attempts: 1, + completed: true, + trackerIsInHtml: true, + plausibleIsInitialized: true, + plausibleIsOnWindow: true, + disallowedByCsp: false, + plausibleVersion: version, + plausibleVariant: 'web', + testEvent: { + callbackResult: { status: 202 }, + requestUrl: 'https://plausible.io/api/event', + normalizedBody: { + domain: 'example.com', + name: 'verification-agent-test', + version + }, + responseStatus: 202, + error: undefined, + testPlausibleFunctionError: undefined + }, + cookiesConsentResult: incompleteCookiesConsentResult + } + }) + }) + + test('using provided snippet and the events endpoint responds slower than the timeout', async ({ + page + }, { testId }) => { + const responseDelayMs = 2000 + const timeoutMs = 1000 + await mockManyRequests({ + page, + path: `https://plausible.io/api/event`, + awaitedRequestCount: 1, + fulfill: { + status: 202, + contentType: 'text/plain', + body: 'ok' + }, + responseDelay: responseDelayMs + }) + + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: { + domain: 'example.com', + endpoint: `https://plausible.io/api/event`, + captureOnLocalhost: true + }, + bodyContent: '' + }) + + const response = await page.goto(url) + const responseHeaders = response?.headers() ?? {} + + const result = await executeVerifier(page, { + ...DEFAULT_VERIFICATION_OPTIONS, + responseHeaders, + timeoutMs + }) + + expect(result).toEqual({ + data: { + attempts: 1, + completed: true, + trackerIsInHtml: true, + plausibleIsInitialized: true, + plausibleIsOnWindow: true, + disallowedByCsp: false, + plausibleVersion: version, + plausibleVariant: 'web', + testEvent: { + callbackResult: undefined, + requestUrl: 'https://plausible.io/api/event', + normalizedBody: { + domain: 'example.com', + name: 'verification-agent-test', + version + }, + responseStatus: undefined, + error: undefined, + testPlausibleFunctionError: 'Test Plausible function timeout exceeded' + }, + cookiesConsentResult: incompleteCookiesConsentResult + } + }) + }) + + test('using provided snippet and the events endpoint responds with 400', async ({ + page + }, { testId }) => { + await mockManyRequests({ + page, + path: `https://plausible.io/api/event`, + awaitedRequestCount: 1, + fulfill: { + status: 400, + contentType: 'text/plain', + body: 'Bad Request' + } + }) + + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: { + domain: 'example.com', + endpoint: `https://plausible.io/api/event`, + captureOnLocalhost: true + }, + bodyContent: '' + }) + + const response = await page.goto(url) + const responseHeaders = response?.headers() ?? {} + + const result = await executeVerifier(page, { + ...DEFAULT_VERIFICATION_OPTIONS, + responseHeaders + }) + + expect(result).toEqual({ + data: { + attempts: 1, + completed: true, + trackerIsInHtml: true, + plausibleIsInitialized: true, + plausibleIsOnWindow: true, + disallowedByCsp: false, + plausibleVersion: version, + plausibleVariant: 'web', + testEvent: { + callbackResult: { status: 400 }, + requestUrl: 'https://plausible.io/api/event', + normalizedBody: { + domain: 'example.com', + name: 'verification-agent-test', + version + }, + responseStatus: 400, + error: undefined, + testPlausibleFunctionError: undefined + }, + cookiesConsentResult: incompleteCookiesConsentResult + } + }) + }) + + test('using provided snippet and captureOnLocalhost: false', async ({ + page + }, { testId }) => { + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: { + domain: 'example.com/foobar', + endpoint: 'https://plausible.io/api/event', + captureOnLocalhost: false + }, + bodyContent: '' + }) + + const response = await page.goto(url) + const responseHeaders = response?.headers() ?? {} + + const result = await executeVerifier(page, { + ...DEFAULT_VERIFICATION_OPTIONS, + responseHeaders + }) + + expect(result).toEqual({ + data: { + attempts: 1, + completed: true, + trackerIsInHtml: true, + plausibleIsInitialized: true, + plausibleIsOnWindow: true, + disallowedByCsp: false, + plausibleVersion: version, + plausibleVariant: 'web', + testEvent: { + callbackResult: 'undefined or null', + requestUrl: undefined, + normalizedBody: undefined, + responseStatus: undefined, + error: undefined, + testPlausibleFunctionError: undefined + }, + cookiesConsentResult: incompleteCookiesConsentResult + } + }) + }) + + test('there is a JS navigation immediately on page load to a page with the provided snippet', async ({ + page + }, { testId }) => { + const { url: urlBeta } = await initializePageDynamically(page, { + path: '/beta', + testId, + scriptConfig: { + domain: 'example.com/beta', + endpoint: 'https://plausible.io/api/event', + captureOnLocalhost: true + }, + bodyContent: 'beta' + }) + + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: '', // no tracker on initial page + bodyContent: 'alfa' + }) + + const response = await page.goto(url) + const responseHeaders = response?.headers() ?? {} + + await expect(page.getByText('alfa')).toBeVisible() + + const [result, _] = await Promise.all([ + executeVerifier(page, { + ...DEFAULT_VERIFICATION_OPTIONS, + timeoutMs: 1500, + responseHeaders + }), + (async () => { + // start navigation timer only when the verifier code has been added to the page + await page.waitForFunction(`() => !!window.verifyPlausibleInstallation`) + await page.evaluate( + ({ targetUrl }) => { + setTimeout(() => { + window.location.href = targetUrl + }, 500) + }, + { targetUrl: urlBeta } + ) + })() + ]) + await expect(page.getByText('beta')).toBeVisible() + + expect(result).toEqual({ + data: { + attempts: 2, + completed: true, + trackerIsInHtml: true, + plausibleIsInitialized: true, + plausibleIsOnWindow: true, + disallowedByCsp: false, + plausibleVersion: version, + plausibleVariant: 'web', + testEvent: { + callbackResult: { status: 202 }, + requestUrl: 'https://plausible.io/api/event', + normalizedBody: { + domain: 'example.com/beta', + name: 'verification-agent-test', + version + }, + responseStatus: 202, + error: undefined + }, + cookiesConsentResult: incompleteCookiesConsentResult + } + }) + }) + + test('there are more than maxAttempts JS navigations', async ({ page }, { + testId + }) => { + const maxAttempts = 2 + + const { url: urlGamma } = await initializePageDynamically(page, { + path: '/gamma', + testId, + scriptConfig: { + domain: 'example.com/gamma', + endpoint: 'https://plausible.io/api/event', + captureOnLocalhost: true + }, + bodyContent: 'gamma' + }) + + const { url: urlBeta } = await initializePageDynamically(page, { + path: '/beta', + testId, + scriptConfig: '', // no tracker + bodyContent: 'beta' + }) + + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: '', // no tracker + bodyContent: 'alfa' + }) + const response = await page.goto(url) + const responseHeaders = response?.headers() ?? {} + + await expect(page.getByText('alfa')).toBeVisible() + + const [result] = await Promise.all([ + executeVerifier(page, { + ...DEFAULT_VERIFICATION_OPTIONS, + timeoutMs: 1500, + timeoutBetweenAttemptsMs: 100, + maxAttempts, + responseHeaders + }), + (async () => { + // start navigation timer only when the verifier code has been added to the page + await page.waitForFunction(`() => !!window.verifyPlausibleInstallation`) + await page.evaluate( + ({ targetUrl }) => { + setTimeout(() => { + console.debug(`navigation 1`) + window.location.href = targetUrl + }, 500) + }, + { targetUrl: urlBeta } + ) + await expect(page.getByText('beta')).toBeVisible() + // start navigation timer only when the verifier code has been added to the page + await page.waitForFunction(`() => !!window.verifyPlausibleInstallation`) + await page.evaluate( + ({ targetUrl }) => { + setTimeout(() => { + window.location.href = targetUrl + }, 500) + }, + { targetUrl: urlGamma } + ) + })() + ]) + + await expect(page.getByText('gamma')).toBeVisible() + + expect(result).toEqual({ + data: { + completed: false, + error: { + message: + 'page.evaluate: Execution context was destroyed, most likely because of a navigation.' + } + } + }) + }) + + test('using provided snippet but disallowed by CSP', async ({ page }, { + testId + }) => { + await mockManyRequests({ + page, + path: `https://plausible.io/api/event`, + awaitedRequestCount: 1, + fulfill: { + status: 202, + contentType: 'text/plain', + body: 'ok' + } + }) + + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: { + domain: 'example.com', + endpoint: `https://plausible.io/api/event`, + captureOnLocalhost: true + }, + bodyContent: '', + headers: { + 'content-security-policy': "default-src 'self'; img-src 'self'" + } + }) + + const response = await page.goto(url) + const responseHeaders = response?.headers() ?? {} + + const result = await executeVerifier(page, { + ...DEFAULT_VERIFICATION_OPTIONS, + responseHeaders + }) + + expect(result).toEqual({ + data: { + attempts: 1, + completed: true, + disallowedByCsp: true, + trackerIsInHtml: true, + plausibleIsOnWindow: true, + plausibleIsInitialized: undefined, + plausibleVersion: undefined, + plausibleVariant: undefined, + testEvent: { + error: undefined, + normalizedBody: undefined, + requestUrl: undefined, + responseStatus: undefined, + testPlausibleFunctionError: 'Test Plausible function timeout exceeded' + }, + cookiesConsentResult: incompleteCookiesConsentResult + } + }) + }) + + test(`using provided snippet and there is a strict CSP without 'unsafe-inline'`, async ({ + page + }, { testId }) => { + const endpoint = `${LOCAL_SERVER_ADDR}/api/event` + const cspHostToCheck = LOCAL_SERVER_ADDR.replace('http://', '') + const headers = { + // 'unsafe-inline' is needed to allow the bootstrapper snippet to be executed + 'content-security-policy': `default-src 'self'; script-src ${cspHostToCheck}; connect-src ${cspHostToCheck}` + } + + await mockManyRequests({ + page, + path: endpoint, + awaitedRequestCount: 1, + fulfill: { + status: 202, + contentType: 'text/plain', + body: 'ok' + } + }) + + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: { + endpoint, + domain: 'example.com', + captureOnLocalhost: true + }, + bodyContent: '', + headers + }) + + const response = await page.goto(url) + const responseHeaders = response?.headers() ?? {} + + const result = await executeVerifier(page, { + ...DEFAULT_VERIFICATION_OPTIONS, + cspHostToCheck, + responseHeaders + }) + + expect(result).toEqual({ + data: { + attempts: 1, + completed: true, + disallowedByCsp: false, // scripts from our domain are allowed, but the inline sourceless snippet can't run because 'unsafe-inline' is not present in the CSP + trackerIsInHtml: true, + plausibleIsOnWindow: true, + plausibleIsInitialized: undefined, + plausibleVersion: undefined, + plausibleVariant: undefined, + testEvent: { + error: undefined, + normalizedBody: undefined, + requestUrl: undefined, + responseStatus: undefined, + testPlausibleFunctionError: 'Test Plausible function timeout exceeded' + }, + cookiesConsentResult: incompleteCookiesConsentResult + } + }) + }) + + test(`using provided snippet and there is a strict CSP with 'unsafe-inline'`, async ({ + page + }, { testId }) => { + const endpoint = `${LOCAL_SERVER_ADDR}/api/event` + const cspHostToCheck = LOCAL_SERVER_ADDR.replace('http://', '') + const headers = { + // 'unsafe-inline' is needed to allow the bootstrapper snippet to be executed + 'content-security-policy': `default-src 'self'; script-src 'unsafe-inline' ${cspHostToCheck}; connect-src ${cspHostToCheck}` + } + + await mockManyRequests({ + page, + path: endpoint, + awaitedRequestCount: 1, + fulfill: { + status: 202, + contentType: 'text/plain', + body: 'ok' + } + }) + + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: { + endpoint, + domain: 'example.com', + captureOnLocalhost: true + }, + bodyContent: '', + headers + }) + + const response = await page.goto(url) + const responseHeaders = response?.headers() ?? {} + + const result = await executeVerifier(page, { + ...DEFAULT_VERIFICATION_OPTIONS, + cspHostToCheck, + responseHeaders + }) + + expect(result).toEqual({ + data: { + attempts: 1, + completed: true, + trackerIsInHtml: true, + disallowedByCsp: false, + plausibleIsOnWindow: true, + plausibleIsInitialized: true, + plausibleVersion: version, + plausibleVariant: 'web', + testEvent: { + callbackResult: { status: 202 }, + requestUrl: `${LOCAL_SERVER_ADDR}/api/event`, + normalizedBody: { + domain: 'example.com', + name: 'verification-agent-test', + version + }, + responseStatus: 202 + }, + cookiesConsentResult: incompleteCookiesConsentResult + } + }) + }) +}) + +test.describe('installed plausible esm variant', () => { + test('using `, + bodyContent: '' + }) + + const response = await page.goto(url) + const responseHeaders = response?.headers() ?? {} + + const result = await executeVerifier(page, { + ...DEFAULT_VERIFICATION_OPTIONS, + responseHeaders + }) + + expect(result).toEqual({ + data: { + attempts: 1, + completed: true, + trackerIsInHtml: false, + plausibleIsInitialized: true, + plausibleIsOnWindow: true, + disallowedByCsp: false, + plausibleVersion: version, + plausibleVariant: 'npm', + testEvent: { + callbackResult: { status: 202 }, + requestUrl: 'https://plausible.io/api/event', + normalizedBody: { + domain: 'example.com', + name: 'verification-agent-test', + version + }, + responseStatus: 202, + error: undefined + }, + cookiesConsentResult: incompleteCookiesConsentResult + } + }) + }) + + test('using `, + bodyContent: '' + }) + + const response = await page.goto(url) + const responseHeaders = response?.headers() ?? {} + + const result = await executeVerifier(page, { + ...DEFAULT_VERIFICATION_OPTIONS, + responseHeaders + }) + + expect(result).toEqual({ + data: { + attempts: 1, + completed: true, + trackerIsInHtml: false, + plausibleIsInitialized: true, + plausibleIsOnWindow: true, + disallowedByCsp: false, + plausibleVersion: version, + plausibleVariant: 'npm', + testEvent: { + callbackResult: { status: 202 }, + requestUrl: '/events', + normalizedBody: { + domain: 'example.com', + name: 'verification-agent-test', + version + }, + responseStatus: 202, + error: undefined + }, + cookiesConsentResult: incompleteCookiesConsentResult + } + }) + }) + + test('using `, + bodyContent: '' + }) + + await mockManyRequests({ + page, + path: `https://example.com/events`, + awaitedRequestCount: 1, + fulfill: { + status: 500, + contentType: 'text/plain', + body: 'Unknown error' + } + }) + + const response = await page.goto(url) + const responseHeaders = response?.headers() ?? {} + + const result = await executeVerifier(page, { + ...DEFAULT_VERIFICATION_OPTIONS, + responseHeaders + }) + + expect(result).toEqual({ + data: { + attempts: 1, + completed: true, + trackerIsInHtml: false, + plausibleIsInitialized: true, + plausibleIsOnWindow: true, + disallowedByCsp: false, + plausibleVersion: version, + plausibleVariant: 'npm', + testEvent: { + callbackResult: { status: 500 }, + requestUrl: 'https://example.com/events', + normalizedBody: { + domain: 'example.com', + name: 'verification-agent-test', + version + }, + responseStatus: 500, + error: undefined + }, + cookiesConsentResult: incompleteCookiesConsentResult + } + }) + }) + + test('using `, + bodyContent: '' + }) + + const response = await page.goto(url) + const responseHeaders = response?.headers() ?? {} + + const result = await executeVerifier(page, { + ...DEFAULT_VERIFICATION_OPTIONS, + responseHeaders + }) + + expect(result).toEqual({ + data: { + attempts: 1, + completed: true, + trackerIsInHtml: false, + plausibleIsInitialized: true, + plausibleIsOnWindow: true, + disallowedByCsp: false, + plausibleVersion: version, + plausibleVariant: 'npm', + testEvent: { + callbackResult: { + error: expect.objectContaining({ message: 'Failed to fetch' }) + }, + requestUrl: 'invalid:/plausible.io/api/event', + normalizedBody: { + domain: 'example.com/foobar', + name: 'verification-agent-test', + version + }, + responseStatus: undefined, + error: { message: 'Failed to fetch' } + }, + cookiesConsentResult: incompleteCookiesConsentResult + } + }) + }) +}) + +test.describe('installed legacy .compat script', () => { + test('with masked events endpoint', async ({ page }, { testId }) => { + await mockManyRequests({ + page, + path: `https://example.com/any/masked/events/endpoint`, + awaitedRequestCount: 1, + fulfill: { + status: 202, + contentType: 'text/plain', + body: 'ok' + } + }) + + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: /* HTML */ ``, + bodyContent: '' + }) + + const response = await page.goto(url) + const responseHeaders = response?.headers() ?? {} + + const result = await executeVerifier(page, { + ...DEFAULT_VERIFICATION_OPTIONS, + responseHeaders + }) + + expect(result).toEqual({ + data: { + attempts: 1, + completed: true, + trackerIsInHtml: false, + plausibleIsInitialized: true, + plausibleIsOnWindow: true, + disallowedByCsp: false, + plausibleVersion: version, + plausibleVariant: undefined, + testEvent: { + callbackResult: { + status: 202 + }, + normalizedBody: { + __legacyCompatInstallation: true, + domain: 'example.com' + }, + responseStatus: 202 + }, + cookiesConsentResult: incompleteCookiesConsentResult + } + }) + }) +}) + +test.describe('opts in on cookie banners', () => { + for (const { url, expectedCookiesConsentResult } of [ + { + url: `${LOCAL_SERVER_ADDR}/cookies-onetrust.html`, + expectedCookiesConsentResult: { + cmp: 'Onetrust', + handled: true + } + }, + { + url: `${LOCAL_SERVER_ADDR}/cookies-iubenda.html`, + expectedCookiesConsentResult: { + cmp: 'iubenda', + handled: true + } + }, + { + url: `${LOCAL_SERVER_ADDR}/cookies-cookiebot.html`, + expectedCookiesConsentResult: { + cmp: 'cookiebot', + handled: true + } + }, + { + url: `${LOCAL_SERVER_ADDR}/cookies-quantcast.html`, + expectedCookiesConsentResult: { + cmp: 'quantcast', + handled: true + } + } + ]) { + test(`accepts cookies of cmp ${expectedCookiesConsentResult.cmp}`, async ({ + page + }) => { + const response = await page.goto(url) + const responseHeaders = response?.headers() ?? {} + + const result = await executeVerifier(page, { + ...DEFAULT_VERIFICATION_OPTIONS, + timeoutMs: 2000, + responseHeaders + }) + + expect(result.data).toEqual( + expect.objectContaining({ + cookiesConsentResult: expectedCookiesConsentResult + }) + ) + }) + } +}) diff --git a/tracker/test/legacy-custom-properties.spec.js b/tracker/test/legacy-custom-properties.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..9d5c4f18c8d146213bee2e6dc77e886b1f0c3e96 --- /dev/null +++ b/tracker/test/legacy-custom-properties.spec.js @@ -0,0 +1,39 @@ +import { expectPlausibleInAction, isPageviewEvent } from './support/test-utils' +import { test } from '@playwright/test' + +test.describe('legacy custom properties support', () => { + test('sends custom properties via dom attributes', async ({ page }) => { + await expectPlausibleInAction(page, { + action: async () => { + await page.goto('/legacy-pageview-properties.html') + await page.click('#custom-event-button') + }, + expectedRequests: [ + { n: 'pageview', p: { author: 'John', foo: 'bar' } }, + { n: 'Custom event', p: { author: 'Karl', foo: 'bar' } } + ] + }) + }) + + test('sends custom properties via `props`', async ({ page }) => { + await expectPlausibleInAction(page, { + action: async () => { + await page.goto('/legacy-custom-properties.html') + await page.click('#custom-props-button') + }, + expectedRequests: [{ n: 'Props event', p: { type: 'props' } }], + shouldIgnoreRequest: isPageviewEvent + }) + }) + + test('sends custom properties via `meta`', async ({ page }) => { + await expectPlausibleInAction(page, { + action: async () => { + await page.goto('/legacy-custom-properties.html') + await page.click('#custom-meta-button') + }, + expectedRequests: [{ n: 'Meta event', m: '{"type":"meta"}' }], + shouldIgnoreRequest: isPageviewEvent + }) + }) +}) diff --git a/tracker/test/logging.spec.ts b/tracker/test/logging.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..9696031772e6b5d89085654bf443ec1a6b8a89fb --- /dev/null +++ b/tracker/test/logging.spec.ts @@ -0,0 +1,79 @@ +import { initializePageDynamically } from './support/initialize-page-dynamically' +import { expectPlausibleInAction, switchByMode } from './support/test-utils' +import { expect, test } from '@playwright/test' +import { ScriptConfig } from './support/types' +import { LOCAL_SERVER_ADDR } from './support/server' + +const DEFAULT_CONFIG: ScriptConfig = { + domain: 'example.com', + endpoint: `${LOCAL_SERVER_ADDR}/api/event`, + captureOnLocalhost: false +} + +for (const mode of ['web', 'esm']) { + test.describe(`respects "logging" v2 config option (${mode})`, () => { + test('if logging is not explicitly set, it is treated as true and logs are emitted on ignored events', async ({ + page + }, { testId }) => { + const config = { ...DEFAULT_CONFIG } + const consoleMessages: [string, string][] = [] + page.on('console', (message) => { + consoleMessages.push([message.type(), message.text()]) + }) + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: config, + esm: `` + }, + mode + ), + bodyContent: 'hello world' + }) + + await expectPlausibleInAction(page, { + action: () => page.goto(url), + expectedRequests: [], + refutedRequests: [{ n: 'pageview' }] + }) + + expect(consoleMessages).toEqual([ + ['warning', 'Ignoring Event: localhost'] + ]) + }) + + test('if logging is explicitly set to false, logs are not emitted on ignored events', async ({ + page + }, { testId }) => { + const config = { ...DEFAULT_CONFIG, logging: false } + const consoleMessages: [string, string][] = [] + page.on('console', (message) => { + consoleMessages.push([message.type(), message.text()]) + }) + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: config, + esm: `` + }, + mode + ), + bodyContent: 'hello world' + }) + + await expectPlausibleInAction(page, { + action: () => page.goto(url), + expectedRequests: [], + refutedRequests: [{ n: 'pageview' }] + }) + + expect(consoleMessages).toEqual([]) + }) + }) +} diff --git a/tracker/test/manual.spec.js b/tracker/test/manual.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..4e358c046e9463f86a0d9bf5a74f22850bf99a06 --- /dev/null +++ b/tracker/test/manual.spec.js @@ -0,0 +1,76 @@ +import { expectPlausibleInAction } from './support/test-utils' +import { test } from '@playwright/test' +import { LOCAL_SERVER_ADDR } from './support/server' + +test.describe('manual extension', () => { + test('can trigger custom events with and without a custom URL if pageview was sent with the default URL', async ({ + page + }) => { + await page.goto('/manual.html') + + await expectPlausibleInAction(page, { + action: () => page.click('#pageview-trigger'), + expectedRequests: [ + { n: 'pageview', u: `${LOCAL_SERVER_ADDR}/manual.html` } + ] + }) + await expectPlausibleInAction(page, { + action: () => page.click('#custom-event-trigger'), + expectedRequests: [ + { n: 'CustomEvent', u: `${LOCAL_SERVER_ADDR}/manual.html` } + ] + }) + await expectPlausibleInAction(page, { + action: () => page.click('#custom-event-trigger-custom-url'), + expectedRequests: [ + { n: 'CustomEvent', u: `https://example.com/custom/location` } + ] + }) + }) + + test('can trigger custom events with and without a custom URL if pageview was sent with a custom URL', async ({ + page + }) => { + await page.goto('/manual.html') + + await expectPlausibleInAction(page, { + action: () => page.click('#pageview-trigger-custom-url'), + expectedRequests: [ + { n: 'pageview', u: `https://example.com/custom/location` } + ] + }) + await expectPlausibleInAction(page, { + action: () => page.click('#custom-event-trigger'), + expectedRequests: [ + { n: 'CustomEvent', u: `${LOCAL_SERVER_ADDR}/manual.html` } + ] + }) + await expectPlausibleInAction(page, { + action: () => page.click('#custom-event-trigger-custom-url'), + expectedRequests: [ + { n: 'CustomEvent', u: `https://example.com/custom/location` } + ] + }) + }) + + test('can trigger custom events with interactive: false', async ({ + page + }) => { + await page.goto('/manual.html') + await expectPlausibleInAction(page, { + action: () => + page.evaluate(() => + window.plausible('Non-Interactive Custom Event', { + interactive: false + }) + ), + expectedRequests: [ + { + n: 'Non-Interactive Custom Event', + u: `${LOCAL_SERVER_ADDR}/manual.html`, + i: false + } + ] + }) + }) +}) diff --git a/tracker/test/outbound-links.spec.ts b/tracker/test/outbound-links.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..cc733db1732b5cd1b9f3feacf4ee79d822084e04 --- /dev/null +++ b/tracker/test/outbound-links.spec.ts @@ -0,0 +1,534 @@ +import { initializePageDynamically } from './support/initialize-page-dynamically' +import { + mockManyRequests, + resolveWithTimestamps +} from './support/mock-many-requests' +import { + expectPlausibleInAction, + isEngagementEvent, + isPageviewEvent, + switchByMode +} from './support/test-utils' +import { expect, test } from '@playwright/test' +import { ScriptConfig } from './support/types' +import { LOCAL_SERVER_ADDR } from './support/server' + +const DEFAULT_CONFIG: ScriptConfig = { + domain: 'example.com', + endpoint: `${LOCAL_SERVER_ADDR}/api/event`, + captureOnLocalhost: true +} + +for (const mode of ['web', 'esm']) { + test.describe(`respects "outboundLinks" v2 config option (${mode})`, () => { + test('does not track outbound links when "outboundLinks: false"', async ({ + page + }, { testId }) => { + const outboundUrl = 'https://other.example.com/target' + const outboundMock = await mockManyRequests({ + page, + path: outboundUrl, + fulfill: { + status: 200, + contentType: 'text/html', + body: OTHER_PAGE_BODY + }, + awaitedRequestCount: 1 + }) + const config = { ...DEFAULT_CONFIG, outboundLinks: false } + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: config, + esm: `` + }, + mode + ), + bodyContent: /* HTML */ `➡️` + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(url) + await page.click('a') + }, + expectedRequests: [ + { + n: 'pageview', + d: DEFAULT_CONFIG.domain, + u: `${LOCAL_SERVER_ADDR}${url}` + } + ], + refutedRequests: [{ n: 'Outbound Link: Click' }], + shouldIgnoreRequest: isEngagementEvent + }) + await expect(outboundMock.getRequestList()).resolves.toHaveLength(1) + }) + + test('tracks outbound links when "outboundLinks: true"', async ({ page }, { + testId + }) => { + const outboundUrl = 'https://other.example.com/target' + const outboundMock = await mockManyRequests({ + page, + path: outboundUrl, + fulfill: { + status: 200, + contentType: 'text/html', + body: OTHER_PAGE_BODY + }, + awaitedRequestCount: 1 + }) + const config = { ...DEFAULT_CONFIG, outboundLinks: true } + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: config, + esm: `` + }, + mode + ), + bodyContent: /* HTML */ `➡️` + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(url) + await page.click('a') + }, + expectedRequests: [ + { + n: 'pageview', + d: DEFAULT_CONFIG.domain, + u: `${LOCAL_SERVER_ADDR}${url}` + }, + { n: 'Outbound Link: Click', p: { url: outboundUrl } } + ], + shouldIgnoreRequest: isEngagementEvent + }) + await expect(outboundMock.getRequestList()).resolves.toHaveLength(1) + }) + }) +} + +for (const mode of ['legacy', 'web']) + test.describe(`outbound links feature legacy/v2 parity (${mode})`, () => { + for (const { clickName, click, skip } of [ + { + clickName: 'when left clicking on link', + click: { element: 'a' } + }, + { + clickName: 'when left clicking on link with Ctrl or Meta key', + click: { element: 'a', modifiers: ['ControlOrMeta' as const] }, + skip: (browserName) => + test.skip( + browserName === 'webkit', + 'does not open links with such clicks (works when testing manually in macOS Safari)' + ) + }, + { + clickName: 'when left clicking on child element of link', + click: { element: 'a > h1' } + } + ]) { + test(`sends event ${clickName}`, async ({ page, browserName }, { + testId + }) => { + if (skip) { + skip(browserName) + } + const outboundUrl = 'https://other.example.com/target' + const outboundMock = await mockManyRequests({ + page, + path: outboundUrl, + fulfill: { + status: 200, + contentType: 'text/html', + body: OTHER_PAGE_BODY + }, + awaitedRequestCount: 1 + }) + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: { + ...DEFAULT_CONFIG, + autoCapturePageviews: false, + outboundLinks: true + }, + legacy: + '' + }, + mode + ), + bodyContent: /* HTML */ `

➡️

` + }) + await page.goto(url) + + await expectPlausibleInAction(page, { + action: () => + page.click(click.element, { modifiers: click.modifiers }), + expectedRequests: [ + { n: 'Outbound Link: Click', p: { url: outboundUrl } } + ] + }) + + await expect(outboundMock.getRequestList()).resolves.toHaveLength(1) + }) + } + + test('tracks links without delaying navigation, relying on fetch options.keepalive to deliver tracking events', async ({ + page + }, { testId }) => { + const eventsApiMock = await mockManyRequests({ + page, + path: '**/api/event', + awaitedRequestCount: 1, + responseDelay: 500 + }) + const outboundUrl = 'https://other.example.com/target' + const outboundMock = await mockManyRequests({ + page, + path: outboundUrl, + fulfill: { + status: 200, + contentType: 'text/html', + body: OTHER_PAGE_BODY + }, + awaitedRequestCount: 1 + }) + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: { + ...DEFAULT_CONFIG, + outboundLinks: true, + autoCapturePageviews: false + }, + legacy: + '' + }, + mode + ), + bodyContent: /* HTML */ `>➡️` + }) + await page.goto(url) + await page.click('a') + const [ + [trackingRequestList, trackingResponseTime], + [outboundMockRequestList, outboundRequestTime] + ] = await resolveWithTimestamps([ + eventsApiMock.getRequestList(), + outboundMock.getRequestList() + ]) + + expect(outboundRequestTime).toBeLessThan(trackingResponseTime) + expect(outboundMockRequestList).toHaveLength(1) + expect(trackingRequestList).toEqual([ + expect.objectContaining({ + n: 'Outbound Link: Click', + p: { + url: outboundUrl + } + }) + ]) + }) + + test('limitation: does not track outbound links within svg elements', async ({ + page + }, { testId }) => { + const outboundUrl = 'https://other.example.com/target' + const outboundMock = await mockManyRequests({ + page, + path: outboundUrl, + fulfill: { + status: 200, + contentType: 'text/html', + body: OTHER_PAGE_BODY + }, + awaitedRequestCount: 1 + }) + + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: { ...DEFAULT_CONFIG, outboundLinks: true }, + legacy: + '' + }, + mode + ), + bodyContent: /* HTML */ ` + + + + + + ` + }) + + const pageErrors: Error[] = [] + page.on('pageerror', (err) => pageErrors.push(err)) + + await page.goto(url) + + await expectPlausibleInAction(page, { + action: () => page.click('a'), + refutedRequests: [{ n: 'Outbound Link: Click' }], + shouldIgnoreRequest: [isPageviewEvent, isEngagementEvent] + }) + + expect(pageErrors).toHaveLength(0) + await expect(outboundMock.getRequestList()).resolves.toHaveLength(1) + }) + }) + +test.describe('outbound links feature when using legacy .compat extension', () => { + for (const { caseName, linkAttributes, click, expected, skip } of [ + { + caseName: 'navigates when left clicking on link', + linkAttributes: '', + click: { element: 'a' }, + expected: { requestsOnSamePage: 1, requestsOnOtherPages: 0 } + }, + { + caseName: 'navigates when left clicking on link with Ctrl or Meta key', + linkAttributes: '', + click: { element: 'a', modifiers: ['ControlOrMeta' as const] }, + expected: { requestsOnSamePage: 0, requestsOnOtherPages: 1 }, + skip: (browserName) => + test.skip( + browserName === 'webkit', + 'does not open links with such clicks (works when testing manually in macOS Safari)' + ) + }, + { + caseName: + 'navigates when left clicking on child element of target="_blank" link', + linkAttributes: 'target="_blank"', + click: { element: 'a[target="_blank"] > h1' }, + expected: { requestsOnSamePage: 0, requestsOnOtherPages: 1 } + }, + { + caseName: + 'does not navigate when left clicking on link that has called event.preventDefault()', + linkAttributes: 'onclick="event.preventDefault()"', + click: { element: 'a' }, + expected: { requestsOnSamePage: 0, requestsOnOtherPages: 0 } + } + ]) { + test(`tracks and ${caseName}`, async ({ page, browserName }, { + testId + }) => { + if (skip) { + skip(browserName) + } + const outboundUrl = 'https://other.example.com/target' + const outboundMockOptions = { + page, + path: outboundUrl, + fulfill: { + status: 200, + contentType: 'text/html', + body: OTHER_PAGE_BODY + }, + awaitedRequestCount: 2, + mockRequestTimeout: 2000 + } + + const outboundMockForOtherPages = await mockManyRequests({ + ...outboundMockOptions, + scopeMockToPage: false + }) + const outboundMockForSamePage = await mockManyRequests({ + ...outboundMockOptions, + scopeMockToPage: true + }) + + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: + '', + bodyContent: /* HTML */ `

➡️

` + }) + await page.goto(url) + + await expectPlausibleInAction(page, { + action: () => page.click(click.element, { modifiers: click.modifiers }), + expectedRequests: [ + { n: 'Outbound Link: Click', p: { url: outboundUrl } } + ] + }) + + const [requestsOnOtherPages, requestsOnSamePage] = await Promise.all([ + outboundMockForOtherPages.getRequestList().then((d) => d.length), + outboundMockForSamePage.getRequestList().then((d) => d.length) + ]) + expect({ requestsOnOtherPages, requestsOnSamePage }).toEqual(expected) + }) + } + + test(`tracking delays navigation until the tracking request has finished`, async ({ + page + }, { testId }) => { + const eventsApiMock = await mockManyRequests({ + page, + path: '**/api/event', + awaitedRequestCount: 1, + responseDelay: 1000 + }) + const outboundUrl = 'https://other.example.com/target' + const outboundMock = await mockManyRequests({ + page, + path: outboundUrl, + fulfill: { + status: 200, + contentType: 'text/html', + body: OTHER_PAGE_BODY + }, + awaitedRequestCount: 1 + }) + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: + '', + bodyContent: /* HTML */ `📥` + }) + await page.goto(url) + + const navigationPromise = page.waitForRequest(outboundUrl, { + timeout: 2000 + }) + const trackingPromise = page.waitForResponse('**/api/event', { + timeout: 2000 + }) + + await page.click('a') + const [[, trackingResponseTime], [, navigationTime]] = + await resolveWithTimestamps([trackingPromise, navigationPromise]) + await expect(page.getByText('other page')).toBeVisible() + await expect(outboundMock.getRequestList()).resolves.toHaveLength(1) + expect(trackingResponseTime).toBeLessThanOrEqual(navigationTime) + await expect(eventsApiMock.getRequestList()).resolves.toEqual([ + expect.objectContaining({ + n: 'Outbound Link: Click', + p: { + url: outboundUrl + } + }) + ]) + }) + + test('if the tracking requests delays navigation for more than 5s, it navigates anyway, without waiting for the request to resolve', async ({ + page + }, { testId }) => { + test.setTimeout(20000) + await mockManyRequests({ + page, + path: '**/api/event', + awaitedRequestCount: 1, + responseDelay: 6000 + }) + const outboundUrl = 'https://other.example.com/target' + const outboundMock = await mockManyRequests({ + page, + path: outboundUrl, + fulfill: { + status: 200, + contentType: 'text/html', + body: OTHER_PAGE_BODY + }, + awaitedRequestCount: 1 + }) + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: + '', + bodyContent: /* HTML */ `➡️` + }) + await page.goto(url) + const navigationPromise = page.waitForRequest(outboundUrl, { + timeout: 6000 + }) + await page.click('a') + await navigationPromise + await expect(page.getByText('other page')).toBeVisible() + await expect(outboundMock.getRequestList()).resolves.toHaveLength(1) + }) + + test(`limitation: does not track outbound links within svg elements, but follows link properly`, async ({ + page + }, { testId }) => { + const outboundUrl = 'https://other.example.com/target' + const outboundMockOptions = { + page, + path: outboundUrl, + fulfill: { + status: 200, + contentType: 'text/html', + body: OTHER_PAGE_BODY + }, + awaitedRequestCount: 2, + mockRequestTimeout: 2000 + } + + const outboundMockForOtherPages = await mockManyRequests({ + ...outboundMockOptions, + scopeMockToPage: false + }) + const outboundMockForSamePage = await mockManyRequests({ + ...outboundMockOptions, + scopeMockToPage: true + }) + + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: + '', + bodyContent: /* HTML */ ` + + + + + + ` + }) + await page.goto(url) + + await expectPlausibleInAction(page, { + action: () => page.click('a'), + refutedRequests: [{ n: 'Outbound Link: Click' }] + }) + + const [requestsOnOtherPages, requestsOnSamePage] = await Promise.all([ + outboundMockForOtherPages.getRequestList().then((d) => d.length), + outboundMockForSamePage.getRequestList().then((d) => d.length) + ]) + expect({ requestsOnOtherPages, requestsOnSamePage }).toEqual({ + requestsOnOtherPages: 0, + requestsOnSamePage: 1 + }) + }) +}) + +const OTHER_PAGE_BODY = /* HTML */ ` + + + other page + + + other page + + ` diff --git a/tracker/test/pageview.spec.ts b/tracker/test/pageview.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..c7a4d63b5bea5db886592ce2cae3c771e8ae885c --- /dev/null +++ b/tracker/test/pageview.spec.ts @@ -0,0 +1,152 @@ +import { initializePageDynamically } from './support/initialize-page-dynamically' +import { + e, + expectPlausibleInAction, + hideAndShowCurrentTab, + isEngagementEvent, + switchByMode, + tracker_script_version +} from './support/test-utils' +import { test } from '@playwright/test' +import { ScriptConfig } from './support/types' +import { LOCAL_SERVER_ADDR } from './support/server' + +const DEFAULT_CONFIG: ScriptConfig = { + domain: 'example.com', + endpoint: `${LOCAL_SERVER_ADDR}/api/event`, + captureOnLocalhost: true +} + +for (const mode of ['web', 'esm']) { + test.describe(`"autoCapturePageviews" v2 config option (${mode})`, () => { + test('if autoCapturePageviews is not explicitly set, it is treated as true and a pageview is sent on navigating to page, engagement tracking is triggered', async ({ + page + }, { testId }) => { + const config = { ...DEFAULT_CONFIG } + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: config, + esm: `` + }, + mode + ), + bodyContent: 'hello world' + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(url) + await hideAndShowCurrentTab(page, { delay: 200 }) + }, + expectedRequests: [ + { + n: 'pageview', + d: config.domain, + u: `${LOCAL_SERVER_ADDR}${url}`, + v: tracker_script_version, + p: e.toBeUndefined() + }, + { + n: 'engagement', + d: config.domain, + u: `${LOCAL_SERVER_ADDR}${url}`, + v: tracker_script_version, + p: e.toBeUndefined() + } + ] + }) + }) + + test('if autoCapturePageviews is explicitly set to false, a pageview is not sent on navigating to page, but sending pageviews manually works and it starts engagement tracking logic', async ({ + page + }, { testId }) => { + const config = { ...DEFAULT_CONFIG, autoCapturePageviews: false } + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: config, + esm: `` + }, + mode + ), + bodyContent: /* HTML */ ` + A + B + ` + }) + + await expectPlausibleInAction(page, { + action: () => page.goto(url), + refutedRequests: [{ n: 'pageview' }, { n: 'engagement' }] + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.click('#alfa') + await page.click('#beta') + await hideAndShowCurrentTab(page, { delay: 200 }) + }, + expectedRequests: [ + { n: 'pageview', u: '/:masked/alfa', d: config.domain }, + { n: 'engagement', u: '/:masked/alfa', d: config.domain }, + { n: 'pageview', u: '/:masked/beta', d: config.domain }, + { n: 'engagement', u: '/:masked/beta', d: config.domain } + ] + }) + }) + }) +} + +for (const mode of ['legacy', 'web', 'esm']) { + test.describe(`pageviews parity legacy/v2 (${mode})`, () => { + test('sends pageview on navigating to page', async ({ page }, { + testId + }) => { + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: DEFAULT_CONFIG, + esm: ``, + legacy: `` + }, + mode + ), + bodyContent: '' + }) + + await expectPlausibleInAction(page, { + action: () => page.goto(url), + expectedRequests: [ + { + n: 'pageview', + d: DEFAULT_CONFIG.domain, + u: `${LOCAL_SERVER_ADDR}${url}`, + v: tracker_script_version, + h: e.toBeUndefined() + } + ], + shouldIgnoreRequest: [isEngagementEvent] + }) + }) + }) +} diff --git a/tracker/test/plausible-npm-init.spec.ts b/tracker/test/plausible-npm-init.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..9848863f768fda207fdfb49ce0f713f87b2b1f70 --- /dev/null +++ b/tracker/test/plausible-npm-init.spec.ts @@ -0,0 +1,202 @@ +import { test, expect } from '@playwright/test' +import { LOCAL_SERVER_ADDR } from './support/server' +import { + isEngagementEvent, + expectPlausibleInAction, + tracker_script_version, + hideAndShowCurrentTab +} from './support/test-utils' +import { initializePageDynamically } from './support/initialize-page-dynamically' + +const DEFAULT_CONFIG = { + domain: 'example.com', + endpoint: `${LOCAL_SERVER_ADDR}/api/event`, + captureOnLocalhost: true +} + +declare global { + interface Window { + init: (...args: unknown[]) => void + track: (eventName: string, options?: unknown) => void + plausible?: { + l: boolean + v: string + s: string + } + } +} + +test('if `init` is called without domain, it throws', async ({ page }, { + testId +}) => { + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: /* HTML */ ``, + bodyContent: 'body' + }) + await page.goto(url) + const config = { ...DEFAULT_CONFIG, domain: undefined } + await expect( + page.evaluate((config) => window.init(config), { config }) + ).rejects.toThrow('plausible.init(): domain argument is required') +}) + +test('if `init` is called with no configuration, it throws', async ({ page }, { + testId +}) => { + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: /* HTML */ ``, + bodyContent: 'body' + }) + await page.goto(url) + await expect(page.evaluate(() => window.init())).rejects.toThrow( + 'plausible.init(): domain argument is required' + ) +}) + +test('if `track` is called before `init`, it throws', async ({ page }, { + testId +}) => { + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: /* HTML */ ``, + bodyContent: 'body' + }) + await page.goto(url) + await expect(page.evaluate(() => window.track('purchase'))).rejects.toThrow( + 'plausible.track() can only be called after plausible.init()' + ) +}) + +test('if `init` is called twice, it throws, but tracking still works', async ({ + page +}, { testId }) => { + const config = { ...DEFAULT_CONFIG } + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: /* HTML */ ``, + bodyContent: 'body' + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(url) + }, + expectedRequests: [{ n: 'pageview' }], + shouldIgnoreRequest: isEngagementEvent + }) + + await expect( + page.evaluate((config) => window.init(config), config) + ).rejects.toThrow('plausible.init() can only be called once') + + await expectPlausibleInAction(page, { + action: async () => { + await hideAndShowCurrentTab(page, { delay: 200 }) + }, + expectedRequests: [{ n: 'engagement' }] + }) +}) + +test('`bindToWindow` is true by default, and plausible is attached to window', async ({ + page +}, { testId }) => { + const config = { ...DEFAULT_CONFIG } + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: /* HTML */ ``, + bodyContent: 'body' + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(url) + }, + expectedRequests: [{ n: 'pageview' }] + }) + await expect( + page.waitForFunction(() => window.plausible?.l !== undefined) + ).resolves.toBeTruthy() + await expect( + page.evaluate(() => { + if (window.plausible?.l) { + return { + l: window.plausible.l, + v: window.plausible.v, + s: window.plausible.s + } + } + return false + }) + ).resolves.toEqual({ l: true, v: tracker_script_version, s: 'npm' }) +}) + +test('if `bindToWindow` is false, plausible is not attached to window', async ({ + page +}, { testId }) => { + const config = { ...DEFAULT_CONFIG, bindToWindow: false } + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: /* HTML */ ``, + bodyContent: 'body' + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(url) + }, + expectedRequests: [{ n: 'pageview' }], + shouldIgnoreRequest: isEngagementEvent + }) + + await expect( + page.waitForFunction(() => window.plausible !== undefined, undefined, { + timeout: 1000 + }) + ).rejects.toThrow('page.waitForFunction: Timeout 1000ms exceeded.') +}) + +test('allows overriding `endpoint` with a custom URL via `init`', async ({ + page +}, { testId }) => { + const config = { ...DEFAULT_CONFIG, endpoint: 'http://example.com/event' } + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: /* HTML */ ``, + bodyContent: 'body' + }) + await expectPlausibleInAction(page, { + pathToMock: config.endpoint, + action: () => page.goto(url), + expectedRequests: [ + { n: 'pageview', d: config.domain, u: `${LOCAL_SERVER_ADDR}${url}` } + ], + shouldIgnoreRequest: isEngagementEvent + }) +}) diff --git a/tracker/test/plausible-web-init.spec.ts b/tracker/test/plausible-web-init.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..adc745e8af7fd5bf345c1a306aaebe9407ab0b5e --- /dev/null +++ b/tracker/test/plausible-web-init.spec.ts @@ -0,0 +1,168 @@ +/* +Tests for plausible-web.js script variant + +Unlike in production, we're manually interpolating the script config in this file to +better test the script in isolation of the plausible codebase. +*/ + +import { + expectPlausibleInAction, + isEngagementEvent +} from './support/test-utils' +import { test, expect } from '@playwright/test' +import { LOCAL_SERVER_ADDR } from './support/server' +import { + getConfiguredPlausibleWebSnippet, + initializePageDynamically +} from './support/initialize-page-dynamically' + +const DEFAULT_CONFIG = { + domain: 'example.com', + endpoint: `${LOCAL_SERVER_ADDR}/api/event`, + captureOnLocalhost: true +} + +test('with queue code from the web snippet, tracks `plausible` calls made before the script is loaded', async ({ + page +}, { testId }) => { + const config = { ...DEFAULT_CONFIG } + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: config, + bodyContent: + '' + }) + await expectPlausibleInAction(page, { + action: () => page.goto(url), + expectedRequests: [ + { + n: 'loaded', + p: { plausibleLoadedAtEventTime: false }, + i: false, + d: config.domain, + u: `${LOCAL_SERVER_ADDR}${url}` + }, + { n: 'pageview', d: config.domain, u: `${LOCAL_SERVER_ADDR}${url}` } + ] + }) +}) + +test('handles double-initialization of the script with a console.warn', async ({ + page +}, { testId }) => { + const config = { ...DEFAULT_CONFIG, customProperties: { init: 1 } } + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: config, + bodyContent: /* HTML */ `` + }) + const messages: [string, string][] = [] + page.on('console', (message) => { + messages.push([message.type(), message.text()]) + }) + + await expectPlausibleInAction(page, { + action: () => page.goto(url), + expectedRequests: [{ n: 'pageview', p: { init: 1 } }], + shouldIgnoreRequest: isEngagementEvent + }) + + await expect( + page.evaluate(() => + // @ts-expect-error - window.plausible is defined + window.plausible.init({ + captureOnLocalhost: true, + customProperties: { init: 2 } + }) + ) + ).resolves.toBeUndefined() + + expect(messages).toEqual([ + [ + 'warning', + 'Plausible analytics script was already initialized, skipping init' + ] + ]) + + await expectPlausibleInAction(page, { + action: () => page.click('button'), + expectedRequests: [{ n: 'Purchase', p: { init: 1 } }] + }) +}) + +test('if there are two snippets on the page, one wins, no warning is emitted', async ({ + page +}, { testId }) => { + const config = { ...DEFAULT_CONFIG } + const snippetAlfa = getConfiguredPlausibleWebSnippet({ + ...config, + customProperties: { alfa: true } + }) + const initCallAlfa = + 'plausible.init({"captureOnLocalhost":true,"customProperties":{"alfa":true}})' + expect(snippetAlfa).toEqual(expect.stringContaining(initCallAlfa)) + const snippetBeta = getConfiguredPlausibleWebSnippet({ + ...config, + customProperties: { beta: true } + }) + const initCallBeta = `plausible.init({"captureOnLocalhost":true,"customProperties":{"beta":true}})` + expect(snippetBeta).toEqual(expect.stringContaining(initCallBeta)) + + const messages: [string, string][] = [] + page.on('console', (message) => { + messages.push([message.type(), message.text()]) + }) + + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: /* HTML */ `${snippetAlfa}${snippetBeta}`, + bodyContent: '' + }) + await expectPlausibleInAction(page, { + action: () => page.goto(url), + expectedRequests: [ + { + n: 'pageview', + d: config.domain, + u: `${LOCAL_SERVER_ADDR}${url}`, + p: { beta: true } + } + ], + shouldIgnoreRequest: isEngagementEvent + }) + expect(messages).toEqual([]) +}) + +test('if domain is provided in `init`, it is ignored', async ({ page }, { + testId +}) => { + const config = { ...DEFAULT_CONFIG } + const scriptConfig = getConfiguredPlausibleWebSnippet(config) + const originalInitCall = 'plausible.init({"captureOnLocalhost":true})' + // verify that the original snippet is what we expect it to be + expect(scriptConfig).toEqual(expect.stringContaining(originalInitCall)) + const initCallWithDomainOverride = `plausible.init({"captureOnLocalhost":true,"domain":"sub.${config.domain}"})` + const updatedScriptConfig = scriptConfig.replace( + originalInitCall, + initCallWithDomainOverride + ) + // verify that the updated snippet has the domain override + expect(updatedScriptConfig).toEqual( + expect.stringContaining(initCallWithDomainOverride) + ) + + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: updatedScriptConfig, + bodyContent: '' + }) + await expectPlausibleInAction(page, { + action: () => page.goto(url), + expectedRequests: [ + { n: 'pageview', d: config.domain, u: `${LOCAL_SERVER_ADDR}${url}` } + ], + shouldIgnoreRequest: isEngagementEvent + }) +}) diff --git a/tracker/test/revenue.spec.js b/tracker/test/revenue.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..f17a8888821ab7ee4be8c9e47e2096169cb9368c --- /dev/null +++ b/tracker/test/revenue.spec.js @@ -0,0 +1,28 @@ +import { expectPlausibleInAction } from './support/test-utils' +import { test } from '@playwright/test' + +test.describe('with revenue script extension', () => { + test('sends revenue currency and amount in manual mode', async ({ page }) => { + await page.goto('/revenue.html') + + await expectPlausibleInAction(page, { + action: () => page.click('#manual-purchase'), + expectedRequests: [ + { n: 'Purchase', $: { amount: 15.99, currency: 'USD' } } + ] + }) + }) + + test('sends revenue currency and amount with tagged class name', async ({ + page + }) => { + await page.goto('/revenue.html') + + await expectPlausibleInAction(page, { + action: () => page.click('#tagged-purchase'), + expectedRequests: [ + { n: 'Purchase', $: { amount: '13.32', currency: 'EUR' } } + ] + }) + }) +}) diff --git a/tracker/test/scroll-depth.spec.js b/tracker/test/scroll-depth.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..daf9ed58c2bff69adcb35db145fd3e7fc334ac2b --- /dev/null +++ b/tracker/test/scroll-depth.spec.js @@ -0,0 +1,206 @@ +import { + expectPlausibleInAction, + hideCurrentTab, + hideAndShowCurrentTab +} from './support/test-utils' +import { test, expect } from '@playwright/test' +import { LOCAL_SERVER_ADDR } from './support/server' + +test.describe('scroll depth (engagement events)', () => { + test('sends scroll_depth in the pageleave payload when navigating to the next page', async ({ + page + }) => { + await expectPlausibleInAction(page, { + action: () => page.goto('/scroll-depth.html'), + expectedRequests: [{ n: 'pageview' }] + }) + + await page.evaluate(() => window.scrollBy(0, 300)) + await page.evaluate(() => window.scrollBy(0, 0)) + + await expectPlausibleInAction(page, { + action: () => page.click('#navigate-away'), + expectedRequests: [ + { n: 'engagement', u: `${LOCAL_SERVER_ADDR}/scroll-depth.html`, sd: 20 } + ] + }) + }) + + test('sends scroll depth on hash navigation', async ({ page }) => { + await expectPlausibleInAction(page, { + action: () => page.goto('/scroll-depth-hash.html'), + expectedRequests: [{ n: 'pageview' }] + }) + + await expectPlausibleInAction(page, { + action: () => page.click('#about-link'), + expectedRequests: [ + { + n: 'engagement', + u: `${LOCAL_SERVER_ADDR}/scroll-depth-hash.html`, + sd: 100 + }, + { + n: 'pageview', + u: `${LOCAL_SERVER_ADDR}/scroll-depth-hash.html#about` + } + ] + }) + + await expectPlausibleInAction(page, { + action: () => page.click('#home-link'), + expectedRequests: [ + { + n: 'engagement', + u: `${LOCAL_SERVER_ADDR}/scroll-depth-hash.html#about`, + sd: 34 + }, + { n: 'pageview', u: `${LOCAL_SERVER_ADDR}/scroll-depth-hash.html#home` } + ] + }) + }) + + test('document height gets reevaluated after window load', async ({ + page + }) => { + await expectPlausibleInAction(page, { + action: () => page.goto('/scroll-depth-slow-window-load.html'), + expectedRequests: [{ n: 'pageview' }] + }) + + // Wait for the image to be loaded + await page.waitForFunction(() => { + return document.getElementById('slow-image').complete + }) + + await expectPlausibleInAction(page, { + action: () => page.click('#navigate-away'), + expectedRequests: [ + { + n: 'engagement', + u: `${LOCAL_SERVER_ADDR}/scroll-depth-slow-window-load.html`, + sd: 24 + } + ] + }) + }) + + test('dynamically loaded content affects documentHeight', async ({ + page + }) => { + await expectPlausibleInAction(page, { + action: () => page.goto('/scroll-depth-dynamic-content-load.html'), + expectedRequests: [{ n: 'pageview' }] + }) + + // The link appears dynamically after 500ms. + await expectPlausibleInAction(page, { + action: () => page.click('#navigate-away'), + expectedRequests: [ + { + n: 'engagement', + u: `${LOCAL_SERVER_ADDR}/scroll-depth-dynamic-content-load.html`, + sd: 14 + } + ] + }) + }) + + test('document height gets reevaluated on scroll', async ({ page }) => { + await expectPlausibleInAction(page, { + action: () => page.goto('/scroll-depth-content-onscroll.html'), + expectedRequests: [{ n: 'pageview' }] + }) + + // During the first 3 seconds, the script periodically updates document height + // to account for dynamically loaded content. Since we want to test document + // height also getting updated on scroll, we need to just wait for 3 seconds. + await page.waitForTimeout(3100) + + // scroll to the bottom of the page + await page.evaluate(() => window.scrollBy(0, document.body.scrollHeight)) + + // Wait until documentHeight gets increased by the fixture JS + await expect(page.getByText('Navigate away')).toBeVisible() + + await page.evaluate(() => window.scrollBy(0, 1000)) + + await expectPlausibleInAction(page, { + action: () => page.click('#navigate-away'), + expectedRequests: [ + { + n: 'engagement', + u: `${LOCAL_SERVER_ADDR}/scroll-depth-content-onscroll.html`, + sd: 80 + } + ] + }) + }) + + test('sends scroll depth when minimizing the tab', async ({ page }) => { + await expectPlausibleInAction(page, { + action: () => page.goto('/scroll-depth.html'), + expectedRequests: [{ n: 'pageview' }] + }) + + await page.evaluate(() => window.scrollBy(0, 300)) + await page.waitForTimeout(100) // Wait for the scroll event to be processed + + await expectPlausibleInAction(page, { + action: () => hideCurrentTab(page), + expectedRequests: [ + { n: 'engagement', u: `${LOCAL_SERVER_ADDR}/scroll-depth.html`, sd: 20 } + ] + }) + }) + + test('re-sends engagement events only when user has scrolled in-between', async ({ + page, + browserName + }) => { + test.skip(browserName === 'webkit', 'flaky') + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto('/scroll-depth.html') + await hideAndShowCurrentTab(page) + }, + expectedRequests: [ + { n: 'pageview' }, + { n: 'engagement', u: `${LOCAL_SERVER_ADDR}/scroll-depth.html`, sd: 14 } + ] + }) + + await expectPlausibleInAction(page, { + action: () => hideAndShowCurrentTab(page), + expectedRequests: [], + refutedRequests: [{ n: 'engagement' }] + }) + + await page.evaluate(() => window.scrollBy(0, 300)) + + await expectPlausibleInAction(page, { + action: () => hideCurrentTab(page), + expectedRequests: [ + { n: 'engagement', u: `${LOCAL_SERVER_ADDR}/scroll-depth.html`, sd: 20 } + ] + }) + }) + + test('gets correct scroll depth when script has no async', async ({ + page + }) => { + await expectPlausibleInAction(page, { + action: async () => { + await page.goto('/no-async.html') + await hideAndShowCurrentTab(page) + }, + expectedRequests: [ + { n: 'pageview' }, + { n: 'engagement', u: `${LOCAL_SERVER_ADDR}/no-async.html`, sd: 100 } + ] + }) + + await page.waitForTimeout(1000) + }) +}) diff --git a/tracker/test/support/html-fixtures.ts b/tracker/test/support/html-fixtures.ts new file mode 100644 index 0000000000000000000000000000000000000000..8ea6f228c0dac6c6ad32a27f3d87f03578697ef4 --- /dev/null +++ b/tracker/test/support/html-fixtures.ts @@ -0,0 +1,7 @@ +/** + * This is a stub for custom form onsubmit handlers Plausible users may have on their websites. + * Overriding onsubmit with a custom handler is common practice in web development for a variety of reasons (mostly UX), + * so this stub can be used in tests to simulate such behavior. + */ +export const customSubmitHandlerStub = + '"event.preventDefault(); console.log(\'Submitted\')"' diff --git a/tracker/test/support/initialize-page-dynamically.ts b/tracker/test/support/initialize-page-dynamically.ts new file mode 100644 index 0000000000000000000000000000000000000000..14a3a98249ad1b088e6cd7c17168be04ee13c576 --- /dev/null +++ b/tracker/test/support/initialize-page-dynamically.ts @@ -0,0 +1,138 @@ +import { Page } from '@playwright/test' +import { ScriptConfig } from './types' + +interface SharedOptions { + /** unique ID that becomes part of the dynamic page URL */ + testId: string + /** optional path to append to the dynamic page URL */ + path?: string + headers?: Record +} + +interface TemplatedResponse { + /** string like `` or ScriptConfig to be set to web snippet */ + scriptConfig: ScriptConfig | string + /** vanilla HTML string, which can contain JS, will be set in the body of the page */ + bodyContent: string +} + +interface FullResponse { + // Full html response + response: string +} + +interface DynamicPageInfo { + /** the url where the page is served */ + url: string +} + +const RESPONSE_BODY_TEMPLATE = ` + + + + + + + Plausible Playwright tests + + + + +` + +const PLAUSIBLE_WEB_SNIPPET = ` + + +` + +export function serializeWithFunctions(obj: Record): string { + const functions: Record = {} + let counter = 0 + + const jsonString = JSON.stringify(obj, (_key, value) => { + if (typeof value === 'function') { + const placeholder = `__FUNCTION_${counter++}__` + functions[placeholder] = value.toString() + return placeholder + } + return value + }) + + // Replace placeholders with actual function strings + let result = jsonString + for (const [placeholder, funcString] of Object.entries(functions)) { + result = result.replace(`"${placeholder}"`, funcString) + } + + return result +} + +export function getConfiguredPlausibleWebSnippet({ + hashBasedRouting, + outboundLinks, + fileDownloads, + formSubmissions, + domain, + endpoint, + ...initOverrideOptions +}: ScriptConfig): string { + const injectedScriptConfig = { + domain, + endpoint, + hashBasedRouting, + outboundLinks, + fileDownloads, + formSubmissions + } + const snippet = PLAUSIBLE_WEB_SNIPPET.replace( + '<%= plausible_script_url %>', + `/tracker/js/plausible-web.js?script_config=${encodeURIComponent( + JSON.stringify(injectedScriptConfig) + )}` + ) + + if ( + Object.entries(initOverrideOptions).some( + ([_key, value]) => value !== undefined + ) + ) { + const serializedOptions = serializeWithFunctions(initOverrideOptions) + + return snippet.replace( + 'plausible.init()', + `plausible.init(${serializedOptions})` + ) + } + return snippet +} + +export async function initializePageDynamically( + page: Page, + options: SharedOptions & (TemplatedResponse | FullResponse) +): Promise { + const url = `/dynamic/${options.testId}${options.path || ''}` + await page.context().route(url, async (route) => { + let responseBody: string + + if ('response' in options) { + responseBody = options.response + } else { + responseBody = RESPONSE_BODY_TEMPLATE.replace( + '', + typeof options.scriptConfig === 'string' + ? options.scriptConfig + : getConfiguredPlausibleWebSnippet(options.scriptConfig) + ).replace('', `${options.bodyContent}`) + } + + await route.fulfill({ + body: responseBody, + contentType: 'text/html', + headers: options.headers + }) + }) + return { url } +} diff --git a/tracker/test/support/installation-support-playwright-wrappers.ts b/tracker/test/support/installation-support-playwright-wrappers.ts new file mode 100644 index 0000000000000000000000000000000000000000..fe4ca8d2632a50fea79cef1cbe37e5180a43b82d --- /dev/null +++ b/tracker/test/support/installation-support-playwright-wrappers.ts @@ -0,0 +1,93 @@ +import { compileFile } from '../../compiler/index.js' +import variantsFile from '../../compiler/variants.json' with { type: 'json' } +import { Page } from '@playwright/test' +import { VerifierArgs, VerifierResult } from './types' + +const VERIFIER_JS_VARIANT = variantsFile.manualVariants.find( + (variant) => variant.name === 'verifier.js' +) +const DETECTOR_JS_VARIANT = variantsFile.manualVariants.find( + (variant) => variant.name === 'detector.js' +) + +export async function executeVerifier( + page: Page, + { + responseHeaders, + maxAttempts, + timeoutBetweenAttemptsMs, + ...functionContext + }: VerifierArgs & { maxAttempts: number; timeoutBetweenAttemptsMs: number } +): Promise { + const verifierCode = (await compileFile(VERIFIER_JS_VARIANT, { + returnCode: true + })) as string + + try { + async function verify() { + await page.evaluate(verifierCode) // injects window.verifyPlausibleInstallation + return await page.evaluate( + // @ts-expect-error - window.verifyPlausibleInstallation has been injected + (c) => { + return window.verifyPlausibleInstallation(c) + }, + { ...functionContext, responseHeaders } + ) + } + + let lastError + for (let attempts = 1; attempts <= maxAttempts; attempts++) { + try { + const output = await verify() + return { + data: { + ...output.data, + attempts + } + } + } catch (error) { + lastError = error + if ( + typeof error?.message === 'string' && + error.message.toLowerCase().includes('execution context') + ) { + await new Promise((resolve) => + setTimeout(resolve, timeoutBetweenAttemptsMs) + ) + continue + } + throw error + } + } + throw lastError + } catch (error) { + return { + data: { + completed: false, + error: { + message: error?.message ?? JSON.stringify(error) + } + } + } + } +} + +export async function detect(page, context) { + const { url, detectV1, timeoutMs } = context + const debug = context.debug ? true : false + + const detectorCode = await compileFile(DETECTOR_JS_VARIANT, { + returnCode: true + }) + + await page.goto(url) + await page.evaluate(detectorCode) + + return await page.evaluate( + async (d) => { + // @ts-expect-error - window.scanPageBeforePlausibleInstallation has been injected + return await window.scanPageBeforePlausibleInstallation(d) + }, + { detectV1, debug, timeoutMs } + ) +} diff --git a/tracker/test/support/mock-many-requests.ts b/tracker/test/support/mock-many-requests.ts new file mode 100644 index 0000000000000000000000000000000000000000..2580abb214d474be1bfa58facfc68b894b5c05d8 --- /dev/null +++ b/tracker/test/support/mock-many-requests.ts @@ -0,0 +1,110 @@ +import { Page } from '@playwright/test' +import { delay } from './test-utils' + +type RequestData = Record +type ShouldIgnoreRequest = (requestData?: RequestData) => boolean + +const DEFAULT_RESPONSE = { status: 200, contentType: 'text/plain', body: 'ok' } + +interface MockManyRequestsOptions { + /** + * Set `scopeMockToPage` to `true` if there is a need to verify that the mock request + * happens on the passed `page`. + * Default value is `false`, which means it records requests that open in new tabs as well. + * Note: You can create two mocks for the same path, one on the page and other on the browser if needed. + */ + scopeMockToPage?: boolean + page: Page + path: string + /** + * Response to fulfill the request with. + * Defaults to DEFAULT_RESPONSE. Allows overriding properties from the default one by one. + * @see DEFAULT_RESPONSE + */ + fulfill?: { + status?: number + contentType?: string + body?: string + } + /** + * When there's at least `awaitedRequestCount` unignored requests on this route, + * getRequestList resolves without waiting for `mockRequestTimeout`. + * If there's less than `awaitedRequestCount` unignored requests on this route, it + * takes `mockRequestTimeout` to resolve `getRequestList`. + * This is so as not miss unexpected requests that are sent slowly. + */ + awaitedRequestCount: number + responseDelay?: number + shouldIgnoreRequest?: ShouldIgnoreRequest | ShouldIgnoreRequest[] + mockRequestTimeout?: number +} + +export async function mockManyRequests({ + scopeMockToPage, + page, + path, + fulfill, + awaitedRequestCount, + responseDelay, + shouldIgnoreRequest, + mockRequestTimeout = 1000 +}: MockManyRequestsOptions) { + const requestList: unknown[] = [] + const scope = scopeMockToPage ? page : page.context() + await scope.route(path, async (route, request) => { + if (responseDelay) { + await delay(responseDelay) + } + const postData = request.postDataJSON() + if (shouldAllow(postData, shouldIgnoreRequest)) { + requestList.push(postData) + } + await route.fulfill({ + ...DEFAULT_RESPONSE, + ...fulfill + }) + }) + + const getRequestList = (): Promise => + new Promise((resolve) => { + let i = 0 + const POLL_INTERVAL_MS = 10 + const interval = setInterval(() => { + if (i > mockRequestTimeout / POLL_INTERVAL_MS) { + clearInterval(interval) + resolve(requestList) + } else if (requestList.length === awaitedRequestCount) { + clearInterval(interval) + resolve(requestList) + } else { + i++ + } + }, POLL_INTERVAL_MS) + }) + + return { getRequestList } +} + +function shouldAllow( + requestData: RequestData, + ignores: ShouldIgnoreRequest | ShouldIgnoreRequest[] | undefined +) { + if (Array.isArray(ignores)) { + return !ignores.some((shouldIgnore) => shouldIgnore(requestData)) + } else if (ignores) { + return !ignores(requestData) + } else { + return true + } +} + +export function resolveWithTimestamps( + promises: Array> +): Promise> { + return Promise.all( + promises.map(async (mock) => { + const result = await mock + return [result, Date.now()] + }) + ) +} diff --git a/tracker/test/support/server.js b/tracker/test/support/server.js new file mode 100644 index 0000000000000000000000000000000000000000..39df1e4a86c83b25f0bc1cafb80521e113bcb603 --- /dev/null +++ b/tracker/test/support/server.js @@ -0,0 +1,53 @@ +import express from 'express' +import path from 'node:path' +import { fileURLToPath } from 'url' +import { compileFile } from '../../compiler/index.js' +import variantsFile from '../../compiler/variants.json' with { type: 'json' } + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const isMainModule = fileURLToPath(import.meta.url) === process.argv[1] + +const app = express() +const LOCAL_SERVER_PORT = 3000 +const FIXTURES_PATH = path.join(__dirname, '/../fixtures') +const VARIANTS = variantsFile.legacyVariants.concat(variantsFile.manualVariants) + +export const LOCAL_SERVER_ADDR = `http://localhost:${LOCAL_SERVER_PORT}` + +export function runLocalFileServer() { + app.use(express.static(FIXTURES_PATH)) + + app.get('/tracker/js/*', async (req, res) => { + const name = req.params[0] + const variant = VARIANTS.find((variant) => variant.name === name) + if (!variant) { + res + .type('application/javascript') + .status(404) + .send({ error: new Error(`Variant not found with name ${name}`) }) + } else { + let code = await compileFile(variant, { returnCode: true }) + + if (name === 'plausible-web.js') { + code = code.replace('"<%= @config_js %>"', req.query.script_config) + } + + res.type('application/javascript').send(code) + } + }) + + // A test utility - serve an image with an artificial delay + app.get('/img/slow-image', (_req, res) => { + setTimeout(() => { + res.sendFile(path.join(FIXTURES_PATH, '/img/black3x3000.png')) + }, 100) + }) + + app.listen(LOCAL_SERVER_PORT, function () { + console.log(`Local server listening on ${LOCAL_SERVER_ADDR}`) + }) +} + +if (isMainModule) { + runLocalFileServer() +} diff --git a/tracker/test/support/test-utils.js b/tracker/test/support/test-utils.js new file mode 100644 index 0000000000000000000000000000000000000000..d2b1cd7b681711b6ef9adc7e8982a3e36b8cdb24 --- /dev/null +++ b/tracker/test/support/test-utils.js @@ -0,0 +1,245 @@ +import { expect } from '@playwright/test' +import packageJson from '../../package.json' with { type: 'json' } +import { mockManyRequests } from './mock-many-requests' + +export const tracker_script_version = packageJson.tracker_script_version + +/** + * A powerful utility function that makes it easy to assert on the event + * requests that should or should not have been made after doing a page + * action (e.g. navigating to the page, clicking a page element, etc). + * + * @param {Page} page - The Playwright Page object. + * @param {Object} args - The object configuring the action and related expectations. + * @param {Function} args.action - A function that returns a promise. The function is called + * without arguments, and is `await`ed. This is the action that should or should not trigger + * Plausible requests on the page. + * @param {string} [args.pathToMock] - The path to create the route at and mock the response for. + * @param {Array} [args.expectedRequests] - A list of partial JSON payloads that get matched + * against the bodies of event requests made. An `expectedRequest` is considered as having + * occurred if all of its key-value pairs are found from the JSON body of an event request + * that was made. The default value is `[]` + * @param {Array} [args.refutedRequests] - Same as `expectedRequests` but the opposite. The + * expectation passes if none of the made requests match with these partial payloads. Note + * that the condition on which a partial payload matches an event request payload is exactly + * the same as it is for `expectedRequests`. The default value is `[]` + * @param {number} [args.awaitedRequestCount] - Sometimes we might want to wait for more events + * to happen, just to make sure they didn't. By default, the number of requests we wait for + * is `expectedRequests.length + refutedRequests.length`. + * @param {number} [args.expectedRequestCount] - When provided, expects the total amount of + * event requests made to match this number. + * @param {Array|Function} [args.shouldIgnoreRequest] - When provided, ignores certain requests + * @param {number} [args.responseDelay] - When provided, delays the response from the Plausible + * API by the given number of milliseconds. + * @param {number} [args.mockRequestTimeout] - How long to wait for the requests to be made + */ +export const expectPlausibleInAction = async function ( + page, + { + action, + expectedRequests = [], + refutedRequests = [], + pathToMock = '/api/event', + awaitedRequestCount, + expectedRequestCount, + responseDelay, + shouldIgnoreRequest, + mockRequestTimeout = 3000 + } +) { + const requestsToExpect = expectedRequestCount + ? expectedRequestCount + : expectedRequests.length + const requestsToAwait = awaitedRequestCount + ? awaitedRequestCount + : requestsToExpect + refutedRequests.length + + const { getRequestList } = await mockManyRequests({ + page, + path: pathToMock, + fulfill: { status: 202, contentType: 'text/plain', body: 'ok' }, + responseDelay, + shouldIgnoreRequest, + awaitedRequestCount: requestsToAwait, + mockRequestTimeout + }) + await action() + const requestBodies = await getRequestList() + + const expectedButNotFoundBodySubsets = [] + + expectedRequests.forEach((bodySubset) => { + const wasFound = requestBodies.some((requestBody) => { + return includesSubset(requestBody, bodySubset) + }) + + if (!wasFound) { + expectedButNotFoundBodySubsets.push(bodySubset) + } + }) + + const refutedButFoundRequestBodies = [] + + refutedRequests.forEach((bodySubset) => { + const found = requestBodies.find((requestBody) => { + return includesSubset(requestBody, bodySubset) + }) + + if (found) { + refutedButFoundRequestBodies.push(found) + } + }) + + const expectedBodySubsetsErrorMessage = `The following body subsets were not found from the requests that were made:\n\n${JSON.stringify(expectedButNotFoundBodySubsets, null, 4)}\n\nReceived requests with the following bodies:\n\n${JSON.stringify(requestBodies, null, 4)}` + expect( + expectedButNotFoundBodySubsets, + expectedBodySubsetsErrorMessage + ).toHaveLength(0) + + const refutedBodySubsetsErrorMessage = `The following requests were made, but were not expected:\n\n${JSON.stringify(refutedButFoundRequestBodies, null, 4)}` + expect( + refutedButFoundRequestBodies, + refutedBodySubsetsErrorMessage + ).toHaveLength(0) + + const unexpectedRequestBodiesErrorMessage = `Expected ${requestsToExpect} requests, but received ${requestBodies.length}:\n\n${JSON.stringify(requestBodies, null, 4)}` + expect(requestBodies.length, unexpectedRequestBodiesErrorMessage).toBe( + requestsToExpect + ) + + return requestBodies +} + +export const isPageviewEvent = function (requestPostData) { + return requestPostData.n === 'pageview' +} + +export const isEngagementEvent = function (requestPostData) { + return requestPostData.n === 'engagement' +} + +async function toggleTabVisibility(page, hide) { + await page.evaluate((hide) => { + Object.defineProperty(document, 'visibilityState', { + value: hide ? 'hidden' : 'visible', + writable: true + }) + Object.defineProperty(document, 'hidden', { value: hide, writable: true }) + document.dispatchEvent(new Event('visibilitychange')) + }, hide) +} + +export const hideCurrentTab = async function (page) { + return toggleTabVisibility(page, true) +} + +export const showCurrentTab = async function (page) { + return toggleTabVisibility(page, false) +} + +async function setFocus(page, focus) { + await page.evaluate((focus) => { + Object.defineProperty(document, 'hasFocus', { + value: () => focus, + writable: true + }) + + const eventName = focus ? 'focus' : 'blur' + window.dispatchEvent(new Event(eventName)) + }, focus) +} + +export const focus = async function (page) { + return setFocus(page, true) +} + +export const blur = async function (page) { + return setFocus(page, false) +} + +export const hideAndShowCurrentTab = async function (page, options = {}) { + await hideCurrentTab(page) + if (options.delay > 0) { + await delay(options.delay) + } + await showCurrentTab(page) +} + +export const blurAndFocusPage = async function (page, options = {}) { + await blur(page) + if (options.delay > 0) { + await delay(options.delay) + } + await focus(page) +} + +// Custom assertion methods for checking plausible request bodies +export const e = { + stringContaining: (value) => ({ + expected: value, + __expectation__: (actual) => actual.includes(value) + }), + toBeUndefined: () => ({ + expected: undefined, + __expectation__: (actual) => actual === undefined + }) +} + +function includesSubset(body, subset) { + return Object.keys(subset).every((key) => { + if (typeof subset[key] === 'object' && !subset[key].__expectation__) { + return ( + typeof body[key] === 'object' && + areFlatObjectsEqual(body[key], subset[key]) + ) + } else { + return checkEqual(body[key], subset[key]) + } + }) +} + +// For comparing custom props - all key-value pairs +// must match but the order is not important. +function areFlatObjectsEqual(obj1, obj2) { + const keys1 = Object.keys(obj1) + const keys2 = Object.keys(obj2) + + if (keys1.length !== keys2.length) return false + + return keys1.every((key) => checkEqual(obj2[key], obj1[key])) +} + +function checkEqual(a, b) { + if (typeof b === 'object' && b.__expectation__) { + return b.__expectation__(a) + } + return a === b +} + +export function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +export function switchByMode(cases, mode) { + switch (mode) { + case 'web': + return cases.web + case 'esm': + return cases.esm + case 'legacy': + return cases.legacy + default: + throw new Error(`Unimplemented mode: ${mode}`) + } +} + +/** + * This function ensures that the tracker script has attached the event listener before test is run. + * Note that this race condition happens in the real world as well: + * Events from features like form submissions, file downloads, outbound links, tagged events + * that work with event handlers registered on the document + * will not be tracked if the event happens before the tracker script has attached the event listener. + */ +export function ensurePlausibleInitialized(page) { + return page.waitForFunction(() => window.plausible?.l === true) +} diff --git a/tracker/test/support/types.ts b/tracker/test/support/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..837864a64ee81aa64ed5e9ac12e4024a26f42d8f --- /dev/null +++ b/tracker/test/support/types.ts @@ -0,0 +1,87 @@ +export type Options = { + hashBasedRouting: boolean + outboundLinks: boolean + fileDownloads: boolean | { fileExtensions: string[] } + formSubmissions: boolean + captureOnLocalhost: boolean + autoCapturePageviews: boolean + customProperties: + | Record + | ((eventName: string) => Record) + transformRequest: (payload: unknown) => unknown + logging: boolean +} + +export type ScriptConfig = { + domain: string + endpoint: string +} & Partial + +export type VerifierArgs = { + debug: boolean + responseHeaders: Record + timeoutMs: number + cspHostToCheck: string + trackerScriptSelector: string +} + +type ConsentResult = + // consented to cookies of cmp + | { handled: true; cmp: string } + // did not detect cmps, exhausted detection retries + | { + handled: true + } + // failed init or consent process + | { + handled: false + error: unknown + } + // none of the above, most likely didn't get to finish + | { + handled: null + engineLifecycle: string + } + +export type VerifierResult = { + data: + | { + completed: true + attempts: number + plausibleIsOnWindow: boolean + plausibleIsInitialized: boolean + plausibleVersion: number + plausibleVariant?: string + disallowedByCsp: boolean + cookiesConsentResult: ConsentResult + testEvent: { + /** + * window.plausible (track) callback + */ + callbackResult?: unknown + /** + * intercepted fetch response status + */ + responseStatus?: number + /** + * error caught during intercepted fetch + */ + error?: { + message: string + } + /** + * intercepted fetch request url + */ + requestUrl?: string + /** + * intercepted fetch request body normalized + */ + normalizedBody?: { + domain: string + name: string + version?: number + } + } + } + | { completed: false; error: { message: string } } +} diff --git a/tracker/test/tagged-events.spec.ts b/tracker/test/tagged-events.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..c1e2744f65cab3c15abb4fad4e500ecbd4f7d7f4 --- /dev/null +++ b/tracker/test/tagged-events.spec.ts @@ -0,0 +1,778 @@ +import { + expectPlausibleInAction, + isPageviewEvent, + isEngagementEvent, + switchByMode +} from './support/test-utils' +import { expect, test } from '@playwright/test' +import { LOCAL_SERVER_ADDR } from './support/server' +import { initializePageDynamically } from './support/initialize-page-dynamically' +import { ScriptConfig } from './support/types' +import { + mockManyRequests, + resolveWithTimestamps +} from './support/mock-many-requests' + +const DEFAULT_CONFIG: ScriptConfig = { + domain: 'example.com', + endpoint: `${LOCAL_SERVER_ADDR}/api/event`, + captureOnLocalhost: true +} + +test.beforeEach(async ({ page }) => { + await page + .context() + .route(new RegExp('(http|https)://example\\.com.*'), async (route) => { + await route.fulfill({ + status: 200, + contentType: 'text/html', + body: /* HTML */ ` + + + mocked page + + + mocked page + + ` + }) + }) +}) + +for (const mode of ['web', 'esm']) { + test(`tagged events tracking is always on (${mode})`, async ({ page }, { + testId + }) => { + const config = { ...DEFAULT_CONFIG } + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: config, + esm: `` + }, + mode + ), + bodyContent: /* HTML */ `Purchase` + }) + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(url) + await page.click('a') + }, + expectedRequests: [ + { + n: 'Purchase', + p: { discounted: 'true', url: 'https://example.com/target' }, + $: { currency: 'EUR', amount: '13.32' } + } + ], + shouldIgnoreRequest: [isPageviewEvent, isEngagementEvent] + }) + await expect(page.getByText('mocked page')).toBeVisible() + }) +} + +for (const mode of ['legacy', 'web']) { + test.describe(`tagged events feature legacy/v2 parity (${mode})`, () => { + test('tracks link click and child of link click when link is tagged (using plausible-event-... double dash syntax)', async ({ + page + }, { testId }) => { + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: { ...DEFAULT_CONFIG }, + legacy: + '' + }, + mode + ), + bodyContent: /* HTML */ ` +

+
` + }) + await page.goto(url) + + const expectedRequests = [ + { + n: 'Payment Complete', + p: { + amount: '100', + method: 'Credit Card', + url: 'https://example.com/payment?secret=foo' + } + } + ] + await expectPlausibleInAction(page, { + action: () => page.click('a', { modifiers: ['ControlOrMeta'] }), // open in new tab + expectedRequests, + shouldIgnoreRequest: [isPageviewEvent, isEngagementEvent] + }) + + await expectPlausibleInAction(page, { + action: () => page.click('h1'), + expectedRequests, + shouldIgnoreRequest: [isPageviewEvent, isEngagementEvent] + }) + }) + + test('tracks link press when its parent div is tagged (using plausible-event-... equals syntax)', async ({ + page + }, { testId }) => { + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: { ...DEFAULT_CONFIG }, + legacy: + '' + }, + mode + ), + bodyContent: /* HTML */ `` + }) + await page.goto(url) + + await expectPlausibleInAction(page, { + action: () => page.press('a', 'Enter'), + expectedRequests: [ + { + n: 'Reset Password', + p: { + foo: 'bar', + url: 'https://example.com/reset?verification=123' + } + } + ], + shouldIgnoreRequest: [isPageviewEvent, isEngagementEvent] + }) + }) + + test('tracks tagged form submitted using Enter in text input', async ({ + page + }, { testId }) => { + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: { ...DEFAULT_CONFIG }, + legacy: + '' + }, + mode + ), + bodyContent: /* HTML */ `
+

Newsletter Signup

+ + + +
` + }) + await page.goto(url) + + // if the form is tagged, clicks within the form should not trigger events + await expectPlausibleInAction(page, { + action: async () => { + await page.click('form') + await page.click('h2') + await page.click('label', { button: 'right' }) + }, + expectedRequests: [], + shouldIgnoreRequest: [isPageviewEvent, isEngagementEvent] + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.fill('input[type="email"]', 'anything@example.com') + await page.press('input[type="email"]', 'Enter') + }, + expectedRequests: [ + { + n: 'Signup', + p: { type: 'Newsletter' } + } + ], + shouldIgnoreRequest: [isPageviewEvent, isEngagementEvent] + }) + }) + + test('tracks only submit in a form when its parent is tagged', async ({ + page + }, { testId }) => { + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: { ...DEFAULT_CONFIG }, + legacy: + '' + }, + mode + ), + bodyContent: /* HTML */ `
+
+ + +
+
` + }) + await page.goto(url) + + // if the form parent is tagged, clicks within the form should not trigger events + await expectPlausibleInAction(page, { + action: async () => { + await page.click('form') + await page.click('input[type="text"]') + }, + expectedRequests: [], + shouldIgnoreRequest: [isPageviewEvent, isEngagementEvent] + }) + + await expectPlausibleInAction(page, { + action: () => page.click('input[type="submit"]'), + expectedRequests: [ + { + n: 'Form Submit' + } + ], + shouldIgnoreRequest: [isPageviewEvent, isEngagementEvent] + }) + }) + + test('tracks tagged span click and clicks on any child', async ({ page }, { + testId + }) => { + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: { ...DEFAULT_CONFIG }, + legacy: + '' + }, + mode + ), + bodyContent: /* HTML */ `anytext` + }) + await page.goto(url) + + const expectedRequests = [ + { + n: 'Custom Event', + p: { foo: 'bar' } + } + ] + + await expectPlausibleInAction(page, { + action: () => page.click('span'), + expectedRequests, + shouldIgnoreRequest: [isPageviewEvent, isEngagementEvent] + }) + + await expectPlausibleInAction(page, { + action: () => page.click('strong'), + expectedRequests, + shouldIgnoreRequest: [isPageviewEvent, isEngagementEvent] + }) + }) + + test('tracks tagged button click', async ({ page }, { testId }) => { + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: { ...DEFAULT_CONFIG }, + legacy: + '' + }, + mode + ), + bodyContent: /* HTML */ `` + }) + await page.goto(url) + + await expectPlausibleInAction(page, { + action: () => page.click('button'), + expectedRequests: [ + { + n: 'Custom Event', + p: { foo: 'bar' } + } + ], + shouldIgnoreRequest: [isPageviewEvent, isEngagementEvent] + }) + }) + + test('tracks dynamically added tagged button', async ({ page }, { + testId + }) => { + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: { ...DEFAULT_CONFIG }, + legacy: + '' + }, + mode + ), + bodyContent: '' + }) + await page.goto(url) + + await page.evaluate(() => { + return new Promise((resolve) => { + let taggedElement + while (!taggedElement) { + if (window.plausible?.l !== true) { + continue + } else { + taggedElement = document.createElement('button') + taggedElement.classList.add('plausible-event-name=Custom+Event') + taggedElement.innerHTML += 'Dynamic' + document.body.appendChild(taggedElement) + } + } + resolve(true) + }) + }) + + await expectPlausibleInAction(page, { + action: () => page.click('button'), + expectedRequests: [ + { + n: 'Custom Event' + } + ], + shouldIgnoreRequest: [isPageviewEvent, isEngagementEvent] + }) + }) + + test('tracks tagged links without delaying navigation, relying on fetch options.keepalive to deliver tracking events', async ({ + page + }, { testId }) => { + const eventsApiMock = await mockManyRequests({ + page, + path: '**/api/event', + awaitedRequestCount: 1, + responseDelay: 500 + }) + const targetPage = await initializePageDynamically(page, { + testId, + scriptConfig: '', + bodyContent: /* HTML */ `

Subscription successful

`, + path: '/target' + }) + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: { ...DEFAULT_CONFIG, autoCapturePageviews: false }, + legacy: + '' + }, + mode + ), + bodyContent: /* HTML */ `Click to subscribe` + }) + await page.goto(url) + const navigationPromise = page.waitForRequest(targetPage.url, { + timeout: 2000 + }) + await page.click('a') + const [[trackingRequestList, trackingResponseTime], [, navigationTime]] = + await resolveWithTimestamps([ + eventsApiMock.getRequestList(), + navigationPromise + ]) + await expect(page.getByText('Subscription successful')).toBeVisible() + expect(navigationTime).toBeLessThanOrEqual(trackingResponseTime) + expect(trackingRequestList).toEqual([ + expect.objectContaining({ + n: 'Subscribe', + p: { + url: `${LOCAL_SERVER_ADDR}${targetPage.url}` + } + }) + ]) + }) + + test('tracks tagged link clicks even if the link is within an svg tag, but fails to include href properly', async ({ + page + }, { testId }) => { + const targetPage = await initializePageDynamically(page, { + testId, + scriptConfig: '', + bodyContent: /* HTML */ `

Navigation successful

`, + path: '/target' + }) + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: { ...DEFAULT_CONFIG }, + legacy: + '' + }, + mode + ), + bodyContent: /* HTML */ ` + + + + + + ` + }) + + const pageErrors: Error[] = [] + page.on('pageerror', (err) => pageErrors.push(err)) + + await page.goto(url) + + await expectPlausibleInAction(page, { + action: () => page.click('circle'), + expectedRequests: [ + { + n: 'link click', + p: { + expected: { url: {} }, + __expectation__: (actual) => + actual && JSON.stringify(actual) === '{"url":{}}' + } + } + ], + shouldIgnoreRequest: [isPageviewEvent, isEngagementEvent] + }) + + expect(pageErrors).toHaveLength(0) + await expect(page.getByText('Navigation successful')).toBeVisible() + }) + + test('does not track button without plausible-event-name class', async ({ + page + }, { testId }) => { + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: { ...DEFAULT_CONFIG }, + legacy: + '' + }, + mode + ), + bodyContent: /* HTML */ `` + }) + await page.goto(url) + + await expectPlausibleInAction(page, { + action: () => page.click('button'), + refutedRequests: [{}], + shouldIgnoreRequest: [isPageviewEvent, isEngagementEvent] + }) + }) + + test('does not track click on span with class="plausible-event-name="', async ({ + page + }, { testId }) => { + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: { ...DEFAULT_CONFIG }, + legacy: + '' + }, + mode + ), + bodyContent: /* HTML */ `anything` + }) + await page.goto(url) + + await expectPlausibleInAction(page, { + action: () => page.click('span'), + refutedRequests: [{}], + shouldIgnoreRequest: [isPageviewEvent, isEngagementEvent] + }) + }) + }) +} + +test.describe('tagged events feature when using legacy .compat extension', () => { + for (const { caseName, linkAttributes, click, expected, skip } of [ + { + caseName: 'navigates when left clicking on tagged link', + linkAttributes: '', + click: { element: 'a' }, + expected: { requestsOnSamePage: 1, requestsOnOtherPages: 0 } + }, + { + caseName: + 'navigates when left clicking on tagged link with Ctrl or Meta key', + linkAttributes: '', + click: { element: 'a', modifiers: ['ControlOrMeta' as const] }, + expected: { requestsOnSamePage: 0, requestsOnOtherPages: 1 }, + skip: (browserName) => + test.skip( + browserName === 'webkit', + 'does not open links with such clicks (works when testing manually in macOS Safari)' + ) + }, + { + caseName: + 'navigates when left clicking on child element of target="_blank" tagged link', + linkAttributes: 'target="_blank"', + click: { element: 'a[target="_blank"] > h1' }, + expected: { requestsOnSamePage: 0, requestsOnOtherPages: 1 } + }, + { + caseName: + 'does not navigate when left clicking on tagged link that has called event.preventDefault()', + linkAttributes: 'onclick="event.preventDefault()"', + click: { element: 'a' }, + expected: { requestsOnSamePage: 0, requestsOnOtherPages: 0 } + } + ]) { + test(`tracks and ${caseName}`, async ({ page, browserName }, { + testId + }) => { + if (skip) { + skip(browserName) + } + const outboundUrl = 'https://other.example.com/target' + const outboundMockOptions = { + page, + path: outboundUrl, + fulfill: { + status: 200, + contentType: 'text/html', + body: /* HTML */ ` + + + other page + + + other page + + ` + }, + awaitedRequestCount: 2, + mockRequestTimeout: 2000 + } + + const outboundMockForOtherPages = await mockManyRequests({ + ...outboundMockOptions, + scopeMockToPage: false + }) + const outboundMockForSamePage = await mockManyRequests({ + ...outboundMockOptions, + scopeMockToPage: true + }) + + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: + '', + bodyContent: /* HTML */ `

➡️

` + }) + await page.goto(url) + + await expectPlausibleInAction(page, { + action: () => page.click(click.element, { modifiers: click.modifiers }), + expectedRequests: [{ n: 'outbound', p: { url: outboundUrl } }] + }) + + const [requestsOnOtherPages, requestsOnSamePage] = await Promise.all([ + outboundMockForOtherPages.getRequestList().then((d) => d.length), + outboundMockForSamePage.getRequestList().then((d) => d.length) + ]) + expect({ requestsOnOtherPages, requestsOnSamePage }).toEqual(expected) + }) + } + + test('tracking delays navigation until the tracking request has finished', async ({ + page + }, { testId }) => { + const eventsApiMock = await mockManyRequests({ + page, + path: '**/api/event', + awaitedRequestCount: 1, + responseDelay: 1000 + }) + const targetPage = await initializePageDynamically(page, { + testId, + scriptConfig: '', + bodyContent: /* HTML */ `

Subscription successful

`, + path: '/target' + }) + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: + '', + bodyContent: /* HTML */ `Click to subscribe` + }) + await page.goto(url) + const navigationPromise = page.waitForRequest(targetPage.url, { + timeout: 2000 + }) + const trackingPromise = page.waitForResponse('**/api/event', { + timeout: 2000 + }) + await page.click('a') + const [[, trackingResponseTime], [, navigationTime]] = + await resolveWithTimestamps([trackingPromise, navigationPromise]) + await expect(page.getByText('Subscription successful')).toBeVisible() + expect(trackingResponseTime).toBeLessThanOrEqual(navigationTime) + await expect(eventsApiMock.getRequestList()).resolves.toEqual([ + expect.objectContaining({ + n: 'Subscribe', + p: { + url: `${LOCAL_SERVER_ADDR}${targetPage.url}` + } + }) + ]) + }) + + test('if the tracking requests delays navigation for more than 5s, it navigates anyway, without waiting for the request to resolve', async ({ + page + }, { testId }) => { + test.setTimeout(20000) + await mockManyRequests({ + page, + path: '**/api/event', + awaitedRequestCount: 1, + responseDelay: 6000 + }) + const targetPage = await initializePageDynamically(page, { + testId, + scriptConfig: '', + bodyContent: /* HTML */ `

Subscription successful

`, + path: '/target' + }) + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: + '', + bodyContent: /* HTML */ `Click to subscribe` + }) + await page.goto(url) + const navigationPromise = page.waitForRequest(targetPage.url, { + timeout: 6000 + }) + await page.click('a') + await navigationPromise + await expect(page.getByText('Subscription successful')).toBeVisible() + }) + + test('does not track link without plausible-event-name class, the link still navigates as it should', async ({ + page + }, { testId }) => { + const targetPage = await initializePageDynamically(page, { + testId, + scriptConfig: '', + bodyContent: /* HTML */ `

Subscription successful

`, + path: '/target' + }) + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: + '', + bodyContent: /* HTML */ `Click to subscribe` + }) + await page.goto(url) + + await expectPlausibleInAction(page, { + action: () => page.click('a'), + refutedRequests: [{}] + }) + + await expect(page.getByText('Subscription successful')).toBeVisible() + }) + + test('does not intercept navigation for links within svgs, causing slow track requests to fail', async ({ + page + }, { testId }) => { + const targetPage = await initializePageDynamically(page, { + testId, + scriptConfig: '', + bodyContent: /* HTML */ `

Subscription successful

`, + path: '/target' + }) + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: + '', + bodyContent: /* HTML */ ` + + + + + + ` + }) + const pageErrors: Error[] = [] + page.on('pageerror', (err) => pageErrors.push(err)) + await page.goto(url) + + await expectPlausibleInAction(page, { + action: () => page.click('a'), + refutedRequests: [{}], + responseDelay: 500, + mockRequestTimeout: 250 + }) + expect(pageErrors).toHaveLength(0) + + await expect(page.getByText('Subscription successful')).toBeVisible() + }) +}) diff --git a/tracker/test/transform-request.spec.ts b/tracker/test/transform-request.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..8e02b997dbac195edc76523972236257a2ac8f9d --- /dev/null +++ b/tracker/test/transform-request.spec.ts @@ -0,0 +1,442 @@ +import { + initializePageDynamically, + serializeWithFunctions +} from './support/initialize-page-dynamically' +import { + e, + expectPlausibleInAction, + hideAndShowCurrentTab, + isPageviewEvent, + isEngagementEvent, + switchByMode +} from './support/test-utils' +import { test, expect } from '@playwright/test' +import { ScriptConfig } from './support/types' +import { LOCAL_SERVER_ADDR } from './support/server' + +const DEFAULT_CONFIG: ScriptConfig = { + domain: 'example.com', + endpoint: `${LOCAL_SERVER_ADDR}/api/event`, + captureOnLocalhost: true +} + +for (const mode of ['web', 'esm']) { + test.describe(`respects "transformRequest" config option (${mode})`, () => { + test('if "transformRequest" is not a function, nothing happens', async ({ + page + }, { testId }) => { + const config = { + ...DEFAULT_CONFIG, + transformRequest: 123 + } + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: config, + esm: `` + }, + mode + ), + bodyContent: '' + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(url) + await hideAndShowCurrentTab(page, { delay: 200 }) + }, + expectedRequests: [ + { + n: 'pageview', + p: e.toBeUndefined() + }, + { n: 'engagement', p: e.toBeUndefined() } + ] + }) + }) + + test('if "transformRequest" is set to be a function that returns null conditionally, those events are not sent', async ({ + page + }, { testId }) => { + const config = { + ...DEFAULT_CONFIG, + transformRequest: (payload) => { + if (payload.n === 'Purchase') { + return null + } + return payload + } + } + + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: config, + esm: `` + }, + mode + ), + bodyContent: /* HTML */ `` + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(url) + await page.click('button') + }, + expectedRequests: [{ n: 'pageview' }], + refutedRequests: [ + { + n: 'Purchase' + } + ], + shouldIgnoreRequest: [isEngagementEvent] + }) + }) + + test('if "transformRequest" is set to be a function, it will be called for all events', async ({ + page + }, { testId }) => { + const config = { + ...DEFAULT_CONFIG, + transformRequest: (payload) => { + return { ...payload, u: '/:masked/path' } + } + } + + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: config, + esm: `` + }, + mode + ), + bodyContent: /* HTML */ `` + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(url) + await page.click('button') + await hideAndShowCurrentTab(page, { delay: 200 }) + }, + expectedRequests: [ + { n: 'pageview', u: '/:masked/path' }, + { n: 'Purchase', u: '/:masked/path' }, + { n: 'engagement', u: '/:masked/path' } + ] + }) + }) + + test('"transformRequest" does not allow making engagement event props different from pageview event props', async ({ + page + }, { testId }) => { + const config = { + ...DEFAULT_CONFIG, + transformRequest: (payload) => { + // @ts-expect-error - defines window.requestCount + window.requestCount = (window.requestCount ?? 0) + 1 + // @ts-expect-error - window.requestCount is defined + return { ...payload, p: { requestCount: window.requestCount } } + } + } + + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: config, + esm: `` + }, + mode + ), + bodyContent: /* HTML */ `` + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(url) + await page.click('button') + await hideAndShowCurrentTab(page, { delay: 200 }) + }, + expectedRequests: [ + { n: 'pageview', p: { requestCount: 1 } }, + { n: 'Purchase', p: { requestCount: 2 } }, + { n: 'engagement', p: { requestCount: 1 } } + ] + }) + }) + + test('specificity: "transformRequest" runs after custom properties are determined', async ({ + page + }, { testId }) => { + const config = { + ...DEFAULT_CONFIG, + customProperties: () => ({ + author: 'John Smith' + }), + transformRequest: (payload) => { + return { ...payload, p: { author: 'Jane Doe' } } + } + } + + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: config, + esm: `` + }, + mode + ), + bodyContent: '' + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(url) + }, + shouldIgnoreRequest: [isEngagementEvent], + expectedRequests: [ + { + n: 'pageview', + p: { + author: 'Jane Doe' + } + } + ] + }) + }) + + test('if "transformRequest" is defined to be a function that does not return an object, the request is still attempted', async ({ + page + }, { testId }) => { + const config = { + ...DEFAULT_CONFIG, + transformRequest: () => 'not an object' + } + + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: switchByMode( + { + web: config, + esm: `` + }, + mode + ), + bodyContent: /* HTML */ `` + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(url) + await page.click('button') + }, + shouldIgnoreRequest: [isEngagementEvent], + expectedRequests: ['not an object', 'not an object'] + }) + }) + }) +} + +test.describe(`transformRequest examples from /docs work`, () => { + test.beforeEach(async ({ page }) => { + await page + .context() + .route(new RegExp('(http|https)://example\\.com.*'), async (route) => { + await route.fulfill({ + status: 200, + contentType: 'text/html', + body: /* HTML */ ` + + + mocked page + + + mocked page + + ` + }) + }) + }) + + test('you can omit automatically tracked url property from tagged link clicks', async ({ + page + }, { testId }) => { + function omitAutomaticUrlProperty(payload) { + if (payload.p && payload.p.url) { + delete payload.p.url + } + return payload + } + const config = { + ...DEFAULT_CONFIG, + transformRequest: omitAutomaticUrlProperty + } + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: config, + bodyContent: /* HTML */ `Purchase` + }) + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(url) + await page.click('a') + }, + expectedRequests: [ + { + n: 'Purchase', + p: { discounted: 'true' } // <-- no url property + } + ], + shouldIgnoreRequest: [isPageviewEvent, isEngagementEvent] + }) + await expect(page.getByText('mocked page')).toBeVisible() + }) + + for (const { hashBasedRouting, urlSuffix, expectedUrlSuffix } of [ + { + hashBasedRouting: true, + urlSuffix: + '?utm_source=example&utm_medium=referral&utm_campaign=test#fragment', + expectedUrlSuffix: '#fragment' + }, + { + hashBasedRouting: false, + urlSuffix: '?utm_source=example&utm_medium=referral&utm_campaign=test', + expectedUrlSuffix: '' + } + ]) { + test(`you can omit UTM properties from pageview urls (hashBasedRouting: ${hashBasedRouting})`, async ({ + page + }, { testId }) => { + function omitUTMProperties(payload) { + const parts = payload.u.split('?') + let urlWithoutQuery = parts.shift() + + if (payload.h) { + const fragment = parts.join('?').split('#')[1] + urlWithoutQuery = + typeof fragment === 'string' + ? urlWithoutQuery + '#' + fragment + : urlWithoutQuery + } + + payload.u = urlWithoutQuery + return payload + } + + const config = { + ...DEFAULT_CONFIG, + hashBasedRouting, + transformRequest: omitUTMProperties + } + + // the star path is needed for the dynamic page to load when accessing it with query params + const path = '*' + const { url } = await initializePageDynamically(page, { + testId, + path, + scriptConfig: config, + bodyContent: '' + }) + + const [actualUrl] = url.split('*') + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(`${actualUrl}${urlSuffix}`) + // await page.click('a') + }, + expectedRequests: [ + { + n: 'pageview', + u: `${LOCAL_SERVER_ADDR}${actualUrl}${expectedUrlSuffix}` + } + ], + shouldIgnoreRequest: [isEngagementEvent] + }) + }) + } + + test('you can track pages using their canonical url', async ({ page }, { + testId + }) => { + function rewriteUrlToCanonicalUrl(payload) { + // Get the canonical URL element + const canonicalMeta = document.querySelector('link[rel="canonical"]') + // Use the canonical URL if it exists, falling back on the regular URL when it doesn't. + if (canonicalMeta) { + // @ts-expect-error - canonicalMeta definitely has the href attribute + payload.u = canonicalMeta.href + window.location.search + } + return payload + } + + // the star path is needed for the dynamic page to load when accessing it with query params + const nonCanonicalPath = '/products/clothes/shoes/banana-leather-shoe*' + const { url } = await initializePageDynamically(page, { + testId, + path: nonCanonicalPath, + scriptConfig: /* HTML */ ` + + + `, + bodyContent: '' + }) + const [actualUrl] = url.split('*') + + await expectPlausibleInAction(page, { + action: async () => { + await page.goto(`${actualUrl}?utm_source=example`) + }, + expectedRequests: [ + { + n: 'pageview', + u: `${LOCAL_SERVER_ADDR}/products/banana-leather-shoe?utm_source=example` + } + ], + shouldIgnoreRequest: [isEngagementEvent] + }) + }) +})