"""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