Files
Clarium/backend/tests/test_books.py
T
mroberts 7f5d034a1f feat: auth by default, theme toggle, CoderPad startup, review fixes
- Require a session on every route; public routes opt out with @allow_anonymous
- Split password hashing and pepper loading into passwords.py
- Add a system/light/dark theme toggle backed by light-dark() colors
- Ignore stale 401s from an earlier session, PATCH only changed book fields,
  and block overlapping journal-entry saves
- Add bin/start and CoderPad Vite server settings for the pad's start/restart
- Rewrite README as a mise onboarding guide; expand .gitignore
- Include review-round fixes and tests
2026-10-02 16:49:14 -05:00

203 lines
7.7 KiB
Python

import os
import threading
import time
import psycopg2
from tests.support import ApiTestCase, db_execute
class BookTests(ApiTestCase):
def setUp(self):
super().setUp()
self.register()
def test_create_returns_full_shape(self):
book = self.create_book()
self.assertEqual(book["title"], "Dune")
self.assertEqual(
book["genre"],
{"id": self.genre_id("Science Fiction"), "name": "Science Fiction"},
)
self.assertEqual(
(book["current_page"], book["total_pages"], book["status"]),
(0, 412, "not_started"),
)
self.assertIn("T", book["created_at"])
def test_create_trims_text(self):
book = self.create_book(title=" Dune ")
self.assertEqual(book["title"], "Dune")
def test_progress_updates_status(self):
book = self.create_book()
reading = self.call(
"PATCH", f"/api/books/{book['id']}", {"current_page": 100}
).get_json()
self.assertEqual((reading["current_page"], reading["status"]), (100, "reading"))
finished = self.call(
"PATCH", f"/api/books/{book['id']}", {"current_page": 412}
).get_json()
self.assertEqual(finished["status"], "finished")
self.assertEqual(
self.call("GET", f"/api/books/{book['id']}").get_json()["current_page"], 412
)
def test_edit_details(self):
book = self.create_book()
response = self.call(
"PATCH",
f"/api/books/{book['id']}",
{
"title": "Dune Messiah",
"genre_id": self.genre_id("Fantasy"),
"total_pages": 256,
},
)
self.assertEqual(response.status_code, 200)
updated = response.get_json()
self.assertEqual(
(updated["title"], updated["genre"]["name"], updated["total_pages"]),
("Dune Messiah", "Fantasy", 256),
)
self.assertGreater(updated["updated_at"], book["updated_at"])
def test_create_validation(self):
valid = {
"title": "Dune",
"author": "Frank Herbert",
"genre_id": self.genre_id("Fiction"),
"total_pages": 412,
}
cases = [
({**valid, "title": " "}, "title"),
({k: v for k, v in valid.items() if k != "author"}, "author"),
({**valid, "title": "x" * 501}, "title"),
({**valid, "title": "a\x00b"}, "title"),
({**valid, "title": "\ud800"}, "title"),
({**valid, "genre_id": 999}, "genre_id"),
({**valid, "genre_id": "5"}, "genre_id"),
({**valid, "total_pages": True}, "total_pages"),
({**valid, "total_pages": 12.5}, "total_pages"),
({**valid, "total_pages": 0}, "total_pages"),
({**valid, "current_page": 413, "total_pages": 412}, "current_page"),
({**valid, "user_id": 2}, "user_id"),
]
for payload, field in cases:
with self.subTest(field=field, payload=payload):
response = self.call("POST", "/api/books", payload)
self.assertEqual(response.status_code, 400, response.get_json())
self.assertEqual(response.get_json()["field"], field)
def test_lowering_total_below_current_page_is_rejected_with_fix(self):
book = self.create_book(current_page=312, total_pages=400)
response = self.call("PATCH", f"/api/books/{book['id']}", {"total_pages": 300})
self.assertEqual(response.status_code, 400)
body = response.get_json()
self.assertEqual(body["field"], "total_pages")
self.assertIn("312", body["error"])
self.assertIn("300", body["error"])
def test_empty_patch_is_rejected(self):
book = self.create_book()
self.assertEqual(
self.call("PATCH", f"/api/books/{book['id']}", {}).status_code, 400
)
def test_delete(self):
book = self.create_book()
self.assertEqual(
self.call("DELETE", f"/api/books/{book['id']}").status_code, 204
)
self.assertEqual(self.call("GET", f"/api/books/{book['id']}").status_code, 404)
self.assertEqual(
self.call("DELETE", f"/api/books/{book['id']}").status_code, 404
)
def test_out_of_range_id_is_404(self):
self.assertEqual(self.call("GET", "/api/books/99999999999").status_code, 404)
def test_list_is_most_recently_updated_first(self):
first = self.create_book(title="First")
self.create_book(title="Second")
self.call("PATCH", f"/api/books/{first['id']}", {"current_page": 1})
titles = [b["title"] for b in self.call("GET", "/api/books").get_json()]
self.assertEqual(titles, ["First", "Second"])
def test_genres_sorted_and_require_login(self):
genres = self.call("GET", "/api/genres").get_json()
self.assertEqual(len(genres), 16)
self.assertEqual([g["name"] for g in genres], sorted(g["name"] for g in genres))
self.call("POST", "/api/auth/logout")
self.assertEqual(self.call("GET", "/api/genres").status_code, 401)
def test_books_require_login(self):
self.call("POST", "/api/auth/logout")
self.assertEqual(self.call("GET", "/api/books").status_code, 401)
class BookIsolationTests(ApiTestCase):
def test_other_user_cannot_see_or_modify_book(self):
self.register("alice")
book = self.create_book()
bob = self.other_user("bob")
path = f"/api/books/{book['id']}"
for method, payload in (
("GET", None),
("PATCH", {"title": "Hacked"}),
("DELETE", None),
):
with self.subTest(method=method):
response = self.call(method, path, payload, client=bob)
self.assertEqual(response.status_code, 404)
self.assertEqual(response.get_json(), {"error": "Book not found"})
self.assertEqual(self.call("GET", "/api/books", client=bob).get_json(), [])
self.assertEqual(self.call("GET", path).get_json()["title"], "Dune")
class ConcurrentUpdateTests(ApiTestCase):
def setUp(self):
super().setUp()
self.register()
def wait_for_lock_wait(self):
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
waiting = db_execute(
"SELECT count(*) FROM pg_stat_activity"
" WHERE datname = current_database() AND wait_event_type = 'Lock'"
)[0][0]
if waiting:
return
time.sleep(0.02)
self.fail("PATCH never waited on the locked book row")
def test_concurrent_updates_to_different_fields_both_persist(self):
book = self.create_book()
other_session = psycopg2.connect(os.environ["DATABASE_URL"])
result = {}
try:
with other_session.cursor() as cur:
cur.execute(
"UPDATE books SET title = 'Dune Messiah' WHERE id = %s",
(book["id"],),
)
patch = threading.Thread(
target=lambda: result.update(
response=self.call(
"PATCH", f"/api/books/{book['id']}", {"current_page": 100}
)
)
)
patch.start()
self.wait_for_lock_wait()
other_session.commit()
patch.join(timeout=10)
finally:
other_session.close()
self.assertFalse(patch.is_alive(), "PATCH did not finish")
self.assertEqual(result["response"].status_code, 200)
saved = self.call("GET", f"/api/books/{book['id']}").get_json()
self.assertEqual((saved["title"], saved["current_page"]), ("Dune Messiah", 100))