ig-v1 / tests /test_persistence.py
harshith99's picture
Feat: client-side library persistence (localStorage restore card)
1c43cdb
Raw
History Blame Contribute Delete
7.26 kB
"""
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)