All checks were successful
deploy / deploy (push) Successful in 9m48s
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>
403 lines
13 KiB
Elixir
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
|