The web layer's serialization story had three gaps: /api/run started a stage without the lock, so a decision mid-save could pass the rewrite guard and still be clobbered by the stage's full rewrite (now the start itself serializes); /api/photos accepted a replacement photo while extract was running, permanently pairing the new bytes with the old photo's reads (now refuses like every other mutation); and /api/queue read session rows lock-free and stale (now freshens under the lock). The localhost Host allowlist applied only to writes — a DNS-rebound page could read pipeline state and shelf photos with plain GETs; it now covers all methods (foreign-Origin reads still pass: without CORS headers a cross-origin page can't read the response anyway). Data-loss finds: the off-BGG edit form re-rendered from games.json, which only sees hand data after enrich — so a second save resubmitted pre-save blanks and cleared the first (the detail endpoint now overlays local_games.json live). The local key embeds the photo list, so a new sighting orphaned hand-written facts silently; enrich now migrates them when the title still matches exactly one line, and warns instead of ever dropping. research() left the previous game's version verdicts on the row, riding a stale version_id onto the next pick; it clears all four fields as reopen does. find_row now prefers the version-open sibling on duplicate keys, mirroring _adopt. Re-adding a removed hand-added title silently no-opped behind a 200 — it now rescinds the removal (an explicit undo), and a true duplicate add answers 409. Smaller: parse_search's dedupe collapsed same-id rows under DIFFERENT names, discarding the alternate-name row whose exact match downstream scoring needed (now collapses same-name only; research merges its ballot per game preferring exact evidence); rpgitems rank in their own family so their rank parsed null; the pipeline badge counted review-retired queue rows as pending; the catalog pairing cascade ran per-entry so a tier-3 claim could steal a sibling's exact row (now tier-by-tier across all entries, as resolve does); library cards render a lone player bound without "undefined" and the seats filter tolerates it; added_no_version reads "done · no version" instead of a bare green done. Every finding verified against the code before fixing; each fix carries a regression test. 337 tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
191 lines
6.6 KiB
Python
191 lines
6.6 KiB
Python
"""XML parser tests against hand-built API2 response shapes."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from bggpipe.models import (
|
|
BGGResponseError,
|
|
parse_collection,
|
|
parse_search,
|
|
parse_things,
|
|
parse_things_full,
|
|
)
|
|
|
|
SEARCH_XML = """<items total="2">
|
|
<item type="boardgame" id="13">
|
|
<name type="primary" value="CATAN"/><yearpublished value="1995"/>
|
|
</item>
|
|
<item type="boardgameexpansion" id="290448">
|
|
<name type="alternate" value="Wingspan: Europa"/>
|
|
</item>
|
|
</items>"""
|
|
|
|
THING_XML = """<items>
|
|
<item type="boardgame" id="266192">
|
|
<name type="primary" sortindex="1" value="Wingspan"/>
|
|
<name type="alternate" sortindex="1" value="Flügelschlag"/>
|
|
<yearpublished value="2019"/>
|
|
<statistics page="1"><ratings>
|
|
<owned value="123456"/>
|
|
<ranks>
|
|
<rank type="subtype" id="1" name="boardgame" value="30"/>
|
|
<rank type="family" id="5497" name="strategygames" value="25"/>
|
|
</ranks>
|
|
</ratings></statistics>
|
|
<versions>
|
|
<item type="boardgameversion" id="465063">
|
|
<name type="primary" value="English edition"/>
|
|
<yearpublished value="2019"/>
|
|
<link type="boardgamepublisher" id="23202" value="Stonemaier Games"/>
|
|
<link type="language" id="2184" value="English"/>
|
|
</item>
|
|
<item type="boardgameversion" id="465064">
|
|
<name type="primary" value="German edition"/>
|
|
<yearpublished value="2019"/>
|
|
<link type="boardgamepublisher" id="22160" value="Feuerland Spiele"/>
|
|
<link type="language" id="2188" value="German"/>
|
|
</item>
|
|
</versions>
|
|
</item>
|
|
</items>"""
|
|
|
|
COLLECTION_XML = """<items totalitems="2">
|
|
<item objecttype="thing" objectid="13" subtype="boardgame" collid="101">
|
|
<name sortindex="1">Catan</name>
|
|
<yearpublished>1995</yearpublished>
|
|
<status own="1" wanttoplay="0"/>
|
|
</item>
|
|
<item objecttype="thing" objectid="266192" subtype="boardgame" collid="102">
|
|
<name sortindex="1">Wingspan</name>
|
|
<status own="0" wishlist="1"/>
|
|
<version><item type="boardgameversion" id="465063"/></version>
|
|
</item>
|
|
</items>"""
|
|
|
|
NOT_RANKED_XML = """<items>
|
|
<item type="boardgame" id="99999">
|
|
<name type="primary" value="Obscurity"/>
|
|
<statistics><ratings>
|
|
<owned value="12"/>
|
|
<ranks><rank type="subtype" id="1" name="boardgame" value="Not Ranked"/></ranks>
|
|
</ratings></statistics>
|
|
</item>
|
|
</items>"""
|
|
|
|
ERROR_XML = (
|
|
"<errors><error><message>Invalid username specified</message></error></errors>"
|
|
)
|
|
|
|
|
|
def test_parse_search():
|
|
results = parse_search(SEARCH_XML)
|
|
assert [r.bgg_id for r in results] == [13, 290448]
|
|
assert results[0].name == "CATAN"
|
|
assert results[0].year == 1995
|
|
assert results[1].type == "boardgameexpansion"
|
|
assert results[1].name_type == "alternate"
|
|
assert results[1].year is None
|
|
|
|
|
|
def test_parse_things_with_stats_and_versions():
|
|
(thing,) = parse_things(THING_XML)
|
|
assert thing.name == "Wingspan" # primary, not alternate
|
|
assert thing.owned == 123456
|
|
assert thing.rank == 30 # boardgame rank, not the family rank
|
|
assert len(thing.versions) == 2
|
|
english = thing.versions[0]
|
|
assert english.version_id == 465063
|
|
assert english.publishers == ("Stonemaier Games",)
|
|
assert english.languages == ("English",)
|
|
|
|
|
|
def test_parse_things_not_ranked_is_none():
|
|
(thing,) = parse_things(NOT_RANKED_XML)
|
|
assert thing.rank is None
|
|
assert thing.owned == 12
|
|
|
|
|
|
def test_parse_collection():
|
|
catan, wingspan = parse_collection(COLLECTION_XML)
|
|
assert (catan.object_id, catan.coll_id, catan.own) == (13, 101, True)
|
|
assert catan.version_id is None
|
|
assert wingspan.own is False # wishlist item must not count as owned
|
|
assert wingspan.version_id == 465063
|
|
|
|
|
|
def test_error_document_raises():
|
|
with pytest.raises(BGGResponseError, match="Invalid username"):
|
|
parse_collection(ERROR_XML)
|
|
|
|
|
|
def test_search_all_items_malformed_raises():
|
|
xml = '<items total="2"><item type="boardgame"/><item type="boardgame"/></items>'
|
|
with pytest.raises(BGGResponseError):
|
|
parse_search(xml)
|
|
|
|
|
|
def test_search_partial_malformed_tolerated_with_warning():
|
|
xml = (
|
|
'<items total="2">'
|
|
'<item type="boardgame" id="13"><name type="primary" value="CATAN"/></item>'
|
|
'<item type="boardgame"/>'
|
|
"</items>"
|
|
)
|
|
with pytest.warns(UserWarning, match="unparseable"):
|
|
results = parse_search(xml)
|
|
assert [r.bgg_id for r in results] == [13]
|
|
|
|
|
|
def test_parse_search_dedupes_multitype_duplicates_preferring_specific():
|
|
"""A multi-type search lists an expansion under BOTH types; keeping the
|
|
bare-boardgame copy would erode the base-vs-expansion guard."""
|
|
xml = """<items total="2">
|
|
<item type="boardgame" id="290448">
|
|
<name type="primary" value="Wingspan: European Expansion"/>
|
|
</item>
|
|
<item type="boardgameexpansion" id="290448">
|
|
<name type="primary" value="Wingspan: European Expansion"/>
|
|
</item>
|
|
</items>"""
|
|
(result,) = parse_search(xml)
|
|
assert result.type == "boardgameexpansion"
|
|
|
|
|
|
def test_parse_things_full_reads_rpggeek_link_vocabulary():
|
|
"""RPGGeek items use their own link types; a board-game-only reader
|
|
silently returns nothing for them."""
|
|
xml = """<items>
|
|
<item type="rpgitem" id="311654">
|
|
<name type="primary" sortindex="1" value="Alice is Missing"/>
|
|
<yearpublished value="2020"/>
|
|
<link type="rpgdesigner" id="1" value="Spenser Starke"/>
|
|
<link type="rpgpublisher" id="2" value="Hunters Entertainment"/>
|
|
<link type="rpggenre" id="3" value="Modern"/>
|
|
<link type="rpgcategory" id="4" value="Core Rules (min needed to play)"/>
|
|
<link type="rpgmechanic" id="5" value="Card Play"/>
|
|
<link type="rpgproducer" id="6" value="Someone"/>
|
|
</item>
|
|
</items>"""
|
|
(game,) = parse_things_full(xml)
|
|
assert game["designers"] == ["Spenser Starke"]
|
|
assert game["publishers"] == ["Hunters Entertainment"]
|
|
assert game["categories"] == ["Modern", "Core Rules (min needed to play)"]
|
|
assert game["mechanics"] == ["Card Play"]
|
|
assert game["producers"] == ["Someone"]
|
|
|
|
|
|
def test_parse_search_keeps_alternate_name_duplicates():
|
|
"""The same id listed under two NAMES is evidence, not noise — the
|
|
alternate may be the one that exact-matches the query."""
|
|
xml = """<items total="2">
|
|
<item type="boardgame" id="140509">
|
|
<name type="primary" value="Dragones Y Mazmorras"/>
|
|
</item>
|
|
<item type="boardgame" id="140509">
|
|
<name type="alternate" value="Dungeons & Dragons"/>
|
|
</item>
|
|
</items>"""
|
|
results = parse_search(xml)
|
|
assert [r.name for r in results] == ["Dragones Y Mazmorras", "Dungeons & Dragons"]
|