Eric's Character Trove problem, both halves. The Titles add panel gains "search BGG and tick them off": one API search (rate-limited, cached) returns the whole family as a checklist, already-cataloged ids greyed out, and every ticked result lands as an APPROVED match row plus a title addition — the human picked it off BGG's own list, so resolve has nothing left to derive. A pick whose name matches an undecided photo line decides THAT line (photos kept) instead of duplicating it; BGG's true name twins (two games both called "Citadels") skip with an honest message rather than fusing. And the half Eric spotted mid-build: containment is real data, not a convention. A stored_in column on the match row (the container's bgg_id — human curation, riding the same durable CSV as dedupe_veto) is set by the pick panel's "they all live inside" selector, flows through enrich onto games.json, and surfaces both directions in the Library — "where it lives" on the content, "in this box" on the container. The dims report excludes contained games from the Kallax unknowns and counts them separately: a game with no box of its own has no shelf space to plan. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
1543 lines
53 KiB
Python
1543 lines
53 KiB
Python
"""Web review UI tests via FastAPI's TestClient — no server, no network,
|
|
no live BGG (the injected client 401s on any cache miss)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import json
|
|
|
|
import httpx
|
|
from fastapi.testclient import TestClient
|
|
|
|
from bggpipe.bgg_client import BGGClient, cache_key
|
|
from bggpipe.config import Config
|
|
from bggpipe.resolve import read_matches, write_matches
|
|
from bggpipe.webreview import create_app, load_thumbnails
|
|
|
|
CITADELS_CANDIDATES = json.dumps(
|
|
[
|
|
{
|
|
"bgg_id": 478,
|
|
"name": "Citadels",
|
|
"year": 2000,
|
|
"type": "boardgame",
|
|
"owned": 85000,
|
|
"rank": 250,
|
|
},
|
|
{
|
|
"bgg_id": 205398,
|
|
"name": "Citadels",
|
|
"year": 2016,
|
|
"type": "boardgame",
|
|
"owned": 24000,
|
|
"rank": 400,
|
|
},
|
|
]
|
|
)
|
|
VERSION_CANDIDATES = json.dumps(
|
|
[
|
|
{
|
|
"version_id": 111,
|
|
"name": "First edition",
|
|
"year": 1975,
|
|
"publishers": ["TSR"],
|
|
"languages": ["English"],
|
|
"score": 3,
|
|
},
|
|
{
|
|
"version_id": 222,
|
|
"name": "Second edition",
|
|
"year": 1980,
|
|
"publishers": ["TSR"],
|
|
"languages": ["English"],
|
|
"score": 3,
|
|
},
|
|
]
|
|
)
|
|
|
|
|
|
def _row(**overrides) -> dict:
|
|
row = {
|
|
"title_raw": "",
|
|
"bgg_id": "",
|
|
"bgg_name": "",
|
|
"year": "",
|
|
"type": "",
|
|
"match_status": "auto",
|
|
"version_id": "",
|
|
"version_name": "",
|
|
"version_status": "version_unknown",
|
|
"candidates_json": "[]",
|
|
"version_candidates_json": "[]",
|
|
"source_photos": "shelf.jpg",
|
|
}
|
|
row.update(overrides)
|
|
return row
|
|
|
|
|
|
def make_cfg(tmp_path) -> Config:
|
|
cfg = Config(data_dir=tmp_path / "data", photos_dir=tmp_path / "photos")
|
|
cfg.photos_dir.mkdir(parents=True)
|
|
(cfg.photos_dir / "shelf.jpg").write_bytes(b"\xff\xd8\xff\xdbfakejpeg")
|
|
write_matches(
|
|
cfg.matches_path,
|
|
[
|
|
_row(
|
|
title_raw="Citadels",
|
|
match_status="ambiguous",
|
|
candidates_json=CITADELS_CANDIDATES,
|
|
),
|
|
_row(title_raw="Mystery", match_status="unmatched"),
|
|
_row(
|
|
title_raw="Dungeon!",
|
|
match_status="auto",
|
|
bgg_id="1339",
|
|
bgg_name="Dungeon!",
|
|
version_status="version_ambiguous",
|
|
version_candidates_json=VERSION_CANDIDATES,
|
|
),
|
|
],
|
|
)
|
|
cfg.titles_path.write_text(
|
|
json.dumps(
|
|
[
|
|
{
|
|
"title_raw": "Citadels",
|
|
"confidence": "high",
|
|
"publisher_hint": "Fantasy Flight",
|
|
"edition_hint": "",
|
|
"source_photos": ["shelf.jpg"],
|
|
},
|
|
{
|
|
# extracted but never resolved (no BGG token yet)
|
|
"title_raw": "Fresh Off The Shelf",
|
|
"confidence": "high",
|
|
"source_photos": ["shelf.jpg"],
|
|
},
|
|
]
|
|
)
|
|
)
|
|
cfg.unidentified_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"shelf.jpg": [
|
|
{
|
|
"location": "top shelf, far left",
|
|
"partial_text": "EMP",
|
|
"art_notes": "black box, gold letters",
|
|
}
|
|
]
|
|
}
|
|
)
|
|
)
|
|
return cfg
|
|
|
|
|
|
def unauthorized_client(tmp_path) -> BGGClient:
|
|
return BGGClient(
|
|
cache_dir=tmp_path / "no_cache",
|
|
transport=httpx.MockTransport(
|
|
lambda req: httpx.Response(401, text="Unauthorized")
|
|
),
|
|
)
|
|
|
|
|
|
def make_client(tmp_path) -> tuple[TestClient, Config]:
|
|
cfg = make_cfg(tmp_path)
|
|
app = create_app(cfg, client=unauthorized_client(tmp_path))
|
|
return TestClient(app), cfg
|
|
|
|
|
|
def test_index_serves_page(tmp_path):
|
|
web, _ = make_client(tmp_path)
|
|
response = web.get("/")
|
|
assert response.status_code == 200
|
|
assert "bggpipe" in response.text
|
|
|
|
|
|
def test_state_lists_pending_versions_and_tickets(tmp_path):
|
|
web, _ = make_client(tmp_path)
|
|
state = web.get("/api/state").json()
|
|
assert [r["title_raw"] for r in state["pending"]] == ["Citadels", "Mystery"]
|
|
citadels = state["pending"][0]
|
|
assert citadels["cues"]["publisher"] == "Fantasy Flight"
|
|
assert citadels["photos"] == ["shelf.jpg"]
|
|
assert citadels["candidates"][0]["thumbnail"] is None # stub cache: placeholder
|
|
assert [v["title_raw"] for v in state["versions"]] == ["Dungeon!"]
|
|
assert state["unidentified"][0]["location"] == "top shelf, far left"
|
|
assert state["summary"]["total"] == 3
|
|
|
|
|
|
def test_pick_candidate_persists(tmp_path):
|
|
web, cfg = make_client(tmp_path)
|
|
state = web.post(
|
|
"/api/decision",
|
|
json={
|
|
"title_raw": "Citadels",
|
|
"source_photos": "shelf.jpg",
|
|
"action": "pick",
|
|
"bgg_id": 205398,
|
|
},
|
|
).json()
|
|
assert [r["title_raw"] for r in state["pending"]] == ["Mystery"]
|
|
saved = {r["title_raw"]: r for r in read_matches(cfg.matches_path)}
|
|
assert saved["Citadels"]["match_status"] == "approved"
|
|
assert saved["Citadels"]["bgg_id"] == "205398"
|
|
|
|
|
|
def test_manual_id_degrades_without_token(tmp_path):
|
|
web, cfg = make_client(tmp_path)
|
|
response = web.post(
|
|
"/api/decision",
|
|
json={
|
|
"title_raw": "Mystery",
|
|
"source_photos": "shelf.jpg",
|
|
"action": "manual",
|
|
"bgg_id": 99999,
|
|
},
|
|
)
|
|
assert response.status_code == 200
|
|
saved = {r["title_raw"]: r for r in read_matches(cfg.matches_path)}
|
|
assert saved["Mystery"]["match_status"] == "approved"
|
|
assert saved["Mystery"]["bgg_id"] == "99999"
|
|
assert saved["Mystery"]["bgg_name"] == "" # lookup blocked, id recorded
|
|
|
|
|
|
def test_reject_and_bad_pick(tmp_path):
|
|
web, cfg = make_client(tmp_path)
|
|
web.post(
|
|
"/api/decision",
|
|
json={"title_raw": "Mystery", "source_photos": "shelf.jpg", "action": "reject"},
|
|
)
|
|
saved = {r["title_raw"]: r for r in read_matches(cfg.matches_path)}
|
|
assert saved["Mystery"]["match_status"] == "rejected"
|
|
bad = web.post(
|
|
"/api/decision",
|
|
json={
|
|
"title_raw": "Citadels",
|
|
"source_photos": "shelf.jpg",
|
|
"action": "pick",
|
|
"bgg_id": 42,
|
|
},
|
|
)
|
|
assert bad.status_code == 400
|
|
|
|
|
|
def test_version_pick_and_unknown(tmp_path):
|
|
web, cfg = make_client(tmp_path)
|
|
state = web.post(
|
|
"/api/version",
|
|
json={
|
|
"title_raw": "Dungeon!",
|
|
"source_photos": "shelf.jpg",
|
|
"action": "pick",
|
|
"version_id": 222,
|
|
},
|
|
).json()
|
|
assert state["versions"] == []
|
|
saved = {r["title_raw"]: r for r in read_matches(cfg.matches_path)}
|
|
assert saved["Dungeon!"]["version_status"] == "version_approved"
|
|
assert saved["Dungeon!"]["version_id"] == "222"
|
|
assert state["summary"]["version_updates"] == 1
|
|
|
|
|
|
def test_dismiss_persists_across_restarts(tmp_path):
|
|
web, cfg = make_client(tmp_path)
|
|
state = web.post(
|
|
"/api/dismiss",
|
|
json={
|
|
"photo": "shelf.jpg",
|
|
"location": "top shelf, far left",
|
|
"partial_text": "EMP",
|
|
"art_notes": "black box, gold letters",
|
|
},
|
|
).json()
|
|
assert state["unidentified"] == []
|
|
# a brand-new app instance (fresh server) still honors the dismissal
|
|
web2 = TestClient(create_app(cfg, client=unauthorized_client(tmp_path)))
|
|
assert web2.get("/api/state").json()["unidentified"] == []
|
|
|
|
|
|
def test_photo_serving_is_locked_down(tmp_path):
|
|
web, _ = make_client(tmp_path)
|
|
assert web.get("/photos/shelf.jpg").status_code == 200
|
|
assert web.get("/photos/nope.jpg").status_code == 404
|
|
assert web.get("/photos/..%2Fdata%2Fmatches.csv").status_code == 404
|
|
|
|
|
|
def test_thumbnails_come_from_cached_thing_xml(tmp_path):
|
|
cache = tmp_path / "cache"
|
|
cache.mkdir()
|
|
key = cache_key("thing", {"id": "478", "stats": "1"})
|
|
(cache / key).write_text(
|
|
'<items><item type="boardgame" id="478">'
|
|
"<thumbnail>https://cf.example/citadels.jpg</thumbnail>"
|
|
'<name type="primary" value="Citadels"/></item></items>'
|
|
)
|
|
thumbs = load_thumbnails(cache)
|
|
assert thumbs == {478: "https://cf.example/citadels.jpg"}
|
|
|
|
|
|
def test_catalog_and_unresolved_backlog_are_visible(tmp_path):
|
|
"""Extracted-but-unresolved titles must not vanish from the UI, and the
|
|
done state must not claim diff-readiness while they exist."""
|
|
web, _ = make_client(tmp_path)
|
|
state = web.get("/api/state").json()
|
|
assert state["summary"]["extracted"] == 2
|
|
assert state["summary"]["unresolved"] == 1
|
|
by_title = {c["title_raw"]: c for c in state["catalog"]}
|
|
assert by_title["Fresh Off The Shelf"]["status"] == "awaiting_resolve"
|
|
assert by_title["Citadels"]["status"] == "ambiguous"
|
|
|
|
|
|
def test_merge_veto_roundtrip(tmp_path):
|
|
cfg = make_cfg(tmp_path)
|
|
rows = read_matches(cfg.matches_path)
|
|
rows.append(
|
|
_row(
|
|
title_raw="Jokin Ha...",
|
|
match_status="merged",
|
|
bgg_id="193621",
|
|
bgg_name="Joking Hazard",
|
|
source_photos="shelf.jpg",
|
|
)
|
|
)
|
|
rows[-1]["merged_into"] = "Joking Hazard"
|
|
write_matches(cfg.matches_path, rows)
|
|
web = TestClient(create_app(cfg, client=unauthorized_client(tmp_path)))
|
|
|
|
state = web.get("/api/state").json()
|
|
(merge,) = state["merges"]
|
|
assert merge["title_raw"] == "Jokin Ha..."
|
|
assert merge["merged_into"] == "Joking Hazard"
|
|
|
|
state = web.post(
|
|
"/api/veto-merge",
|
|
json={"title_raw": "Jokin Ha...", "source_photos": "shelf.jpg"},
|
|
).json()
|
|
assert state["merges"] == []
|
|
saved = {r["title_raw"]: r for r in read_matches(cfg.matches_path)}
|
|
assert saved["Jokin Ha..."]["match_status"] == "approved"
|
|
assert saved["Jokin Ha..."]["merged_into"] == ""
|
|
|
|
# veto on a non-merged row is refused
|
|
bad = web.post(
|
|
"/api/veto-merge",
|
|
json={"title_raw": "Jokin Ha...", "source_photos": "shelf.jpg"},
|
|
)
|
|
assert bad.status_code == 400
|
|
|
|
|
|
def test_static_assets_served_with_allowlist(tmp_path):
|
|
web, _ = make_client(tmp_path)
|
|
logo = web.get("/static/logo.jpg")
|
|
assert logo.status_code == 200
|
|
assert logo.headers["content-type"] == "image/jpeg"
|
|
assert web.get("/static/favicon.png").status_code == 200
|
|
assert web.get("/static/nope.js").status_code == 404
|
|
assert web.get("/static/..%2Ftemplates%2Freview.html").status_code == 404
|
|
|
|
|
|
# -- live data reload ---------------------------------------------------
|
|
|
|
|
|
def test_state_picks_up_external_matches_rewrite(tmp_path):
|
|
web, cfg = make_client(tmp_path)
|
|
assert web.get("/api/state").json()["summary"]["total"] == 3
|
|
|
|
rows = read_matches(cfg.matches_path)
|
|
rows.append(_row(title_raw="Newcomer", match_status="unmatched"))
|
|
write_matches(cfg.matches_path, rows)
|
|
|
|
fresh = web.get("/api/state").json()
|
|
assert fresh["summary"]["total"] == 4
|
|
assert any(p["title_raw"] == "Newcomer" for p in fresh["pending"])
|
|
|
|
|
|
def test_decision_lands_on_externally_added_row(tmp_path):
|
|
# A row that did not exist at server start is still reviewable.
|
|
web, cfg = make_client(tmp_path)
|
|
rows = read_matches(cfg.matches_path)
|
|
rows.append(_row(title_raw="Newcomer", match_status="unmatched"))
|
|
write_matches(cfg.matches_path, rows)
|
|
|
|
res = web.post(
|
|
"/api/decision",
|
|
json={
|
|
"title_raw": "Newcomer",
|
|
"source_photos": "shelf.jpg",
|
|
"action": "reject",
|
|
},
|
|
)
|
|
assert res.status_code == 200
|
|
saved = {r["title_raw"]: r for r in read_matches(cfg.matches_path)}
|
|
assert saved["Newcomer"]["match_status"] == "rejected"
|
|
|
|
|
|
def test_own_saves_do_not_count_as_external_changes(tmp_path):
|
|
from bggpipe.review import ReviewSession
|
|
|
|
cfg = make_cfg(tmp_path)
|
|
session = ReviewSession(cfg, client=unauthorized_client(tmp_path))
|
|
row = next(r for r in session.rows if r["title_raw"] == "Mystery")
|
|
session.decide_reject(row)
|
|
assert session.reload_if_changed() is False # my own write
|
|
write_matches(cfg.matches_path, session.rows + [_row(title_raw="X")])
|
|
assert session.reload_if_changed() is True # someone else's
|
|
assert any(r["title_raw"] == "X" for r in session.rows)
|
|
|
|
|
|
def test_session_warnings_surface_in_state(tmp_path):
|
|
web, cfg = make_client(tmp_path)
|
|
# a manual id triggers a lookup against the 401-ing client: the session
|
|
# degrades and the warning must reach the payload (the session console
|
|
# is a StringIO here — this is the only way the user ever sees it)
|
|
res = web.post(
|
|
"/api/decision",
|
|
json={
|
|
"title_raw": "Mystery",
|
|
"source_photos": "shelf.jpg",
|
|
"action": "manual",
|
|
"bgg_id": 42,
|
|
},
|
|
)
|
|
assert res.status_code == 200
|
|
warnings = res.json()["warnings"]
|
|
assert any("couldn't look up id 42" in w for w in warnings)
|
|
assert warnings == web.get("/api/state").json()["warnings"]
|
|
|
|
|
|
def test_duplicate_rows_are_individually_decidable_via_row_ix(tmp_path):
|
|
# two editions of one game in one photo: byte-identical rows. The
|
|
# ordinal must land each decision on its own row.
|
|
from bggpipe.resolve import write_matches as write_m
|
|
|
|
cfg = make_cfg(tmp_path)
|
|
dup = _row(title_raw="Twins", match_status="unmatched")
|
|
write_m(cfg.matches_path, [dict(dup), dict(dup)])
|
|
web = TestClient(create_app(cfg, client=unauthorized_client(tmp_path)))
|
|
|
|
pending = web.get("/api/state").json()["pending"]
|
|
assert [p["title_raw"] for p in pending] == ["Twins", "Twins"]
|
|
assert pending[0]["row_ix"] != pending[1]["row_ix"] # identity, not ==
|
|
|
|
second = pending[1]
|
|
web.post(
|
|
"/api/decision",
|
|
json={
|
|
"title_raw": second["title_raw"],
|
|
"source_photos": second["source_photos"],
|
|
"row_ix": second["row_ix"],
|
|
"action": "reject",
|
|
},
|
|
)
|
|
rows = read_matches(cfg.matches_path)
|
|
assert [r["match_status"] for r in rows] == ["unmatched", "rejected"]
|
|
|
|
|
|
# -- catalog curation: rowless splits and title edits ---------------------
|
|
|
|
|
|
def _rowless_wizwar(cfg) -> None:
|
|
"""Add a multi-photo extracted title with NO matches row (still awaiting
|
|
resolve — the Wiz-War shape)."""
|
|
titles = json.loads(cfg.titles_path.read_text())
|
|
titles.append(
|
|
{
|
|
"title_raw": "Wiz-War",
|
|
"confidence": "high",
|
|
"publisher_hint": "Fantasy Flight Games",
|
|
"edition_hint": "",
|
|
"source_photos": ["shelf.jpg", "shelf2.jpg"],
|
|
}
|
|
)
|
|
cfg.titles_path.write_text(json.dumps(titles))
|
|
|
|
|
|
def test_rowless_multiphoto_title_is_splittable(tmp_path):
|
|
cfg = make_cfg(tmp_path)
|
|
_rowless_wizwar(cfg)
|
|
web = TestClient(create_app(cfg, client=unauthorized_client(tmp_path)))
|
|
line = next(
|
|
c
|
|
for c in web.get("/api/state").json()["catalog"]
|
|
if c["title_raw"] == "Wiz-War"
|
|
)
|
|
assert line["status"] == "awaiting_resolve"
|
|
assert line["can_split"] is True
|
|
|
|
res = web.post(
|
|
"/api/split",
|
|
json={
|
|
"title_raw": "Wiz-War",
|
|
"source_photos": "shelf.jpg;shelf2.jpg",
|
|
"row_ix": None,
|
|
},
|
|
)
|
|
assert res.status_code == 200
|
|
copies = [c for c in res.json()["catalog"] if c["title_raw"] == "Wiz-War"]
|
|
assert [c["photos"] for c in copies] == [["shelf.jpg"], ["shelf2.jpg"]]
|
|
assert all(c["can_split"] is False for c in copies)
|
|
# the decision persisted: the store holds it (photo-scoped) and
|
|
# titles.json is split
|
|
(record,) = json.loads(cfg.title_splits_path.read_text())
|
|
assert record == {"title": "Wiz-War", "photos": ["shelf.jpg", "shelf2.jpg"]}
|
|
per_photo = [
|
|
e["source_photos"]
|
|
for e in json.loads(cfg.titles_path.read_text())
|
|
if e["title_raw"] == "Wiz-War"
|
|
]
|
|
assert per_photo == [["shelf.jpg"], ["shelf2.jpg"]]
|
|
|
|
|
|
def test_split_still_requires_multiple_photos(tmp_path):
|
|
web, _ = make_client(tmp_path)
|
|
res = web.post(
|
|
"/api/split",
|
|
json={
|
|
"title_raw": "Fresh Off The Shelf",
|
|
"source_photos": "shelf.jpg",
|
|
"row_ix": None,
|
|
},
|
|
)
|
|
assert res.status_code == 400
|
|
|
|
|
|
def test_edit_title_corrects_read_and_requeues_row(tmp_path):
|
|
web, cfg = make_client(tmp_path)
|
|
# Citadels has an ambiguous matches row; correcting its read must drop
|
|
# the stale row (it was searched with the old data) and store the fix
|
|
res = web.post(
|
|
"/api/edit-title",
|
|
json={
|
|
"title_raw": "Citadels",
|
|
"source_photos": "shelf.jpg",
|
|
"title_new": "Citadels: Dark City",
|
|
"edition": "2nd Edition",
|
|
"publisher": "",
|
|
"year": "2004",
|
|
},
|
|
)
|
|
assert res.status_code == 200
|
|
state = res.json()
|
|
titles = [c["title_raw"] for c in state["catalog"]]
|
|
assert "Citadels: Dark City" in titles and "Citadels" not in titles
|
|
corrected = next(
|
|
e
|
|
for e in json.loads(cfg.titles_path.read_text())
|
|
if e["title_raw"] == "Citadels: Dark City"
|
|
)
|
|
assert corrected["year_hint"] == 2004
|
|
assert corrected["edition_hint"] == "2nd Edition"
|
|
assert corrected["publisher_hint"] == "" # empty string cleared the cue
|
|
assert not corrected.get("language_hint") # untouched (was unset)
|
|
assert not any(r["title_raw"] == "Citadels" for r in read_matches(cfg.matches_path))
|
|
(record,) = json.loads(cfg.title_edits_path.read_text())
|
|
assert record["match"] == "Citadels"
|
|
assert record["publisher_hint"] == ""
|
|
|
|
|
|
def test_edit_title_rejects_empty_and_noop(tmp_path):
|
|
web, _ = make_client(tmp_path)
|
|
assert (
|
|
web.post(
|
|
"/api/edit-title",
|
|
json={
|
|
"title_raw": "Citadels",
|
|
"source_photos": "shelf.jpg",
|
|
"title_new": " ",
|
|
},
|
|
).status_code
|
|
== 400
|
|
)
|
|
assert (
|
|
web.post(
|
|
"/api/edit-title",
|
|
json={"title_raw": "Citadels", "source_photos": "shelf.jpg"},
|
|
).status_code
|
|
== 400
|
|
)
|
|
assert (
|
|
web.post(
|
|
"/api/edit-title",
|
|
json={
|
|
"title_raw": "No Such Game",
|
|
"source_photos": "shelf.jpg",
|
|
"title_new": "X",
|
|
},
|
|
).status_code
|
|
== 404
|
|
)
|
|
|
|
|
|
def test_edit_preserves_vetoed_rows_and_renames_them(tmp_path):
|
|
cfg = make_cfg(tmp_path)
|
|
rows = read_matches(cfg.matches_path)
|
|
rows.append(
|
|
_row(
|
|
title_raw="Citadels",
|
|
match_status="approved",
|
|
bgg_id="478",
|
|
source_photos="shelf2.jpg",
|
|
dedupe_veto="1",
|
|
)
|
|
)
|
|
write_matches(cfg.matches_path, rows)
|
|
web = TestClient(create_app(cfg, client=unauthorized_client(tmp_path)))
|
|
res = web.post(
|
|
"/api/edit-title",
|
|
json={
|
|
"title_raw": "Citadels",
|
|
"source_photos": "shelf.jpg",
|
|
"title_new": "Citadels (2016)",
|
|
},
|
|
)
|
|
assert res.status_code == 200
|
|
after = read_matches(cfg.matches_path)
|
|
# the human-vetoed row survives the cull, renamed to follow the entry;
|
|
# the unvetoed ambiguous row is requeued (dropped)
|
|
veto = [r for r in after if r.get("dedupe_veto")]
|
|
assert len(veto) == 1
|
|
assert veto[0]["title_raw"] == "Citadels (2016)"
|
|
assert veto[0]["match_status"] == "approved"
|
|
assert not any(
|
|
r["title_raw"] == "Citadels" and not r.get("dedupe_veto") for r in after
|
|
)
|
|
|
|
|
|
def test_drop_rows_photo_narrowing_spares_other_copy(tmp_path):
|
|
from rich.console import Console
|
|
|
|
from bggpipe.review import ReviewSession
|
|
|
|
cfg = make_cfg(tmp_path)
|
|
write_matches(
|
|
cfg.matches_path,
|
|
[
|
|
_row(title_raw="Wiz-War", match_status="auto", source_photos="a.jpg"),
|
|
_row(title_raw="Wiz-War", match_status="auto", source_photos="b.jpg"),
|
|
],
|
|
)
|
|
session = ReviewSession(
|
|
cfg,
|
|
console=Console(file=io.StringIO()),
|
|
input_fn=lambda prompt: "",
|
|
client=unauthorized_client(tmp_path),
|
|
)
|
|
assert session.drop_rows("Wiz-War", ["a.jpg"]) == 1
|
|
(survivor,) = read_matches(cfg.matches_path)
|
|
assert survivor["source_photos"] == "b.jpg"
|
|
|
|
|
|
def test_split_and_edit_refuse_while_pipeline_rewrites(tmp_path):
|
|
import threading
|
|
|
|
from bggpipe.jobs import JobRunner
|
|
|
|
cfg = make_cfg(tmp_path)
|
|
_rowless_wizwar(cfg)
|
|
release = threading.Event()
|
|
started = threading.Event()
|
|
|
|
def blocking_extract():
|
|
started.set()
|
|
release.wait(timeout=5)
|
|
|
|
jobs = JobRunner()
|
|
web = TestClient(
|
|
create_app(
|
|
cfg,
|
|
client=unauthorized_client(tmp_path),
|
|
stages={"extract": blocking_extract},
|
|
jobs=jobs,
|
|
)
|
|
)
|
|
assert web.post("/api/run/extract").status_code == 200
|
|
assert started.wait(timeout=5)
|
|
try:
|
|
split = web.post(
|
|
"/api/split",
|
|
json={
|
|
"title_raw": "Wiz-War",
|
|
"source_photos": "shelf.jpg;shelf2.jpg",
|
|
"row_ix": None,
|
|
},
|
|
)
|
|
edit = web.post(
|
|
"/api/edit-title",
|
|
json={
|
|
"title_raw": "Wiz-War",
|
|
"source_photos": "shelf.jpg;shelf2.jpg",
|
|
"title_new": "Wiz-War!",
|
|
},
|
|
)
|
|
finally:
|
|
release.set()
|
|
assert split.status_code == 409
|
|
assert edit.status_code == 409
|
|
# neither curation store was written under the in-flight rewrite
|
|
assert not cfg.title_splits_path.exists()
|
|
assert not cfg.title_edits_path.exists()
|
|
|
|
|
|
def test_renaming_a_split_copy_keeps_split_protection(tmp_path):
|
|
cfg = make_cfg(tmp_path)
|
|
_rowless_wizwar(cfg)
|
|
web = TestClient(create_app(cfg, client=unauthorized_client(tmp_path)))
|
|
assert (
|
|
web.post(
|
|
"/api/split",
|
|
json={
|
|
"title_raw": "Wiz-War",
|
|
"source_photos": "shelf.jpg;shelf2.jpg",
|
|
"row_ix": None,
|
|
},
|
|
).status_code
|
|
== 200
|
|
)
|
|
# rename BOTH copies to the same corrected title, one at a time — the
|
|
# store must keep protecting them or the rebuild re-merges the copies
|
|
for photo in ("shelf.jpg", "shelf2.jpg"):
|
|
assert (
|
|
web.post(
|
|
"/api/edit-title",
|
|
json={
|
|
"title_raw": "Wiz-War",
|
|
"source_photos": photo,
|
|
"title_new": "Wiz War 2000",
|
|
},
|
|
).status_code
|
|
== 200
|
|
)
|
|
per_photo = [
|
|
e["source_photos"]
|
|
for e in json.loads(cfg.titles_path.read_text())
|
|
if e["title_raw"] == "Wiz War 2000"
|
|
]
|
|
assert per_photo == [["shelf.jpg"], ["shelf2.jpg"]] # still two copies
|
|
|
|
|
|
def test_single_photo_rowless_entry_is_not_splittable(tmp_path):
|
|
web, _ = make_client(tmp_path)
|
|
line = next(
|
|
c
|
|
for c in web.get("/api/state").json()["catalog"]
|
|
if c["title_raw"] == "Fresh Off The Shelf"
|
|
)
|
|
assert line["can_split"] is False
|
|
|
|
|
|
def test_remove_title_is_durable_and_drops_vetoed_rows(tmp_path):
|
|
cfg = make_cfg(tmp_path)
|
|
rows = read_matches(cfg.matches_path)
|
|
rows.append(
|
|
_row(
|
|
title_raw="Citadels",
|
|
match_status="approved",
|
|
bgg_id="478",
|
|
source_photos="shelf.jpg",
|
|
dedupe_veto="1",
|
|
)
|
|
)
|
|
write_matches(cfg.matches_path, rows)
|
|
web = TestClient(create_app(cfg, client=unauthorized_client(tmp_path)))
|
|
res = web.post(
|
|
"/api/remove-title",
|
|
json={"title_raw": "Citadels", "source_photos": "shelf.jpg"},
|
|
)
|
|
assert res.status_code == 200
|
|
assert "Citadels" not in [c["title_raw"] for c in res.json()["catalog"]]
|
|
# removal is explicit: the veto'd row goes too (unlike edits)
|
|
assert not any(r["title_raw"] == "Citadels" for r in read_matches(cfg.matches_path))
|
|
(record,) = json.loads(cfg.title_removals_path.read_text())
|
|
assert record == {"title": "Citadels", "photos": ["shelf.jpg"]}
|
|
# durable: a fresh replay keeps it gone; the sibling title is untouched
|
|
from bggpipe.extract import replay_titles
|
|
|
|
replay_titles(cfg)
|
|
titles = {e["title_raw"] for e in json.loads(cfg.titles_path.read_text())}
|
|
assert titles == {"Fresh Off The Shelf"}
|
|
# unknown titles still 404
|
|
assert (
|
|
web.post(
|
|
"/api/remove-title",
|
|
json={"title_raw": "No Such Game", "source_photos": "x.jpg"},
|
|
).status_code
|
|
== 404
|
|
)
|
|
|
|
|
|
def test_help_page_serves_and_is_in_nav(tmp_path):
|
|
web, _ = make_client(tmp_path)
|
|
res = web.get("/help")
|
|
assert res.status_code == 200
|
|
assert "Fixing the titles" in res.text
|
|
# every page's nav includes the Help entry
|
|
assert 'href="/help"' in web.get("/").text
|
|
|
|
|
|
def test_shaky_reads_badge_counts_and_drains_on_edit(tmp_path):
|
|
cfg = make_cfg(tmp_path)
|
|
titles = json.loads(cfg.titles_path.read_text())
|
|
titles.append(
|
|
{
|
|
"title_raw": "Hebarceos",
|
|
"confidence": "low",
|
|
"source_photos": ["shelf.jpg"],
|
|
}
|
|
)
|
|
cfg.titles_path.write_text(json.dumps(titles))
|
|
web = TestClient(create_app(cfg, client=unauthorized_client(tmp_path)))
|
|
assert web.get("/api/pipeline").json()["shaky_reads"] == 1
|
|
# an edit means a human read the line: it is verified, the badge drains
|
|
assert (
|
|
web.post(
|
|
"/api/edit-title",
|
|
json={
|
|
"title_raw": "Hebarceos",
|
|
"source_photos": "shelf.jpg",
|
|
"title_new": "Herbaceous",
|
|
},
|
|
).status_code
|
|
== 200
|
|
)
|
|
assert web.get("/api/pipeline").json()["shaky_reads"] == 0
|
|
|
|
|
|
def test_nav_order_matches_workflow(tmp_path):
|
|
web, _ = make_client(tmp_path)
|
|
html = web.get("/").text
|
|
order = [
|
|
html.index(f'href="{href}"')
|
|
for href in (
|
|
"/",
|
|
"/photos",
|
|
"/titles",
|
|
"/review",
|
|
"/queue",
|
|
"/library",
|
|
"/help",
|
|
)
|
|
]
|
|
assert order == sorted(order)
|
|
# the old address still lands on the page
|
|
assert web.get("/catalog", follow_redirects=False).headers["location"] == "/titles"
|
|
|
|
|
|
def test_confirm_marks_shaky_read_verified_without_requeue(tmp_path):
|
|
cfg = make_cfg(tmp_path)
|
|
titles = json.loads(cfg.titles_path.read_text())
|
|
titles.append(
|
|
{
|
|
"title_raw": "Blurry Spine",
|
|
"confidence": "low",
|
|
"source_photos": ["shelf.jpg"],
|
|
}
|
|
)
|
|
cfg.titles_path.write_text(json.dumps(titles))
|
|
web = TestClient(create_app(cfg, client=unauthorized_client(tmp_path)))
|
|
line = next(
|
|
c
|
|
for c in web.get("/api/state").json()["catalog"]
|
|
if c["title_raw"] == "Blurry Spine"
|
|
)
|
|
assert line["shaky"] is True
|
|
rows_before = read_matches(cfg.matches_path)
|
|
|
|
res = web.post(
|
|
"/api/edit-title",
|
|
json={
|
|
"title_raw": "Blurry Spine",
|
|
"source_photos": "shelf.jpg",
|
|
"confirm": True,
|
|
},
|
|
)
|
|
assert res.status_code == 200
|
|
line = next(c for c in res.json()["catalog"] if c["title_raw"] == "Blurry Spine")
|
|
assert line["shaky"] is False # verified: chip and badge both clear
|
|
assert web.get("/api/pipeline").json()["shaky_reads"] == 0
|
|
# confirm changed no data, so nothing was re-queued
|
|
assert read_matches(cfg.matches_path) == rows_before
|
|
(record,) = json.loads(cfg.title_edits_path.read_text())
|
|
assert record["confidence"] == "high"
|
|
# a change-free save without confirm is still refused
|
|
assert (
|
|
web.post(
|
|
"/api/edit-title",
|
|
json={"title_raw": "Blurry Spine", "source_photos": "shelf.jpg"},
|
|
).status_code
|
|
== 400
|
|
)
|
|
|
|
|
|
def test_lan_allowed_hosts_admit_network_but_not_strangers(tmp_path):
|
|
cfg = make_cfg(tmp_path)
|
|
app = create_app(
|
|
cfg,
|
|
client=unauthorized_client(tmp_path),
|
|
allowed_hosts={"192.168.1.5", "erics-mac.local"},
|
|
)
|
|
body = {"title_raw": "Citadels", "source_photos": "shelf.jpg", "confirm": True}
|
|
lan = TestClient(app, base_url="http://192.168.1.5")
|
|
assert lan.post("/api/edit-title", json=body).status_code == 200
|
|
# a hostname NOT on the allowlist (DNS rebinding shape) is still refused
|
|
stranger = TestClient(app, base_url="http://attacker.example")
|
|
assert stranger.post("/api/edit-title", json=body).status_code == 403
|
|
# and without the opt-in, the LAN host is refused too
|
|
plain = create_app(cfg, client=unauthorized_client(tmp_path))
|
|
assert (
|
|
TestClient(plain, base_url="http://192.168.1.5")
|
|
.post("/api/edit-title", json=body)
|
|
.status_code
|
|
== 403
|
|
)
|
|
|
|
|
|
def test_lan_hosts_are_lowercase_nonempty_and_v4_only(monkeypatch):
|
|
import socket as socket_mod
|
|
|
|
from bggpipe.webreview import lan_hosts
|
|
|
|
monkeypatch.setattr(socket_mod, "gethostname", lambda: "Erics-Mac.Example.COM")
|
|
monkeypatch.setattr(
|
|
socket_mod,
|
|
"getaddrinfo",
|
|
lambda *a, **k: (_ for _ in ()).throw(OSError("no dns")),
|
|
)
|
|
hosts = lan_hosts()
|
|
assert "erics-mac.example.com" in hosts
|
|
assert "erics-mac.local" in hosts
|
|
assert "" not in hosts # an empty entry would admit Host-less mutations
|
|
assert all(":" not in h for h in hosts) # no v6 forms, no ports
|
|
|
|
|
|
def test_lan_token_gates_every_request(tmp_path):
|
|
cfg = make_cfg(tmp_path)
|
|
app = create_app(cfg, client=unauthorized_client(tmp_path), lan_token="sekret")
|
|
phone = TestClient(app, base_url="http://192.168.1.99:8377")
|
|
# reads are gated too: shelf photos and state are private
|
|
assert phone.get("/api/state").status_code == 403
|
|
assert phone.get("/api/state?k=wrong").status_code == 403
|
|
# the app's own assets are public: the preload scanner fetches them
|
|
# before the document's cookie commits
|
|
assert phone.get("/static/app.css").status_code == 200
|
|
first = phone.get("/titles?k=sekret", follow_redirects=False)
|
|
assert first.status_code == 303 # cookie commits BEFORE the document
|
|
assert first.headers["location"] == "/titles" # key scrubbed from URL
|
|
assert "bggpipe_key" in first.cookies
|
|
# pairing is one-time per device: a durable cookie, not a session one
|
|
assert "Max-Age=31536000" in first.headers["set-cookie"]
|
|
assert phone.get("/titles").status_code == 200 # cookie carries it now
|
|
# cookie carries the session: mutations work from ANY host the phone
|
|
# used (no allowlist dependence — DHCP/multi-interface safe), with a
|
|
# same-origin Origin header and a port, like a real phone browser
|
|
res = phone.post(
|
|
"/api/edit-title",
|
|
json={"title_raw": "Citadels", "source_photos": "shelf.jpg", "confirm": True},
|
|
headers={"origin": "http://192.168.1.99:8377"},
|
|
)
|
|
assert res.status_code == 200
|
|
# a foreign Origin is still refused even with the key
|
|
assert (
|
|
phone.post(
|
|
"/api/dismiss",
|
|
json={"photo": "shelf.jpg"},
|
|
headers={"origin": "http://evil.example"},
|
|
).status_code
|
|
== 403
|
|
)
|
|
|
|
|
|
def test_lan_loopback_stays_keyless_but_guarded(tmp_path):
|
|
cfg = make_cfg(tmp_path)
|
|
app = create_app(cfg, client=unauthorized_client(tmp_path), lan_token="sekret")
|
|
local = TestClient(
|
|
app, base_url="http://127.0.0.1:8377", client=("127.0.0.1", 50000)
|
|
)
|
|
# the desktop browser (old tabs, the auto-opened one) needs no key
|
|
assert local.get("/api/pipeline").status_code == 200
|
|
assert (
|
|
local.post(
|
|
"/api/edit-title",
|
|
json={
|
|
"title_raw": "Citadels",
|
|
"source_photos": "shelf.jpg",
|
|
"confirm": True,
|
|
},
|
|
headers={"origin": "http://127.0.0.1:8377"},
|
|
).status_code
|
|
== 200
|
|
)
|
|
# but loopback keylessness never extends to foreign Hosts (rebinding)
|
|
# or foreign Origins (classic CSRF from a local browser)
|
|
rebound = TestClient(
|
|
app, base_url="http://evil.example:8377", client=("127.0.0.1", 50000)
|
|
)
|
|
assert rebound.get("/api/state").status_code == 403
|
|
assert (
|
|
local.post(
|
|
"/api/dismiss",
|
|
json={"photo": "shelf.jpg"},
|
|
headers={"origin": "http://evil.example"},
|
|
).status_code
|
|
== 403
|
|
)
|
|
|
|
|
|
def test_localhost_mutations_pass_with_origin_and_port(tmp_path):
|
|
# the path every real browser takes: Origin present + port in Host —
|
|
# regressing the header parsing must fail loudly here
|
|
cfg = make_cfg(tmp_path)
|
|
app = create_app(cfg, client=unauthorized_client(tmp_path))
|
|
web = TestClient(app, base_url="http://localhost:8377")
|
|
res = web.post(
|
|
"/api/edit-title",
|
|
json={"title_raw": "Citadels", "source_photos": "shelf.jpg", "confirm": True},
|
|
headers={"origin": "http://localhost:8377"},
|
|
)
|
|
assert res.status_code == 200
|
|
assert (
|
|
TestClient(app, base_url="http://attacker.example:8377")
|
|
.post(
|
|
"/api/edit-title",
|
|
json={"title_raw": "Citadels", "source_photos": "shelf.jpg"},
|
|
)
|
|
.status_code
|
|
== 403
|
|
)
|
|
|
|
|
|
def test_run_web_review_lan_branch_binds_and_warns(tmp_path, monkeypatch, capsys):
|
|
import bggpipe.webreview as wr
|
|
|
|
captured = {}
|
|
monkeypatch.setattr("uvicorn.run", lambda app, **kw: captured.update(kw, app=app))
|
|
cfg = make_cfg(tmp_path)
|
|
monkeypatch.setattr(wr, "_route_ip", lambda: "192.168.1.5")
|
|
monkeypatch.setattr(
|
|
wr,
|
|
"lan_hosts",
|
|
lambda: {"127.0.0.1", "192.168.1.5", "192.168.64.1", "erics-mac.local"},
|
|
)
|
|
wr.run_web_review(cfg, port=9999, lan=True)
|
|
out = capsys.readouterr().out
|
|
assert captured["host"] == "0.0.0.0"
|
|
# the key persists (0600) so restarts don't strand the phone's cookie
|
|
assert (cfg.lan_key_path.stat().st_mode & 0o777) == 0o600
|
|
key = cfg.lan_key_path.read_text().strip()
|
|
assert f"?k={key}" in out
|
|
wr.run_web_review(cfg, port=9999, lan=True)
|
|
assert f"?k={key}" in capsys.readouterr().out # same key after restart
|
|
# the default-route address leads; other interfaces are fallbacks
|
|
assert "on your phone, open: http://192.168.1.5:9999/?k=" in out
|
|
assert "▀" in out or "█" in out # the pairing QR rendered
|
|
fallback = next(line for line in out.splitlines() if "try:" in line)
|
|
assert "192.168.64.1" in fallback and "erics-mac.local" in fallback
|
|
# no phone can reach loopback: only the desktop line mentions it
|
|
assert out.count("http://127.0.0.1:9999") == 1
|
|
assert "Use only on a network you trust" in out
|
|
wr.run_web_review(cfg, port=9999, lan=False)
|
|
assert captured["host"] == "127.0.0.1"
|
|
|
|
|
|
def test_lan_403_is_html_for_navigations(tmp_path):
|
|
cfg = make_cfg(tmp_path)
|
|
app = create_app(cfg, client=unauthorized_client(tmp_path), lan_token="sekret")
|
|
phone = TestClient(app, base_url="http://192.168.1.99:8377")
|
|
res = phone.get("/titles", headers={"accept": "text/html,application/xhtml+xml"})
|
|
assert res.status_code == 403
|
|
assert "access key needed" in res.text # a person sees prose, not JSON
|
|
|
|
|
|
def test_home_screen_icon_is_served_and_public(tmp_path):
|
|
cfg = make_cfg(tmp_path)
|
|
app = create_app(cfg, client=unauthorized_client(tmp_path), lan_token="sekret")
|
|
phone = TestClient(app, base_url="http://192.168.1.99:8377")
|
|
# iOS probes cookie-less; both the root probe and the linked path work
|
|
assert phone.get("/apple-touch-icon.png").status_code == 200
|
|
assert phone.get("/static/apple-touch-icon.png").status_code == 200
|
|
page = phone.get("/?k=sekret", follow_redirects=True)
|
|
assert 'rel="apple-touch-icon"' in page.text
|
|
|
|
|
|
def test_generic_camera_names_never_overwrite(tmp_path):
|
|
web, cfg = make_client(tmp_path)
|
|
|
|
def send(*photos):
|
|
return web.post(
|
|
"/api/photos",
|
|
files=[("files", (name, data, "image/jpeg")) for name, data in photos],
|
|
)
|
|
|
|
# two camera captures in one batch, both "image.jpg" (the iOS shape)
|
|
res = send(("image.jpg", b"\xff\xd8first"), ("image.jpg", b"\xff\xd8second"))
|
|
assert res.status_code == 200
|
|
saved = res.json()["saved"]
|
|
assert len(saved) == len(set(saved)) == 2
|
|
assert all(n.startswith("shelf-") for n in saved)
|
|
on_disk = {p.name: p.read_bytes() for p in cfg.photos_dir.glob("shelf-*")}
|
|
assert set(on_disk.values()) == {b"\xff\xd8first", b"\xff\xd8second"}
|
|
|
|
# a third capture later gets its own name too
|
|
res2 = send(("image.jpg", b"\xff\xd8third"))
|
|
(third,) = res2.json()["saved"]
|
|
assert third.startswith("shelf-") and third not in saved
|
|
|
|
# a named photo keeps the deliberate replace-to-reshoot semantics
|
|
(cfg.extract_raw_dir).mkdir(parents=True, exist_ok=True)
|
|
(cfg.extract_raw_dir / "IMG_9999.jpeg.json").write_text("{}")
|
|
res3 = send(("IMG_9999.jpeg", b"\xff\xd8reshoot"))
|
|
assert res3.json()["saved"] == ["IMG_9999.jpeg"]
|
|
assert (cfg.photos_dir / "IMG_9999.jpeg").read_bytes() == b"\xff\xd8reshoot"
|
|
assert not (cfg.extract_raw_dir / "IMG_9999.jpeg.json").exists()
|
|
|
|
|
|
def test_open_versions_endpoint_routes_row_into_review(tmp_path):
|
|
from pathlib import Path as _P
|
|
|
|
cfg = make_cfg(tmp_path)
|
|
rows = read_matches(cfg.matches_path)
|
|
rows.append(
|
|
_row(
|
|
title_raw="Wingspan",
|
|
match_status="auto",
|
|
bgg_id="266192",
|
|
bgg_name="Wingspan",
|
|
version_status="version_unknown",
|
|
)
|
|
)
|
|
write_matches(cfg.matches_path, rows)
|
|
fixtures = BGGClient(cache_dir=_P("tests/fixtures/bgg_cache"))
|
|
web = TestClient(create_app(cfg, client=fixtures))
|
|
res = web.post(
|
|
"/api/open-versions",
|
|
json={"title_raw": "Wingspan", "source_photos": "shelf.jpg"},
|
|
)
|
|
assert res.status_code == 200
|
|
versions = res.json()["versions"]
|
|
assert any(v["title_raw"] == "Wingspan" for v in versions) # in the pass
|
|
# a BGG outage surfaces as a gateway error, not a fake success
|
|
rows = read_matches(cfg.matches_path)
|
|
for r in rows:
|
|
if r["title_raw"] == "Dungeon!":
|
|
r["version_status"] = "version_unknown"
|
|
unauthorized = TestClient(
|
|
create_app(cfg, client=unauthorized_client(tmp_path / "x"))
|
|
)
|
|
res = unauthorized.post(
|
|
"/api/open-versions",
|
|
json={"title_raw": "Dungeon!", "source_photos": "shelf.jpg"},
|
|
)
|
|
assert res.status_code == 502
|
|
|
|
|
|
def test_local_decision_via_web(tmp_path):
|
|
web, cfg = make_client(tmp_path)
|
|
state = web.post(
|
|
"/api/decision",
|
|
json={"title_raw": "Mystery", "source_photos": "shelf.jpg", "action": "local"},
|
|
).json()
|
|
saved = {r["title_raw"]: r for r in read_matches(cfg.matches_path)}
|
|
assert saved["Mystery"]["match_status"] == "local"
|
|
assert state["summary"]["local"] == 1
|
|
|
|
|
|
def test_add_title_by_hand(tmp_path):
|
|
web, cfg = make_client(tmp_path)
|
|
res = web.post(
|
|
"/api/add-title",
|
|
json={"title": "Wingspan: European Expansion", "publisher": "Stonemaier Games"},
|
|
)
|
|
assert res.status_code == 200
|
|
line = next(
|
|
c
|
|
for c in res.json()["catalog"]
|
|
if c["title_raw"] == "Wingspan: European Expansion"
|
|
)
|
|
assert line["status"] == "awaiting_resolve"
|
|
assert line["photos"] == [] # no photo — shown as added by hand
|
|
assert web.post("/api/add-title", json={"title": " "}).status_code == 400
|
|
assert (
|
|
web.post("/api/add-title", json={"title": "X", "year": "199Y"}).status_code
|
|
== 400
|
|
)
|
|
|
|
|
|
def test_wrong_match_reopens_for_research(tmp_path):
|
|
cfg = make_cfg(tmp_path)
|
|
rows = read_matches(cfg.matches_path)
|
|
rows.append(
|
|
_row(
|
|
title_raw="Dungeom", # the misread that matched an impostor
|
|
match_status="auto",
|
|
bgg_id="345770",
|
|
bgg_name=".dungeon",
|
|
version_status="version_unknown",
|
|
)
|
|
)
|
|
write_matches(cfg.matches_path, rows)
|
|
web = TestClient(create_app(cfg, client=unauthorized_client(tmp_path)))
|
|
res = web.post(
|
|
"/api/reopen-match",
|
|
json={"title_raw": "Dungeom", "source_photos": "shelf.jpg"},
|
|
)
|
|
assert res.status_code == 200
|
|
saved = {r["title_raw"]: r for r in read_matches(cfg.matches_path)}
|
|
assert saved["Dungeom"]["match_status"] == "unmatched"
|
|
assert saved["Dungeom"]["bgg_id"] == ""
|
|
assert saved["Dungeom"]["version_candidates_json"] == "[]"
|
|
# it's back in the review queue
|
|
assert any(p["title_raw"] == "Dungeom" for p in res.json()["pending"])
|
|
# an unmatched row can't be "reopened"
|
|
assert (
|
|
web.post(
|
|
"/api/reopen-match",
|
|
json={"title_raw": "Mystery", "source_photos": "shelf.jpg"},
|
|
).status_code
|
|
== 400
|
|
)
|
|
|
|
|
|
def test_version_cards_carry_their_photos(tmp_path):
|
|
web, _ = make_client(tmp_path)
|
|
(card,) = web.get("/api/state").json()["versions"]
|
|
assert card["title_raw"] == "Dungeon!"
|
|
assert card["photos"] == ["shelf.jpg"] # same-title copies stay tellable
|
|
|
|
|
|
def test_same_title_lines_pair_rows_by_photos_not_csv_order(tmp_path):
|
|
cfg = make_cfg(tmp_path)
|
|
titles = json.loads(cfg.titles_path.read_text())
|
|
titles += [
|
|
{"title_raw": "WIZ-WAR", "confidence": "high", "source_photos": ["a.jpg"]},
|
|
{"title_raw": "WIZ-WAR", "confidence": "high", "source_photos": ["b.jpg"]},
|
|
]
|
|
cfg.titles_path.write_text(json.dumps(titles))
|
|
rows = read_matches(cfg.matches_path)
|
|
# csv order REVERSED vs entry order: b's row first (an edit re-queue
|
|
# recreates a row at the file's end)
|
|
rows.append(
|
|
_row(
|
|
title_raw="WIZ-WAR",
|
|
match_status="auto",
|
|
bgg_id="589",
|
|
source_photos="b.jpg",
|
|
version_status="version_ambiguous",
|
|
version_candidates_json=VERSION_CANDIDATES,
|
|
)
|
|
)
|
|
rows.append(
|
|
_row(
|
|
title_raw="WIZ-WAR",
|
|
match_status="auto",
|
|
bgg_id="589",
|
|
source_photos="a.jpg",
|
|
version_status="version_unknown",
|
|
)
|
|
)
|
|
write_matches(cfg.matches_path, rows)
|
|
web = TestClient(create_app(cfg, client=unauthorized_client(tmp_path)))
|
|
lines = {
|
|
tuple(c["photos"]): c
|
|
for c in web.get("/api/state").json()["catalog"]
|
|
if c["title_raw"] == "WIZ-WAR"
|
|
}
|
|
assert lines[("a.jpg",)]["version_status"] == "version_unknown"
|
|
assert lines[("b.jpg",)]["version_status"] == "version_ambiguous"
|
|
|
|
|
|
def test_library_detail_serves_one_game_with_provenance(tmp_path):
|
|
cfg = make_cfg(tmp_path)
|
|
rows = read_matches(cfg.matches_path)
|
|
rows.append(
|
|
_row(
|
|
title_raw="Britannia",
|
|
match_status="auto",
|
|
bgg_id="240",
|
|
bgg_name="Britannia",
|
|
version_id="24621",
|
|
source_photos="shelf.jpg",
|
|
)
|
|
)
|
|
write_matches(cfg.matches_path, rows)
|
|
cfg.games_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"240:24621": {
|
|
"bgg_id": 240,
|
|
"name": "Britannia",
|
|
"year": 1986,
|
|
"type": "boardgame",
|
|
"description": "A long description.",
|
|
"version": {"version_id": 24621, "name": "Avalon Hill second"},
|
|
}
|
|
}
|
|
)
|
|
)
|
|
web = TestClient(create_app(cfg, client=unauthorized_client(tmp_path)))
|
|
|
|
(listed,) = web.get("/api/library").json()
|
|
assert listed["key"] == "240:24621"
|
|
assert "description" not in listed # the list view stays light
|
|
|
|
detail = web.get("/api/library/240:24621").json()
|
|
assert detail["name"] == "Britannia"
|
|
assert detail["description"] == "A long description."
|
|
# provenance the pipeline knows and games.json doesn't: the shelf photo
|
|
assert detail["photos"] == ["shelf.jpg"]
|
|
|
|
assert web.get("/api/library/nope").status_code == 404
|
|
page = web.get("/library/game/240:24621")
|
|
assert page.status_code == 200 and 'href="/library"' in page.text
|
|
|
|
|
|
def test_local_game_notes_and_art_round_trip(tmp_path):
|
|
"""BGG has nothing for an off-BGG game, so the owner's own words and
|
|
photo are its only metadata — and must survive enrich rebuilding
|
|
games.json from titles.json."""
|
|
from bggpipe.enrich import run_enrich
|
|
|
|
cfg = make_cfg(tmp_path)
|
|
rows = read_matches(cfg.matches_path)
|
|
rows.append(
|
|
_row(title_raw="Homebrew Game", match_status="local", source_photos="shelf.jpg")
|
|
)
|
|
write_matches(cfg.matches_path, rows)
|
|
web = TestClient(create_app(cfg, client=unauthorized_client(tmp_path)))
|
|
key = "local:homebrew game:shelf.jpg"
|
|
|
|
saved = web.post(
|
|
f"/api/local-game/{key}",
|
|
json={
|
|
"name": "Homebrew Game",
|
|
"year": "1998",
|
|
"min_players": "2",
|
|
"max_players": "6",
|
|
"publishers": "Basement Press, Friend's Garage",
|
|
"description": " Made by a friend. ",
|
|
},
|
|
).json()["saved"]
|
|
assert saved["year"] == 1998
|
|
assert saved["publishers"] == ["Basement Press", "Friend's Garage"]
|
|
assert saved["description"] == "Made by a friend."
|
|
|
|
art = web.post(
|
|
f"/api/local-art/{key}",
|
|
files={"file": ("box.jpg", b"\xff\xd8jpeg", "image/jpeg")},
|
|
).json()
|
|
assert art["image"].startswith("/local-art/")
|
|
assert web.get(art["image"]).status_code == 200
|
|
|
|
# enrich folds both into the library entry
|
|
games = run_enrich(cfg, client=unauthorized_client(tmp_path))
|
|
entry = games[key]
|
|
assert entry["year"] == 1998 and entry["max_players"] == 6
|
|
assert entry["image"] == art["image"]
|
|
assert entry["publishers"] == ["Basement Press", "Friend's Garage"]
|
|
|
|
# guards: BGG-matched games and non-photos are refused
|
|
assert web.post("/api/local-game/13", json={"name": "Catan"}).status_code == 400
|
|
assert (
|
|
web.post(
|
|
f"/api/local-art/{key}",
|
|
files={"file": ("notes.txt", b"hi", "text/plain")},
|
|
).status_code
|
|
== 400
|
|
)
|
|
assert web.post(f"/api/local-game/{key}", json={"year": "19x8"}).status_code == 400
|
|
|
|
|
|
def test_research_endpoint_contract(tmp_path):
|
|
"""The web UI's manual-search button: 200 replaces the ballot, blank
|
|
query 400s, BGG-down 502s, and `types` reaches the client (the
|
|
RPGGeek toggle is the whole point)."""
|
|
cfg = make_cfg(tmp_path)
|
|
rows = read_matches(cfg.matches_path)
|
|
rows.append(_row(title_raw="MYSTERY BOX", match_status="unmatched"))
|
|
write_matches(cfg.matches_path, rows)
|
|
|
|
seen_queries = []
|
|
|
|
class _Recorder(BGGClient):
|
|
def search(self, query, types=None):
|
|
seen_queries.append((query, types))
|
|
return []
|
|
|
|
client = _Recorder(cache_dir=tmp_path / "no_cache")
|
|
web = TestClient(create_app(cfg, client=client))
|
|
body = {
|
|
"title_raw": "MYSTERY BOX",
|
|
"source_photos": "shelf.jpg",
|
|
"query": "Alice is Missing",
|
|
"types": "rpgitem",
|
|
}
|
|
assert web.post("/api/research", json=body).status_code == 200
|
|
assert ("Alice is Missing", "rpgitem") in seen_queries
|
|
|
|
assert web.post("/api/research", json={**body, "query": " "}).status_code == 400
|
|
|
|
down = TestClient(create_app(cfg, client=unauthorized_client(tmp_path)))
|
|
assert down.post("/api/research", json=body).status_code == 502
|
|
|
|
|
|
def test_corrupt_local_games_store_returns_500_not_silence(tmp_path):
|
|
web, cfg = make_client(tmp_path)
|
|
cfg.local_games_path.write_text("{torn")
|
|
res = web.post("/api/local-game/local:x:y.jpg", json={"name": "X"})
|
|
assert res.status_code == 500
|
|
assert "local_games.json" in res.json()["detail"]
|
|
|
|
|
|
def test_local_game_detail_reflects_saved_facts_before_enrich(tmp_path):
|
|
"""The edit form re-renders from the detail payload; serving pre-save
|
|
values there would resubmit as blanks and clear the store."""
|
|
cfg = make_cfg(tmp_path)
|
|
rows = read_matches(cfg.matches_path)
|
|
rows.append(
|
|
_row(title_raw="Homebrew Game", match_status="local", source_photos="s.jpg")
|
|
)
|
|
write_matches(cfg.matches_path, rows)
|
|
key = "local:homebrew game:s.jpg"
|
|
cfg.games_path.write_text(
|
|
json.dumps({key: {"name": "Homebrew Game", "type": "localgame"}})
|
|
)
|
|
web = TestClient(create_app(cfg, client=unauthorized_client(tmp_path)))
|
|
web.post(f"/api/local-game/{key}", json={"designers": "A Friend", "year": "1998"})
|
|
detail = web.get(f"/api/library/{key}").json()
|
|
assert detail["designers"] == ["A Friend"] # live, before any enrich
|
|
assert detail["year"] == 1998
|
|
|
|
|
|
def test_readding_a_removed_hand_added_title_rescinds_the_removal(tmp_path):
|
|
"""add → remove → add again must resurrect the line, not silently
|
|
no-op behind a success response."""
|
|
web, cfg = make_client(tmp_path)
|
|
add = {
|
|
"title": "Homebrew Quest",
|
|
"publisher": "",
|
|
"edition": "",
|
|
"year": "",
|
|
"language": "",
|
|
}
|
|
assert web.post("/api/add-title", json=add).status_code == 200
|
|
# a second identical add is a refused no-op, not a silent success
|
|
assert web.post("/api/add-title", json=add).status_code == 409
|
|
state = web.post(
|
|
"/api/remove-title",
|
|
json={"title_raw": "Homebrew Quest", "source_photos": ""},
|
|
)
|
|
assert state.status_code == 200
|
|
assert web.post("/api/add-title", json=add).status_code == 200
|
|
titles = json.loads(cfg.titles_path.read_text())
|
|
assert any(t["title_raw"] == "Homebrew Quest" for t in titles)
|
|
|
|
|
|
def test_review_summary_reports_token_presence_not_value(tmp_path, monkeypatch):
|
|
"""The done-card's advice differs between "run resolve" and "get a
|
|
token": the payload carries presence as a boolean, never the value."""
|
|
monkeypatch.setenv("BGG_API_TOKEN", "secret-token-value")
|
|
web, _ = make_client(tmp_path)
|
|
body = web.get("/api/state")
|
|
assert body.json()["summary"]["token_present"] is True
|
|
assert "secret-token-value" not in body.text
|
|
|
|
monkeypatch.delenv("BGG_API_TOKEN")
|
|
web2, _ = make_client(tmp_path / "tokenless")
|
|
assert web2.get("/api/state").json()["summary"]["token_present"] is False
|
|
|
|
|
|
def test_bgg_search_and_add_picked_flow(tmp_path):
|
|
"""A whole box of expansions in one pass: search once, tick, and each
|
|
pick lands already-matched with its container recorded."""
|
|
cfg = make_cfg(tmp_path)
|
|
web = TestClient(create_app(cfg, client=fixture_search_client()))
|
|
|
|
found = web.get("/api/bgg-search", params={"q": "Citadels"}).json()["results"]
|
|
assert len(found) >= 2
|
|
ids = {r["bgg_id"] for r in found}
|
|
assert 478 in ids
|
|
assert all(r["already"] is False for r in found)
|
|
|
|
picks = [r for r in found if r["bgg_id"] in (478, 13291, 205398)]
|
|
res = web.post(
|
|
"/api/add-picked", json={"picks": picks, "stored_in": "173634"}
|
|
).json()
|
|
# 478 and The Dark City land; 205398 is BGG's OTHER game named
|
|
# "Citadels" — a true name twin, skipped honestly rather than fused
|
|
assert res["added"] == 2
|
|
assert res["skipped"] == ["Citadels — a same-named line already exists"]
|
|
|
|
rows = {r["bgg_id"]: r for r in read_matches(cfg.matches_path)}
|
|
# "Citadels" already existed as an undecided photo line: the pick
|
|
# DECIDED that line (photos kept) instead of duplicating it
|
|
assert rows["478"]["match_status"] == "approved"
|
|
assert rows["478"]["source_photos"] == "shelf.jpg"
|
|
assert rows["478"]["stored_in"] == "173634"
|
|
# the expansion had no line: appended photo-less, already matched
|
|
assert rows["13291"]["match_status"] == "approved"
|
|
assert rows["13291"]["source_photos"] == ""
|
|
|
|
# re-searching now flags them; re-adding skips instead of duplicating
|
|
found2 = web.get("/api/bgg-search", params={"q": "Citadels"}).json()["results"]
|
|
assert next(r for r in found2 if r["bgg_id"] == 478)["already"] is True
|
|
res2 = web.post("/api/add-picked", json={"picks": picks}).json()
|
|
assert res2["added"] == 0 and len(res2["skipped"]) == 3
|
|
|
|
# blank query is a 400, and BGG-down is a 502, not a crash
|
|
assert web.get("/api/bgg-search", params={"q": " "}).status_code == 400
|
|
down = TestClient(create_app(cfg, client=unauthorized_client(tmp_path)))
|
|
assert down.get("/api/bgg-search", params={"q": "x"}).status_code == 502
|
|
|
|
|
|
def fixture_search_client() -> BGGClient:
|
|
from pathlib import Path as _P
|
|
|
|
fixtures = _P(__file__).parent / "fixtures" / "bgg_cache"
|
|
return BGGClient(
|
|
cache_dir=fixtures,
|
|
transport=httpx.MockTransport(
|
|
lambda req: httpx.Response(401, text="Unauthorized")
|
|
),
|
|
)
|
|
|
|
|
|
def test_library_detail_shows_containment_both_ways(tmp_path):
|
|
cfg = make_cfg(tmp_path)
|
|
rows = read_matches(cfg.matches_path)
|
|
rows.append(
|
|
_row(
|
|
title_raw="The Trove",
|
|
match_status="approved",
|
|
bgg_id="173634",
|
|
source_photos="shelf.jpg",
|
|
)
|
|
)
|
|
rows.append(
|
|
_row(
|
|
title_raw="Witchdoctor",
|
|
match_status="approved",
|
|
bgg_id="999",
|
|
source_photos="",
|
|
stored_in="173634",
|
|
)
|
|
)
|
|
write_matches(cfg.matches_path, rows)
|
|
cfg.games_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"173634": {"bgg_id": 173634, "name": "The Trove", "type": "boardgame"},
|
|
"999": {
|
|
"bgg_id": 999,
|
|
"name": "Witchdoctor",
|
|
"type": "boardgameexpansion",
|
|
"stored_in": "173634",
|
|
},
|
|
}
|
|
)
|
|
)
|
|
web = TestClient(create_app(cfg, client=unauthorized_client(tmp_path)))
|
|
inside = web.get("/api/library/999").json()
|
|
assert inside["stored_in_game"] == {"key": "173634", "name": "The Trove"}
|
|
box = web.get("/api/library/173634").json()
|
|
assert box["contains"] == [{"key": "999", "name": "Witchdoctor"}]
|