59ff48ae90
Sharpen the existing daily-game share loop into something measurable (per Codex's
"instrument what you have, then feed people into it" plan), ahead of a Show HN launch.
Analytics:
- Per-game funnel events <game>_{arrival,started,completed,shared} (article_id=0).
arrival = landed via a shared link (utm_source=game_share); started = first move
(guess/find/flip); completed = solved/cleared/Full Bloom; shared = on share success.
- trackVisit() moved into the global layout so direct /play landings count; the
server-rendered /a/ share page now creates a visitor token + sends a daily visit
beacon (first-time /a/-only visitors were previously dropped).
- Admin "Games funnel" panel: arrivals / engaged / completed / shared, per game.
Sharing:
- Memory Match gains a Share button (it was the only game without one).
- All shares deep-link to the exact game+variant with a full https:// URL +
utm_source=game_share (gameShareUrl helper), instead of a bare /play.
- "shared" is counted only after navigator.share()/clipboard.writeText() succeeds.
/play social metadata:
- /play served homepage canonical/OG (static SPA, ssr=false). postbuild script
patches build/play.html's head to /play canonical/title/description/OG; fails the
build if the homepage tags drift. Caddy try_files now serves {path}.html so /play
is served from the patched file (snapshot in deploy/caddy/).
Tests: backend 352, frontend 27.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
85 lines
3.4 KiB
Python
85 lines
3.4 KiB
Python
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
@pytest.fixture
|
|
def app_db(tmp_path, monkeypatch):
|
|
db = tmp_path / "t.sqlite3"
|
|
monkeypatch.setenv("GOODNEWS_DB", str(db))
|
|
monkeypatch.setenv("GOODNEWS_SESSION_SECRET", "test-secret")
|
|
import importlib
|
|
import goodnews.api as api
|
|
importlib.reload(api)
|
|
from goodnews.db import connect, init_db
|
|
connect(str(db)).close() # creates schema lazily? ensure init
|
|
c = connect(str(db)); init_db(c); c.close()
|
|
return api.create_app(), db
|
|
|
|
|
|
def _count(db, **where):
|
|
from goodnews.db import connect
|
|
c = connect(str(db))
|
|
clause = " AND ".join(f"{k}=?" for k in where)
|
|
sql = "SELECT COUNT(*) FROM events" + (f" WHERE {clause}" if where else "")
|
|
n = c.execute(sql, tuple(where.values())).fetchone()[0]
|
|
c.close()
|
|
return n
|
|
|
|
|
|
def test_event_recorded_and_deduped(app_db):
|
|
app, db = app_db
|
|
tc = TestClient(app)
|
|
for _ in range(3): # same (kind, article, visitor, day) → one row
|
|
assert tc.post("/api/events", json={"kind": "open", "article_id": 5, "visitor": "tok"}).json() == {"ok": True}
|
|
assert _count(db, kind="open", article_id=5) == 1
|
|
# a different visitor is a distinct row
|
|
tc.post("/api/events", json={"kind": "open", "article_id": 5, "visitor": "other"})
|
|
assert _count(db, kind="open", article_id=5) == 2
|
|
|
|
|
|
def test_visitor_token_is_hashed_not_stored_raw(app_db):
|
|
app, db = app_db
|
|
TestClient(app).post("/api/events", json={"kind": "visit", "visitor": "secret-token"})
|
|
from goodnews.db import connect
|
|
c = connect(str(db))
|
|
vh = c.execute("SELECT visitor_hash FROM events").fetchone()[0]
|
|
c.close()
|
|
assert vh and vh != "secret-token" and len(vh) == 64 # sha256 hex
|
|
|
|
|
|
def test_unknown_kind_is_ignored(app_db):
|
|
app, db = app_db
|
|
assert TestClient(app).post("/api/events", json={"kind": "evil", "visitor": "x"}).json() == {"ok": True}
|
|
assert _count(db) == 0
|
|
|
|
|
|
def test_game_event_kinds_are_allowed(app_db):
|
|
app, db = app_db
|
|
tc = TestClient(app)
|
|
# the per-game funnel kinds (incl. the share-loop arrival) pass the allowlist
|
|
for kind in ("word_started", "word_completed", "word_shared", "word_arrival", "match_arrival"):
|
|
assert tc.post("/api/events", json={"kind": kind, "article_id": 0, "visitor": "t"}).json() == {"ok": True}
|
|
assert _count(db, kind=kind) == 1
|
|
# a bogus game kind is still rejected
|
|
tc.post("/api/events", json={"kind": "chess_started", "visitor": "t"})
|
|
assert _count(db, kind="chess_started") == 0
|
|
|
|
|
|
def test_admin_stats_games_funnel_aggregates(app_db):
|
|
app, db = app_db
|
|
tc = TestClient(app)
|
|
# two visitors arrive at Daily Word via a shared link; one engages + shares; a Match completes
|
|
for v in ("a", "b"):
|
|
tc.post("/api/events", json={"kind": "word_arrival", "article_id": 0, "visitor": v})
|
|
tc.post("/api/events", json={"kind": "word_started", "article_id": 0, "visitor": "a"})
|
|
tc.post("/api/events", json={"kind": "word_shared", "article_id": 0, "visitor": "a"})
|
|
tc.post("/api/events", json={"kind": "match_completed", "article_id": 0, "visitor": "a"})
|
|
from goodnews.db import connect
|
|
from goodnews import queries
|
|
c = connect(str(db))
|
|
games = queries.admin_stats(c, days=30)["games"]
|
|
c.close()
|
|
assert games["by_game"]["word"] == {"arrival": 2, "started": 1, "completed": 0, "shared": 1}
|
|
assert games["by_game"]["match"]["completed"] == 1
|
|
assert games["totals"]["arrival"] == 2 and games["totals"]["shared"] == 1
|