Files
cc-ci/tests/plausible/custom/test_event_tracking.py
T
cc-ci eecc4aaa51
continuous-integration/drone/push Build is failing
test(plausible): provision the site through the app, not SQL; add tests/STYLE.md
The first cut of this fix added a team INSERT next to the existing sites INSERT.
That fixed the symptom and kept the cause: a fixture that knows the apps table
layout breaks whenever the app changes it, which is exactly what happened here.

_register_site now calls Plausible.Sites.create/2 through the app release console,
so the app provisions whatever its data model currently requires - including the
team it introduced in v3. Verified against BOTH versions on cc-ci: the identical
expression works on v2.0.0, which has no teams table at all, and on v3.2.1. No
version gate is needed because the fixture no longer depends on the schema.

The HTTP provisioning API (POST /api/v1/sites) would have been first choice, but
it is gated behind a paid plan and answers :upgrade_required on CE. That is
recorded in the code so the next person does not re-derive it.

tests/STYLE.md writes the rule down, along with the others this failure exercised:
gate on version rather than supporting both schemas (old-version tests can just be
deleted - the older version is only exercised through the upgrade tier); correct
the fixture or the wait but never the assertion; assert stored state rather than a
202 ack; size waits from the recipes declared readiness; and read the apps own
telemetry before deciding a test is stale.

Full cold suite against the recipe PR head: level 5 of 5, GREEN.
2026-08-11 14:50:41 +00:00

198 lines
8.9 KiB
Python

