"""Static export: pages, art downloads, badge slot, privacy. Offline — cover downloads run against a mock CDN transport.""" from __future__ import annotations import json from pathlib import Path import httpx import pytest from bggpipe.config import Config from bggpipe.export import run_export GAMES = { "240:24621": { "bgg_id": 240, "type": "boardgame", "name": "Britannia", "year": 1986, "description": "A game of & settlement.", "image": "https://cf.geekdo-images.com/orig/pic1.jpg", "thumbnail": "https://cf.geekdo-images.com/thumb/pic1.jpg", "min_players": 3, "max_players": 5, "best_player_counts": ["4"], "playtime": 240, "designers": ["Lewis Pulsipher"], "rank": 1210, "weight": 3.16, "version": { "version_id": 24621, "name": "Avalon Hill second edition", "year": 1987, "publishers": ["Avalon Hill"], "languages": ["English"], }, "source_photos": ["IMG_1.jpeg"], }, "311654": { "bgg_id": 311654, "type": "rpgitem", "name": "Alice is Missing", "year": 2020, "image": "https://cf.geekdo-images.com/orig/pic2.png", "thumbnail": "", "source_photos": ["IMG_2.jpeg"], }, "local:homebrew:x.jpg": { "type": "localgame", "name": "Britannia", # deliberate name collision with 240:24621 "image": "/local-art/abc123.jpg", "source_photos": ["x.jpg"], }, } def _cfg(tmp_path: Path) -> Config: cfg = Config(data_dir=tmp_path / "data") cfg.data_dir.mkdir(parents=True) cfg.games_path.write_text(json.dumps(GAMES)) cfg.local_art_dir.mkdir() (cfg.local_art_dir / "abc123.jpg").write_bytes(FAKE_JPEG) return cfg FAKE_JPEG = b"\xff\xd8" + b"\x00" * 100 # magic + enough body to pass sniffing def _cdn(requests_log: list[str]) -> httpx.Client: def handler(request: httpx.Request) -> httpx.Response: requests_log.append(str(request.url)) return httpx.Response(200, content=FAKE_JPEG) return httpx.Client(transport=httpx.MockTransport(handler)) def test_export_writes_pages_art_and_no_shelf_photos(tmp_path): cfg = _cfg(tmp_path) out = tmp_path / "site" log: list[str] = [] summary = run_export( cfg, out, title="Test Shelves", fetch=_cdn(log), sleep=lambda s: None ) assert summary == {"games": 3, "downloaded": 3, "failures": 0, "removed": 0} index = (out / "index.html").read_text() assert "Test Shelves" in index and "Powered by BGG" in index assert "trademarks of" in index # name collision -> distinct stable slugs assert (out / "britannia" / "index.html").exists() assert (out / "britannia-2" / "index.html").exists() assert (out / "alice-is-missing" / "index.html").exists() detail = (out / "britannia" / "index.html").read_text() assert "Avalon Hill second edition" in detail assert "<invasions> & settlement" in detail # escaped, not raw # BGG double-encodes entities; the page shows the character, not ’ assert "&rsquo" not in detail assert "boardgamegeek.com/boardgame/240" in detail rpg = (out / "alice-is-missing" / "index.html").read_text() assert "rpggeek.com/rpgitem/311654" in rpg # covers landed locally and pages never hotlink the CDN # 2 full + 1 thumb + local art + the colophon's piper assert len(list((out / "art").glob("*"))) == 5 all_html = index + detail + rpg assert "geekdo-images.com" not in all_html # the privacy rule: shelf photo names appear nowhere in the output assert "IMG_1.jpeg" not in all_html and "IMG_2.jpeg" not in all_html def test_export_is_idempotent_and_offline_on_rerun(tmp_path): cfg = _cfg(tmp_path) out = tmp_path / "site" run_export(cfg, out, fetch=_cdn([]), sleep=lambda s: None) def refuse(request: httpx.Request) -> httpx.Response: raise AssertionError(f"re-run fetched {request.url}") summary = run_export( cfg, out, fetch=httpx.Client(transport=httpx.MockTransport(refuse)), sleep=lambda s: None, ) assert summary["downloaded"] == 0 def test_failed_cover_reports_and_ships_page_anyway(tmp_path): cfg = _cfg(tmp_path) def flaky(request: httpx.Request) -> httpx.Response: if "pic2" in str(request.url): return httpx.Response(503) return httpx.Response(200, content=FAKE_JPEG) summary = run_export( cfg, tmp_path / "site", fetch=httpx.Client(transport=httpx.MockTransport(flaky)), sleep=lambda s: None, ) assert summary["failures"] == 1 page = (tmp_path / "site" / "alice-is-missing" / "index.html").read_text() assert "Alice is Missing" in page # the page exists, just coverless # nothing half-written left behind to poison the idempotent re-run assert not list((tmp_path / "site" / "art").glob("*.part")) def test_official_badge_upgrades_the_footer(tmp_path): cfg = _cfg(tmp_path) (cfg.data_dir / "powered-by-bgg.png").write_bytes(b"\x89PNG" + b"\x00" * 100) out = tmp_path / "site" run_export(cfg, out, fetch=_cdn([]), sleep=lambda s: None) assert (out / "art" / "powered-by-bgg.png").exists() index = (out / "index.html").read_text() assert "art/powered-by-bgg.png" in index and "Powered by BGG" in index detail = (out / "britannia" / "index.html").read_text() assert "../art/powered-by-bgg.png" in detail def test_export_without_games_json_exits_with_guidance(tmp_path): cfg = Config(data_dir=tmp_path / "data") import typer with pytest.raises(typer.Exit): run_export(cfg, tmp_path / "site", fetch=_cdn([]), sleep=lambda s: None) def test_urls_survive_adding_and_removing_games(tmp_path): """The manifest's whole point: a published URL never moves and is never stolen — not by an earlier-sorting new edition, not by the base-slug holder leaving.""" cfg = _cfg(tmp_path) out = tmp_path / "site" run_export(cfg, out, fetch=_cdn([]), sleep=lambda s: None) original = (out / "britannia" / "index.html").read_text() assert "Avalon Hill second edition" in original # a new same-named edition whose key sorts FIRST must not steal slugs games = json.loads(cfg.games_path.read_text()) games["100:1"] = {"bgg_id": 100, "type": "boardgame", "name": "Britannia"} cfg.games_path.write_text(json.dumps(games)) run_export(cfg, out, fetch=_cdn([]), sleep=lambda s: None) assert ( "Avalon Hill second edition" in (out / "britannia" / "index.html").read_text() ) assert (out / "britannia-3" / "index.html").exists() # the newcomer # removing the base-slug holder must not renumber the survivors — # and must actually remove the stale page del games["240:24621"] cfg.games_path.write_text(json.dumps(games)) summary = run_export(cfg, out, fetch=_cdn([]), sleep=lambda s: None) assert summary["removed"] == 1 assert not (out / "britannia" / "index.html").exists() assert (out / "britannia-2" / "index.html").exists() # localgame, unmoved index = (out / "index.html").read_text() assert "britannia/" not in index.replace("britannia-2/", "").replace( "britannia-3/", "" ) def test_changed_cover_url_is_refetched(tmp_path): """BGG replaces box art under the same extension: the manifest records each cover's source URL so a change re-downloads.""" cfg = _cfg(tmp_path) out = tmp_path / "site" run_export(cfg, out, fetch=_cdn([]), sleep=lambda s: None) games = json.loads(cfg.games_path.read_text()) games["240:24621"]["image"] = "https://cf.geekdo-images.com/orig/pic9.jpg" cfg.games_path.write_text(json.dumps(games)) log: list[str] = [] summary = run_export(cfg, out, fetch=_cdn(log), sleep=lambda s: None) assert summary["downloaded"] == 1 assert any("pic9" in u for u in log) def test_garbage_200_is_a_failure_not_a_cached_cover(tmp_path): """A CDN interstitial served with 200 must not be cached as a cover: exists() would then skip it forever.""" cfg = _cfg(tmp_path) def interstitial(request: httpx.Request) -> httpx.Response: return httpx.Response(200, content=b"challenge page" * 10) summary = run_export( cfg, tmp_path / "site", fetch=httpx.Client(transport=httpx.MockTransport(interstitial)), sleep=lambda s: None, ) assert summary["failures"] == 3 assert summary["downloaded"] == 0 # only the local-art copy survives; no CDN garbage was cached names = {f.name for f in (tmp_path / "site" / "art").iterdir()} assert names == {"abc123.jpg", "bggpipe-piper.jpg"} def test_missing_local_art_is_a_counted_failure(tmp_path): cfg = _cfg(tmp_path) (cfg.local_art_dir / "abc123.jpg").unlink() summary = run_export(cfg, tmp_path / "site", fetch=_cdn([]), sleep=lambda s: None) assert summary["failures"] == 1 # local_art is the ONLY source: say so def test_interrupted_download_leaves_no_cover_and_recovers(tmp_path, monkeypatch): """The non-vacuous atomicity test: die BETWEEN response and write, assert nothing half-made survives and the re-run completes.""" import bggpipe.export as export_mod cfg = _cfg(tmp_path) out = tmp_path / "site" real_write = export_mod.atomic_write_bytes calls = {"n": 0} def die_on_second(path, data): calls["n"] += 1 if calls["n"] == 2: raise KeyboardInterrupt real_write(path, data) monkeypatch.setattr(export_mod, "atomic_write_bytes", die_on_second) with pytest.raises(KeyboardInterrupt): run_export(cfg, out, fetch=_cdn([]), sleep=lambda s: None) assert len(list((out / "art").glob("*.part"))) == 0 monkeypatch.setattr(export_mod, "atomic_write_bytes", real_write) summary = run_export(cfg, out, fetch=_cdn([]), sleep=lambda s: None) assert summary["failures"] == 0 assert (out / "index.html").exists() def test_minimal_and_numeric_entries_render(tmp_path): """Entries vary wildly: a name-only localgame and enrich-shaped numerics (int best counts, int year) must render, not crash.""" cfg = Config(data_dir=tmp_path / "data") cfg.data_dir.mkdir(parents=True) cfg.games_path.write_text( json.dumps( { "local:mystery:x.jpg": {"type": "localgame", "name": "Mystery"}, "7": { "bgg_id": 7, "type": "boardgame", "name": "Numeric", "year": 1980, "min_players": 1, "max_players": 1, "best_player_counts": [1], "weight": 2, "rating": 7, }, } ) ) out = tmp_path / "site" run_export(cfg, out, fetch=_cdn([]), sleep=lambda s: None) numeric = (out / "numeric" / "index.html").read_text() assert "1 player" in numeric and "best at 1" in numeric assert (out / "mystery" / "index.html").exists() def test_cli_export_wiring(tmp_path, monkeypatch): from typer.testing import CliRunner from bggpipe.cli import app received = {} def fake_run_export(cfg, out, *, title): received.update(out=out, title=title) return {"games": 0, "downloaded": 0, "failures": 0, "removed": 0} monkeypatch.setattr("bggpipe.export.run_export", fake_run_export) runner = CliRunner() result = runner.invoke( app, ["export", "--out", str(tmp_path / "s"), "--title", "T"] ) assert result.exit_code == 0 assert received["title"] == "T" and received["out"] == tmp_path / "s" assert runner.invoke(app, ["export"]).exit_code != 0 # --out is required def failing_run_export(cfg, out, *, title): return {"games": 1, "downloaded": 0, "failures": 2, "removed": 0} monkeypatch.setattr("bggpipe.export.run_export", failing_run_export) result = runner.invoke(app, ["export", "--out", str(tmp_path / "s")]) assert result.exit_code == 1 # scripted publishes must see failures def test_colophon_card_closes_the_shelf(tmp_path): """The last card: piper, badge, fine print — a div the search filter (which selects a.game) can never hide.""" cfg = _cfg(tmp_path) out = tmp_path / "site" run_export(cfg, out, title="Test Shelves", fetch=_cdn([]), sleep=lambda s: None) index = (out / "index.html").read_text() assert 'class="game colophon"' in index assert "bggpipe-piper.jpg" in index and "Piper art by Juniper" in index assert "trademarks of BoardGameGeek" in index assert (out / "art" / "bggpipe-piper.jpg").exists() # the filter only ever hides — the colophon is a div assert '