""" Tests for the Book domain (T-1 / D-68, D-70) against `core_api_test`. `get_async_session` is overridden to the test database for every test here — the Book routes list `caller` before `session` in their dependencies, but overriding is what actually guarantees the suite never opens a connection to production regardless of FastAPI's dependency resolution order. """ import uuid import pytest from fastapi.testclient import TestClient from src.domains.auth.oidc import oidc_config from src.main import app from src.shared.config import get_settings from src.shared.database import get_async_session settings = get_settings() OFFICE_KEY = "test-office-key" @pytest.fixture(autouse=True) def office_key(): """Every Book test authenticates as the office caller unless it says otherwise.""" original = settings.office_service_key settings.office_service_key = OFFICE_KEY yield settings.office_service_key = original @pytest.fixture def client(clean_book_tables, test_database_url): """ A TestClient whose DB dependency is overridden to core_api_test. The override must build its own engine/session per call rather than reuse `clean_book_tables`'s already-open one: TestClient drives the ASGI app from a different event loop than the one pytest-asyncio ran the fixture's setup in, and an asyncpg connection is not portable across loops — reusing it here raised "Future ... attached to a different loop" and then hung. Building fresh per call keeps everything inside whichever loop is actually running the request. """ from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from sqlalchemy.pool import NullPool asyncpg_url = test_database_url.replace("postgresql://", "postgresql+asyncpg://", 1) async def _override(): engine = create_async_engine(asyncpg_url, poolclass=NullPool) session_factory = async_sessionmaker(bind=engine, expire_on_commit=False) async with session_factory() as session: yield session await engine.dispose() app.dependency_overrides[get_async_session] = _override yield TestClient(app) app.dependency_overrides.pop(get_async_session, None) def office_headers(identity: str = "jeroen", client_name: str | None = None) -> dict: headers = {"Authorization": f"Bearer {OFFICE_KEY}", "X-Office-Identity": identity} if client_name: headers["X-Office-Client"] = client_name return headers class TestCallerMatrixOnBookRoutes: """Dossier verification #3, the Book-specific half.""" def test_no_auth_is_401_on_book_routes(self, client): oidc_config.configure(enabled=True, issuers=[], audiences=[]) try: response = client.post("/book/records", json={"body": "hi"}) finally: oidc_config.configure(enabled=False, issuers=[], audiences=[]) assert response.status_code == 401 def test_office_write_without_identity_is_400(self, client): response = client.post( "/book/records", json={"body": "hi"}, headers={"Authorization": f"Bearer {OFFICE_KEY}"}, ) assert response.status_code == 400 assert response.json()["detail"]["code"] == "identity_required" def test_scheduler_key_is_403_on_book_records(self, client): settings.scheduler_service_key = "test-scheduler-key" try: response = client.post( "/book/records", json={"body": "hi"}, headers={"Authorization": "Bearer test-scheduler-key"}, ) finally: settings.scheduler_service_key = "" assert response.status_code == 403 assert response.json()["detail"]["code"] == "forbidden" def test_malformed_identity_is_400(self, client): response = client.post( "/book/records", json={"body": "hi"}, headers={"Authorization": f"Bearer {OFFICE_KEY}", "X-Office-Identity": "Not Valid"}, ) assert response.status_code == 400 assert response.json()["detail"]["code"] == "identity_invalid" class TestAddShowListFind: def test_add_note_defaults_to_personal_scope_and_inbox_free(self, client): response = client.post( "/book/records", json={"body": "buy milk"}, headers=office_headers() ) assert response.status_code == 201 data = response.json() assert data["kind"] == "note" assert data["status"] == "active" assert data["scope"] == "personal" assert data["created_by"] == "jeroen" def test_add_rejects_bad_status_for_kind(self, client): response = client.post( "/book/records", json={"kind": "task", "body": "x", "status": "settled"}, headers=office_headers(), ) assert response.status_code == 400 assert response.json()["detail"]["code"] == "invalid_status" def test_every_record_carries_exactly_one_of_work_or_personal(self, client): default_scope = client.post( "/book/records", json={"body": "default scope"}, headers=office_headers() ).json()["scope"] assert default_scope in ("work", "personal") work_scope = client.post( "/book/records", json={"body": "work item", "scope": "work"}, headers=office_headers(), ).json()["scope"] assert work_scope == "work" rejected = client.post( "/book/records", json={"body": "bad scope", "scope": "both"}, headers=office_headers(), ) assert rejected.status_code == 422 # pydantic Literal rejects a third value def test_show_returns_record_tags_links_and_backlinks(self, client): parent = client.post( "/book/records", json={"kind": "task", "body": "project"}, headers=office_headers() ).json() child = client.post( "/book/records", json={"kind": "task", "body": "subtask", "parent": parent["id"], "tags": ["chore"]}, headers=office_headers(), ).json() child_shown = client.get(f"/book/records/{child['id']}", headers=office_headers()).json() assert child_shown["tags"] == ["chore"] assert len(child_shown["links"]) == 1 assert child_shown["links"][0]["relation"] == "parent" assert child_shown["links"][0]["to_id"] == parent["id"] parent_shown = client.get(f"/book/records/{parent['id']}", headers=office_headers()).json() assert len(parent_shown["backlinks"]) == 1 assert parent_shown["backlinks"][0]["from_id"] == child["id"] def test_show_missing_record_is_404(self, client): response = client.get(f"/book/records/{uuid.uuid4()}", headers=office_headers()) assert response.status_code == 404 assert response.json()["detail"]["code"] == "not_found" def test_list_filters_by_scope(self, client): client.post("/book/records", json={"body": "a", "scope": "work"}, headers=office_headers()) client.post("/book/records", json={"body": "b", "scope": "personal"}, headers=office_headers()) work_only = client.get("/book/records", params={"scope": "work"}, headers=office_headers()).json() assert work_only["total"] == 1 assert work_only["records"][0]["scope"] == "work" def test_find_full_text_matches_title_and_body(self, client): client.post( "/book/records", json={"kind": "note", "title": "Weekend plans", "body": "visit the garden centre"}, headers=office_headers(), ) client.post( "/book/records", json={"kind": "task", "body": "renew the car insurance"}, headers=office_headers(), ) found = client.get("/book/records/find", params={"q": "garden"}, headers=office_headers()).json() assert found["total"] == 1 assert "garden" in found["records"][0]["body"] found_title = client.get("/book/records/find", params={"q": "weekend"}, headers=office_headers()).json() assert found_title["total"] == 1 class TestChange: def test_change_updates_only_given_fields(self, client): record = client.post( "/book/records", json={"kind": "task", "body": "water the plants"}, headers=office_headers() ).json() changed = client.patch( f"/book/records/{record['id']}", json={"priority": 1}, headers=office_headers(), ).json() assert changed["priority"] == 1 assert changed["body"] == "water the plants" def test_change_without_identity_is_400(self, client): record = client.post( "/book/records", json={"body": "x"}, headers=office_headers() ).json() response = client.patch( f"/book/records/{record['id']}", json={"priority": 1}, headers={"Authorization": f"Bearer {OFFICE_KEY}"}, ) assert response.status_code == 400 assert response.json()["detail"]["code"] == "identity_required" class TestClose: def test_close_task_done(self, client): task = client.post( "/book/records", json={"kind": "task", "body": "mow the lawn"}, headers=office_headers() ).json() closed = client.post( f"/book/records/{task['id']}/close", json={"outcome": "done"}, headers=office_headers() ).json() assert closed["status"] == "done" def test_close_question_via_resolves_sets_settled(self, client): question = client.post( "/book/records", json={"kind": "question", "body": "what colour for the fence?"}, headers=office_headers(), ).json() decision = client.post( "/book/records", json={"kind": "decision", "body": "fence will be green"}, headers=office_headers(), ).json() closed = client.post( f"/book/records/{question['id']}/close", json={"resolved_by": decision["id"]}, headers=office_headers(), ).json() assert closed["status"] == "settled" shown = client.get(f"/book/records/{question['id']}", headers=office_headers()).json() assert any(b["relation"] == "resolves" and b["from_id"] == decision["id"] for b in shown["backlinks"]) def test_close_decision_via_supersedes_sets_superseded(self, client): old_decision = client.post( "/book/records", json={"kind": "decision", "body": "fence will be green"}, headers=office_headers() ).json() new_decision = client.post( "/book/records", json={"kind": "decision", "body": "fence will be red"}, headers=office_headers() ).json() closed = client.post( f"/book/records/{old_decision['id']}/close", json={"superseded_by": new_decision["id"]}, headers=office_headers(), ).json() assert closed["status"] == "superseded" def test_close_note_has_no_transition(self, client): note = client.post("/book/records", json={"body": "just a note"}, headers=office_headers()).json() response = client.post( f"/book/records/{note['id']}/close", json={}, headers=office_headers() ) assert response.status_code == 400 assert response.json()["detail"]["code"] == "invalid_status" class TestLinkUnlink: def test_link_then_unlink(self, client): a = client.post("/book/records", json={"body": "a"}, headers=office_headers()).json() b = client.post("/book/records", json={"body": "b"}, headers=office_headers()).json() client.post( f"/book/records/{a['id']}/link", json={"to_id": b["id"], "relation": "related"}, headers=office_headers(), ) shown = client.get(f"/book/records/{a['id']}", headers=office_headers()).json() assert len(shown["links"]) == 1 unlinked = client.post( f"/book/records/{a['id']}/unlink", json={"to_id": b["id"], "relation": "related"}, headers=office_headers(), ) assert unlinked.status_code == 200 shown_after = client.get(f"/book/records/{a['id']}", headers=office_headers()).json() assert len(shown_after["links"]) == 0 def test_unlink_nonexistent_link_is_404(self, client): a = client.post("/book/records", json={"body": "a"}, headers=office_headers()).json() b = client.post("/book/records", json={"body": "b"}, headers=office_headers()).json() response = client.post( f"/book/records/{a['id']}/unlink", json={"to_id": b["id"], "relation": "blocks"}, headers=office_headers(), ) assert response.status_code == 404 class TestTagUntag: def test_tag_then_untag(self, client): record = client.post("/book/records", json={"body": "x"}, headers=office_headers()).json() tagged = client.post( f"/book/records/{record['id']}/tag", json={"tag": "chore"}, headers=office_headers() ).json() assert tagged["tags"] == ["chore"] untagged = client.post( f"/book/records/{record['id']}/untag", json={"tag": "chore"}, headers=office_headers() ).json() assert untagged["tags"] == [] def test_tagging_twice_is_idempotent(self, client): record = client.post("/book/records", json={"body": "x"}, headers=office_headers()).json() client.post(f"/book/records/{record['id']}/tag", json={"tag": "chore"}, headers=office_headers()) second = client.post( f"/book/records/{record['id']}/tag", json={"tag": "chore"}, headers=office_headers() ) assert second.status_code == 200 assert second.json()["tags"] == ["chore"] class TestHistory: def test_every_write_appends_an_event_carrying_identity(self, client): record = client.post( "/book/records", json={"body": "x"}, headers=office_headers(identity="jeroen") ).json() client.patch( f"/book/records/{record['id']}", json={"priority": 2}, headers=office_headers(identity="tower-control"), ) history = client.get(f"/book/records/{record['id']}/history", headers=office_headers()).json() actions = [e["action"] for e in history["events"]] identities = {e["identity"] for e in history["events"]} assert "add" in actions assert "change" in actions assert identities == {"jeroen", "tower-control"} def test_history_events_are_immutable_at_the_api(self, client): """There is no update/delete verb for events — only `add` and `change` write them, and the append-only trigger (tested in test_book_migration.py) is what actually enforces it.""" record = client.post("/book/records", json={"body": "x"}, headers=office_headers()).json() history = client.get(f"/book/records/{record['id']}/history", headers=office_headers()).json() assert len(history["events"]) == 1 assert history["events"][0]["action"] == "add"