mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 03:09:57 +00:00
feat(achievements): opt-in, privacy controls & data-min gate (epic PR2) (#591)
Sharing earned Feats on the (forthcoming) public wall is strictly opt-in,
default OFF, with a binding data-minimization contract.
- Onboarding (static/v3/profile.js): a new opt-in step (now a 5-step wizard)
after song-directory / before paths — publishes only display name + earned
Feats, never songs/skills/scores; off by default.
- Settings (plugins/achievements/settings.html, System tab via
settings.category): the same toggle + a "Remove me from the wall" button
(POST remove-me — wipes local synced state offline + enqueues removal).
- Core (server.py): achievements_enabled (bool, default false) in
_default_settings + /api/settings validation + _RESETTABLE_SETTINGS_KEYS;
mirrored to localStorage in app.js loadSettings().
- Data-minimization gate: engine.build_wall_payload is the single explicit-dict
serializer; key-set is EXACTLY {display_name, player_hash, achievement_id,
unlocked_at}, achievement_id always a Feat id. Enqueue is gated on
opted-in AND profile identity (reused player_hash); competency never
enqueues (integration law).
Verified natively: settings round-trip + validation + remove-me; opted-in
activity enqueues exactly one 4-field Feat payload; Playwright confirms the
5-step wizard + opt-in card (default unchecked), zero console errors.
29 plugin tests + new settings tests pass.
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
05dd3d227a
commit
287c23a532
@@ -0,0 +1,85 @@
|
||||
"""Data-minimization contract + opt-in gating (binding).
|
||||
|
||||
The outbound wall payload must be EXACTLY {display_name, player_hash,
|
||||
achievement_id, unlocked_at}; competency unlocks must never enqueue; and nothing
|
||||
enqueues unless the user opted in AND has a profile identity.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import engine
|
||||
import routes as ach_routes
|
||||
|
||||
|
||||
class _FakeMetaDB:
|
||||
def __init__(self, name="Ada", phash="deadbeefcafe"):
|
||||
self._p = {"display_name": name, "player_hash": phash}
|
||||
|
||||
def get_profile(self):
|
||||
return dict(self._p)
|
||||
|
||||
|
||||
def _make_client(tmp_path, *, opted_in, meta_db=None):
|
||||
if opted_in:
|
||||
(tmp_path / "config.json").write_text(json.dumps({"achievements_enabled": True}))
|
||||
app = FastAPI()
|
||||
ach_routes.setup(app, {"config_dir": str(tmp_path), "meta_db": meta_db})
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _queue_rows(tmp_path):
|
||||
db = sqlite3.connect(str(tmp_path / "achievements" / "achievements.db"))
|
||||
try:
|
||||
return [
|
||||
{"kind": k, "payload": p, "state": s}
|
||||
for (k, p, s) in db.execute("SELECT kind, payload, state FROM sync_queue")
|
||||
]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ── The pure serializer is the only gate ─────────────────────────────────────
|
||||
|
||||
def test_serializer_keyset_is_exactly_four():
|
||||
payload = engine.build_wall_payload("Ada", "hash", "notes_total", "2026-06-24T00:00:00Z")
|
||||
assert set(payload.keys()) == set(engine.WALL_PAYLOAD_KEYS)
|
||||
assert len(payload) == 4 # go red if a fifth field is ever added
|
||||
|
||||
|
||||
# ── End-to-end enqueue gating ────────────────────────────────────────────────
|
||||
|
||||
def test_opted_out_never_enqueues(tmp_path):
|
||||
client = _make_client(tmp_path, opted_in=False, meta_db=_FakeMetaDB())
|
||||
res = client.post("/api/plugins/achievements/activity", json={"notes": 100000}).json()
|
||||
assert "notes_total" in [u["id"] for u in res["unlocked"]] # feat did unlock
|
||||
assert _queue_rows(tmp_path) == [] # but nothing queued
|
||||
|
||||
|
||||
def test_opted_in_enqueues_exactly_one_four_field_payload(tmp_path):
|
||||
client = _make_client(tmp_path, opted_in=True, meta_db=_FakeMetaDB())
|
||||
client.post("/api/plugins/achievements/activity", json={"notes": 100000})
|
||||
rows = [r for r in _queue_rows(tmp_path) if r["kind"] == "unlock"]
|
||||
assert len(rows) == 1
|
||||
payload = json.loads(rows[0]["payload"])
|
||||
assert set(payload.keys()) == set(engine.WALL_PAYLOAD_KEYS)
|
||||
assert payload["achievement_id"] == "notes_total"
|
||||
assert payload["display_name"] == "Ada"
|
||||
|
||||
|
||||
def test_opted_in_without_identity_does_not_enqueue(tmp_path):
|
||||
client = _make_client(tmp_path, opted_in=True, meta_db=None)
|
||||
client.post("/api/plugins/achievements/activity", json={"notes": 100000})
|
||||
assert [r for r in _queue_rows(tmp_path) if r["kind"] == "unlock"] == []
|
||||
|
||||
|
||||
def test_competency_never_enqueues_even_opted_in(tmp_path):
|
||||
client = _make_client(tmp_path, opted_in=True, meta_db=_FakeMetaDB())
|
||||
client.post("/api/plugins/achievements/report-unlock", json={
|
||||
"id": "ascendant", "kind": "achievement", "category": "global", "tier": 1})
|
||||
assert [r for r in _queue_rows(tmp_path) if r["kind"] == "unlock"] == []
|
||||
Reference in New Issue
Block a user