Audit: 5-reviewer sweep — 19 fixes across every stage, +24 tests

Correctness: review vetoes persist via a dedupe_veto column (resolve
re-runs no longer overturn humans); diff emits second copies whose
confident version matches no owned copy (spec: pairs own only on both
ids) and fetches the live collection with refresh; resolve pairs
titles.json entries to rows by title so a reshoot photo updates
provenance instead of duplicating rows; version lookups survive empty
/thing results; publisher tie-break now honors the mixed
base/expansion veto and refuses multi-candidate picks; empty-normalized
(non-Latin) titles never count as exact.

Upload: LoginError aborts a run instead of logging N bogus failures
(and 3 identical consecutive failures abort as systemic); Cloudflare
interstitials are detected; added-without-version gets its own logged
status that verify understands; same-game updates run one per pass so
the name-targeted row edit can't overwrite a fresh version; absent
diff outputs fail loudly; pagination clicks are paced.

Web review: a lock serializes freshen/decide (threadpool race dropped
decisions); failed saves roll memory back and always alert the browser
(non-JSON 500s included); session warnings reach the page instead of a
StringIO; state-load failures and dead servers show banners instead of
a blank page; duplicate (title, photos) rows are addressable by
ordinal.

Consistency: shared CONFIDENT_VERSION_STATUSES, client_for(),
Config paths for every artifact, one review-port constant, named
matching thresholds, strict collection-id parsing, error-doc responses
never cached, unknown config keys warn, extract reports dropped vision
entries, fixture generators share escaping + marker text.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-02 14:10:57 -04:00
parent abf7181475
commit 38e20f2c30
26 changed files with 1003 additions and 249 deletions
+88 -5
View File
@@ -111,7 +111,7 @@ class FakeUploader:
def test_build_queue_skips_logged_successes():
jobs, done, failed = build_queue(
jobs, done, failed, _ = build_queue(
[_add_row(bgg_id="1"), _add_row(bgg_id="2", name="Catan")],
[_update_row(collid="9")],
[
@@ -127,7 +127,7 @@ def test_build_queue_skips_logged_successes():
def test_build_queue_second_copy_is_a_distinct_job():
# Same game, different version: a separate physical copy, so a
# logged add of one version must not swallow the other.
jobs, done, _ = build_queue(
jobs, done, _, _ = build_queue(
[
_add_row(bgg_id="1", version_id="10", version_name="First ed."),
_add_row(bgg_id="1", version_id="11", version_name="Second ed."),
@@ -141,9 +141,11 @@ def test_build_queue_second_copy_is_a_distinct_job():
def test_build_queue_failures_need_retry_flag():
log = [_log_row(action="add", bgg_id="1", status="failed")]
jobs, _, skipped = build_queue([_add_row(bgg_id="1")], [], log)
jobs, _, skipped, _ = build_queue([_add_row(bgg_id="1")], [], log)
assert jobs == [] and skipped == 1
jobs, _, skipped = build_queue([_add_row(bgg_id="1")], [], log, retry_failed=True)
jobs, _, skipped, _ = build_queue(
[_add_row(bgg_id="1")], [], log, retry_failed=True
)
assert len(jobs) == 1 and skipped == 0
@@ -153,7 +155,7 @@ def test_build_queue_latest_log_entry_wins():
_log_row(action="add", bgg_id="1", status="failed"),
_log_row(action="add", bgg_id="1", status="added"),
]
jobs, done, _ = build_queue([_add_row(bgg_id="1")], [], log, retry_failed=True)
jobs, done, _, _ = build_queue([_add_row(bgg_id="1")], [], log, retry_failed=True)
assert jobs == [] and done == 1
@@ -300,3 +302,84 @@ def test_fresh_clone_marker_blocks_upload_without_cache_dir(tmp_path):
_seed_data(tmp_path, to_add=[_add_row()])
with pytest.raises(typer.Exit):
run_upload(cfg, uploader=FakeUploader(), sleep=lambda s: None, now=NOW)
# -- audit-fix regressions ----------------------------------------------
def test_login_error_aborts_without_poisoning_the_log(tmp_path):
from bggpipe.upload import LoginError
class BrokenLogin(FakeUploader):
def add_game(self, job):
raise LoginError("Cloudflare is challenging this browser")
cfg = _cfg(tmp_path)
_seed_data(tmp_path, to_add=[_add_row(bgg_id=str(i)) for i in range(1, 4)])
results = run_upload(cfg, uploader=BrokenLogin(), sleep=lambda s: None, now=NOW)
assert results == [] # nothing logged: next run retries everything
assert not (tmp_path / "upload_log.csv").exists()
def test_three_identical_failures_abort_as_systemic(tmp_path):
cfg = _cfg(tmp_path)
_seed_data(tmp_path, to_add=[_add_row(bgg_id=str(i)) for i in range(1, 6)])
fake = FakeUploader(failures={"Wingspan"}) # every job shares the name
results = run_upload(cfg, uploader=fake, sleep=lambda s: None, now=NOW)
assert len(results) == 3 # aborted after the third identical failure
logged = list(csv.DictReader((tmp_path / "upload_log.csv").open()))
assert len(logged) == 3 # jobs 4-5 left unlogged and retryable
def test_added_no_version_is_done_and_verify_tolerates_it(tmp_path):
class NoVersionPicker(FakeUploader):
def add_game(self, job):
self.calls.append(job)
return "added_no_version", "version not in picker; added without version"
cfg = _cfg(tmp_path)
_seed_data(tmp_path, to_add=[_add_row(version_id="99", version_name="4th ed.")])
run_upload(cfg, uploader=NoVersionPicker(), sleep=lambda s: None, now=NOW)
# done: re-running must NOT re-add (a duplicate collection entry)
again = FakeUploader()
assert run_upload(cfg, uploader=again, sleep=lambda s: None, now=NOW) == []
assert again.calls == []
# verify: game present without the version is the EXPECTED outcome
log = list(csv.DictReader((tmp_path / "upload_log.csv").open()))
assert verify_uploads(log, [_item(1, 10)]) == []
def test_missing_to_add_csv_is_a_loud_precondition_failure(tmp_path):
cfg = _cfg(tmp_path) # no diff outputs seeded at all
with pytest.raises(typer.Exit):
run_upload(cfg, uploader=FakeUploader(), sleep=lambda s: None, now=NOW)
def test_second_update_for_same_game_is_deferred(tmp_path):
# the row-edit flow can't target a collid, so only one update per game
# per run is safe
cfg = _cfg(tmp_path)
_seed_data(
tmp_path,
to_update=[
_update_row(collid="9", bgg_id="2"),
_update_row(collid="10", bgg_id="2", vid="26", vname="2nd ed."),
],
)
fake = FakeUploader()
results = run_upload(cfg, uploader=fake, sleep=lambda s: None, now=NOW)
assert [r["collid"] for r in results] == ["9"]
# after the first lands, the next run picks up the deferred one
again = FakeUploader()
results = run_upload(cfg, uploader=again, sleep=lambda s: None, now=NOW)
assert [j.collid for j in again.calls] == ["10"]
def test_real_run_without_credentials_exits_before_any_browser(tmp_path, monkeypatch):
monkeypatch.delenv("BGG_USERNAME", raising=False)
monkeypatch.delenv("BGG_PASSWORD", raising=False)
cfg = _cfg(tmp_path)
_seed_data(tmp_path, to_add=[_add_row()])
with pytest.raises(typer.Exit):
run_upload(cfg, sleep=lambda s: None, now=NOW) # uploader=None: real path
assert not (tmp_path / "upload_log.csv").exists()