mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-25 14:21:21 +00:00
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>
86 lines
3.3 KiB
Python
86 lines
3.3 KiB
Python
"""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"] == []
|