Files
bggpipe/tests/test_upload.py
T
Eric WagonerandClaude Fable 5 6ae2667c58 First real upload lands: three browser-flow bugs, found by doing it
A Gentle Rain is on BGG (collid 148198034, version 701315 — the
English Bloom edition matched from a shelf photo), verified by
re-fetching the collection. The failures on the way, all in the
login gate the docs called "verified" and none in the version-picker
code they called "unverified":

1. BGG's Sign In is an <a class="btn"> with NO href, so it has no
   implicit link role: get_by_role("link", name="Sign In") matched
   zero elements in EVERY state, and "no Sign In link" was read as
   "already signed in". Every run browsed anonymously.
2. The header hydrates after domcontentloaded, so for a moment
   neither control exists — a check resting on one absence guesses.
   _signed_out() now polls until the page proves one state or the
   other (Sign In vs Sign Out) and raises after 30s rather than
   assume; login is verified by the transition, and the session file
   saves only after that proof.
3. get_by_label("Own") also matched "Prev. Owned" — strict-mode
   violation; the Own checkbox is now matched exactly.

Also: Playwright's multi-line call logs no longer break upload_log.csv
into ragged rows (errors flatten to one line). Three unit tests cover
the hydration window, positive detection, and the undeterminable case.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-05 22:22:11 -04:00

548 lines
19 KiB
Python

