All checks were successful
PR build (required check) / changes (pull_request) Successful in 6s
secrets-guard / encrypted (pull_request) Successful in 5s
shell-lint / shellcheck (pull_request) Successful in 7s
PR build (required check) / build-frontend (pull_request) Has been skipped
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / build-backend (pull_request) Successful in 1m0s
PR build (required check) / gate (pull_request) Successful in 2s
160 lines
6.2 KiB
Python
160 lines
6.2 KiB
Python
"""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
|