189c534315
The two-page dashboard/review split becomes a proper information architecture: Pipeline (stages + live activity), Photos (drag-and-drop, gallery with per-photo extraction state, reshoot tickets — photo work lives with photos), Review (decisions only, keyboard-first), Catalog (the full title ledger with filtering), Queue (what upload will do and everything it has done), and Library (the enriched collection browser, with an honest empty state until real BGG data lands). Pages render server-side from a shared shell — sidebar rail with the rainbow path running its edge, live count badges on Photos/Review/Queue, and Juniper's full portrait finally displayed, with her credit and a standard third-party trademark attribution beneath it (one notice, not per-mention symbols — the convention for referring to another party's mark). Shared client plumbing moves to static/app.js (escaping contract documented at the innerHTML sink). New endpoints: /api/photos-list, /api/queue, /api/library, plus a reshoot count in /api/pipeline. Screenshot review caught two real bugs: photos-list crashed on bare-array raw caches, and .DS_Store was listed as a shelf photo — photo_names() now filters by suffix everywhere, including the /photos allowlist. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
318 lines
9.9 KiB
Python
318 lines
9.9 KiB
Python
"""Dashboard/API tests: job lifecycle, pipeline status, photo upload —
|
|
stage functions are injected so nothing slow or networked ever runs."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import threading
|
|
|
|
import typer
|
|
from fastapi.testclient import TestClient
|
|
|
|
from bggpipe.config import Config
|
|
from bggpipe.jobs import JobRunner
|
|
from bggpipe.webreview import create_app
|
|
|
|
|
|
def _cfg(tmp_path) -> Config:
|
|
cfg = Config(data_dir=tmp_path / "data", photos_dir=tmp_path / "photos")
|
|
cfg.photos_dir.mkdir(parents=True)
|
|
cfg.data_dir.mkdir(parents=True)
|
|
return cfg
|
|
|
|
|
|
def _app(cfg, stages=None, jobs=None) -> TestClient:
|
|
return TestClient(create_app(cfg, stages=stages or {}, jobs=jobs))
|
|
|
|
|
|
# -- JobRunner ----------------------------------------------------------
|
|
|
|
|
|
def test_job_captures_output_and_finishes():
|
|
runner = JobRunner()
|
|
|
|
def stage():
|
|
typer.echo("line one")
|
|
typer.echo("line two")
|
|
|
|
assert runner.start("extract", stage)
|
|
runner.wait()
|
|
snap = runner.snapshot()
|
|
assert snap["status"] == "done"
|
|
assert snap["log"] == ["line one", "line two"]
|
|
|
|
|
|
def test_job_failure_is_reported_not_swallowed():
|
|
runner = JobRunner()
|
|
runner.start("resolve", lambda: (_ for _ in ()).throw(RuntimeError("boom")))
|
|
runner.wait()
|
|
snap = runner.snapshot()
|
|
assert snap["status"] == "failed"
|
|
assert "RuntimeError: boom" in snap["error"]
|
|
|
|
|
|
def test_typer_exit_code_counts_as_failure():
|
|
runner = JobRunner()
|
|
|
|
def stage():
|
|
raise typer.Exit(code=1)
|
|
|
|
runner.start("diff", stage)
|
|
runner.wait()
|
|
assert runner.snapshot()["status"] == "failed"
|
|
|
|
|
|
def test_single_slot_rejects_second_job():
|
|
runner = JobRunner()
|
|
release = threading.Event()
|
|
assert runner.start("extract", release.wait)
|
|
assert not runner.start("resolve", lambda: None) # slot busy
|
|
release.set()
|
|
runner.wait()
|
|
assert runner.start("resolve", lambda: None) # slot free again
|
|
runner.wait()
|
|
|
|
|
|
# -- /api/run + /api/job ------------------------------------------------
|
|
|
|
|
|
def test_run_stage_lifecycle_via_api(tmp_path):
|
|
cfg = _cfg(tmp_path)
|
|
ran = []
|
|
jobs = JobRunner()
|
|
web = _app(cfg, stages={"extract": lambda: ran.append(1)}, jobs=jobs)
|
|
|
|
res = web.post("/api/run/extract")
|
|
assert res.status_code == 200
|
|
jobs.wait()
|
|
assert ran == [1]
|
|
assert web.get("/api/job").json()["status"] == "done"
|
|
|
|
|
|
def test_unknown_stage_404s(tmp_path):
|
|
web = _app(_cfg(tmp_path))
|
|
assert web.post("/api/run/frobnicate").status_code == 404
|
|
|
|
|
|
def test_busy_runner_409s(tmp_path):
|
|
cfg = _cfg(tmp_path)
|
|
release = threading.Event()
|
|
jobs = JobRunner()
|
|
web = _app(
|
|
cfg, stages={"extract": release.wait, "resolve": lambda: None}, jobs=jobs
|
|
)
|
|
assert web.post("/api/run/extract").status_code == 200
|
|
assert web.post("/api/run/resolve").status_code == 409
|
|
release.set()
|
|
jobs.wait()
|
|
|
|
|
|
def test_upload_defaults_to_dry_run(tmp_path):
|
|
cfg = _cfg(tmp_path)
|
|
calls = []
|
|
jobs = JobRunner()
|
|
|
|
def upload(dry_run=True, limit=None):
|
|
calls.append({"dry_run": dry_run, "limit": limit})
|
|
|
|
web = _app(cfg, stages={"upload": upload}, jobs=jobs)
|
|
web.post("/api/run/upload") # no body: the safe direction
|
|
jobs.wait()
|
|
web.post("/api/run/upload", json={"dry_run": False, "limit": 2})
|
|
jobs.wait()
|
|
assert calls == [
|
|
{"dry_run": True, "limit": None},
|
|
{"dry_run": False, "limit": 2},
|
|
]
|
|
|
|
|
|
# -- /api/pipeline ------------------------------------------------------
|
|
|
|
|
|
def test_pipeline_reports_counts_and_never_values(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("BGG_USERNAME", "supersecretname")
|
|
monkeypatch.delenv("BGG_API_TOKEN", raising=False)
|
|
cfg = _cfg(tmp_path)
|
|
(cfg.photos_dir / "a.jpg").write_bytes(b"x")
|
|
web = _app(cfg)
|
|
payload = web.get("/api/pipeline").json()
|
|
assert payload["photos"] == 1
|
|
assert payload["env"]["BGG_USERNAME"] is True
|
|
assert payload["env"]["BGG_API_TOKEN"] is False
|
|
assert "supersecretname" not in web.get("/api/pipeline").text
|
|
|
|
|
|
def test_pipeline_flags_stub_data(tmp_path):
|
|
cfg = _cfg(tmp_path)
|
|
(cfg.data_dir / "STUB_DATA.marker").write_text("stub")
|
|
assert _app(cfg).get("/api/pipeline").json()["stub_data"] is True
|
|
|
|
|
|
# -- /api/photos --------------------------------------------------------
|
|
|
|
|
|
def test_photo_upload_saves_and_invalidates_raw_cache(tmp_path):
|
|
cfg = _cfg(tmp_path)
|
|
cfg.extract_raw_dir.mkdir(parents=True)
|
|
stale = cfg.extract_raw_dir / "shelf.jpg.json"
|
|
stale.write_text("{}")
|
|
web = _app(cfg)
|
|
res = web.post(
|
|
"/api/photos", files={"files": ("shelf.jpg", b"\xff\xd8jpegdata", "image/jpeg")}
|
|
)
|
|
assert res.status_code == 200
|
|
assert (cfg.photos_dir / "shelf.jpg").read_bytes() == b"\xff\xd8jpegdata"
|
|
assert not stale.exists() # re-upload means re-extract
|
|
|
|
|
|
def test_photo_upload_rejects_non_photos_and_path_tricks(tmp_path):
|
|
cfg = _cfg(tmp_path)
|
|
web = _app(cfg)
|
|
res = web.post("/api/photos", files={"files": ("notes.txt", b"hi", "text/plain")})
|
|
assert res.status_code == 400
|
|
res = web.post(
|
|
"/api/photos",
|
|
files={"files": ("../../escape.jpg", b"x", "image/jpeg")},
|
|
)
|
|
if res.status_code == 200: # client may strip the path; the name must be bare
|
|
assert (cfg.photos_dir / "escape.jpg").exists()
|
|
assert not (tmp_path / "escape.jpg").exists()
|
|
|
|
|
|
# -- pages --------------------------------------------------------------
|
|
|
|
|
|
def test_dashboard_and_review_pages_serve(tmp_path):
|
|
web = _app(_cfg(tmp_path))
|
|
assert "Pipeline" in web.get("/").text
|
|
assert "bggpipe" in web.get("/review").text
|
|
|
|
|
|
# -- design system + navigation -----------------------------------------
|
|
|
|
|
|
def test_stylesheet_is_served_and_linked_by_both_pages(tmp_path):
|
|
web = _app(_cfg(tmp_path))
|
|
css = web.get("/static/app.css")
|
|
assert css.status_code == 200
|
|
assert css.headers["content-type"].startswith("text/css")
|
|
assert "--accent" in css.text # the token layer, not an empty file
|
|
for path in ("/", "/review"):
|
|
assert 'href="/static/app.css"' in web.get(path).text
|
|
|
|
|
|
def test_both_pages_carry_navigation_and_skip_link(tmp_path):
|
|
web = _app(_cfg(tmp_path))
|
|
for path, current in (("/", 'href="/"'), ("/review", 'href="/review"')):
|
|
html = web.get(path).text
|
|
assert 'nav aria-label="Primary"' in html
|
|
assert f'<a {current} aria-current="page"' in html.replace("\n", " ") or (
|
|
current in html and 'aria-current="page"' in html
|
|
)
|
|
assert 'class="skip"' in html
|
|
|
|
|
|
def test_activity_region_announces_politely(tmp_path):
|
|
html = _app(_cfg(tmp_path)).get("/").text
|
|
assert 'aria-live="polite"' in html
|
|
|
|
|
|
# -- six-page shell -----------------------------------------------------
|
|
|
|
|
|
def test_every_page_serves_with_shared_shell(tmp_path):
|
|
web = _app(_cfg(tmp_path))
|
|
for path, marker in (
|
|
("/", "Pipeline"),
|
|
("/photos", "Reshoot"),
|
|
("/review", "Review"),
|
|
("/catalog", "Catalog"),
|
|
("/queue", "Upload queue"),
|
|
("/library", "Library"),
|
|
):
|
|
html = web.get(path).text
|
|
assert marker in html, path
|
|
assert 'nav aria-label="Primary"' in html, path
|
|
assert 'aria-current="page"' in html, path
|
|
assert "logo-full.jpg" in html, path # Juniper's portrait in the rail
|
|
assert "art by Juniper" in html, path
|
|
assert "trademarks of" in html, path # attribution notice
|
|
|
|
|
|
def test_photos_list_reports_extraction_state(tmp_path):
|
|
import json as _json
|
|
|
|
cfg = _cfg(tmp_path)
|
|
(cfg.photos_dir / "done.jpg").write_bytes(b"x")
|
|
(cfg.photos_dir / "fresh.jpg").write_bytes(b"x")
|
|
cfg.extract_raw_dir.mkdir(parents=True)
|
|
(cfg.extract_raw_dir / "done.jpg.json").write_text(
|
|
_json.dumps({"titles": [{"title_raw": "Catan"}], "unidentified": [{}, {}]})
|
|
)
|
|
listing = {p["name"]: p for p in _app(cfg).get("/api/photos-list").json()}
|
|
assert listing["done.jpg"] == {
|
|
"name": "done.jpg",
|
|
"extracted": True,
|
|
"titles": 1,
|
|
"unidentified": 2,
|
|
}
|
|
assert listing["fresh.jpg"]["extracted"] is False
|
|
|
|
|
|
def test_queue_endpoint_serves_all_three_ledgers(tmp_path):
|
|
cfg = _cfg(tmp_path)
|
|
(cfg.to_add_path).write_text("bgg_id,bgg_name\n13,Catan\n")
|
|
q = _app(cfg).get("/api/queue").json()
|
|
assert q["to_add"] == [{"bgg_id": "13", "bgg_name": "Catan"}]
|
|
assert q["to_update"] == [] and q["log"] == []
|
|
|
|
|
|
def test_library_serves_games_sorted_or_empty(tmp_path):
|
|
import json as _json
|
|
|
|
cfg = _cfg(tmp_path)
|
|
web = _app(cfg)
|
|
assert web.get("/api/library").json() == []
|
|
cfg.games_path.write_text(
|
|
_json.dumps(
|
|
{
|
|
"13": {"name": "Catan", "year": 1995},
|
|
"266192": {"name": "Wingspan", "year": 2019},
|
|
"1": {"name": "aliens", "year": 2000},
|
|
}
|
|
)
|
|
)
|
|
names = [g["name"] for g in web.get("/api/library").json()]
|
|
assert names == ["aliens", "Catan", "Wingspan"] # casefold sort
|
|
|
|
|
|
def test_pipeline_reports_reshoot_count(tmp_path):
|
|
import json as _json
|
|
|
|
cfg = _cfg(tmp_path)
|
|
cfg.unidentified_path.write_text(
|
|
_json.dumps({"a.jpg": [{"location": "top shelf"}]})
|
|
)
|
|
assert _app(cfg).get("/api/pipeline").json()["reshoot"] == 1
|
|
|
|
|
|
def test_photos_list_tolerates_bare_array_raw_cache(tmp_path):
|
|
import json as _json
|
|
|
|
cfg = _cfg(tmp_path)
|
|
(cfg.photos_dir / "old.jpg").write_bytes(b"x")
|
|
cfg.extract_raw_dir.mkdir(parents=True)
|
|
(cfg.extract_raw_dir / "old.jpg.json").write_text(
|
|
_json.dumps([{"title_raw": "Catan"}, {"title_raw": "Risk"}])
|
|
)
|
|
(item,) = _app(cfg).get("/api/photos-list").json()
|
|
assert item["extracted"] is True and item["titles"] == 2
|
|
|
|
|
|
def test_non_photo_files_are_invisible(tmp_path):
|
|
cfg = _cfg(tmp_path)
|
|
(cfg.photos_dir / "shelf.jpg").write_bytes(b"x")
|
|
(cfg.photos_dir / ".DS_Store").write_bytes(b"junk")
|
|
web = _app(cfg)
|
|
assert [p["name"] for p in web.get("/api/photos-list").json()] == ["shelf.jpg"]
|
|
assert web.get("/photos/.DS_Store").status_code == 404
|