89c0fbe1f6
The deploy pipeline runs from the working tree, so a wave of shipped features
had never been committed. This snapshots git to what's actually running.
SEO impression recovery (live + verified):
- Duplicate /a/{id} now 301-redirect to their canonical twin instead of 404
(a hard 404 silently dropped already-indexed URLs and tanked impressions).
- Dedup representative selection reworked: accepted/serveable -> established
rep (URL stability) -> quality score, so an accepted page never retires to a
rejected rep and an indexed canonical doesn't churn when a newer twin arrives.
- HEAD /a/{id} returns the same status as GET (api_route GET+HEAD) instead of
falling through to the static mount and 404ing.
- `dedup --force-recluster`: cycle-locked, model-free re-cluster to re-apply the
policy to the existing corpus (shared cycle_lock context manager).
- CLI honors GOODNEWS_DB for its default --db (was silently ignored).
Publishing Desk (admin tool to post highlights to X via Web Intents):
- publishing.py queue/rank/handle-resolution; admin UI; full searchable emoji
picker (bundled data, no CDN) for the blurb editor.
Play games + site:
- Bloom (word-wheel), Memory Match, daily ritual set, Zen Den (dev-gated).
- English-only language gate; source prospecting; paywall + dedup hardening.
Tests: full suite green (349). Ignores tightened (node_modules, data/*.db).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
135 lines
6.2 KiB
Python
135 lines
6.2 KiB
Python
"""Memory Match server state — light, durability-only (no anti-cheat; the board is
|
|
deterministic and fully visible). Locks: malformed keys dropped, matched stored as
|
|
deduped face KEYS, `done` DERIVED from the matched count vs the tier's target (never
|
|
trusted from the client), cross-device merge unions matched, and the sync endpoint
|
|
accepts only valid match variants."""
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from goodnews import games
|
|
from goodnews.db import connect, init_db
|
|
|
|
|
|
def _san(variant, state):
|
|
return games.sanitize_game_state(None, "match", variant, "2026-06-16", state)
|
|
|
|
|
|
def test_sanitize_drops_junk_and_dedupes():
|
|
s = _san("standard-icons", {
|
|
"matched": ["leaf", "leaf", "color-rose", "banana", "BAD KEY!", 42, "x" * 40, "sun"],
|
|
"moves": -5, "done": "yes",
|
|
})
|
|
# deduped + validated against the real face set ("banana"/junk dropped), order kept
|
|
assert s["matched"] == ["leaf", "color-rose", "sun"]
|
|
assert s["moves"] == 0 # clamped ≥ 0
|
|
assert s["done"] is False # 3 < 8 faces — client's "yes" ignored
|
|
|
|
|
|
def test_done_is_derived_from_matched_count_not_client_flag():
|
|
real8 = ["leaf", "sun", "star", "moon", "cloud", "wave", "tree", "heart"] # real faces
|
|
# client lies that it's done with no progress → server says not done
|
|
assert _san("standard-icons", {"matched": [], "done": True})["done"] is False
|
|
# reaching the tier's face target (standard = 8) → done
|
|
assert _san("standard-icons", {"matched": real8, "done": False})["done"] is True
|
|
# gentle target is only 6
|
|
assert _san("gentle-icons", {"matched": real8[:6]})["done"] is True
|
|
assert _san("standard-icons", {"matched": real8[:6]})["done"] is False
|
|
|
|
|
|
def test_sanitize_caps_face_count():
|
|
many = ["color-rose", "color-coral", "color-amber", "color-gold", "color-lime",
|
|
"color-green", "color-teal", "color-cyan", "color-sky", "color-blue",
|
|
"color-indigo", "color-violet", "color-plum", "color-brown", "color-sand"] # 15 real
|
|
s = _san("expert-colors", {"matched": many})
|
|
assert len(s["matched"]) == 12 # _MATCH_MAX_FACES
|
|
|
|
|
|
def test_merge_unions_matched_and_keeps_moves_without_trusting_done():
|
|
a = {"matched": ["leaf", "sun"], "moves": 7, "done": False}
|
|
b = {"matched": ["sun", "star"], "moves": 4, "done": True}
|
|
m = games.merge_game_state("match", a, b)
|
|
assert sorted(m["matched"]) == ["leaf", "star", "sun"] # union
|
|
assert m["moves"] == 7 # larger move count
|
|
assert "done" not in m # merge doesn't carry done; sanitize derives it
|
|
|
|
|
|
@pytest.fixture
|
|
def api_app(tmp_path, monkeypatch):
|
|
db = tmp_path / "t.sqlite3"
|
|
monkeypatch.setenv("GOODNEWS_DB", str(db))
|
|
monkeypatch.setenv("GOODNEWS_PUBLIC_BASE_URL", "http://testserver")
|
|
monkeypatch.setenv("GOODNEWS_ADMIN_EMAILS", "admin@b.com")
|
|
import importlib
|
|
import goodnews.api as api
|
|
importlib.reload(api)
|
|
c = connect(str(db)); init_db(c); c.commit(); c.close()
|
|
return api.create_app()
|
|
|
|
|
|
def _signin(app, email="p@b.com"):
|
|
tc = TestClient(app)
|
|
sent = {}
|
|
import goodnews.email_send as es
|
|
orig = es.send_magic_link
|
|
es.send_magic_link = lambda to, link: sent.update(link=link)
|
|
try:
|
|
tc.post("/api/auth/email/start", json={"email": email})
|
|
tc.post("/api/auth/email/verify", json={"token": sent["link"].split("token=")[1]})
|
|
finally:
|
|
es.send_magic_link = orig
|
|
return tc
|
|
|
|
|
|
def _put(tc, variant, state):
|
|
return tc.put("/api/games/state", json={
|
|
"game": "match", "variant": variant, "date": "2026-06-16", "state": state})
|
|
|
|
|
|
def test_sync_endpoint_flow(api_app):
|
|
tc = _signin(api_app)
|
|
r1 = _put(tc, "standard-icons", {"matched": ["leaf", "sun"], "moves": 3, "done": False})
|
|
assert r1.status_code == 200
|
|
assert sorted(r1.json()["state"]["matched"]) == ["leaf", "sun"]
|
|
assert r1.json()["state"]["done"] is False
|
|
# a second device merges in (still partial → not done)
|
|
r2 = _put(tc, "standard-icons", {"matched": ["star"], "moves": 1, "done": True})
|
|
assert sorted(r2.json()["state"]["matched"]) == ["leaf", "star", "sun"]
|
|
assert r2.json()["state"]["done"] is False # 3 < 8, client's done ignored
|
|
# completing the board (8 real faces) → done
|
|
r3 = _put(tc, "standard-icons",
|
|
{"matched": ["leaf", "sun", "star", "moon", "cloud", "wave", "tree", "heart"], "moves": 12})
|
|
assert r3.json()["state"]["done"] is True
|
|
# unknown variant rejected
|
|
assert _put(tc, "huge-icons", {}).status_code == 404
|
|
|
|
|
|
def test_batch_endpoint_reconciles_many_and_drops_bad(api_app):
|
|
tc = _signin(api_app)
|
|
body = {"date": "2026-06-16", "items": [
|
|
{"game": "match", "variant": "standard-icons", "state": {"matched": ["leaf", "sun"], "moves": 2}},
|
|
{"game": "bloom", "variant": "", "state": {"found": []}},
|
|
{"game": "match", "variant": "bogus-xyz", "state": {}}, # unknown variant → dropped
|
|
]}
|
|
r = tc.put("/api/games/state/batch", json=body)
|
|
assert r.status_code == 200
|
|
states = r.json()["states"]
|
|
variants = {(s["game"], s["variant"]) for s in states}
|
|
assert ("match", "standard-icons") in variants
|
|
assert ("bloom", "") in variants
|
|
assert ("match", "bogus-xyz") not in variants # invalid item dropped, not fatal
|
|
m = next(s for s in states if s["variant"] == "standard-icons")
|
|
assert sorted(m["state"]["matched"]) == ["leaf", "sun"] # merged + sanitized
|
|
# a second device merges via the same batch path
|
|
r2 = tc.put("/api/games/state/batch", json={"date": "2026-06-16", "items": [
|
|
{"game": "match", "variant": "standard-icons", "state": {"matched": ["star"], "moves": 5}}]})
|
|
m2 = r2.json()["states"][0]["state"]
|
|
assert sorted(m2["matched"]) == ["leaf", "star", "sun"] and m2["moves"] == 5
|
|
|
|
|
|
def test_batch_endpoint_signed_out_echoes(api_app):
|
|
from fastapi.testclient import TestClient
|
|
r = TestClient(api_app).put("/api/games/state/batch", json={"date": "2026-06-16", "items": [
|
|
{"game": "match", "variant": "gentle-colors", "state": {"matched": ["color-rose"]}}]})
|
|
assert r.status_code == 200
|
|
assert r.json()["states"][0]["state"] == {"matched": ["color-rose"]} # echo, no sync
|