"""Upload-stage tests: queue building, idempotency via upload_log.csv, the
stub-fixture guard, per-game failure isolation, pacing, and verify — all
against a fake uploader. No browser, no network."""
from __future__ import annotations
import csv
import random
from pathlib import Path
import pytest
import typer
from bggpipe.config import Config
from bggpipe.models import CollectionItem
from bggpipe.upload import (
UPLOAD_LOG_COLUMNS,
LoginError,
UploadJob,
_scrub,
build_queue,
run_upload,
verify_uploads,
)
def _now() -> str:
return "2026-08-01T00:00:00+00:00"
def _cfg(tmp_path: Path) -> Config:
return Config(bgg_username="tester", data_dir=tmp_path)
def _write_csv(path: Path, columns: list[str], rows: list[dict]) -> None:
with path.open("w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=columns)
writer.writeheader()
writer.writerows(rows)
def _add_row(bgg_id="1", name="Wingspan", version_id="", version_name=""):
return {
"bgg_id": bgg_id,
"bgg_name": name,
"year": "2019",
"type": "boardgame",
"version_id": version_id,
"version_name": version_name,
"title_raw": name.upper(),
"source_photos": "x.jpg",
}
def _update_row(collid="9", bgg_id="2", name="Britannia", vid="25", vname="AH ed."):
return {
"collid": collid,
"bgg_id": bgg_id,
"bgg_name": name,
"version_id": vid,
"version_name": vname,
}
def _log_row(action="add", bgg_id="1", collid="", version_id="", status="added"):
return {
"action": action,
"bgg_id": bgg_id,
"collid": collid,
"name": "Game",
"version_id": version_id,
"status": status,
"timestamp": _now(),
"error": "",
}
def _seed_data(tmp_path: Path, to_add=None, to_update=None, log=None) -> None:
_write_csv(
tmp_path / "to_add.csv",
list(_add_row().keys()),
to_add if to_add is not None else [],
)
_write_csv(
tmp_path / "to_update.csv",
list(_update_row().keys()),
to_update if to_update is not None else [],
)
if log is not None:
_write_csv(tmp_path / "upload_log.csv", UPLOAD_LOG_COLUMNS, log)
class FakeUploader:
"""Records jobs; raises for names listed in `failures`."""
def __init__(self, failures: set[str] | None = None):
self.calls: list[UploadJob] = []
self.failures = failures or set()
def _handle(self, job: UploadJob, status: str) -> tuple[str, str]:
self.calls.append(job)
if job.name in self.failures:
raise RuntimeError("dialog never appeared")
return status, ""
def add_game(self, job: UploadJob) -> tuple[str, str]:
return self._handle(job, "added")
def update_entry(self, job: UploadJob) -> tuple[str, str]:
return self._handle(job, "updated")
# -- build_queue --------------------------------------------------------
def test_build_queue_skips_logged_successes():
jobs, done, failed, _ = build_queue(
[_add_row(bgg_id="1"), _add_row(bgg_id="2", name="Catan")],
[_update_row(collid="9")],
[
_log_row(action="add", bgg_id="1", status="added"),
_log_row(action="update", bgg_id="2", collid="9", status="updated"),
],
)
assert [j.bgg_id for j in jobs] == ["2"]
assert done == 2
assert failed == 0
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(
[
_add_row(bgg_id="1", version_id="10", version_name="First ed."),
_add_row(bgg_id="1", version_id="11", version_name="Second ed."),
],
[],
[_log_row(action="add", bgg_id="1", version_id="10", status="added")],
)
assert [j.version_id for j in jobs] == ["11"]
assert done == 1
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)
assert jobs == [] and skipped == 1
jobs, _, skipped, _ = build_queue(
[_add_row(bgg_id="1")], [], log, retry_failed=True
)
assert len(jobs) == 1 and skipped == 0
def test_build_queue_latest_log_entry_wins():
# failed then added on retry -> done, not retriable
log = [
_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)
assert jobs == [] and done == 1
# -- stub-fixture guard -------------------------------------------------
def test_real_run_refuses_while_stub_marker_exists(tmp_path):
cfg = _cfg(tmp_path)
cfg.cache_dir.mkdir(parents=True)
(cfg.cache_dir / "STUB_FIXTURES.marker").write_text("stub")
_seed_data(tmp_path, to_add=[_add_row()])
with pytest.raises(typer.Exit):
run_upload(cfg, uploader=FakeUploader(), sleep=lambda s: None, now=_now)
def test_dry_run_allowed_with_stub_marker_and_writes_nothing(tmp_path, capsys):
cfg = _cfg(tmp_path)
cfg.cache_dir.mkdir(parents=True)
(cfg.cache_dir / "STUB_FIXTURES.marker").write_text("stub")
_seed_data(tmp_path, to_add=[_add_row()], to_update=[_update_row()])
run_upload(cfg, dry_run=True, now=_now)
out = capsys.readouterr().out
assert "WARNING" in out and "SYNTHETIC" in out
assert "would add Wingspan" in out
assert "collid 9" in out
assert not (tmp_path / "upload_log.csv").exists()
# -- run_upload with a fake browser -------------------------------------
def test_run_logs_every_attempt_and_continues_past_failures(tmp_path):
cfg = _cfg(tmp_path)
_seed_data(
tmp_path,
to_add=[_add_row(bgg_id="1"), _add_row(bgg_id="2", name="Catan")],
to_update=[_update_row(collid="9")],
)
fake = FakeUploader(failures={"Catan"})
results = run_upload(cfg, uploader=fake, sleep=lambda s: None, now=_now)
assert [r["status"] for r in results] == ["added", "failed", "updated"]
logged = list(csv.DictReader((tmp_path / "upload_log.csv").open()))
assert len(logged) == 3
assert logged[1]["error"] == "RuntimeError: dialog never appeared"
assert logged[2]["collid"] == "9"
def test_rerun_skips_completed_work(tmp_path):
cfg = _cfg(tmp_path)
_seed_data(tmp_path, to_add=[_add_row(bgg_id="1"), _add_row(bgg_id="2", name="C")])
fake = FakeUploader()
run_upload(cfg, uploader=fake, sleep=lambda s: None, now=_now)
assert len(fake.calls) == 2
again = FakeUploader()
results = run_upload(cfg, uploader=again, sleep=lambda s: None, now=_now)
assert again.calls == [] and results == []
def test_pacing_sleeps_2_to_4s_between_games_only(tmp_path):
cfg = _cfg(tmp_path)
_seed_data(tmp_path, to_add=[_add_row(bgg_id=str(i)) for i in range(1, 5)])
sleeps: list[float] = []
run_upload(
cfg,
uploader=FakeUploader(),
sleep=sleeps.append,
rng=random.Random(42),
now=_now,
)
assert len(sleeps) == 3 # between games, not before the first
assert all(2.0 <= s <= 4.0 for s in sleeps)
def test_limit_caps_the_queue(tmp_path):
cfg = _cfg(tmp_path)
_seed_data(tmp_path, to_add=[_add_row(bgg_id=str(i)) for i in range(1, 5)])
fake = FakeUploader()
run_upload(cfg, uploader=fake, limit=2, sleep=lambda s: None, now=_now)
assert len(fake.calls) == 2
# -- credential hygiene -------------------------------------------------
def test_scrub_removes_credentials_from_error_text(monkeypatch):
monkeypatch.setenv("BGG_USERNAME", "erics-user")
monkeypatch.setenv("BGG_PASSWORD", "s3cret-pw")
text = 'fill("erics-user") then fill("s3cret-pw") timed out'
assert "s3cret-pw" not in _scrub(text)
assert "erics-user" not in _scrub(text)
# -- verify -------------------------------------------------------------
def _item(object_id, coll_id, name="Game", version_id=None):
return CollectionItem(
object_id=object_id,
coll_id=coll_id,
name=name,
subtype="boardgame",
own=True,
year=None,
version_id=version_id,
)
def test_verify_flags_missing_and_confirms_present():
log = [
_log_row(action="add", bgg_id="1", status="added"),
_log_row(action="add", bgg_id="2", status="added"),
_log_row(
action="update", bgg_id="3", collid="30", version_id="7", status="updated"
),
]
collection = [_item(1, 10), _item(3, 30, version_id=7)]
problems = verify_uploads(log, collection)
assert len(problems) == 1
assert "not in collection" in problems[0]
def test_verify_checks_version_on_adds_and_updates():
log = [
_log_row(action="add", bgg_id="1", version_id="99", status="added"),
_log_row(
action="update", bgg_id="3", collid="30", version_id="7", status="updated"
),
]
collection = [_item(1, 10, version_id=11), _item(3, 30, version_id=8)]
problems = verify_uploads(log, collection)
assert len(problems) == 2
def test_verify_ignores_failed_rows():
log = [_log_row(action="add", bgg_id="5", status="failed")]
assert verify_uploads(log, []) == []
def test_fresh_clone_marker_blocks_upload_without_cache_dir(tmp_path):
# A fresh clone has the committed data/STUB_DATA.marker but no
# gitignored bgg_cache/ at all — upload must still refuse.
cfg = _cfg(tmp_path)
(tmp_path / "STUB_DATA.marker").write_text("stub-derived CSVs")
_seed_data(tmp_path, to_add=[_add_row()])
with pytest.raises(typer.Exit):
run_upload(cfg, uploader=FakeUploader(), sleep=lambda s: None, now=_now)
# -- failure isolation and resume ---------------------------------------
def test_login_error_aborts_without_poisoning_the_log(tmp_path):
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()
def test_same_key_second_copy_survives_limit_and_interrupts(tmp_path):
# two vetoed duplicate copies share ("add", bgg_id, version): one logged
# success must complete exactly ONE of them, not both
cfg = _cfg(tmp_path)
twin = _add_row(bgg_id="13", name="Catan", version_id="123", version_name="3rd")
_seed_data(tmp_path, to_add=[dict(twin), dict(twin)])
first = FakeUploader()
run_upload(cfg, uploader=first, limit=1, sleep=lambda s: None, now=_now)
assert len(first.calls) == 1
second = FakeUploader()
run_upload(cfg, uploader=second, sleep=lambda s: None, now=_now)
assert len(second.calls) == 1 # the second copy, not zero, not two
third = FakeUploader()
assert run_upload(cfg, uploader=third, sleep=lambda s: None, now=_now) == []
def test_consecutive_failure_counter_resets_on_success(tmp_path):
class FlakyPairs(FakeUploader):
def add_game(self, job):
self.calls.append(job)
if job.bgg_id in ("1", "2", "4", "5"):
raise RuntimeError("dialog never appeared")
return "added", ""
cfg = _cfg(tmp_path)
_seed_data(tmp_path, to_add=[_add_row(bgg_id=str(i)) for i in range(1, 7)])
fake = FlakyPairs()
run_upload(cfg, uploader=fake, sleep=lambda s: None, now=_now)
assert len(fake.calls) == 6 # fail,fail,ok,fail,fail,ok — never aborts
def test_empty_game_name_is_refused_not_uploaded(tmp_path):
cfg = _cfg(tmp_path)
_seed_data(tmp_path, to_add=[_add_row(bgg_id="42", name="")])
fake = FakeUploader()
results = run_upload(cfg, uploader=fake, sleep=lambda s: None, now=_now)
assert results == [] and fake.calls == []
def test_verify_shortfall_reported_once_per_game(tmp_path):
# two DONE adds (different versions) of one game, one copy on BGG:
# the shortfall is reported once per game, not once per logged add
log = [
_log_row(action="add", bgg_id="7", version_id="1", status="added"),
_log_row(action="add", bgg_id="7", version_id="2", status="added"),
]
problems = verify_uploads(log, [_item(7, 70, version_id=1)])
shortfalls = [p for p in problems if "add(s) logged" in p]
assert len(shortfalls) == 1
class _VerifyClient:
def __init__(self, collection):
self.collection = collection
self.calls: list[dict] = []
def collection_full(self, username, *, refresh=False):
self.calls.append({"username": username, "refresh": refresh})
return self.collection
def test_run_upload_verify_wiring(tmp_path, capsys):
# verify=True must re-fetch the LIVE collection (refresh) and cross-check
cfg = _cfg(tmp_path)
_seed_data(tmp_path, to_add=[_add_row(bgg_id="1", name="Wingspan")])
run_upload(cfg, uploader=FakeUploader(), sleep=lambda s: None, now=_now)
client = _VerifyClient([_item(1, 10, name="Wingspan")])
run_upload(
cfg,
uploader=FakeUploader(),
verify=True,
client=client,
sleep=lambda s: None,
now=_now,
)
assert client.calls == [{"username": "tester", "refresh": True}]
assert "Verification OK" in capsys.readouterr().out
def test_verify_marks_second_copy_adds_unverifiable():
log = [
{**_log_row(action="add", bgg_id="13", status="added"), "second_copy": "1"},
]
problems = verify_uploads(log, [_item(13, 900, version_id=7)])
(problem,) = problems
assert "can't be verified" in problem and "confirm by eye" in problem
def test_drift_warning_suppressed_for_multi_edition_partial_run(tmp_path, capsys):
# run 1 added edition A; edition B is still queued alongside A's row:
# that's a two-edition game mid-way, not a re-review drift
cfg = _cfg(tmp_path)
_seed_data(
tmp_path,
to_add=[
_add_row(bgg_id="13", name="Catan", version_id="1", version_name="A"),
_add_row(bgg_id="13", name="Catan", version_id="2", version_name="B"),
],
log=[_log_row(action="add", bgg_id="13", version_id="1", status="added")],
)
run_upload(cfg, uploader=FakeUploader(), sleep=lambda s: None, now=_now)
assert "manual correction" not in capsys.readouterr().out
# -- sign-in detection: never guess from a single absence ----------------
class _FakePage:
"""Counts per selector, replayed one poll at a time — the header
hydrates late, so the first reads legitimately see nothing."""
def __init__(self, script):
self.script = list(script)
self.polls = 0
def locator(self, selector):
counts = self.script[min(self.polls, len(self.script) - 1)]
class _Loc:
def count(self):
return counts["out" if "Sign In" in selector else "in"]
return _Loc()
def wait_for_timeout(self, ms):
self.polls += 1
def _uploader_with(script):
from bggpipe.upload import PlaywrightUploader
up = PlaywrightUploader("someone")
up._page = _FakePage(script)
return up
def test_signed_out_waits_through_hydration():
# neither control for two polls (the window the old check misread as
# "already signed in"), then the Sign In control appears
up = _uploader_with([{"out": 0, "in": 0}, {"out": 0, "in": 0}, {"out": 1, "in": 0}])
assert up._signed_out() is True
def test_signed_in_is_detected_positively():
up = _uploader_with([{"out": 0, "in": 0}, {"out": 0, "in": 1}])
assert up._signed_out() is False
def test_undeterminable_state_raises_instead_of_guessing(monkeypatch):
from bggpipe.upload import LoginError
ticks = iter([0, 1, 2, 31]) # force the deadline to pass
monkeypatch.setattr("time.monotonic", lambda: next(ticks))
up = _uploader_with([{"out": 0, "in": 0}])
with pytest.raises(LoginError, match="could not tell"):
up._signed_out()