bookmarks: account-synced saved locations API #117

Merged
admin_emi merged 9 commits from feat/location-bookmarks into dev 2026-07-26 18:40:24 +00:00
Showing only changes of commit 41858f3e4b - Show all commits

View file

@ -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