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:
+88
-5
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user