mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 03:09:57 +00:00
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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
287c23a532
commit
d2569cc2a8
@@ -10,6 +10,17 @@ from fastapi.testclient import TestClient
|
||||
import routes as ach_routes
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_live_drain():
|
||||
# routes now defaults the wall URL to the live onrender service, so setup()
|
||||
# would auto-start the drain thread. Disable it for every test so no test
|
||||
# ever POSTs to production; the drain logic is exercised via _drain_once()
|
||||
# with an injected poster instead.
|
||||
ach_routes._WALL_URL = ""
|
||||
ach_routes._drain_started = False
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path):
|
||||
app = FastAPI()
|
||||
|
||||
@@ -54,6 +54,35 @@ def test_report_unlock_is_idempotent_and_tier_monotonic(client):
|
||||
assert higher["changed"] is True
|
||||
|
||||
|
||||
def test_witching_feat_unlocks_on_seventh_consecutive_night(client):
|
||||
# Regression: the derived witching_nights_run counter must NOT be pre-written
|
||||
# before the prev snapshot, or diff_unlocks never sees the fresh unlock.
|
||||
unlocked_ever = []
|
||||
for day in range(1, 8):
|
||||
res = client.post("/api/plugins/achievements/activity",
|
||||
json={"night_session": True, "night_date": "2026-06-%02d" % day}).json()
|
||||
unlocked_ever += [u["id"] for u in res["unlocked"]]
|
||||
assert "secret_witching" in unlocked_ever, "witching feat never reported as unlocked"
|
||||
feats = [f["id"] for f in client.get("/api/plugins/achievements/feats").json()["feats"]]
|
||||
assert "secret_witching" in feats
|
||||
|
||||
|
||||
def test_witching_not_unlocked_before_seven(client):
|
||||
for day in range(1, 7): # only 6 nights
|
||||
client.post("/api/plugins/achievements/activity",
|
||||
json={"night_session": True, "night_date": "2026-06-%02d" % day})
|
||||
feats = [f["id"] for f in client.get("/api/plugins/achievements/feats").json()["feats"]]
|
||||
assert "secret_witching" not in feats
|
||||
|
||||
|
||||
def test_chart_key_is_stable_not_builtin_hash(client):
|
||||
import hashlib
|
||||
import routes
|
||||
# Deterministic across processes (sha1-based), unlike the salted builtin hash().
|
||||
assert routes._chart_key("song.sloppak") == "chart_plays:" + hashlib.sha1(b"song.sloppak").hexdigest()[:16]
|
||||
assert routes._chart_key("a") != routes._chart_key("b")
|
||||
|
||||
|
||||
def test_report_criterion_counts_distinct(client):
|
||||
url = "/api/plugins/achievements/report-criterion"
|
||||
assert client.post(url, json={"criterion_id": "x", "token": "a"}).json()["count"] == 1
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user