Files
bggpipe/tests/test_diff.py
T
Eric Wagoner 08b741671d Re-audit round 4: 5 blind reviewers over the new surface — 24 fixes, +28 tests
The findings clustered exactly where prediction said: the unreviewed web
layer. The big ones: decisions made while an extract/resolve job runs
are now refused with a 409 (the job's end-of-run rewrite from a
start-of-run snapshot would silently revert them); a cross-origin guard
blocks preflight-free mutations from hostile webpages (bodyless run
triggers, cross-site photo form posts); the JobRunner sets terminal
status in a finally catching BaseException (a greenlet death could
wedge every future run behind 409s) and writes tracebacks into the
visible job log; and a boot token lets clients accept the revision
reset after a server restart instead of freezing forever.

Even the thrice-audited core yielded one HIGH: an unvetoed bare
typo-read sibling of a confident row duplicated its add when the game
wasn't in the collection — diff now treats it as satisfied. Second-copy
adds carry a flag through to_add.csv and the upload log so verify
honestly reports them unverifiable instead of OK. Also: merged_into
chains collapse transitively; diff/enrich treat a BGG queue timeout
like a missing token; enrich prunes orphaned games.json keys; the
wizard shell-quotes .env values and creates the file 0600 from the
first byte; fsio stats the tmp inode before replace and uses unique tmp
names; an explicit missing --config errors; storage state is
owner-only; extract re-extracts corrupt caches, aborts on 3 identical
failures, and exits nonzero when nothing succeeded; torn JSON artifacts
degrade with in-browser warnings instead of 500ing every page; photo
uploads are atomic with cache-invalidation ordered first; the pipeline
page computes `running` before the buttons that depend on it; the
photo dropzone alerts on network failure; and lost-contact banners
clear on recovery everywhere.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 18:13:34 -04:00

400 lines
14 KiB
Python

