""" Reconstruction-cache contract for client-side library persistence. The planned localStorage feature never invents a new data shape: it caches the canonical CSV (GET /download/{job}/csv) in the browser and, on a later visit with no live job, "restores" by replaying that CSV through POST /import. This test pins the server-side guarantee that makes that safe: download CSV → re-import → same library, with status + coords + edits intact If this round-trip ever stops being lossless, the persistence feature would silently corrupt users' saved libraries on restore. There is no browser here, so the JS layer (cacheLibrary/maybeShowRestore) still needs Playwright once built — this guards the foundation it stands on. Run: python3 tests/test_persistence.py """ import csv import io import shutil import sys from pathlib import Path ROOT = Path(__file__).parent.parent sys.path.insert(0, str(ROOT)) try: from starlette.testclient import TestClient except Exception as exc: print(f"SKIP: TestClient unavailable ({exc}). `pip install httpx` to run.") sys.exit(0) from pipeline import geocode as geocode_mod from pipeline.extract import FIELDNAMES from web import app as webapp failures: list[str] = [] def check(label: str, cond: bool) -> None: print(("PASS" if cond else "FAIL"), "-", label) if not cond: failures.append(label) def _make_csv(*rows) -> bytes: buf = io.StringIO() w = csv.DictWriter(buf, fieldnames=FIELDNAMES) w.writeheader() for r in rows: base = {k: "" for k in FIELDNAMES} base.update(r) w.writerow(base) return buf.getvalue().encode() def _rows_by_name(client, job_id): return {r["name"]: r for r in client.get(f"/results/{job_id}").json()["rows"]} created_jobs: list[str] = [] # No network: keep geocoding inert so re-import never mutates coords. _orig_gr = geocode_mod.geocode_row geocode_mod.geocode_row = lambda *a, **kw: (None, None) try: with TestClient(webapp.app) as client: # ── Seed a library the way an extraction would leave it ─────────────── original = _make_csv( {"name": "Ichiran", "city": "Tokyo", "country": "Japan", "category": "Restaurant", "cuisine": "Ramen", "price_range": "$$", "status": "unvisited", "lat": "35.6595", "lng": "139.7005", "instagram_url": "https://instagram.com/p/aaa"}, {"name": "Park Hyatt", "city": "Tokyo", "country": "Japan", "category": "Hotel", "status": "want_to_go", "lat": "35.6860", "lng": "139.6960", "instagram_url": "https://instagram.com/p/bbb"}, {"name": "Blue Bottle", "city": "Kyoto", "country": "Japan", "category": "Cafe", "status": "visited", "lat": "35.0116", "lng": "135.7681", "instagram_url": "https://instagram.com/p/ccc"}, # A place that never geocoded — blank coords must round-trip as blank # (invariant: blank beats wrong; restore must not invent coordinates). {"name": "Mystery Bar", "city": "Osaka", "country": "Japan", "category": "Bar", "status": "unvisited", "lat": "", "lng": "", "instagram_url": "https://instagram.com/p/ddd"}, ) job1 = client.post( "/import", files={"file": ("places_full.csv", original, "text/csv")} ).json()["job_id"] created_jobs.append(job1) # ── User edits status (the thing cacheLibrary must capture) ─────────── client.patch(f"/results/{job1}", json={ "url": "https://instagram.com/p/aaa", "updates": {"status": "visited"}, }) before = _rows_by_name(client, job1) check("seed library has 4 places", len(before) == 4) check("edit applied: Ichiran now visited", before["Ichiran"]["status"] == "visited") # ── cacheLibrary(): grab the canonical CSV the browser would store ──── cached = client.get(f"/download/{job1}/csv") check("GET /download/csv returns 200", cached.status_code == 200) cached_csv = cached.content check("cached CSV carries the edited status", "visited" in cached.text) # ── Restore: replay the cached CSV through /import (new visit) ───────── job2 = client.post( "/import", files={"file": ("places_full.csv", cached_csv, "text/csv")} ).json()["job_id"] created_jobs.append(job2) check("restored job is a fresh job_id", job2 != job1) after = _rows_by_name(client, job2) # ── The round-trip must be lossless ────────────────────────────────── check("restore preserves place count", len(after) == len(before)) check("restore preserves all place names", set(after) == set(before)) # status (incl. the user edit) survives — status is user-owned check("restore preserves edited status (Ichiran=visited)", after["Ichiran"]["status"] == "visited") check("restore preserves want_to_go (Park Hyatt)", after["Park Hyatt"]["status"] == "want_to_go") check("restore preserves visited (Blue Bottle)", after["Blue Bottle"]["status"] == "visited") # coordinates survive for geocoded rows, stay blank for the un-geocoded check("restore preserves coords (Ichiran lat)", after["Ichiran"]["lat"].startswith("35.6595")) check("restore keeps blank coords blank (Mystery Bar)", not after["Mystery Bar"]["lat"] and not after["Mystery Bar"]["lng"]) # category / cuisine / price metadata survive check("restore preserves category (Ichiran=Restaurant)", after["Ichiran"]["category"] == "Restaurant") check("restore preserves cuisine (Ichiran=Ramen)", after["Ichiran"]["cuisine"] == "Ramen") check("restore preserves price (Ichiran=$$)", after["Ichiran"]["price_range"] == "$$") # ── Idempotency: cache-of-a-restore equals the restore ──────────────── recached = client.get(f"/download/{job2}/csv").text twice = _rows_by_name(client, job2) check("second cache/restore cycle is stable", {n: twice[n]["status"] for n in twice} == {n: after[n]["status"] for n in after}) # ── Corrupt cache must fail loudly, not silently mangle ─────────────── garbage = client.post( "/import", files={"file": ("places_full.csv", b"not,a,places,file\n1,2,3,4\n", "text/csv")}, ) check("garbage cache rejected with 422", garbage.status_code == 422) finally: geocode_mod.geocode_row = _orig_gr for j in created_jobs: shutil.rmtree(webapp.JOBS_DIR / j, ignore_errors=True) print() if failures: print(f"FAIL — {len(failures)} persistence check(s) failed:") for f in failures: print(f" ✗ {f}") sys.exit(1) print("PASS — reconstruction-cache round-trip is lossless.") sys.exit(0)