feedBack/tests/plugins/achievements/test_sync.py
Byron Gamatos d2569cc2a8
feat(achievements): wall sync drain worker + review fixes (epic PR3) (#592)
* feat(achievements): wall sync drain worker (epic PR3, client side)

Background dead-letter worker that POSTs queued Feat unlocks/removals to the
hosted feedback-achievements wall. Idle unless FEEDBACK_ACHIEVEMENTS_WALL_URL
is set; uses requests + the client-token header (mirrors lyrics_transcribe).

Dead-letter, never drop (pure engine.drain_decision):
  network err / 429 / 5xx -> keep pending (retry)
  other 4xx               -> dead_letter (diagnosable, replayable)
  2xx                     -> delete on server ack
remove-me enqueues a wall removal keyed by the reused player_hash.

Verified by an end-to-end staging round-trip (earn a Feat -> drains onto the
wall with name + short hash -> remove-me -> wall empties) with no IP in tables
or access logs. 42 plugin tests pass (test_sync.py adds the decision table +
ack/retry/dead-letter retention + four-field on-the-wire payload).

The hosted service lives in the new feedback-achievements repo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(achievements): address local review findings (epic)

Bugs caught in the pre-merge review loop:

- secret_witching Feat was DEAD: post_activity wrote witching_nights_run to the
  DB before snapshotting prev_tiers, so diff_unlocks never saw the fresh unlock.
  Fold the run into the activity delta instead (same asymmetry chart_encore
  uses) so the 7th-night unlock is detected. +regression tests.
- chart_encore broke across restarts: per-chart counter keyed on abs(hash(str)),
  which Python salts per-process (PYTHONHASHSEED). Use a stable sha1 digest so
  the same chart accumulates across sessions. +regression test.
- Bounded the per-activity counter read: _read_counters no longer pulls the
  unbounded chart_plays:* rows (they're bumped/read individually).
- screen.js: gate note:hit/miss on an active-song flag so tuner/calibration note
  events can't inflate Feats or flush a phantom chart:null session.
- screen.js: P-III — prefix the plugin localStorage key (achievements:profile-cat).
- screen.js: extract the duplicated local-ISO-date helper.

45 plugin tests pass (3 new). Wall-side review fixes are in the
feedback-achievements repo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(achievements): default the drain worker to the hosted wall

Point FEEDBACK_ACHIEVEMENTS_WALL_URL's default at the live got-feedback wall
(https://feedback-achievements.onrender.com) so the drain worker targets it out
of the box; still env-overridable for self-hosting/staging. Nothing publishes
unless the user opted in AND has a profile identity, so a default URL alone
sends nothing.

Tests disable the default (autouse fixture) so no test ever POSTs to production;
drain logic is covered via _drain_once() with an injected poster. 45 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 17:01:48 +02:00

85 lines
3.0 KiB
Python

"""Wall-sync drain worker — dead-letter state machine (never drop)."""
import json
import sqlite3
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
import engine
import routes as ach_routes
# ── Pure decision table ──────────────────────────────────────────────────────
@pytest.mark.parametrize("status,expected", [
(200, "ack"), (201, "ack"),
(None, "retry"), # network error
(429, "retry"), # backoff
(500, "retry"), (503, "retry"), # transient server-side
(400, "dead"), (404, "dead"), (422, "dead"), # client 4xx → dead-letter
])
def test_drain_decision(status, expected):
assert engine.drain_decision(status) == expected
# ── Worker over a seeded queue ───────────────────────────────────────────────
class _FakeMetaDB:
def get_profile(self):
return {"display_name": "Ada", "player_hash": "deadbeefcafe"}
@pytest.fixture
def opted_in_client(tmp_path):
(tmp_path / "config.json").write_text(json.dumps({"achievements_enabled": True}))
app = FastAPI()
ach_routes.setup(app, {"config_dir": str(tmp_path), "meta_db": _FakeMetaDB()})
c = TestClient(app)
c._tmp = tmp_path
return c
def _queue(tmp_path):
db = sqlite3.connect(str(tmp_path / "achievements" / "achievements.db"))
try:
return [(i, k, s) for (i, k, s) in db.execute("SELECT id, kind, state FROM sync_queue")]
finally:
db.close()
def test_ack_deletes_row(opted_in_client):
opted_in_client.post("/api/plugins/achievements/activity", json={"notes": 100000})
assert len(_queue(opted_in_client._tmp)) == 1
ach_routes._drain_once(post_fn=lambda kind, payload: 200)
assert _queue(opted_in_client._tmp) == [] # acked → gone
def test_network_error_keeps_pending(opted_in_client):
opted_in_client.post("/api/plugins/achievements/activity", json={"notes": 100000})
ach_routes._drain_once(post_fn=lambda kind, payload: None)
rows = _queue(opted_in_client._tmp)
assert len(rows) == 1 and rows[0][2] == "pending" # retained for retry
def test_4xx_dead_letters_but_retains(opted_in_client):
opted_in_client.post("/api/plugins/achievements/activity", json={"notes": 100000})
ach_routes._drain_once(post_fn=lambda kind, payload: 400)
rows = _queue(opted_in_client._tmp)
assert len(rows) == 1 and rows[0][2] == "dead_letter" # diagnosable, not dropped
def test_drain_sends_exact_four_field_payload(opted_in_client):
opted_in_client.post("/api/plugins/achievements/activity", json={"notes": 100000})
captured = {}
def fake_post(kind, payload):
captured["kind"] = kind
captured["payload"] = payload
return 200
ach_routes._drain_once(post_fn=fake_post)
assert captured["kind"] == "unlock"
assert set(captured["payload"].keys()) == set(engine.WALL_PAYLOAD_KEYS)