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>
108 lines
5.0 KiB
Python
108 lines
5.0 KiB
Python
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
@pytest.fixture
|
|
def client(tmp_path, monkeypatch):
|
|
db = tmp_path / "t.sqlite3"
|
|
monkeypatch.setenv("GOODNEWS_DB", str(db))
|
|
monkeypatch.setenv("GOODNEWS_PUBLIC_BASE_URL", "https://upbeatbytes.com")
|
|
import importlib
|
|
import goodnews.api as api
|
|
importlib.reload(api)
|
|
from goodnews.db import connect, init_db
|
|
c = connect(str(db)); init_db(c)
|
|
c.execute("INSERT INTO sources (id,name,feed_url,trust_score) VALUES (1,'BBC','http://s/f',5)")
|
|
# 1: accepted (renders), 2: rejected, 3: duplicate
|
|
c.execute("INSERT INTO articles (id,source_id,canonical_url,title,url_hash,image_url) "
|
|
"VALUES (1,1,'https://bbc.com/x','Water voles return','h1','https://img/v.jpg')")
|
|
c.execute("INSERT INTO articles (id,source_id,canonical_url,title,url_hash) VALUES (2,1,'https://bbc.com/y','Meh','h2')")
|
|
c.execute("INSERT INTO articles (id,source_id,canonical_url,title,url_hash,duplicate_of) VALUES (3,1,'https://bbc.com/z','Dup','h3',1)")
|
|
c.execute("INSERT INTO article_scores (article_id,accepted,reason_text) VALUES (1,1,'A hopeful restoration story.')")
|
|
c.execute("INSERT INTO article_scores (article_id,accepted) VALUES (2,0)")
|
|
c.execute("INSERT INTO article_scores (article_id,accepted) VALUES (3,1)")
|
|
c.commit(); c.close()
|
|
return api.create_app()
|
|
|
|
|
|
def test_share_page_renders(client):
|
|
r = TestClient(client).get("/a/1")
|
|
assert r.status_code == 200
|
|
html = r.text
|
|
assert "Water voles return" in html
|
|
assert 'property="og:title"' in html and 'name="twitter:card"' in html
|
|
assert 'rel="canonical" href="https://upbeatbytes.com/a/1"' in html
|
|
assert 'https://bbc.com/x' in html # source link present
|
|
assert "A hopeful restoration story." in html
|
|
assert 'content="https://img/v.jpg"' in html # og image
|
|
|
|
|
|
def test_share_page_missing_and_malformed(client):
|
|
tc = TestClient(client)
|
|
assert tc.get("/a/999").status_code == 404 # unknown
|
|
assert tc.get("/a/not-a-number").status_code == 404 # malformed → calm 404
|
|
assert tc.get("/a/2").status_code == 404 # rejected article
|
|
|
|
|
|
def test_share_page_duplicate_redirects_to_canonical(client):
|
|
# article 3 is a duplicate of the live article 1 — its URL may be indexed, so it
|
|
# 301s to the canonical (consolidates) rather than 404ing and dropping from Google.
|
|
r = TestClient(client).get("/a/3", follow_redirects=False)
|
|
assert r.status_code == 301 and r.headers["location"] == "/a/1"
|
|
|
|
|
|
def test_share_page_creates_visitor_token_for_beacons(client):
|
|
# /a/ is server-rendered outside the SPA, so its inline analytics must CREATE the
|
|
# visitor token when absent (mirroring visitorId) — else a first-time /a/ landing
|
|
# beacons an empty token and is dropped from visitor stats.
|
|
html = TestClient(client).get("/a/1").text
|
|
assert "localStorage.setItem('goodnews:visitor'" in html
|
|
assert "crypto.randomUUID" in html
|
|
assert "kind:'visit'" in html # daily visit beacon present
|
|
|
|
|
|
def test_share_page_no_image_uses_summary_card(client, tmp_path, monkeypatch):
|
|
# article 1 has an image → large card
|
|
assert 'summary_large_image' in TestClient(client).get("/a/1").text
|
|
|
|
|
|
def test_incomplete_page_is_not_cached(client):
|
|
# article 1 has no summary/explanation → "generating" page must not be cached,
|
|
# and carries no-cache so it re-fetches once the summary lands.
|
|
import goodnews.api as api
|
|
r = TestClient(client).get("/a/1")
|
|
assert r.status_code == 200
|
|
assert r.headers.get("cache-control") == "no-cache"
|
|
assert 1 not in api._SHARE_CACHE
|
|
|
|
|
|
@pytest.fixture
|
|
def app_complete(tmp_path, monkeypatch):
|
|
db = tmp_path / "t.sqlite3"
|
|
monkeypatch.setenv("GOODNEWS_DB", str(db))
|
|
monkeypatch.setenv("GOODNEWS_PUBLIC_BASE_URL", "https://upbeatbytes.com")
|
|
import importlib
|
|
import goodnews.api as api
|
|
importlib.reload(api)
|
|
from goodnews.db import connect, init_db
|
|
c = connect(str(db)); init_db(c)
|
|
c.execute("INSERT INTO sources (id,name,feed_url,trust_score) VALUES (1,'BBC','http://s/f',5)")
|
|
c.execute("INSERT INTO articles (id,source_id,canonical_url,title,url_hash,image_url) "
|
|
"VALUES (1,1,'https://bbc.com/x','Water voles return','h1','https://img/v.jpg')")
|
|
c.execute("INSERT INTO article_scores (article_id,accepted,reason_text) VALUES (1,1,'Hopeful.')")
|
|
c.execute("INSERT INTO article_summaries (article_id,summary,what_happened,why_matters,why_belongs) "
|
|
"VALUES (1,'Voles are back.','They returned to the river.','Biodiversity rebound.','Quietly hopeful.')")
|
|
c.commit(); c.close()
|
|
return api
|
|
|
|
|
|
def test_complete_page_is_cached_and_served_from_cache(app_complete):
|
|
api = app_complete
|
|
tc = TestClient(api.create_app())
|
|
r1 = tc.get("/a/1")
|
|
assert r1.status_code == 200
|
|
assert r1.headers.get("cache-control") == "public, max-age=300"
|
|
assert 1 in api._SHARE_CACHE # finished page cached
|
|
r2 = tc.get("/a/1") # second hit served from cache
|
|
assert r2.status_code == 200 and r2.text == r1.text
|