diff --git a/src/bggpipe/resolve.py b/src/bggpipe/resolve.py index a9606cf..1c92cd9 100644 --- a/src/bggpipe/resolve.py +++ b/src/bggpipe/resolve.py @@ -18,7 +18,7 @@ from pathlib import Path import typer from rapidfuzz import fuzz -from bggpipe.bgg_client import BGGClient +from bggpipe.bgg_client import BGGAuthError, BGGClient from bggpipe.config import Config from bggpipe.models import GameVersion from bggpipe.normalize import normalize_title @@ -417,12 +417,20 @@ def run_resolve( new_rows: list[MatchRow] = [] skipped = 0 + blocked: list[str] = [] for entry in entries: key = _row_key(entry.title_raw, ";".join(entry.source_photos)) if key in existing: skipped += 1 continue - row = resolve_entry(client, entry) + try: + row = resolve_entry(client, entry) + except BGGAuthError: + # No API token yet: cached titles still resolve; the rest wait. + # No row is written, so a future run picks them up untouched. + blocked.append(entry.title_raw) + typer.echo(f" {entry.title_raw!r} -> waiting on BGG API token") + continue new_rows.append(row) detail = f"{row.bgg_name} ({row.bgg_id})" if row.bgg_id else "-" version = f" [{row.version_status}]" if row.version_status else "" @@ -438,4 +446,11 @@ def run_resolve( f"Resolved {len(new_rows)} title(s) ({summary or 'nothing new'}); " f"skipped {skipped} already in {cfg.matches_path}." ) + if blocked: + typer.echo( + f"\n{len(blocked)} title(s) are waiting on the BGG API " + "(register at https://boardgamegeek.com/applications, then set " + "BGG_API_TOKEN and re-run resolve — everything above is saved): " + + ", ".join(blocked) + ) return new_rows diff --git a/tests/test_resolve.py b/tests/test_resolve.py index 95197db..4c9f897 100644 --- a/tests/test_resolve.py +++ b/tests/test_resolve.py @@ -294,3 +294,52 @@ def test_wrong_year_hint_never_drives_a_version(client): assert (row.match_status, row.bgg_id) == ("auto", 2529) assert row.version_status == "version_unknown" assert row.version_id is None + + +# -- graceful degradation without a BGG token --------------------------- + + +def test_run_resolve_saves_progress_when_token_missing(tmp_path): + """Cached titles resolve; uncached ones wait for the token instead of + crashing the run and losing everything.""" + import shutil as _shutil + + partial_cache = tmp_path / "cache" + partial_cache.mkdir() + for f in FIXTURES.glob("search_query=Catan-*"): + _shutil.copy(f, partial_cache / f.name) + + data_dir = tmp_path / "data" + data_dir.mkdir() + (data_dir / "titles.json").write_text( + json.dumps( + [ + {"title_raw": "Catan", "source_photos": ["a.jpg"]}, + {"title_raw": "Wingspan", "source_photos": ["a.jpg"]}, + ] + ) + ) + cfg = Config(data_dir=data_dir) + unauthorized = BGGClient( + cache_dir=partial_cache, + transport=httpx.MockTransport( + lambda req: httpx.Response(401, text="Unauthorized") + ), + ) + + rows = run_resolve(cfg, client=unauthorized) + assert [r.title_raw for r in rows] == ["Catan"] # cached one made it + + with cfg.matches_path.open(newline="") as f: + saved = list(csv.DictReader(f)) + assert len(saved) == 1 # blocked title left for a future run + + # future run (fixtures now "recorded"): picks up only the blocked title + full = BGGClient( + cache_dir=FIXTURES, + transport=httpx.MockTransport( + lambda req: (_ for _ in ()).throw(AssertionError("network")) + ), + ) + rows2 = run_resolve(cfg, client=full) + assert [r.title_raw for r in rows2] == ["Wingspan"]