"""Diff-stage tests: pure compute_diff cases plus the real 2018 collection
snapshots as parsing fixtures. No network anywhere."""
from __future__ import annotations
import shutil
from pathlib import Path
from bggpipe.config import Config
from bggpipe.diff import (
SNAPSHOT_FILES,
compute_diff,
load_snapshot_collection,
run_diff,
)
from bggpipe.models import CollectionItem
from bggpipe.resolve import write_matches
from bggpipe.upload import run_upload
FIXTURES = Path(__file__).parent / "fixtures"
def _item(object_id, coll_id, name="Game", version_id=None, own=True):
return CollectionItem(
object_id=object_id,
coll_id=coll_id,
name=name,
subtype="boardgame",
own=own,
year=None,
version_id=version_id,
)
def _match(
title, bgg_id="", status="auto", vstatus="version_unknown", vid="", vname=""
):
return {
"title_raw": title,
"bgg_id": bgg_id,
"bgg_name": title.title(),
"year": "2000",
"type": "boardgame",
"match_status": status,
"version_id": vid,
"version_name": vname,
"version_status": vstatus,
"candidates_json": "[]",
"version_candidates_json": "[]",
"source_photos": "x.jpg",
}
# -- snapshot loading ---------------------------------------------------
def test_load_real_snapshots_merges_and_dedupes():
collection = load_snapshot_collection(FIXTURES)
# 79 base + 3 expansions, but all 3 expansion collids also appear in
# the base file -> 79 unique physical copies
assert len(collection) == 79
assert len({c.coll_id for c in collection}) == 79
by_id = {c.object_id: c for c in collection}
assert by_id[207830].name == "5-Minute Dungeon"
assert by_id[177].name == "Advanced Civilization"
# hand-entered in 2018: every entry is version-less (parsed, not assumed)
assert all(c.version_id is None for c in collection)
# -- compute_diff -------------------------------------------------------
def test_new_game_goes_to_add_with_version():
result = compute_diff(
[
_match(
"Cat Crimes",
"235096",
vstatus="version_auto",
vid="360982",
vname="ThinkFun edition",
)
],
[_item(13, 1)],
)
(row,) = result.to_add
assert row["bgg_id"] == "235096"
assert row["version_id"] == "360982"
assert not result.to_update
def test_owned_versionless_plus_confident_version_goes_to_update():
result = compute_diff(
[
_match(
"Britannia",
"240",
vstatus="version_auto",
vid="55555",
vname="AH English edition",
)
],
[_item(240, 900001, name="Britannia")],
)
assert result.already_owned == ["Britannia"]
(row,) = result.to_update
assert row == {
"collid": "900001",
"bgg_id": "240",
"bgg_name": "Britannia",
"version_id": "55555",
"version_name": "AH English edition",
}
assert not result.to_add
def test_owned_with_matching_version_is_just_owned():
result = compute_diff(
[_match("Wingspan", "266192", vstatus="version_auto", vid="465063")],
[_item(266192, 5, version_id=465063)],
)
assert result.already_owned == ["Wingspan"]
assert not result.to_update and not result.to_add
def test_version_mismatch_with_unclaimed_copy_is_report_only():
# ONE row, ONE copy with a different version: most likely the same
# physical box mis-scored. Spec: report the disagreement, touch nothing,
# and never risk uploading a duplicate entry.
result = compute_diff(
[
_match(
"Wingspan",
"266192",
vstatus="version_auto",
vid="521212",
vname="fourth printing",
)
],
[_item(266192, 5, version_id=465063)],
)
assert not result.to_update and not result.to_add
assert result.already_owned == ["Wingspan"]
assert "fourth printing" in result.disagreements[0]
def test_vetoed_duplicate_of_same_version_is_a_real_second_copy():
# Two rows, same confident version, ONE owned copy with that version:
# a human vetoed the merge ("these ARE two boxes"), so the exact-version
# match must consume the copy and the second row must become an add.
rows = [
_match("Catan", "13", vstatus="version_auto", vid="123", vname="3rd ed."),
_match("Catan", "13", vstatus="version_auto", vid="123", vname="3rd ed."),
]
result = compute_diff(rows, [_item(13, 900, version_id=123)])
assert result.already_owned == ["Catan"]
assert [r["version_id"] for r in result.to_add] == ["123"]
assert len(result.second_copies) == 1
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():
# row A (v3, no match) must not consume the v2 copy that row B exactly
# matches — exact-version claims settle before disagreements
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 matches no collection entry: a genuine new copy
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
rows = [
_match("Catan", "13"),
_match("Catan", "13", vstatus="version_auto", vid="55", vname="5th ed."),
]
result = compute_diff(rows, [_item(13, 900), _item(13, 901)])
assert [u["version_id"] for u in result.to_update] == ["55"]
assert result.to_add == []
assert result.already_owned.count("Catan") == 2
def test_version_unknown_owned_by_bare_id():
result = compute_diff(
[_match("Catan", "13")],
[_item(13, 1, name="Catan")],
)
assert result.already_owned == ["Catan"]
assert not result.to_add and not result.to_update
def test_two_photo_editions_consume_distinct_collids():
matches = [
_match("Cosmic A", "39", vstatus="version_auto", vid="111"),
_match("Cosmic B", "39", vstatus="version_auto", vid="222"),
]
collection = [_item(39, 701), _item(39, 702)]
result = compute_diff(matches, collection)
assert {r["collid"] for r in result.to_update} == {"701", "702"}
assert {r["version_id"] for r in result.to_update} == {"111", "222"}
def test_pending_rejected_and_unseen_are_reported():
matches = [
_match("Mystery Spine", status="ambiguous"),
_match("Junk", status="rejected"),
_match("Catan", "13"),
]
collection = [_item(13, 1, name="Catan"), _item(9209, 2, name="Ticket to Ride")]
result = compute_diff(matches, collection)
assert result.pending == ["Mystery Spine"]
assert result.rejected == 1
assert [c.object_id for c in result.unseen] == [9209] # informational
assert result.recognized == 1
def test_merged_rows_are_skipped_but_photos_carry_to_survivor():
matches = [
_match("Joking Hazard", "193621"),
{
**_match("Jokin Ha...", "193621", status="merged"),
"merged_into": "Joking Hazard",
"source_photos": "other.jpg",
},
]
result = compute_diff(matches, []) # empty collection -> to_add
assert result.merged == 1
assert result.pending == [] # merged is not "needs review"
(row,) = result.to_add
assert row["title_raw"] == "Joking Hazard"
assert row["source_photos"] == "other.jpg;x.jpg" # combined
def test_versionless_copies_exhaust_then_second_copy_becomes_add():
# two confident-version matches, ONE versionless copy: the first consumes
# it (to_update), the second is an additional physical copy (to_add)
result = compute_diff(
[
_match("Sorcerer", "39", vstatus="version_auto", vid="111", vname="1st"),
_match("Sorcerer", "39", vstatus="version_auto", vid="222", vname="2nd"),
],
[_item(39, 701)],
)
assert [u["version_id"] for u in result.to_update] == ["111"]
assert [a["version_id"] for a in result.to_add] == ["222"]
assert len(result.second_copies) == 1
def test_run_diff_outputs_feed_upload_unchanged(tmp_path, monkeypatch):
# the cross-stage contract: whatever run_diff writes, run_upload must
# read — a column rename on either side has to fail HERE
monkeypatch.delenv("BGG_API_TOKEN", raising=False)
cfg = Config(data_dir=tmp_path)
fixtures = Path(__file__).parent / "fixtures"
for name in SNAPSHOT_FILES:
shutil.copy(fixtures / name, tmp_path / name)
write_matches(
cfg.matches_path,
[
_match("Wingspan", "266192", status="auto"), # not in the snapshots
_match("5 MINUTE DUNGEON", "207830", status="auto"), # owned
],
)
result = run_diff(cfg)
assert [r["bgg_id"] for r in result.to_add] == ["266192"]
from test_upload import FakeUploader
fake = FakeUploader()
run_upload(cfg, uploader=fake, sleep=lambda s: None, now=lambda: "t")
assert [(j.action, j.bgg_id, j.name) for j in fake.calls] == [
("add", "266192", "Wingspan")
]
def test_token_without_username_says_so(tmp_path, monkeypatch, capsys):
monkeypatch.setenv("BGG_API_TOKEN", "tok")
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_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 token IS set; blame the username
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
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):
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
def test_bare_sibling_of_confident_add_is_not_a_duplicate_upload():
# photo A reads "Wingspan" (confident version), photo B misreads
# "Wingspam" (bare) resolving to the same absent game: ONE add, not two
rows = [
_match("Wingspan", "266192", vstatus="version_auto", vid="1", vname="1st"),
_match("Wingspam", "266192"),
]
result = compute_diff(rows, [])
assert len(result.to_add) == 1
assert result.already_owned == ["Wingspam"]
def test_vetoed_bare_sibling_still_adds_for_absent_game():
vetoed = {**_match("Wingspan", "266192"), "dedupe_veto": "1"}
rows = [
_match("Wingspan", "266192", vstatus="version_auto", vid="1", vname="1st"),
vetoed,
]
result = compute_diff(rows, [])
assert len(result.to_add) == 2
assert result.to_add[1]["second_copy"] == "1"
def test_second_copy_flag_travels_on_exhausted_adds():
rows = [
_match("Catan", "13", vstatus="version_auto", vid="123", vname="3rd"),
_match("Catan", "13", vstatus="version_auto", vid="123", vname="3rd"),
]
result = compute_diff(rows, [_item(13, 900, version_id=123)])
(added,) = result.to_add
assert added["second_copy"] == "1"