"""plausible — Phase-2 §4.3 recipe-specific functional tests (event tracking).
plausible's *raison d'être* is ingesting analytics events and storing them in ClickHouse. These two
tests prove the full create-and-read-back path end to end — they are NOT health/200 stand-ins:
* test_pageview_event_roundtrip — POST a `pageview` to the public /api/event ingestion endpoint,
then read the row back out of the ClickHouse `events_v2` table (the primary object: a tracked
event). §4.3 "track a test event, query it back".
* test_custom_event_roundtrip — POST a *custom-named* event (a goal/conversion, plausible's
distinctive non-pageview tracking path) and confirm it lands under that name. Exercises a
characteristic feature beyond the basic pageview.
Both assert real app state (the event reached the analytics store), not just the HTTP 202 ack.
plausible only ingests events for *known* sites — the in-memory `sites_cache` gates ingestion and
drops events for unregistered domains (empirically confirmed: an event for an unregistered domain
never appears in events_v2). Sites are therefore provisioned through plausible's OWN creation path
rather than by writing rows — under v3 a site must belong to a TEAM, and a teamless site is dropped as
`dropped_not_found` while the POST still acks 202, which reads as a silent ingestion stall. Letting the
app create the site sidesteps that entirely, and works unchanged on v2. So each test first provisions
the site, then
POSTs repeatedly while polling ClickHouse: the sites_cache must refresh to admit the new site and the
event write-buffer must flush to ClickHouse, so the first landing is not instantaneous. Re-POSTing the
same event is safe — we assert the row count is >= 1.
No recipe-maintainer corpus exists for plausible (recipe-info/plausible/ has no tests/), so these are
net-new recipe-specific tests rather than parity ports — see tests/plausible/PARITY.md.
"""
from __future__ import annotations
import os
import sys
import time
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "runner"))
from harness import http as harness_http # noqa: E402
from harness import lifecycle # noqa: E402
# A real browser User-Agent — plausible's ingestion drops requests from bot/library UAs (e.g. the
# default python-urllib UA), so the event would silently never reach ClickHouse without this.
_UA = (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
)
# Identity the harness provisions sites under. Ephemeral per-run deploy, never a real account.
_HARNESS_EMAIL = "cc-ci@ci.invalid"
_HARNESS_PW = "ccci-harness-passphrase-2026"
def _ch(domain: str, sql: str) -> str:
"""Run a ClickHouse query against the `plausible_events_db` service; return stdout (stripped)."""
return lifecycle.exec_in_app(
domain,
["clickhouse-client", "--database", "plausible_events_db", "--query", sql],
service="plausible_events_db",
).strip()
# plausible's own provisioning path. Creating a site through the app (rather than INSERTing rows)
# means the app applies whatever its current data model requires — which is what makes this work
# unchanged across the v2→v3 jump, where sites gained a mandatory owning TEAM. Verified on cc-ci
# against BOTH v2.0.0 (no `teams` table at all) and v3.2.1: identical expression, site usable, events
# ingested. See tests/STYLE.md.
#
# The HTTP provisioning API (`POST /api/v1/sites`) would be the first choice, but it is gated behind
# a paid plan — on CE it answers `:upgrade_required` — so the app's release console is the closest
# public interface available here.
_PROVISION_SITE_EXS = """
pw = "__PW__"
email = "__EMAIL__"
user =
case Plausible.Auth.find_user_by(email: email) do
nil ->
{:ok, u} =
Plausible.Auth.User.new(%{name: "cc-ci", email: email, password: pw, password_confirmation: pw})
|> Plausible.Repo.insert()
u
u -> u
end
site = "__SITE__"
result =
case Plausible.Sites.get_by_domain(site) do
nil -> Plausible.Sites.create(user, %{"domain" => site, "timezone" => "UTC"})
s -> {:ok, s}
end
case result do
{:ok, _} -> IO.puts("CCCI_SITE_OK " <> site)
other -> IO.puts("CCCI_SITE_ERR " <> inspect(other))
end
"""
def _register_site(domain: str, site: str) -> None:
"""Provision `site` via plausible's own site-creation path, so it is a site the app will ingest for.
Idempotent: an existing domain is reused rather than re-created.
Do NOT reach into postgres to do this. A `sites` INSERT was enough under v2, but v3 requires the
site to belong to a TEAM and silently discards events for a teamless site — `POST /api/event`
still acks 202 and the row still exists, so the only symptom is that nothing reaches ClickHouse
(ClickHouse's own `ingest_counters` records it as `dropped_not_found`). That is what put this
recipe RED on build 1224. Going through the app removes the whole class of problem: it provisions
the team itself.
"""
exs = (
_PROVISION_SITE_EXS.replace("__PW__", _HARNESS_PW)
.replace("__EMAIL__", _HARNESS_EMAIL)
.replace("__SITE__", site)
)
out = lifecycle.exec_in_app(domain, ["/app/bin/plausible", "rpc", exs], service="app")
assert f"CCCI_SITE_OK {site}" in out, f"could not provision site {site!r} via the app: {out.strip()[-400:]}"
def _post_event(base_domain: str, site: str, name: str, pathname: str) -> int:
"""POST one event to the public ingestion endpoint; return the HTTP status (plausible acks 202)."""
status, _ = harness_http.http_post(
f"https://{base_domain}/api/event",
data={"name": name, "url": f"https://{site}{pathname}", "domain": site},
headers={"User-Agent": _UA, "X-Forwarded-For": "203.0.113.9"},
timeout=15,
)
return status
def _ingest_and_count(
base_domain: str,
site: str,
name: str,
pathname: str,
max_wait: int = 210,
interval: int = 10,
) -> int:
"""Register the site, then POST the event on a poll loop until its row appears in ClickHouse.
Returns the events_v2 row count for (pathname, name). Raises if nothing lands within max_wait —
a genuinely-broken ingestion path therefore FAILS (this is not a vacuous check)."""
_register_site(base_domain, site)
count_sql = f"SELECT count() FROM events_v2 WHERE pathname = '{pathname}' AND name = '{name}'"
deadline = time.time() + max_wait
last_status = None
while True:
last_status = _post_event(base_domain, site, name, pathname)
assert (
last_status == 202
), f"POST /api/event for {name!r} → HTTP {last_status} (expected 202)"
time.sleep(interval)
raw = _ch(base_domain, count_sql)
count = int(raw) if raw.isdigit() else 0
if count >= 1:
return count
if time.time() >= deadline:
raise AssertionError(
f"event name={name!r} pathname={pathname!r} for site={site!r} never reached "
f"ClickHouse events_v2 within {max_wait}s (last POST status={last_status}, "
f"last count={count})"
)
def test_pageview_event_roundtrip(live_app):
"""Track a pageview event via /api/event, read it back from ClickHouse (§4.3 primary object)."""
site = "ccci-pageview.example"
pathname = "/ccci-pageview-roundtrip"
count = _ingest_and_count(live_app, site, "pageview", pathname)
assert count >= 1, f"expected >=1 pageview row, got {count}"
# Read-back: confirm the stored row carries the data we sent (real app state, not just a count).
row = _ch(
live_app,
f"SELECT name, pathname, hostname FROM events_v2 "
f"WHERE pathname = '{pathname}' AND name = 'pageview' LIMIT 1 FORMAT TabSeparated",
)
name, stored_path, hostname = (row.split("\t") + ["", "", ""])[:3]
assert name == "pageview", f"stored event name {name!r} != 'pageview'"
assert stored_path == pathname, f"stored pathname {stored_path!r} != {pathname!r}"
assert hostname == site, f"stored hostname {hostname!r} != site {site!r}"
def test_custom_event_roundtrip(live_app):
"""Track a custom-named event (a goal/conversion — plausible's distinctive non-pageview path) and
confirm it lands under that exact name in ClickHouse, distinct from the pageview path."""
site = "ccci-goal.example"
pathname = "/ccci-custom-event"
event_name = "ccci-Signup"
count = _ingest_and_count(live_app, site, event_name, pathname)
assert count >= 1, f"expected >=1 custom-event row, got {count}"
# The row must be stored under the custom name (not coerced to 'pageview') — proves the
# custom-event/goal ingestion path works.
stored_name = _ch(
live_app,
f"SELECT name FROM events_v2 WHERE pathname = '{pathname}' LIMIT 1",
)
assert (
stored_name == event_name
), f"custom event stored as {stored_name!r}, expected {event_name!r}"