From 41858f3e4b1d73094b554287a417858f74587e21 Mon Sep 17 00:00:00 2001 From: emi Date: Sun, 26 Jul 2026 18:27:45 +0000 Subject: [PATCH] bookmarks: account-synced saved locations API --- backend/tests/accounts/test_bookmarks.py | 160 +++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 backend/tests/accounts/test_bookmarks.py diff --git a/backend/tests/accounts/test_bookmarks.py b/backend/tests/accounts/test_bookmarks.py new file mode 100644 index 0000000..c155abd --- /dev/null +++ b/backend/tests/accounts/test_bookmarks.py @@ -0,0 +1,160 @@ +"""Route-level tests for the bookmarks feature: CRUD, upsert-on-duplicate, +ownership, auth, bulk import (dedupe/counts), and the per-user cap. Uses a +throwaway accounts DB (conftest points THERMOGRAPH_ACCOUNTS_DB at a temp file), +same as test_api_accounts.py.""" +import pytest +from fastapi.testclient import TestClient + +from web import app as appmod +from accounts import db + +V2 = "/thermograph/api/v2" +PW = "supersecret123" + + +@pytest.fixture(scope="module", autouse=True) +def _tables(): + # TestClient(app) (no context manager) skips the lifespan, so create the + # account tables directly against the temp DB. + db.Base.metadata.create_all(db.sync_engine) + + +def _client(): + return TestClient(appmod.app) + + +def _login(client, email): + r = client.post(f"{V2}/auth/register", json={"email": email, "password": PW}) + assert r.status_code in (201, 400) # 400 only if the email already exists + r = client.post(f"{V2}/auth/login", data={"username": email, "password": PW}) + assert r.status_code == 204 # sets the session cookie on the client + + +def test_bookmarks_require_auth(): + c = _client() + assert c.get(f"{V2}/bookmarks").status_code == 401 + assert c.post(f"{V2}/bookmarks", json={"lat": 1, "lon": 1, "label": "x"}).status_code == 401 + assert c.post(f"{V2}/bookmarks/import", json={"items": []}).status_code == 401 + + +def test_bookmark_crud(): + c = _client() + _login(c, "bm-crud@example.com") + + r = c.post(f"{V2}/bookmarks", json={"lat": 47.6, "lon": -122.3, "label": "Seattle"}) + assert r.status_code == 201 + bm = r.json() + assert bm["cell_id"] and bm["label"] == "Seattle" + assert bm["lat"] == 47.6 and bm["lon"] == -122.3 + + listed = c.get(f"{V2}/bookmarks").json()["bookmarks"] + assert len(listed) == 1 and listed[0]["id"] == bm["id"] + + patched = c.patch(f"{V2}/bookmarks/{bm['id']}", json={"label": "Home"}) + assert patched.status_code == 200 + assert patched.json()["label"] == "Home" + + assert c.delete(f"{V2}/bookmarks/{bm['id']}").status_code == 204 + assert c.get(f"{V2}/bookmarks").json()["bookmarks"] == [] + + +def test_bookmark_upsert_on_duplicate(): + c = _client() + _login(c, "bm-upsert@example.com") + body = {"lat": 33.4, "lon": -112.0, "label": "Phoenix"} + + first = c.post(f"{V2}/bookmarks", json=body) + assert first.status_code == 201 + bm_id = first.json()["id"] + + # Re-bookmarking the same location (same cell) upserts the label — 200, not 409. + second = c.post(f"{V2}/bookmarks", json={**body, "label": "Phoenix Home"}) + assert second.status_code == 200 + assert second.json()["id"] == bm_id + assert second.json()["label"] == "Phoenix Home" + + assert len(c.get(f"{V2}/bookmarks").json()["bookmarks"]) == 1 + + +def test_bookmark_validation(): + c = _client() + _login(c, "bm-valid@example.com") + assert c.post(f"{V2}/bookmarks", json={"lat": 1, "lon": 1, "label": " "}).status_code == 422 + assert c.post(f"{V2}/bookmarks", json={"lat": 1, "lon": 1, "label": "x" * 81}).status_code == 422 + assert c.post(f"{V2}/bookmarks", json={"lat": 91, "lon": 1, "label": "x"}).status_code == 422 + assert c.post(f"{V2}/bookmarks", json={"lat": 1, "lon": 181, "label": "x"}).status_code == 422 + # a label that's only over-length due to surrounding whitespace is fine once trimmed + ok = c.post(f"{V2}/bookmarks", json={"lat": 2, "lon": 2, "label": " Trimmed "}) + assert ok.status_code == 201 and ok.json()["label"] == "Trimmed" + + +def test_ownership_is_404(): + a, b = _client(), _client() + _login(a, "bm-owner-a@example.com") + _login(b, "bm-owner-b@example.com") + bm_id = a.post(f"{V2}/bookmarks", json={"lat": 40.7, "lon": -74.0, "label": "NYC"}).json()["id"] + + # B cannot see, patch, or delete A's bookmark — 404, not 403 (no existence leak) + assert b.patch(f"{V2}/bookmarks/{bm_id}", json={"label": "Nope"}).status_code == 404 + assert b.delete(f"{V2}/bookmarks/{bm_id}").status_code == 404 + assert b.get(f"{V2}/bookmarks").json()["bookmarks"] == [] + + +def test_bookmark_import_dedupe_and_counts(): + c = _client() + _login(c, "bm-import@example.com") + # Pre-existing bookmark at the Seattle cell, with a label import must NOT overwrite. + existing = c.post( + f"{V2}/bookmarks", json={"lat": 47.6, "lon": -122.3, "label": "Original"} + ).json() + assert existing["label"] == "Original" + + r = c.post(f"{V2}/bookmarks/import", json={"items": [ + {"lat": 47.6, "lon": -122.3, "label": "Should Not Overwrite"}, # dup of existing cell + {"lat": 34.0, "lon": -118.2, "label": "LA"}, # new + {"lat": 34.0, "lon": -118.2, "label": "LA Dup"}, # dup within payload + {"lat": 51.5, "lon": -0.1, "label": "London"}, # new + ]}) + assert r.status_code == 200 + body = r.json() + assert body["imported"] == 2 + assert body["skipped"] == 2 + assert len(body["bookmarks"]) == 3 + + by_label = {b["label"] for b in body["bookmarks"]} + assert by_label == {"Original", "LA", "London"} # existing label untouched + + +def test_bookmark_cap(): + c = _client() + _login(c, "bm-cap@example.com") + for i in range(200): + r = c.post(f"{V2}/bookmarks", json={"lat": i * 0.1, "lon": 0.0, "label": f"spot-{i}"}) + assert r.status_code == 201, r.text + + over = c.post(f"{V2}/bookmarks", json={"lat": 89.0, "lon": 179.0, "label": "one too many"}) + assert over.status_code == 409 + + assert len(c.get(f"{V2}/bookmarks").json()["bookmarks"]) == 200 + + +def test_bookmark_import_stops_at_cap(): + c = _client() + _login(c, "bm-import-cap@example.com") + for i in range(198): + r = c.post(f"{V2}/bookmarks", json={"lat": i * 0.1, "lon": 10.0, "label": f"spot-{i}"}) + assert r.status_code == 201, r.text + + r = c.post(f"{V2}/bookmarks/import", json={"items": [ + {"lat": 60.0, "lon": 60.0, "label": "a"}, + {"lat": 61.0, "lon": 61.0, "label": "b"}, + {"lat": 62.0, "lon": 62.0, "label": "c"}, + {"lat": 63.0, "lon": 63.0, "label": "d"}, + {"lat": 64.0, "lon": 64.0, "label": "e"}, + ]}) + assert r.status_code == 200 + body = r.json() + # Only 2 slots remained before the 200 cap; the rest count as skipped. + assert body["imported"] == 2 + assert body["skipped"] == 3 + assert len(body["bookmarks"]) == 200