berrypod/test/berrypod/analytics_test.exs
jamey 0c54861eb6
All checks were successful
deploy / deploy (push) Successful in 9m48s
add analytics-powered 404 monitoring with FTS5 auto-resolve
BrokenUrlTracker now queries real analytics pageview counts instead of
hardcoding 0, so broken URLs with prior traffic are distinguished from
bot noise. For /products/ 404s with a single FTS5 search match, auto-
creates a redirect and marks the broken URL resolved. 1232 tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 16:08:25 +00:00

403 lines
13 KiB
Elixir

defmodule Berrypod.AnalyticsTest do
use Berrypod.DataCase, async: false
import Ecto.Query
alias Berrypod.Analytics
alias Berrypod.Analytics.{Buffer, Event}
alias Berrypod.Repo
setup do
# Flush any pending events then clear the table so each test starts clean
send(Buffer, :flush)
:timer.sleep(50)
Repo.delete_all(Event)
:ok
end
# Helper to insert events directly (bypassing the buffer for query tests)
defp insert_event(attrs) do
now = DateTime.utc_now() |> DateTime.truncate(:second)
defaults = %{
id: Ecto.UUID.generate(),
name: "pageview",
pathname: "/",
visitor_hash: :crypto.strong_rand_bytes(8),
session_hash: :crypto.strong_rand_bytes(8),
inserted_at: now
}
event = Map.merge(defaults, attrs)
Repo.insert_all(Event, [Map.to_list(event)])
event
end
defp today_range do
today = Date.utc_today()
start_dt = DateTime.new!(today, ~T[00:00:00], "Etc/UTC")
end_dt = DateTime.new!(Date.add(today, 1), ~T[00:00:00], "Etc/UTC")
{start_dt, end_dt}
end
describe "track_pageview/1" do
test "records a pageview through the buffer" do
visitor_hash = :crypto.strong_rand_bytes(8)
Analytics.track_pageview(%{pathname: "/test", visitor_hash: visitor_hash})
send(Buffer, :flush)
:timer.sleep(50)
[event] =
from(e in Event, where: e.visitor_hash == ^visitor_hash) |> Repo.all()
assert event.name == "pageview"
assert event.pathname == "/test"
end
end
describe "track_event/2" do
test "records a named event through the buffer" do
visitor_hash = :crypto.strong_rand_bytes(8)
Analytics.track_event("add_to_cart", %{
pathname: "/products/tee",
visitor_hash: visitor_hash
})
send(Buffer, :flush)
:timer.sleep(50)
[event] =
from(e in Event, where: e.visitor_hash == ^visitor_hash) |> Repo.all()
assert event.name == "add_to_cart"
assert event.pathname == "/products/tee"
end
end
describe "count_visitors/1" do
test "counts distinct visitors" do
v1 = :crypto.strong_rand_bytes(8)
v2 = :crypto.strong_rand_bytes(8)
insert_event(%{visitor_hash: v1, pathname: "/"})
insert_event(%{visitor_hash: v1, pathname: "/about"})
insert_event(%{visitor_hash: v2, pathname: "/"})
assert Analytics.count_visitors(today_range()) == 2
end
test "returns 0 for empty range" do
assert Analytics.count_visitors(today_range()) == 0
end
end
describe "count_pageviews/1" do
test "counts all pageview events" do
v1 = :crypto.strong_rand_bytes(8)
insert_event(%{visitor_hash: v1, pathname: "/"})
insert_event(%{visitor_hash: v1, pathname: "/about"})
insert_event(%{visitor_hash: v1, pathname: "/products/tee", name: "product_view"})
assert Analytics.count_pageviews(today_range()) == 2
end
end
describe "bounce_rate/1" do
test "100% bounce rate when all sessions have 1 pageview" do
s1 = :crypto.strong_rand_bytes(8)
s2 = :crypto.strong_rand_bytes(8)
insert_event(%{session_hash: s1, pathname: "/"})
insert_event(%{session_hash: s2, pathname: "/about"})
assert Analytics.bounce_rate(today_range()) == 100
end
test "0% bounce rate when all sessions have multiple pageviews" do
session = :crypto.strong_rand_bytes(8)
insert_event(%{session_hash: session, pathname: "/"})
insert_event(%{session_hash: session, pathname: "/about"})
assert Analytics.bounce_rate(today_range()) == 0
end
test "returns 0 for no data" do
assert Analytics.bounce_rate(today_range()) == 0
end
end
describe "top_pages/2" do
test "returns pages sorted by visitor count" do
v1 = :crypto.strong_rand_bytes(8)
v2 = :crypto.strong_rand_bytes(8)
insert_event(%{visitor_hash: v1, pathname: "/"})
insert_event(%{visitor_hash: v2, pathname: "/"})
insert_event(%{visitor_hash: v1, pathname: "/about"})
pages = Analytics.top_pages(today_range())
assert hd(pages).pathname == "/"
assert hd(pages).visitors == 2
end
end
describe "top_sources/2" do
test "returns sources sorted by visitor count" do
v1 = :crypto.strong_rand_bytes(8)
v2 = :crypto.strong_rand_bytes(8)
insert_event(%{visitor_hash: v1, referrer_source: "Google"})
insert_event(%{visitor_hash: v2, referrer_source: "Google"})
insert_event(%{visitor_hash: v1, referrer_source: "Facebook"})
sources = Analytics.top_sources(today_range())
assert hd(sources).source == "Google"
assert hd(sources).visitors == 2
end
end
describe "top_countries/2" do
test "returns countries sorted by visitor count" do
v1 = :crypto.strong_rand_bytes(8)
v2 = :crypto.strong_rand_bytes(8)
insert_event(%{visitor_hash: v1, country_code: "GB"})
insert_event(%{visitor_hash: v2, country_code: "GB"})
insert_event(%{visitor_hash: v1, country_code: "US"})
countries = Analytics.top_countries(today_range())
assert hd(countries).country_code == "GB"
assert hd(countries).visitors == 2
end
end
describe "device_breakdown/2" do
test "returns browser breakdown" do
v1 = :crypto.strong_rand_bytes(8)
v2 = :crypto.strong_rand_bytes(8)
insert_event(%{visitor_hash: v1, browser: "Chrome"})
insert_event(%{visitor_hash: v2, browser: "Chrome"})
insert_event(%{visitor_hash: v1, browser: "Firefox"})
browsers = Analytics.device_breakdown(today_range(), :browser)
assert hd(browsers).name == "Chrome"
assert hd(browsers).visitors == 2
end
end
describe "funnel/1" do
test "returns counts for each funnel step" do
v1 = :crypto.strong_rand_bytes(8)
v2 = :crypto.strong_rand_bytes(8)
insert_event(%{visitor_hash: v1, name: "product_view", pathname: "/products/tee"})
insert_event(%{visitor_hash: v2, name: "product_view", pathname: "/products/tee"})
insert_event(%{visitor_hash: v1, name: "add_to_cart", pathname: "/products/tee"})
insert_event(%{visitor_hash: v1, name: "checkout_start", pathname: "/checkout"})
insert_event(%{
visitor_hash: v1,
name: "purchase",
pathname: "/checkout/success",
revenue: 2500
})
funnel = Analytics.funnel(today_range())
assert funnel.product_views == 2
assert funnel.add_to_carts == 1
assert funnel.checkouts == 1
assert funnel.purchases == 1
end
end
describe "total_revenue/1" do
test "sums revenue from purchase events" do
v1 = :crypto.strong_rand_bytes(8)
insert_event(%{visitor_hash: v1, name: "purchase", pathname: "/", revenue: 2500})
insert_event(%{visitor_hash: v1, name: "purchase", pathname: "/", revenue: 1500})
assert Analytics.total_revenue(today_range()) == 4000
end
test "returns 0 when no purchases" do
assert Analytics.total_revenue(today_range()) == 0
end
end
describe "filtering" do
test "count_visitors respects country_code filter" do
v1 = :crypto.strong_rand_bytes(8)
v2 = :crypto.strong_rand_bytes(8)
insert_event(%{visitor_hash: v1, country_code: "GB"})
insert_event(%{visitor_hash: v2, country_code: "US"})
assert Analytics.count_visitors(today_range(), %{country_code: "GB"}) == 1
end
test "top_pages respects referrer_source filter" do
v1 = :crypto.strong_rand_bytes(8)
v2 = :crypto.strong_rand_bytes(8)
insert_event(%{visitor_hash: v1, pathname: "/", referrer_source: "Google"})
insert_event(%{visitor_hash: v2, pathname: "/about", referrer_source: "Facebook"})
pages = Analytics.top_pages(today_range(), filters: %{referrer_source: "Google"})
assert length(pages) == 1
assert hd(pages).pathname == "/"
end
test "bounce_rate respects filter" do
s1 = :crypto.strong_rand_bytes(8)
s2 = :crypto.strong_rand_bytes(8)
# GB visitor bounces (1 pageview)
insert_event(%{session_hash: s1, country_code: "GB"})
# US visitor doesn't bounce (2 pageviews)
insert_event(%{session_hash: s2, country_code: "US", pathname: "/"})
insert_event(%{session_hash: s2, country_code: "US", pathname: "/about"})
assert Analytics.bounce_rate(today_range(), %{country_code: "GB"}) == 100
assert Analytics.bounce_rate(today_range(), %{country_code: "US"}) == 0
end
test "multiple filters combine with AND logic" do
v1 = :crypto.strong_rand_bytes(8)
v2 = :crypto.strong_rand_bytes(8)
insert_event(%{visitor_hash: v1, country_code: "GB", browser: "Chrome"})
insert_event(%{visitor_hash: v2, country_code: "GB", browser: "Firefox"})
assert Analytics.count_visitors(today_range(), %{country_code: "GB", browser: "Chrome"}) ==
1
end
test "unknown filter keys are ignored" do
v1 = :crypto.strong_rand_bytes(8)
insert_event(%{visitor_hash: v1})
assert Analytics.count_visitors(today_range(), %{nonexistent: "val"}) == 1
end
end
describe "entry_pages/2" do
test "returns the first page per session" do
s1 = :crypto.strong_rand_bytes(8)
s2 = :crypto.strong_rand_bytes(8)
now = DateTime.utc_now() |> DateTime.truncate(:second)
earlier = DateTime.add(now, -60, :second)
insert_event(%{session_hash: s1, pathname: "/", inserted_at: earlier})
insert_event(%{session_hash: s1, pathname: "/about", inserted_at: now})
insert_event(%{session_hash: s2, pathname: "/products", inserted_at: earlier})
pages = Analytics.entry_pages(today_range())
pathnames = Enum.map(pages, & &1.pathname)
assert "/" in pathnames
assert "/products" in pathnames
refute "/about" in pathnames
end
test "counts sessions per entry page" do
s1 = :crypto.strong_rand_bytes(8)
s2 = :crypto.strong_rand_bytes(8)
s3 = :crypto.strong_rand_bytes(8)
now = DateTime.utc_now() |> DateTime.truncate(:second)
insert_event(%{session_hash: s1, pathname: "/"})
insert_event(%{session_hash: s2, pathname: "/"})
insert_event(%{session_hash: s3, pathname: "/about", inserted_at: now})
pages = Analytics.entry_pages(today_range())
home = Enum.find(pages, &(&1.pathname == "/"))
assert home.sessions == 2
end
test "returns empty list with no data" do
assert Analytics.entry_pages(today_range()) == []
end
end
describe "exit_pages/2" do
test "returns the last page per session" do
s1 = :crypto.strong_rand_bytes(8)
s2 = :crypto.strong_rand_bytes(8)
now = DateTime.utc_now() |> DateTime.truncate(:second)
earlier = DateTime.add(now, -60, :second)
insert_event(%{session_hash: s1, pathname: "/", inserted_at: earlier})
insert_event(%{session_hash: s1, pathname: "/about", inserted_at: now})
insert_event(%{session_hash: s2, pathname: "/products", inserted_at: earlier})
pages = Analytics.exit_pages(today_range())
pathnames = Enum.map(pages, & &1.pathname)
assert "/about" in pathnames
assert "/products" in pathnames
refute "/" in pathnames
end
test "counts sessions per exit page" do
s1 = :crypto.strong_rand_bytes(8)
s2 = :crypto.strong_rand_bytes(8)
now = DateTime.utc_now() |> DateTime.truncate(:second)
earlier = DateTime.add(now, -60, :second)
insert_event(%{session_hash: s1, pathname: "/", inserted_at: earlier})
insert_event(%{session_hash: s1, pathname: "/about", inserted_at: now})
insert_event(%{session_hash: s2, pathname: "/", inserted_at: earlier})
insert_event(%{session_hash: s2, pathname: "/about", inserted_at: now})
pages = Analytics.exit_pages(today_range())
about = Enum.find(pages, &(&1.pathname == "/about"))
assert about.sessions == 2
end
test "returns empty list with no data" do
assert Analytics.exit_pages(today_range()) == []
end
end
describe "count_pageviews_for_path/1" do
test "counts all pageviews for a specific path" do
v1 = :crypto.strong_rand_bytes(8)
v2 = :crypto.strong_rand_bytes(8)
insert_event(%{visitor_hash: v1, pathname: "/products/classic-tee"})
insert_event(%{visitor_hash: v2, pathname: "/products/classic-tee"})
insert_event(%{visitor_hash: v1, pathname: "/products/other"})
assert Analytics.count_pageviews_for_path("/products/classic-tee") == 2
end
test "returns 0 for paths with no history" do
assert Analytics.count_pageviews_for_path("/products/never-visited") == 0
end
end
describe "delete_events_before/1" do
test "deletes old events" do
old = DateTime.add(DateTime.utc_now(), -400, :day) |> DateTime.truncate(:second)
recent = DateTime.utc_now() |> DateTime.truncate(:second)
insert_event(%{inserted_at: old, pathname: "/old"})
insert_event(%{inserted_at: recent, pathname: "/recent"})
cutoff = DateTime.add(DateTime.utc_now(), -365, :day) |> DateTime.truncate(:second)
{deleted, _} = Analytics.delete_events_before(cutoff)
assert deleted == 1
assert [event] = Repo.all(Event)
assert event.pathname == "/recent"
end
end
end