"""Integration: saved-chat CRUD (phase 50, task 02) — the ``/api/chats`` contract. Real Postgres (``podman compose up -d db``). The router sits behind the phase-16 ``require_admin`` gate exactly like ``/api/steering`` (the house pattern of ``test_steering_api.py``): anonymous callers get 403 on every route; the admin CRUD exercises the auto-title convention (first user message, whitespace-collapsed, 120-char cap + the no-user-message fallback), the list order (``updated_at desc, id desc``), the full-payload round-trip (a ``bor.chat.v1``-shaped brain record carrying ``sources``/``thinking``/``tools``/``stopped`` survives losslessly), the PUT upsert semantics (replacement + title-keep + title-set + ``updated_at`` bump), and the delete 404/204. Requires: podman compose up -d db """ from __future__ import annotations import time import uuid from collections.abc import Iterator from datetime import UTC, datetime, timedelta from typing import Any import pytest from fastapi.testclient import TestClient from sqlalchemy import select, text from sqlalchemy.orm import Session from app.main import app as fastapi_app from app.models import SavedChat FIRST_QUESTION = "How did I install gitlab?" EXPLICIT_TITLE = "My backup notes" #: A full ``bor.chat.v1`` brain record (phase 14 shape) — every optional #: key present; the round-trip test asserts it survives byte-identical. FULL_BRAIN: dict[str, Any] = { "who": "brain", "text": "Your k3s cluster runs on three nodes — you've got this.", "sources": [ {"source": "Homelab", "path": "kubernetes.md", "title": "Kubernetes Cluster"} ], "deflected": False, "suggestions": ["What ports does Traefik expose?"], "thinking": "The kubernetes doc covers the cluster layout…", "tools": [ {"name": "read_document", "argument": "Homelab/kubernetes.md"}, {"name": "list_documents", "argument": None}, ], "stopped": False, } OUT_KEYS = {"id", "title", "created_at", "updated_at", "message_count", "messages"} ROW_KEYS = {"id", "title", "updated_at", "message_count"} @pytest.fixture(autouse=True) def clean_chats(db: Session) -> Iterator[None]: """``saved_chats`` is global state: reset around every test.""" db.execute(text("TRUNCATE saved_chats")) db.commit() yield db.execute(text("TRUNCATE saved_chats")) db.commit() def _user(text: str) -> dict[str, Any]: return {"who": "user", "text": text} def _simple_conversation() -> list[dict[str, Any]]: return [_user(FIRST_QUESTION), {"who": "brain", "text": "You've got this!"}] def _expect(records: list[dict[str, Any]]) -> list[dict[str, Any]]: """The stored shape of a record list (see app/api/chats.py): every record carries all ``bor.chat.v1`` keys, explicit nulls where an optional key does not apply (the restore path is null-safe). A record that already carries every key (``FULL_BRAIN``) is unchanged by this.""" return [ { "who": m["who"], "text": m["text"], "sources": m.get("sources"), "deflected": m.get("deflected"), "suggestions": m.get("suggestions"), "thinking": m.get("thinking"), "tools": m.get("tools"), "stopped": m.get("stopped"), } for m in records ] def _assert_no_chats(admin_client: TestClient) -> None: assert admin_client.get("/api/chats").json() == {"chats": []} # ---------- anonymous: 403 on every route (phase 16 gate) ---------- def test_anonymous_every_route_returns_403(client: TestClient) -> None: anon = TestClient(fastapi_app) # fresh jar: truly anonymous unknown = uuid.uuid4() cases = [ ("GET", "/api/chats", None), ("POST", "/api/chats", {"messages": _simple_conversation()}), ("GET", f"/api/chats/{unknown}", None), ("PUT", f"/api/chats/{unknown}", {"messages": _simple_conversation()}), ("DELETE", f"/api/chats/{unknown}", None), ] for method, path, body in cases: r = anon.request(method, path, json=body) assert r.status_code == 403, f"{method} {path} must be 403 for anonymous" assert r.json() == {"detail": "admin only"} # ---------- create ---------- def test_create_returns_201_and_auto_titles( admin_client: TestClient, db: Session ) -> None: r = admin_client.post("/api/chats", json={"messages": _simple_conversation()}) assert r.status_code == 201 body = r.json() assert set(body) == OUT_KEYS assert body["title"] == FIRST_QUESTION # auto-title = first user message assert body["message_count"] == 2 assert body["messages"] == _expect(_simple_conversation()) uuid.UUID(body["id"]) # valid UUID # Fresh row: nothing has updated it, so both stamps agree. assert body["created_at"] and body["updated_at"] assert abs( datetime.fromisoformat(body["created_at"]) - datetime.fromisoformat(body["updated_at"]) ).total_seconds() < 5 rows = db.scalars(select(SavedChat)).all() assert [row.title for row in rows] == [FIRST_QUESTION] def test_create_auto_title_collapses_whitespace_and_truncates_to_120( admin_client: TestClient, ) -> None: long_text = "How did I install " + "x" * 200 r = admin_client.post( "/api/chats", json={"messages": [_user(long_text), {"who": "brain", "text": "ok"}]} ) assert r.status_code == 201 assert len(r.json()["title"]) == 120 assert r.json()["title"] == long_text[:120] # Multi-space / tab / newline runs collapse to single spaces. r = admin_client.post( "/api/chats", json={"messages": [_user("What is\nmy\tTraefik port?")]} ) assert r.status_code == 201 assert r.json()["title"] == "What is my Traefik port?" def test_create_honors_explicit_title(admin_client: TestClient) -> None: r = admin_client.post( "/api/chats", json={"title": f" {EXPLICIT_TITLE} ", "messages": _simple_conversation()}, ) assert r.status_code == 201 assert r.json()["title"] == EXPLICIT_TITLE # trimmed, not auto-titled def test_create_blank_title_falls_back_to_auto_title(admin_client: TestClient) -> None: r = admin_client.post( "/api/chats", json={"title": " \t\n ", "messages": _simple_conversation()} ) assert r.status_code == 201 assert r.json()["title"] == FIRST_QUESTION def test_create_without_user_message_falls_back_to_chat_id(admin_client: TestClient) -> None: # Defensive — the UI cannot produce a conversation with no user # message; the auto-title then names the row after its own id. r = admin_client.post( "/api/chats", json={"messages": [{"who": "brain", "text": "hello"}]} ) assert r.status_code == 201 body = r.json() assert body["title"] == f"Chat {body['id'][:8]}" def test_create_round_trips_full_brain_record(admin_client: TestClient) -> None: r = admin_client.post( "/api/chats", json={"messages": [_user(FIRST_QUESTION), FULL_BRAIN]} ) assert r.status_code == 201 # The bor.chat.v1-shaped payload round-trips losslessly: every # optional key (sources/deflected/suggestions/thinking/tools/ # stopped) survives identical. assert r.json()["messages"][1] == FULL_BRAIN def test_create_rejects_empty_messages(admin_client: TestClient) -> None: assert admin_client.post("/api/chats", json={"messages": []}).status_code == 422 _assert_no_chats(admin_client) def test_create_rejects_unknown_who(admin_client: TestClient) -> None: r = admin_client.post( "/api/chats", json={"messages": [{"who": "alien", "text": "hi"}]} ) assert r.status_code == 422 _assert_no_chats(admin_client) def test_create_rejects_empty_text(admin_client: TestClient) -> None: assert ( admin_client.post("/api/chats", json={"messages": [_user("")]}) ).status_code == 422 _assert_no_chats(admin_client) def test_create_rejects_extra_message_keys(admin_client: TestClient) -> None: # A corrupted / HTML-shaped payload must not cross the boundary. message = _user(FIRST_QUESTION) message["html"] = "not allowed" assert admin_client.post("/api/chats", json={"messages": [message]}).status_code == 422 _assert_no_chats(admin_client) def test_create_rejects_title_over_500(admin_client: TestClient) -> None: assert ( admin_client.post( "/api/chats", json={"title": "t" * 501, "messages": _simple_conversation()} ) ).status_code == 422 # ---------- list ---------- def test_list_empty(admin_client: TestClient) -> None: r = admin_client.get("/api/chats") assert r.status_code == 200 assert r.json() == {"chats": []} def test_list_orders_by_updated_at_desc(admin_client: TestClient, db: Session) -> None: base = datetime.now(UTC) db.add_all( [ SavedChat( title="oldest", messages=[_user("one")], updated_at=base, ), SavedChat( title="newest", messages=[_user("two"), _user("three")], updated_at=base + timedelta(hours=2), ), SavedChat( title="middle", messages=[_user("four")], updated_at=base + timedelta(hours=1), ), ] ) db.commit() r = admin_client.get("/api/chats") assert r.status_code == 200 body = r.json() assert [c["title"] for c in body["chats"]] == ["newest", "middle", "oldest"] for c in body["chats"]: assert set(c) == ROW_KEYS uuid.UUID(c["id"]) assert "messages" not in c # no payloads in the list def test_list_reports_message_count(admin_client: TestClient) -> None: admin_client.post("/api/chats", json={"messages": _simple_conversation()}) body = admin_client.get("/api/chats").json() assert [c["message_count"] for c in body["chats"]] == [2] # ---------- get ---------- def test_get_returns_full_payload_round_trip(admin_client: TestClient) -> None: created = admin_client.post( "/api/chats", json={"title": EXPLICIT_TITLE, "messages": [_user(FIRST_QUESTION), FULL_BRAIN]}, ).json() r = admin_client.get(f"/api/chats/{created['id']}") assert r.status_code == 200 body = r.json() assert set(body) == OUT_KEYS assert body["id"] == created["id"] assert body["title"] == EXPLICIT_TITLE assert body["message_count"] == 2 # Byte-identical payload: the brain record with sources/thinking/ # tools/stopped (incl. the `argument: null` tool) survives the trip # to Postgres and back. assert body["messages"] == _expect([_user(FIRST_QUESTION), FULL_BRAIN]) def test_get_unknown_chat_returns_404(admin_client: TestClient) -> None: r = admin_client.get(f"/api/chats/{uuid.uuid4()}") assert r.status_code == 404 assert r.json() == {"detail": "unknown chat"} def test_get_invalid_id_returns_422(admin_client: TestClient) -> None: assert admin_client.get("/api/chats/not-a-uuid").status_code == 422 # ---------- update (PUT) — the re-Save upsert ---------- def test_put_replaces_messages_and_bumps_updated_at(admin_client: TestClient) -> None: created = admin_client.post( "/api/chats", json={"title": EXPLICIT_TITLE, "messages": _simple_conversation()}, ).json() # A second, newer chat — it currently lists first. other = admin_client.post( "/api/chats", json={"messages": [_user("second question")], "title": "Other"} ).json() assert admin_client.get("/api/chats").json()["chats"][0]["id"] == other["id"] updated_before = created["updated_at"] time.sleep(0.1) # now() has µs resolution — make the bump observable new_messages = [ _user("How do I prune deleted docs?"), {"who": "brain", "text": "Use --prune."}, ] r = admin_client.put(f"/api/chats/{created['id']}", json={"messages": new_messages}) assert r.status_code == 200 body = r.json() assert body["id"] == created["id"] assert body["title"] == EXPLICIT_TITLE # absent title keeps the current one assert body["message_count"] == 2 assert body["messages"] == _expect(new_messages) # full replacement assert datetime.fromisoformat(body["created_at"]) == datetime.fromisoformat( created["created_at"] ) # editing does not redate creation assert datetime.fromisoformat(body["updated_at"]) > datetime.fromisoformat( updated_before ), "updated_at must bump on a re-Save (onupdate=func.now())" # The list order follows the bump: this row is first again. body_list = admin_client.get("/api/chats").json()["chats"] assert body_list[0]["id"] == created["id"] def test_put_sets_title_when_supplied(admin_client: TestClient) -> None: created = admin_client.post( "/api/chats", json={"messages": _simple_conversation()} ).json() r = admin_client.put( f"/api/chats/{created['id']}", json={"title": "Renamed notes", "messages": _simple_conversation()}, ) assert r.status_code == 200 assert r.json()["title"] == "Renamed notes" assert ( admin_client.get(f"/api/chats/{created['id']}").json()["title"] == "Renamed notes" ) def test_put_blank_title_keeps_current(admin_client: TestClient) -> None: created = admin_client.post( "/api/chats", json={"title": EXPLICIT_TITLE, "messages": _simple_conversation()}, ).json() r = admin_client.put( f"/api/chats/{created['id']}", json={"title": " ", "messages": _simple_conversation()} ) assert r.status_code == 200 assert r.json()["title"] == EXPLICIT_TITLE def test_put_unknown_chat_returns_404(admin_client: TestClient) -> None: r = admin_client.put( f"/api/chats/{uuid.uuid4()}", json={"messages": _simple_conversation()} ) assert r.status_code == 404 assert r.json() == {"detail": "unknown chat"} def test_put_rejects_empty_messages(admin_client: TestClient) -> None: created = admin_client.post( "/api/chats", json={"messages": _simple_conversation()} ).json() assert ( admin_client.put(f"/api/chats/{created['id']}", json={"messages": []}) ).status_code == 422 # The original payload is untouched. assert ( admin_client.get(f"/api/chats/{created['id']}").json()["messages"] == _expect(_simple_conversation()) ) def test_put_rejects_extra_message_keys(admin_client: TestClient) -> None: created = admin_client.post( "/api/chats", json={"messages": _simple_conversation()} ).json() bad = _user("hi") bad["innerHTML"] = "" r = admin_client.put( f"/api/chats/{created['id']}", json={"messages": [bad, FULL_BRAIN]} ) assert r.status_code == 422 assert ( admin_client.get(f"/api/chats/{created['id']}").json()["messages"] == _expect(_simple_conversation()) ) # ---------- delete ---------- def test_delete_returns_204_and_removes(admin_client: TestClient, db: Session) -> None: created = admin_client.post("/api/chats", json={"messages": _simple_conversation()}).json() assert admin_client.delete(f"/api/chats/{created['id']}").status_code == 204 assert admin_client.get(f"/api/chats/{created['id']}").status_code == 404 assert admin_client.get("/api/chats").json() == {"chats": []} assert db.scalars(select(SavedChat)).all() == [] def test_delete_unknown_chat_returns_404(admin_client: TestClient) -> None: r = admin_client.delete(f"/api/chats/{uuid.uuid4()}") assert r.status_code == 404 assert r.json() == {"detail": "unknown chat"} def test_delete_invalid_id_returns_422(admin_client: TestClient) -> None: assert admin_client.delete("/api/chats/not-a-uuid").status_code == 422