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:
+95
-1
@@ -9,12 +9,13 @@ import json
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from rich.console import Console
|
||||
|
||||
from bggpipe.bgg_client import BGGClient
|
||||
from bggpipe.config import Config
|
||||
from bggpipe.resolve import read_matches, write_matches
|
||||
from bggpipe.review import run_review
|
||||
from bggpipe.review import ReviewSession, run_review
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures" / "bgg_cache"
|
||||
|
||||
@@ -288,3 +289,96 @@ def test_version_pass_is_skippable(tmp_path):
|
||||
)
|
||||
(row,) = read_matches(cfg.matches_path)
|
||||
assert row["version_status"] == "version_ambiguous" # untouched, review later
|
||||
|
||||
|
||||
# -- audit-fix regressions ----------------------------------------------
|
||||
|
||||
|
||||
def test_veto_merge_persists_against_future_dedupe(tmp_path):
|
||||
from bggpipe.resolve import read_matches
|
||||
|
||||
cfg = _setup(
|
||||
tmp_path,
|
||||
[
|
||||
_row(title_raw="CATAN", match_status="merged", merged_into="Catan"),
|
||||
_row(title_raw="Catan", match_status="auto", bgg_id="13"),
|
||||
],
|
||||
)
|
||||
session = ReviewSession(
|
||||
cfg,
|
||||
console=quiet_console(),
|
||||
input_fn=scripted(),
|
||||
client=unauthorized_client(tmp_path),
|
||||
)
|
||||
merged = next(r for r in session.rows if r["match_status"] == "merged")
|
||||
session.veto_merge(merged)
|
||||
saved = {r["title_raw"]: r for r in read_matches(cfg.matches_path)}
|
||||
assert saved["CATAN"]["match_status"] == "approved"
|
||||
assert saved["CATAN"]["dedupe_veto"] == "1" # survives resolve re-runs
|
||||
|
||||
|
||||
def test_failed_save_never_leaves_memory_ahead_of_disk(tmp_path, monkeypatch):
|
||||
cfg = _setup(tmp_path, [_row(title_raw="Mystery", match_status="unmatched")])
|
||||
session = ReviewSession(
|
||||
cfg,
|
||||
console=quiet_console(),
|
||||
input_fn=scripted(),
|
||||
client=unauthorized_client(tmp_path),
|
||||
)
|
||||
row = session.rows[0]
|
||||
|
||||
import bggpipe.review as review_mod
|
||||
|
||||
def exploding_write(path, rows):
|
||||
raise OSError("disk full")
|
||||
|
||||
monkeypatch.setattr(review_mod, "write_matches", exploding_write)
|
||||
with pytest.raises(OSError):
|
||||
session.decide_reject(row)
|
||||
# memory was rolled back to what disk actually holds
|
||||
assert session.rows[0]["match_status"] == "unmatched"
|
||||
assert session.decisions == 0
|
||||
|
||||
|
||||
def test_save_merges_own_decision_over_concurrent_external_rewrite(tmp_path):
|
||||
from bggpipe.resolve import read_matches, write_matches
|
||||
|
||||
cfg = _setup(tmp_path, [_row(title_raw="Mystery", match_status="unmatched")])
|
||||
session = ReviewSession(
|
||||
cfg,
|
||||
console=quiet_console(),
|
||||
input_fn=scripted(),
|
||||
client=unauthorized_client(tmp_path),
|
||||
)
|
||||
row = session.rows[0]
|
||||
|
||||
# resolve appends a new row in another terminal AFTER our session loaded
|
||||
external = read_matches(cfg.matches_path)
|
||||
external.append(_row(title_raw="Newcomer", match_status="auto", bgg_id="7"))
|
||||
write_matches(cfg.matches_path, external)
|
||||
|
||||
session.decide_reject(row)
|
||||
saved = {r["title_raw"]: r for r in read_matches(cfg.matches_path)}
|
||||
assert saved["Mystery"]["match_status"] == "rejected" # our decision
|
||||
assert "Newcomer" in saved # their row survived too
|
||||
|
||||
|
||||
def test_manual_id_unknown_to_bgg_warns_instead_of_crashing(tmp_path):
|
||||
# an empty /thing result (mistyped id) used to crash the whole session
|
||||
import httpx as _httpx
|
||||
|
||||
empty_things = BGGClient(
|
||||
cache_dir=tmp_path / "cache",
|
||||
transport=_httpx.MockTransport(
|
||||
lambda req: _httpx.Response(200, text='<items total="0"></items>')
|
||||
),
|
||||
sleep=lambda s: None,
|
||||
)
|
||||
cfg = _setup(tmp_path, [_row(title_raw="Mystery", match_status="unmatched")])
|
||||
session = ReviewSession(
|
||||
cfg, console=quiet_console(), input_fn=scripted(), client=empty_things
|
||||
)
|
||||
session.decide_manual(session.rows[0], 999999)
|
||||
assert session.rows[0]["match_status"] == "approved"
|
||||
assert session.rows[0]["bgg_id"] == "999999"
|
||||
assert any("no game with id 999999" in w for w in session.warnings)
|
||||
|
||||
Reference in New Issue
Block a user