Re-audit round 3: 5 blind reviewers, 15 fixes, +18 tests — converging
Round 3's two HIGHs: _fill_version resolved versions with the LAST same-title entry's cues (photo-aware lookup existed since round 1 but this caller never used it), and the round-2 diff rework let an earlier row's disagreement consume the exact-version copy a later row matched. Diff claims now settle strongest-first across all rows (exact matches, then versionless upgrades, then disagreement/second-copy), unvetoed bare duplicates stay owned per spec, and updates are withheld with a manual-fix note whenever any copy of the game already carries a version (the row edit targets by name and could hit the wrong copy). Also: entry-to-row pairing matches by photo overlap before position (titles.json order churn from reshoot filenames could swap editions); BGGQueueTimeout defers a title like a missing token; DismissStore writes atomically, mutates memory only after the write, and quarantines a torn file instead of bricking the server; version-picker page-limit exhaustion stays retryable; verify's copy-count shortfall reports once per game (the old guard was dead code); the upload log header is created atomically; transient version-lookup failures record a retryable version_error, not terminal version_unknown; extract isolates per-photo failures and salvages JSON followed by prose; a state revision counter stops stale poll responses reverting decisions; plus the shared-predicate/fsio/docstring consolidation and CLI wiring, live-diff, verify-wiring, and search-guard tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+91
-15
@@ -6,14 +6,10 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
|
||||
from bggpipe.config import Config
|
||||
from bggpipe.diff import compute_diff, load_snapshot_collection
|
||||
from bggpipe.diff import SNAPSHOT_FILES, compute_diff, load_snapshot_collection
|
||||
from bggpipe.models import CollectionItem
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures"
|
||||
SNAPSHOT_NAMES = (
|
||||
"collection_snapshot_base.xml",
|
||||
"collection_snapshot_expansions.xml",
|
||||
)
|
||||
|
||||
|
||||
def _item(object_id, coll_id, name="Game", version_id=None, own=True):
|
||||
@@ -154,17 +150,50 @@ def test_vetoed_duplicate_of_same_version_is_a_real_second_copy():
|
||||
assert len(result.second_copies) == 1
|
||||
|
||||
|
||||
def test_bare_duplicate_beyond_owned_count_is_added_versionless():
|
||||
# Two vetoed version-unknown rows, one owned copy: the extra bare row
|
||||
# is a version-less second copy, not silently "already owned".
|
||||
rows = [_match("Catan", "13"), _match("Catan", "13")]
|
||||
result = compute_diff(rows, [_item(13, 900)])
|
||||
def test_vetoed_bare_duplicate_beyond_owned_count_is_added_versionless():
|
||||
# Two version-unknown rows, one owned copy: only a HUMAN VETO makes the
|
||||
# extra bare row a genuine second copy (spec: a bare id is owned if any
|
||||
# copy exists — an unvetoed typo-read sibling must not upload).
|
||||
vetoed = {**_match("Catan", "13"), "dedupe_veto": "1"}
|
||||
result = compute_diff([_match("Catan", "13"), vetoed], [_item(13, 900)])
|
||||
assert result.already_owned == ["Catan"]
|
||||
(added,) = result.to_add
|
||||
assert added["version_id"] == ""
|
||||
assert len(result.second_copies) == 1
|
||||
|
||||
|
||||
def test_unvetoed_bare_duplicate_stays_owned():
|
||||
# same shape WITHOUT the veto: both rows owned, nothing uploaded
|
||||
rows = [_match("Catan", "13"), _match("Catan", "13")]
|
||||
result = compute_diff(rows, [_item(13, 900)])
|
||||
assert result.already_owned == ["Catan", "Catan"]
|
||||
assert result.to_add == []
|
||||
|
||||
|
||||
def test_earlier_disagreement_cannot_steal_a_later_rows_exact_match():
|
||||
# round-3 ordering bug: row A (v3, no match) must not consume the v2
|
||||
# copy that row B exactly matches — exact matches settle first
|
||||
rows = [
|
||||
_match("Catan", "13", vstatus="version_auto", vid="3", vname="v3"),
|
||||
_match("Catan", "13", vstatus="version_auto", vid="2", vname="v2"),
|
||||
]
|
||||
result = compute_diff(rows, [_item(13, 900, version_id=2)])
|
||||
assert result.already_owned == ["Catan"] # B's exact match claims the copy
|
||||
# A's v3 box exists on the shelf and matches no collection entry: a
|
||||
# genuine new copy — NOT a spurious v2 duplicate, NOT a false disagreement
|
||||
assert [r["version_id"] for r in result.to_add] == ["3"]
|
||||
assert result.disagreements == []
|
||||
|
||||
|
||||
def test_update_withheld_when_another_copy_is_versioned():
|
||||
# upload's row edit targets by NAME: an update is only safe when every
|
||||
# copy is versionless, else it could overwrite the versioned copy
|
||||
rows = [_match("Catan", "13", vstatus="version_auto", vid="5", vname="5th")]
|
||||
result = compute_diff(rows, [_item(13, 900, version_id=7), _item(13, 901)])
|
||||
assert result.to_update == []
|
||||
assert "set it by hand" in result.disagreements[0]
|
||||
|
||||
|
||||
def test_bare_row_does_not_steal_versionless_copy_from_confident_update():
|
||||
# ordering independence: the confident row upgrades the versionless
|
||||
# copy even when a bare row of the same game appears first in the file
|
||||
@@ -256,10 +285,7 @@ def test_run_diff_outputs_feed_upload_unchanged(tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("BGG_API_TOKEN", raising=False)
|
||||
cfg = Config(data_dir=tmp_path)
|
||||
fixtures = Path(__file__).parent / "fixtures"
|
||||
for name in (
|
||||
"collection_snapshot_base.xml",
|
||||
"collection_snapshot_expansions.xml",
|
||||
):
|
||||
for name in SNAPSHOT_FILES:
|
||||
shutil.copy(fixtures / name, tmp_path / name)
|
||||
write_matches(
|
||||
cfg.matches_path,
|
||||
@@ -290,10 +316,60 @@ def test_token_without_username_says_so(tmp_path, monkeypatch, capsys):
|
||||
monkeypatch.delenv("BGG_USERNAME", raising=False)
|
||||
cfg = Config(data_dir=tmp_path) # bgg_username defaults to ""
|
||||
fixtures = Path(__file__).parent / "fixtures"
|
||||
for name in SNAPSHOT_NAMES:
|
||||
for name in SNAPSHOT_FILES:
|
||||
shutil.copy(fixtures / name, tmp_path / name)
|
||||
write_matches(cfg.matches_path, [_match("Catan", "13")])
|
||||
run_diff(cfg)
|
||||
out = capsys.readouterr().out
|
||||
assert "BGG_API_TOKEN is set but BGG_USERNAME is not" in out
|
||||
assert "No BGG_API_TOKEN" not in out # the old message was a lie here
|
||||
|
||||
|
||||
class _LiveClient:
|
||||
"""Fake client recording collection_full calls for the live branch."""
|
||||
|
||||
def __init__(self, collection, fail_auth=False):
|
||||
self.collection = collection
|
||||
self.fail_auth = fail_auth
|
||||
self.calls: list[dict] = []
|
||||
|
||||
def collection_full(self, username, *, refresh=False):
|
||||
from bggpipe.bgg_client import BGGAuthError
|
||||
|
||||
self.calls.append({"username": username, "refresh": refresh})
|
||||
if self.fail_auth:
|
||||
raise BGGAuthError("token rejected")
|
||||
return self.collection
|
||||
|
||||
|
||||
def test_live_diff_fetches_fresh_collection(tmp_path, monkeypatch):
|
||||
# the branch that runs the day the token arrives: must call
|
||||
# collection_full with refresh=True, not serve resolve-era cache
|
||||
|
||||
from bggpipe.diff import run_diff
|
||||
from bggpipe.resolve import write_matches
|
||||
|
||||
monkeypatch.setenv("BGG_API_TOKEN", "tok")
|
||||
monkeypatch.setenv("BGG_USERNAME", "eric")
|
||||
cfg = Config(bgg_username="eric", data_dir=tmp_path)
|
||||
write_matches(cfg.matches_path, [_match("Catan", "13")])
|
||||
client = _LiveClient([_item(13, 1, name="Catan")])
|
||||
result = run_diff(cfg, client=client)
|
||||
assert client.calls == [{"username": "eric", "refresh": True}]
|
||||
assert result.already_owned == ["Catan"]
|
||||
|
||||
|
||||
def test_live_diff_falls_back_to_snapshots_on_auth_failure(tmp_path, monkeypatch):
|
||||
import shutil
|
||||
|
||||
from bggpipe.diff import run_diff
|
||||
from bggpipe.resolve import write_matches
|
||||
|
||||
monkeypatch.setenv("BGG_API_TOKEN", "bad")
|
||||
monkeypatch.setenv("BGG_USERNAME", "eric")
|
||||
cfg = Config(bgg_username="eric", data_dir=tmp_path)
|
||||
for name in SNAPSHOT_FILES:
|
||||
shutil.copy(FIXTURES / name, tmp_path / name)
|
||||
write_matches(cfg.matches_path, [_match("5 MINUTE DUNGEON", "207830")])
|
||||
result = run_diff(cfg, client=_LiveClient([], fail_auth=True))
|
||||
assert result.already_owned == ["5 MINUTE DUNGEON"] # snapshots served
|
||||
|
||||
Reference in New Issue
Block a user