""" Tests for the Book domain (T-1 / D-68, D-70, T-7) 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 asyncio import uuid from unittest.mock import patch 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() OTHER_TENANT = "llm_tester" 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) VALID_CONSUMER = "office-claude" VALID_TENANT = "jpmschweitzer" def office_headers( identity: str = "jeroen", consumer: str = VALID_CONSUMER, tenant: str = VALID_TENANT, ) -> dict: return { "Authorization": f"Bearer {OFFICE_KEY}", "X-Office-Identity": identity, "X-Office-Consumer": consumer, "X-Office-Tenant": tenant, } 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}", "X-Office-Consumer": VALID_CONSUMER, "X-Office-Tenant": VALID_TENANT, }, ) assert response.status_code == 400 assert response.json()["detail"]["code"] == "identity_required" def test_office_write_without_consumer_is_400(self, client): response = client.post( "/book/records", json={"body": "hi"}, headers={ "Authorization": f"Bearer {OFFICE_KEY}", "X-Office-Identity": "jeroen", "X-Office-Tenant": VALID_TENANT, }, ) assert response.status_code == 400 assert response.json()["detail"]["code"] == "consumer_required" def test_office_write_without_tenant_is_400(self, client): response = client.post( "/book/records", json={"body": "hi"}, headers={ "Authorization": f"Bearer {OFFICE_KEY}", "X-Office-Identity": "jeroen", "X-Office-Consumer": VALID_CONSUMER, }, ) assert response.status_code == 400 assert response.json()["detail"]["code"] == "tenant_required" def test_office_read_without_tenant_is_400(self, client): """The tenant scopes reads too — a list call with no tenant is 400, even though list carries no identity_required.""" response = client.get( "/book/records", headers={ "Authorization": f"Bearer {OFFICE_KEY}", "X-Office-Consumer": VALID_CONSUMER, }, ) assert response.status_code == 400 assert response.json()["detail"]["code"] == "tenant_required" def test_office_malformed_consumer_is_400(self, client): response = client.get( "/book/records", headers={ "Authorization": f"Bearer {OFFICE_KEY}", "X-Office-Consumer": "Not Valid", "X-Office-Tenant": VALID_TENANT, }, ) assert response.status_code == 400 assert response.json()["detail"]["code"] == "consumer_invalid" def test_office_malformed_tenant_is_400(self, client): response = client.get( "/book/records", headers={ "Authorization": f"Bearer {OFFICE_KEY}", "X-Office-Consumer": VALID_CONSUMER, "X-Office-Tenant": "Not Valid", }, ) assert response.status_code == 400 assert response.json()["detail"]["code"] == "tenant_invalid" 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", "X-Office-Consumer": VALID_CONSUMER, "X-Office-Tenant": VALID_TENANT, }, ) 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_a_note_and_a_task(self, client): """Dossier verification #4: a note and a task, both found by full-text.""" note = client.post( "/book/records", json={"kind": "note", "title": "Weekend plans", "body": "visit the garden centre"}, headers=office_headers(), ).json() task = client.post( "/book/records", json={"kind": "task", "body": "renew the car insurance"}, headers=office_headers(), ).json() found_note_by_body = client.get( "/book/records/find", params={"q": "garden"}, headers=office_headers() ).json() assert found_note_by_body["total"] == 1 assert found_note_by_body["records"][0]["id"] == note["id"] found_note_by_title = client.get( "/book/records/find", params={"q": "weekend"}, headers=office_headers() ).json() assert found_note_by_title["total"] == 1 assert found_note_by_title["records"][0]["id"] == note["id"] found_task = client.get( "/book/records/find", params={"q": "insurance"}, headers=office_headers() ).json() assert found_task["total"] == 1 assert found_task["records"][0]["id"] == task["id"] assert found_task["records"][0]["kind"] == "task" 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}", "X-Office-Consumer": VALID_CONSUMER, "X-Office-Tenant": VALID_TENANT, }, ) 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" def test_a_write_s_event_records_the_consumer(self, client): """T-4: X-Office-Client is retired; book_events.client (no migration this round) now carries the consumer instead.""" record = client.post( "/book/records", json={"body": "x"}, headers=office_headers(consumer="office-gemini"), ).json() history = client.get( f"/book/records/{record['id']}/history", headers=office_headers() ).json() assert history["events"][0]["action"] == "add" assert history["events"][0]["client"] == "office-gemini" class TestNumbering: """core-api T-7: `number` is allocated per tenant, never globally.""" def test_numbering_starts_at_one_per_tenant_and_increments(self, client): first = client.post( "/book/records", json={"body": "a"}, headers=office_headers(tenant=VALID_TENANT) ).json() second = client.post( "/book/records", json={"body": "b"}, headers=office_headers(tenant=VALID_TENANT) ).json() other_tenant_first = client.post( "/book/records", json={"body": "c"}, headers=office_headers(tenant=OTHER_TENANT) ).json() assert first["number"] == 1 assert second["number"] == 2 # A different tenant starts its own sequence at 1 — it can never # infer VALID_TENANT's activity from its own numbers. assert other_tenant_first["number"] == 1 def test_list_number_finds_the_record(self, client): record = client.post( "/book/records", json={"body": "find me"}, headers=office_headers() ).json() client.post("/book/records", json={"body": "noise"}, headers=office_headers()) found = client.get( "/book/records", params={"number": record["number"]}, headers=office_headers() ).json() assert found["total"] == 1 assert found["records"][0]["id"] == record["id"] @pytest.mark.asyncio async def test_two_concurrent_creates_in_one_tenant_get_distinct_numbers( self, clean_book_tables, test_database_url ): """Direct service calls on two independent sessions, run concurrently via asyncio.gather, rather than through TestClient (whose calls are issued one at a time and so would never actually overlap at the database). Exercises BookService._allocate_number's FOR UPDATE lock for real.""" from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from sqlalchemy.pool import NullPool from src.domains.auth.callers import Caller from src.domains.book.schemas import BookRecordCreate from src.domains.book.service import get_book_service asyncpg_url = test_database_url.replace("postgresql://", "postgresql+asyncpg://", 1) service = get_book_service() caller = Caller(kind="office", identity="jeroen", consumer="office-claude", tenant=VALID_TENANT) async def _create(body: str) -> int: engine = create_async_engine(asyncpg_url, poolclass=NullPool) session_factory = async_sessionmaker(bind=engine, expire_on_commit=False) try: async with session_factory() as session: record, _ = await service.add_record(session, BookRecordCreate(body=body), caller) return record.number finally: await engine.dispose() numbers = await asyncio.gather(_create("one"), _create("two")) assert sorted(numbers) == [1, 2] class TestTenantIsolation: """core-api T-7 / workspace D-73: a record in another tenant answers exactly like one that does not exist, checked in both directions.""" def test_llm_tester_cannot_list_jpmschweitzer_records(self, client): client.post( "/book/records", json={"body": "secret"}, headers=office_headers(tenant=VALID_TENANT) ) as_other = client.get("/book/records", headers=office_headers(tenant=OTHER_TENANT)).json() assert as_other["total"] == 0 def test_llm_tester_cannot_show_a_jpmschweitzer_record(self, client): record = client.post( "/book/records", json={"body": "secret"}, headers=office_headers(tenant=VALID_TENANT) ).json() response = client.get( f"/book/records/{record['id']}", headers=office_headers(tenant=OTHER_TENANT) ) assert response.status_code == 404 assert response.json()["detail"]["code"] == "not_found" def test_jpmschweitzer_cannot_show_an_llm_tester_record(self, client): """The reverse direction of the previous test — the boundary is symmetric, not a one-way allowlist for the production tenant.""" record = client.post( "/book/records", json={"body": "secret"}, headers=office_headers(tenant=OTHER_TENANT) ).json() response = client.get( f"/book/records/{record['id']}", headers=office_headers(tenant=VALID_TENANT) ) assert response.status_code == 404 assert response.json()["detail"]["code"] == "not_found" def test_llm_tester_cannot_change_a_jpmschweitzer_record(self, client): record = client.post( "/book/records", json={"body": "secret"}, headers=office_headers(tenant=VALID_TENANT) ).json() response = client.patch( f"/book/records/{record['id']}", json={"priority": 1}, headers=office_headers(tenant=OTHER_TENANT), ) assert response.status_code == 404 assert response.json()["detail"]["code"] == "not_found" def test_llm_tester_cannot_close_a_jpmschweitzer_record(self, client): task = client.post( "/book/records", json={"kind": "task", "body": "secret"}, headers=office_headers(tenant=VALID_TENANT), ).json() response = client.post( f"/book/records/{task['id']}/close", json={"outcome": "done"}, headers=office_headers(tenant=OTHER_TENANT), ) assert response.status_code == 404 assert response.json()["detail"]["code"] == "not_found" def test_llm_tester_cannot_tag_a_jpmschweitzer_record(self, client): record = client.post( "/book/records", json={"body": "secret"}, headers=office_headers(tenant=VALID_TENANT) ).json() response = client.post( f"/book/records/{record['id']}/tag", json={"tag": "chore"}, headers=office_headers(tenant=OTHER_TENANT), ) assert response.status_code == 404 assert response.json()["detail"]["code"] == "not_found" def test_llm_tester_cannot_link_to_a_jpmschweitzer_record(self, client): """`link` refuses a target in another tenant the same way (dossier).""" a = client.post( "/book/records", json={"body": "a"}, headers=office_headers(tenant=OTHER_TENANT) ).json() b = client.post( "/book/records", json={"body": "b"}, headers=office_headers(tenant=VALID_TENANT) ).json() response = client.post( f"/book/records/{a['id']}/link", json={"to_id": b["id"], "relation": "related"}, headers=office_headers(tenant=OTHER_TENANT), ) assert response.status_code == 404 assert response.json()["detail"]["code"] == "not_found" def test_llm_tester_cannot_read_history_of_a_jpmschweitzer_record(self, client): record = client.post( "/book/records", json={"body": "secret"}, headers=office_headers(tenant=VALID_TENANT) ).json() response = client.get( f"/book/records/{record['id']}/history", headers=office_headers(tenant=OTHER_TENANT) ) assert response.status_code == 404 assert response.json()["detail"]["code"] == "not_found" def test_add_with_a_parent_in_another_tenant_is_404_and_nothing_persists(self, client): """The `parent` convenience link in `add` is a link target too, so it is refused the same way `link` is. The attempted child must not persist either — the whole add is one transaction.""" parent = client.post( "/book/records", json={"body": "parent"}, headers=office_headers(tenant=OTHER_TENANT) ).json() response = client.post( "/book/records", json={"body": "child", "parent": parent["id"]}, headers=office_headers(tenant=VALID_TENANT), ) assert response.status_code == 404 assert response.json()["detail"]["code"] == "not_found" listing = client.get("/book/records", headers=office_headers(tenant=VALID_TENANT)).json() assert listing["total"] == 0 def test_find_does_not_leak_a_jpmschweitzer_record_to_llm_tester(self, client): client.post( "/book/records", json={"body": "unique-secret-garden-term"}, headers=office_headers(tenant=VALID_TENANT), ) found = client.get( "/book/records/find", params={"q": "unique-secret-garden-term"}, headers=office_headers(tenant=OTHER_TENANT), ).json() assert found["total"] == 0 class TestUserCallerTenantRequired: """A user-kind caller without a `tatlock_user` claim gets 400 tenant_required — the Office caller gets this for free from callers.py, but a "user"-kind caller's tenant is optional there, so Book's own `_require_tenant` is what actually enforces it (dossier verification #2).""" def test_user_without_tenant_claim_is_400_on_read(self, client): with patch("src.domains.auth.callers.get_current_user") as mock_get_current_user: mock_get_current_user.return_value = {"preferred_username": "jeroen"} response = client.get("/book/records", headers={"Authorization": "Bearer some-jwt"}) assert response.status_code == 400 assert response.json()["detail"]["code"] == "tenant_required" def test_user_without_tenant_claim_is_400_on_write(self, client): with patch("src.domains.auth.callers.get_current_user") as mock_get_current_user: mock_get_current_user.return_value = {"preferred_username": "jeroen"} response = client.post( "/book/records", json={"body": "hi"}, headers={"Authorization": "Bearer some-jwt"} ) assert response.status_code == 400 assert response.json()["detail"]["code"] == "tenant_required" def test_user_with_tenant_claim_succeeds(self, client): with patch("src.domains.auth.callers.get_current_user") as mock_get_current_user: mock_get_current_user.return_value = { "preferred_username": "jeroen", "tatlock_user": VALID_TENANT, } response = client.post( "/book/records", json={"body": "hi"}, headers={"Authorization": "Bearer some-jwt"} ) assert response.status_code == 201 assert response.json()["created_by"] == "jeroen" assert response.json()["number"] == 1