Spaces:
Sleeping
Sleeping
| """ | |
| 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) | |