Files
bggpipe/tests/test_webreview.py
T
Eric WagonerandClaude Fable 5 fb611e0c87 Containment becomes editable: the where-it-lives card grows controls
Eric's question exposed the gap: stored_in was settable only at
pick-time, with no path for a game already in the system. The Library
detail page's "Where it lives" card now renders for every entry — "in
its own box on a shelf" with an it-lives-inside-another-box control,
or the current container with a change button — saving through a new
/api/stored-in endpoint that updates the match rows (the durable
record) AND the library entry in place, so the page and the shelf math
reflect it immediately, no enrich run needed. Guards refuse
self-containment and direct cycles; local games are addressable
through their key's normalized title.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqdW79g
2026-08-09 12:55:36 -04:00

1568 lines
54 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"}]
# containment is editable after the fact: clear it, then set it back
assert web.post("/api/stored-in", json={"key": "999", "container": ""}).json() == {
"stored_in": None
}
rows2 = {r["bgg_id"]: r for r in read_matches(cfg.matches_path)}
assert rows2["999"]["stored_in"] == ""
assert "stored_in_game" not in web.get("/api/library/999").json()
assert web.post(
"/api/stored-in", json={"key": "999", "container": "173634"}
).json() == {"stored_in": "173634"}
assert read_matches(cfg.matches_path)[-1]["stored_in"] == "173634"
# guards: self-containment and direct cycles refuse
assert (
web.post("/api/stored-in", json={"key": "999", "container": "999"}).status_code
== 400
)
assert (
web.post(
"/api/stored-in", json={"key": "173634", "container": "999"}
).status_code
== 400
)