Audit round 7, upload cluster: evidence over inference at every exit

Five blind reviewers swept the real-data-era surface; this lands the
upload findings, all verified against the code and the documented site
behavior before fixing.

The two HIGHs shared a root: logging outcomes the browser never proved.
add_game waited for an "Add To" button that an owned game's page does
not have — so a second-copy add could never succeed, and worse, an add
that LANDED but missed the log became an unretryable failure loop
(every retry: 30s timeout, logged failed, nothing ever settles).
add_game now polls for either button state: "In Collections" without
second_copy returns the previously-dead already_present status (the
landed-but-unlogged case heals itself on retry); with second_copy it
refuses loudly (that flow is unverified — add by hand). A save whose
dialog is slow to hide reloads the page and asks for ownership evidence
instead of guessing "failed". update_entry no longer trusts the editor
merely closing: the cell must settle on text matching the CHOSEN
version, else the AJAX save failed server-side and "updated" would
mark a job done forever that never touched the site.

Per-copy bookkeeping: stale_jobs endorsed per game, so rejecting one
of two queued editions let the rejected copy upload on the survivor's
endorsement — it now counts endorsements per (bgg_id, version) and
retires the game with "re-run diff" when a copy loses its backing.
annotate_queue stamped every row sharing a job key with the same log
status, so one success marked both vetoed duplicates done; completions
are now claimed one row per done log line.

Smaller findings: the version-drift note queued a doomed re-add after
warning about it (now skips — the entry exists on BGG; re-adding only
duplicates); the one-update-per-game deferral rested on a claim the
collid-exact editor disproves (removed — same-game updates run
together); the 3-identical-failures abort compared exception class
only, so three unrelated problems aborted a healthy run (now compares
whole messages).

Also from the test seat: run_upload's stale filtering finally executes
against a real matches.csv in tests; rejected credentials pin that no
anonymous storage state is saved; update_entry's three guarded exits
each have a test; _scrub's newline flattening is pinned.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
This commit is contained in:
Eric Wagoner
2026-08-06 00:21:52 -04:00
co-authored by Claude Fable 5
parent 22fa17b5ee
commit 32b6aae841
8 changed files with 803 additions and 174 deletions
+39
View File
@@ -9,6 +9,7 @@ from bggpipe.models import (
parse_collection,
parse_search,
parse_things,
parse_things_full,
)
SEARCH_XML = """<items total="2">
@@ -134,3 +135,41 @@ def test_search_partial_malformed_tolerated_with_warning():
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"]