"""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, annotate_queue, build_queue, stale_jobs, 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_same_game_updates_run_together(tmp_path): # update_entry addresses the copy by collid and the edition by radio # value, so two copies of one game are safe in a single run 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", "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() # -- version picker: pagination + never-guess matching ------------------- class _FakePicker: """Stand-in for the picker sub-view. Models the two traps the real one sets: paging controls render twice (a hidden mobile duplicate first), and paging state SURVIVES closing and reopening the sub-view.""" def __init__(self, pages): self.pages = pages self.page = 0 self.clicked = None self.clicked_index = None self.cancelled = False self.reopened = 0 def locator(self, selector): picker = self is_next = 'title="Next Page"' in selector is_pager = "pagination" in selector # anchors: [hidden mobile "1", "1", "2", ...] mirroring the real DOM anchors = ["1"] + [str(n + 1) for n in range(len(picker.pages))] class _Loc: def __init__(self, index=0): self.index = index def all_text_contents(self): return picker.pages[picker.page] def count(self): if is_next: return 2 # hidden mobile + visible desktop if is_pager: return len(anchors) return len(picker.pages[picker.page]) def nth(self, i): return _Loc(i) @property def first(self): return _Loc(0) def is_visible(self): return not (is_pager and self.index == 0) def text_content(self): return anchors[self.index] if is_pager and not is_next else "" def wait_for(self, **kw): return None def evaluate(self, _js): last = picker.page >= len(picker.pages) - 1 return "ng-scope disabled" if last else "ng-scope" def click(self): if is_pager and not self.is_visible(): raise AssertionError("clicked a HIDDEN paging control") if is_next: picker.page += 1 elif is_pager: picker.page = int(anchors[self.index]) - 1 else: picker.clicked_index = self.index picker.clicked = picker.pages[picker.page][self.index] def filter(self, has_text=None): return self return _Loc() def get_by_role(self, role, name=None, **kw): picker = self class _Btn: @property def first(self): return self def click(self): if name == "Cancel": picker.cancelled = True elif name == "Set version/edition": picker.reopened += 1 # note: does NOT reset the page return _Btn() def _picker_uploader(pages): from bggpipe.upload import PlaywrightUploader up = PlaywrightUploader("someone") class _Page: def wait_for_timeout(self, ms): pass up._page = _Page() return up, _FakePicker(pages) def test_version_found_on_a_later_page(): up, dialog = _picker_uploader( [ ["Sleeping Gods (Czech edition) (2023)"], ["Sleeping Gods (English Gamefound edition) (2023)"], ] ) picked, why = up._select_version(dialog, "English Gamefound edition") assert picked is True # the old code gave up after page one assert dialog.clicked_index == 0 # clicked by index, not by regex assert why == "" def test_api_name_with_printing_qualifier_matches_loosely(): up, dialog = _picker_uploader([["Mysterium (English edition) (2018)"]]) picked, why = up._select_version(dialog, "English edition 2018-2") assert picked is True assert "matched loosely" in why def test_ambiguous_versions_are_refused_not_guessed(): up, dialog = _picker_uploader( [ [ "Munchkin (English edition) (2009)", "Munchkin (English edition) (2015)", ] ] ) picked, why = up._select_version(dialog, "English edition 2009") assert picked is False assert "refusing to guess" in why assert dialog.cancelled is True def test_absent_version_reports_pages_scanned(): up, dialog = _picker_uploader([["Catan (German edition) (2015)"]]) picked, why = up._select_version(dialog, "English edition") assert picked is False assert "not offered" in why and "1 page(s)" in why def test_paging_never_clicks_a_hidden_mobile_control(): """BGG renders First/Prev/Next twice — a desktop set and a mobile set that is invisible on a desktop viewport. Clicking the hidden one hangs until timeout (it did, on Munchkin Big Box and Tang Garden).""" up, dialog = _picker_uploader( [ ["Munchkin (German edition) (2010)"], ["Munchkin (English edition) (2009)"], ] ) picked, _ = up._select_version(dialog, "English edition 2009") assert picked is True # no AssertionError from the fake = no hidden click assert dialog.clicked_index == 0 # found on the later page, clicked def test_outstanding_failures_ignores_retried_jobs(): """upload_log.csv is append-only: a failure line stays forever, so the UI badge must count LAST status per job, not every 'failed' ever.""" from bggpipe.upload import outstanding_failures rows = [ { "action": "add", "bgg_id": "1", "collid": "", "version_id": "", "status": "failed", }, { "action": "add", "bgg_id": "1", "collid": "", "version_id": "", "status": "added", }, { "action": "add", "bgg_id": "2", "collid": "", "version_id": "", "status": "failed", }, ] assert outstanding_failures(rows) == 1 # game 1 was retried and landed assert outstanding_failures([]) == 0 def test_paging_state_survives_reopen_so_page_one_is_clicked(): """The real sub-view reopens wherever it was left, so the second pass must navigate back to page 1 explicitly — Sleeping Gods and Gloomhaven both "vanished" when it didn't.""" up, dialog = _picker_uploader( [ ["Sleeping Gods (English Gamefound edition) (2023)"], ["Sleeping Gods (German edition) (2022)"], ] ) dialog.page = 1 # left on the last page by a previous scan picked, why = up._select_version(dialog, "English Gamefound edition") assert picked is True assert why == "" assert dialog.clicked.startswith("Sleeping Gods (English Gamefound") def test_stale_queue_jobs_are_skipped(tmp_path): """to_add.csv is a snapshot from the last diff. A review decision made afterwards — marking a game local, rejecting it, calling the match wrong — must win over the stale queue.""" from bggpipe.upload import stale_jobs queue = [{"bgg_id": "13"}, {"bgg_id": "140509"}, {"bgg_id": "999"}] matches = [ {"bgg_id": "13", "match_status": "auto"}, {"bgg_id": "140509", "match_status": "local"}, ] stale = stale_jobs(queue, matches) assert "13" not in stale # still endorsed assert "now local" in stale["140509"] assert "no longer matched" in stale["999"] # unverifiable (no matches.csv at all) never condemns a job assert stale_jobs(queue, []) == {} def test_failure_badge_ignores_jobs_the_human_retired(): """A failed job whose review decision has since changed will never run again — offering to retry it is a lie the badge kept telling.""" from bggpipe.upload import outstanding_failures log = [ { "action": "add", "bgg_id": "13", "collid": "", "version_id": "", "status": "failed", }, { "action": "add", "bgg_id": "140509", "collid": "", "version_id": "", "status": "failed", }, ] queue = [{"bgg_id": "13"}, {"bgg_id": "140509"}] matches = [ {"bgg_id": "13", "match_status": "auto"}, {"bgg_id": "", "match_status": "local"}, # 140509 was made local ] assert outstanding_failures(log) == 2 # log-only view: both look pending assert outstanding_failures(log, queue, matches) == 1 # only the live one def test_update_targets_collid_and_version_id_exactly(): """The collection row's version cell carries its collid; the editor's radios carry version ids. Both are addressed by value, so neither the copy nor the edition can be picked by resemblance.""" from bggpipe.upload import PlaywrightUploader, UploadJob seen = {"clicked": [], "text": "Editing"} class _Loc: def __init__(self, selector): self.selector = selector @property def first(self): return self def count(self): return 1 def wait_for(self, **kw): return None def click(self): seen["clicked"].append(self.selector) if "radio" in self.selector: seen["text"] = "Avalon Hill second edition" # CE_SaveData ran def text_content(self): return seen["text"] class _Page: def goto(self, url, **kw): seen["url"] = url def locator(self, selector): return _Loc(selector) def wait_for_timeout(self, ms): pass up = PlaywrightUploader("someone") up._page = _Page() up._authed = True job = UploadJob( action="update", bgg_id=240, name="Britannia", collid="53429559", version_id="24621", version_name="Avalon Hill second edition", ) status, note = up.update_entry(job) assert status == "updated" and note == "" assert "objectid=240" in seen["url"] assert any('onclick*="53429559"' in s for s in seen["clicked"]) # the copy assert any('value="24621"' in s for s in seen["clicked"]) # the edition def test_run_upload_skips_jobs_the_review_retired(tmp_path, capsys): """The queue is a snapshot from the last diff; a review decision taken afterwards (local, rejected, re-matched) outranks it. This must hold through run_upload's actual wiring, not just stale_jobs in isolation — a filter that pruned only to_add would still pass the unit test while uploading a retired version update.""" from bggpipe.resolve import MATCH_COLUMNS cfg = _cfg(tmp_path) _seed_data( tmp_path, to_add=[_add_row(bgg_id="140509", name="Dungeons & Dragons")], to_update=[_update_row(collid="9", bgg_id="240", name="Britannia")], ) blank = dict.fromkeys(MATCH_COLUMNS, "") _write_csv( tmp_path / "matches.csv", MATCH_COLUMNS, [ # 140509 was re-reviewed: it's the RPG blue box, kept local {**blank, "title_raw": "D&D", "match_status": "local", "bgg_id": ""}, # 240 vanished from matches entirely (removed from the catalog) {**blank, "title_raw": "WINGSPAN", "match_status": "auto", "bgg_id": "1"}, ], ) fake = FakeUploader() run_upload(cfg, uploader=fake, sleep=lambda s: None, now=_now) assert fake.calls == [] out = capsys.readouterr().out assert "Skipping 2" in out # retired jobs leave no trace to be "already done" later assert not (tmp_path / "upload_log.csv").exists() # control: an endorsed job still runs _write_csv( tmp_path / "matches.csv", MATCH_COLUMNS, [ {**blank, "title_raw": "X", "match_status": "auto", "bgg_id": "140509"}, { **blank, "title_raw": "Y", "match_status": "approved", "bgg_id": "240", "version_id": "25", # per-copy endorsement: version must agree }, ], ) run_upload(cfg, uploader=fake, sleep=lambda s: None, now=_now) assert [(j.action, j.bgg_id) for j in fake.calls] == [ ("add", "140509"), ("update", "240"), ] def test_rejected_credentials_raise_and_persist_no_session(tmp_path, monkeypatch): """If the site still offers Sign In after the form was submitted, the login FAILED — treating it as success would mark the run authed and persist an anonymous storage state that poisons every later run.""" from bggpipe.upload import LoginError, PlaywrightUploader monkeypatch.setenv("BGG_USERNAME", "someone") monkeypatch.setenv("BGG_PASSWORD", "wrong-pw") state_calls = [] class _Loc: def __init__(self, n=1): self._n = n @property def first(self): return self def count(self): return self._n def is_visible(self): return True def wait_for(self, **kw): return None def fill(self, value): pass def click(self): pass class _Page: title = staticmethod(lambda: "BoardGameGeek") def goto(self, url, **kw): pass def locator(self, selector): return _Loc() def get_by_role(self, role, **kw): return _Loc() def wait_for_url(self, pred, **kw): return None # the URL left /login (site redirects even on failure) def wait_for_timeout(self, ms): pass class _Context: def storage_state(self, path): state_calls.append(path) up = PlaywrightUploader("someone", storage_state=tmp_path / "state.json") up._page = _Page() up._context = _Context() # signed-out before AND after submitting the form: Sign In stays visible monkeypatch.setattr(up, "_signed_out", lambda: True) with pytest.raises(LoginError, match="credentials rejected"): up._ensure_logged_in() assert up._authed is False assert state_calls == [] # no anonymous session saved for reuse def test_update_entry_failure_paths_leave_the_entry_untouched(): """The three guarded exits of the collection-cell editor: no cell for the collid, the wanted version id never offered (must press Escape — the editor is open on a REAL entry), and a save that never settles.""" from bggpipe.upload import PlaywrightUploader, UploadJob class _Timeout(Exception): pass def make_page(cell_count=1, radio_appears=True, settles=True): pressed = [] class _Loc: def __init__(self, selector): self.selector = selector @property def first(self): return self def count(self): return cell_count if "collection_version" in self.selector else 1 def wait_for(self, **kw): if "radio" in self.selector and not radio_appears: raise _Timeout("radio never appeared") def click(self): pass def text_content(self): return "Editing" if not settles else "Avalon Hill second" class _Keyboard: def press(self, key): pressed.append(key) class _Page: keyboard = _Keyboard() def goto(self, url, **kw): pass def locator(self, selector): return _Loc(selector) def wait_for_timeout(self, ms): pass return _Page(), pressed def uploader(page): up = PlaywrightUploader("someone") up._page = page up._authed = True up._timeout_error = _Timeout return up job = UploadJob( action="update", bgg_id=240, name="Britannia", collid="53429559", version_id="24621", version_name="AH", ) page, _ = make_page(cell_count=0) with pytest.raises(RuntimeError, match="re-run diff"): uploader(page).update_entry(job) page, pressed = make_page(radio_appears=False) with pytest.raises(RuntimeError, match="entry left untouched"): uploader(page).update_entry(job) assert pressed == ["Escape"] # the open editor was closed, not abandoned page, _ = make_page(settles=False) with pytest.raises(RuntimeError, match="never left its editing state"): uploader(page).update_entry(job) def test_scrub_flattens_multiline_errors_for_the_csv_log(): text = "Call log:\n - waiting for locator\n - retrying click" assert "\n" not in _scrub(text) assert _scrub(text) == "Call log: - waiting for locator - retrying click" def test_stale_jobs_retires_the_copy_not_just_the_game(): """Rejecting ONE of two queued editions must not ride along on the surviving edition's endorsement.""" queue = [ {"bgg_id": "589", "version_id": "100"}, {"bgg_id": "589", "version_id": "200"}, ] matches = [ {"bgg_id": "589", "match_status": "approved", "version_id": "100"}, {"bgg_id": "589", "match_status": "rejected", "version_id": "200"}, ] stale = stale_jobs(queue, matches) assert "589" in stale and "no longer endorses that copy" in stale["589"] # both endorsed: nothing stale matches[1]["match_status"] = "approved" assert stale_jobs(queue, matches) == {} def test_annotate_queue_marks_one_done_per_completion(): """Vetoed duplicate copies share a job key; one success is one copy.""" queue = [ {"bgg_id": "13", "version_id": ""}, {"bgg_id": "13", "version_id": ""}, ] log = [_log_row(bgg_id="13", status="added")] states = [r["state"] for r in annotate_queue(queue, "add", log)] assert states == ["done", ""] log.append(_log_row(bgg_id="13", status="added")) states = [r["state"] for r in annotate_queue(queue, "add", log)] assert states == ["done", "done"] def test_add_game_reports_already_present_on_owned_page(): """A game page showing "In Collections" instead of "Add To" is positive evidence a previous attempt landed — retrying the add would duplicate.""" from bggpipe.upload import PlaywrightUploader, UploadJob class _Btn: def __init__(self, present): self._present = present @property def first(self): return self def count(self): return 1 if self._present else 0 def is_visible(self): return self._present class _Page: def goto(self, url, **kw): pass def get_by_role(self, role, name=None, **kw): return _Btn(name == "In Collections") def wait_for_timeout(self, ms): pass up = PlaywrightUploader("someone") up._page = _Page() up._authed = True job = UploadJob(action="add", bgg_id="240", name="Britannia") status, note = up.add_game(job) assert status == "already_present" assert "previous attempt" in note # a SECOND copy can't go through the unverified In Collections flow job2 = UploadJob(action="add", bgg_id="240", name="Britannia", second_copy=True) with pytest.raises(RuntimeError, match="add this copy by hand"): up.add_game(job2) def test_update_entry_rejects_a_settle_on_the_wrong_version(): """The editor closing proves nothing: if the cell re-renders its OLD content the AJAX save failed and "updated" would be a false record.""" from bggpipe.upload import PlaywrightUploader, UploadJob class _Loc: def __init__(self, selector): self.selector = selector @property def first(self): return self def count(self): return 1 def wait_for(self, **kw): pass def click(self): pass def text_content(self): return "Some Other Edition (2001)" # the OLD content, not ours class _Page: def goto(self, url, **kw): pass def locator(self, selector): return _Loc(selector) def wait_for_timeout(self, ms): pass up = PlaywrightUploader("someone") up._page = _Page() up._authed = True job = UploadJob( action="update", bgg_id="240", name="Britannia", collid="9", version_id="24621", version_name="Avalon Hill second edition", ) with pytest.raises(RuntimeError, match="did not land"): up.update_entry(job) def test_version_drift_skips_the_doomed_readd(tmp_path, capsys): """A game the log shows added under a different version is already on BGG — re-adding can only duplicate it, so the queue skips it.""" cfg = _cfg(tmp_path) _seed_data( tmp_path, to_add=[_add_row(bgg_id="1", name="Wingspan", version_id="99")], log=[_log_row(bgg_id="1", version_id="55", status="added")], ) fake = FakeUploader() run_upload(cfg, uploader=fake, sleep=lambda s: None, now=_now) assert fake.calls == [] assert "correct the version by hand" in capsys.readouterr().out def test_abort_needs_identical_messages_not_just_same_class(tmp_path): """Three DIFFERENT per-game failures are three unlucky games, not a systemic outage; only identical messages abort the run.""" cfg = _cfg(tmp_path) rows = [_add_row(bgg_id=str(i), name=f"Game {i}") for i in (1, 2, 3, 4)] _seed_data(tmp_path, to_add=rows) class _VariedFail(FakeUploader): def add_game(self, job): self.calls.append(job) raise RuntimeError(f"distinct problem with {job.name}") varied = _VariedFail() run_upload(cfg, uploader=varied, sleep=lambda s: None, now=_now) assert len(varied.calls) == 4 # no abort: every message differed