"""Integration: saved-chat CRUD (phase 50, task 02) — the ``/api/chats`` contract. Real Postgres (``podman compose up -d db``). Phase 55 (task 01) split the phase-16 ``require_admin`` gate: the WRITE surface (``POST`` create incl. save-then-share, ``PUT`` re-Save, ``POST /{id}/share``) is public — the guest pins below exercise exactly that — while the MANAGEMENT surface (list / detail / delete / unshare) stays admin-only (guests get 403 on exactly those four routes). The admin CRUD pins stay green unchanged: 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``; phase 175 task 01: server-side pagination — ``limit`` (default 25, clamped 1–100) / ``offset`` + the full ``total`` on every response; out-of- range params 422; an offset beyond the end → an empty list with the honest ``total``), the full-payload round-trip (a ``bor.chat.v1``-shaped brain record carrying ``sources``/``thinking``/``tools``/``stopped``/``failed``/``error`` survives losslessly), the PUT upsert semantics (replacement + title-keep + title-set + ``updated_at`` bump), the delete 404/204, and (phase 53, task 03) the sources-version stamp + ``stale`` flag: create and re-Save stamp the row's ``sources_version``, list/detail expose ``stale`` (true iff the stamp is behind the current generation — computed server-side), and share/unshare stay version-immune. Requires: podman compose up -d db """ from __future__ import annotations import json import re import time import uuid from collections.abc import Iterator from datetime import UTC, datetime, timedelta from pathlib import Path from typing import Any import pytest from fastapi.testclient import TestClient from sqlalchemy import select, text from sqlalchemy.orm import Session from app.config import Settings from app.main import app as fastapi_app from app.models import SavedChat, SourcesMeta from app.rag.sources_meta import bump_sources_version FIRST_QUESTION = "How did I install gitlab?" EXPLICIT_TITLE = "My backup notes" #: The share link's shape: the page path + a canonical (lowercase) UUID. SHARE_URL_RE = re.compile( r"^/shared/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" ) #: The PUBLIC read's exact key set — no id, no timestamps, no token. SHARED_OUT_KEYS = {"title", "messages"} #: A full ``bor.chat.v1`` brain record (phase 14 shape) — every optional #: key present; the round-trip test asserts it survives byte-identical. #: Phase 95 (task 02): the CURRENT full tool-entry shape — the additive #: truncation fields ride each record (a pre-phase-95 entry WITHOUT them #: still validates — the backward-compat pin in test_schemas.py). #: Phase 139 (task 02): the additive range fields join the same entries. 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"} ], # Phase 113's related-doc tier — the UI persists it with every # grounded brain record (the restore path re-renders the row from # it). It must be an ACCEPTED key: the phase-113 omission (the key # missing from ChatMessage) made the extra="forbid" boundary 422 # every done-time auto-save carrying it, so grounded turns' brain # messages never persisted (the A2 quiet failure swallowed the # 422). This round-trip is the regression pin. "related": [ {"source": "Homelab", "path": "traefik.md", "title": "Traefik Notes"} ], "deflected": False, "suggestions": ["What ports does Traefik expose?"], "thinking": "The kubernetes doc covers the cluster layout…", # Phase 139 (task 02): the read's requested range joins the same # entries (additive — the truncation-fields precedent above): a # save → restore round-trip keeps it, so the restore path and the # shared page re-render the "(lines 42–71)" suffix. A pre-phase-139 # entry WITHOUT the keys still validates (test_schemas.py's # backward-compat pin) and stores nulls. "tools": [ { "name": "read", "argument": "Homelab/kubernetes.md", "truncated": True, "chars_shown": 128_000, "chars_total": 204_000, "offset": 42, "limit": 30, }, # Phase 146 (task 02): a SCOPED grep's record — the additive # ``path`` key (the re-searched document's combined # ``source/path``) round-trips through the boundary + Postgres, # so the restore path re-renders the scope. A non-scoped # record WITHOUT the key (the next two entries — every # pre-phase and bare-grep shape) stores/returns WITHOUT it # (ABSENT, never null — the omission serializer): those # entries' exact-dict round-trip is the byte-identity pin. { "name": "grep", "argument": "reese-sentinel-42", "path": "search_docs/reese-notes.md", "truncated": False, "chars_shown": None, "chars_total": None, "offset": None, "limit": None, }, { "name": "ls", "argument": None, "truncated": False, "chars_shown": None, "chars_total": None, "offset": None, "limit": None, }, # Saved chats persisting the pre-phase-70 tool names still # validate — ``name`` is opaque to the API (no migration, # locked: old chats render fine). { "name": "read_document", "argument": "Homelab/legacy-notes.md", "truncated": False, "chars_shown": None, "chars_total": None, "offset": None, "limit": None, }, ], "stopped": False, # Phase 120 (task 01): the failed-turn marker + the persisted error # detail — the phase-48 ``stopped`` precedent. A FULL brain record # carries the keys (``failed: False`` = the marker is explicit, not # absent; the round-trip stays byte-identical through them). "failed": False, "error": None, } OUT_KEYS = { "id", "title", "created_at", "updated_at", "message_count", "messages", "stale", # phase 53: server-computed staleness flag } ROW_KEYS = {"id", "title", "updated_at", "message_count", "stale"} @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() @pytest.fixture(autouse=True) def seeded_sources_meta(db: Session) -> Iterator[None]: """The ``sources_meta`` counter (phase 53) is global state: reset to the migration-0010 seed (id 1, version 0 — "the pre-counter KB") around every test, so each test starts from a known generation and the dev DB is left exactly as the migration left it.""" db.execute(text("DELETE FROM sources_meta")) db.add(SourcesMeta(id=1, version=0)) db.commit() yield db.execute(text("DELETE FROM sources_meta")) db.add(SourcesMeta(id=1, version=0)) db.commit() def _bump(db: Session) -> int: """One simulated KB-changing sync: a single committed bump (task 02's change-gate lives in the sync paths themselves; the API tests only need the counter's contract — caller commits). """ version = bump_sources_version(db) db.commit() # the helper only flushes return version def _stored_sources_version(db: Session, chat_id: str) -> int: """The row's ``sources_version`` via raw SQL — deliberately bypassing the session's identity map, because the API's commits land in the app's own sessions (a cached ORM object could be stale).""" return int( db.execute( text("SELECT sources_version FROM saved_chats WHERE id = :id"), {"id": chat_id}, ).scalar_one() ) 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"), "related": m.get("related"), "deflected": m.get("deflected"), "suggestions": m.get("suggestions"), "thinking": m.get("thinking"), "tools": m.get("tools"), "stopped": m.get("stopped"), "failed": m.get("failed"), "error": m.get("error"), # Phase 160 (task 04): the budget-exhausted marker — # explicit null where the record does not carry it (the # bor.chat.v1 shape is explicit-null for every optional # key; a record WITH the key round-trips it byte- # identically — the pins below). "budget_exhausted": m.get("budget_exhausted"), } for m in records ] def _assert_no_chats(admin_client: TestClient) -> None: assert admin_client.get("/api/chats").json() == {"chats": [], "total": 0} # ---------- guest (no session): the public write surface (phase 55, # task 01 — the router-wide phase-16 gate moved off POST/PUT/share, # onto exactly the four management routes) ---------- def test_guest_create_returns_201_with_id_and_auto_title(client: TestClient) -> None: """A guest saves their own conversation (no session cookie): 201, a valid row id, and the same auto-title convention as the admin (first user message) — the save surface is public since phase 55, task 01.""" r = 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 assert "share_url" not in body # unshared: the key is ABSENT def test_guest_put_replaces_messages_and_bumps_updated_at(client: TestClient) -> None: """The re-Save upsert is guest-reachable (phase 55, task 01): the same row's messages are fully replaced and ``updated_at`` moves — the auto-save contract (task 02) relies on this working without a session.""" created = client.post("/api/chats", json={"messages": _simple_conversation()}).json() 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 = 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"] == FIRST_QUESTION # absent title keeps the current one assert body["messages"] == _expect(new_messages) # full replacement assert datetime.fromisoformat(body["updated_at"]) > datetime.fromisoformat( updated_before ), "updated_at must bump on a guest re-Save (onupdate=func.now())" def test_guest_share_returns_share_url_and_is_idempotent( client: TestClient, db: Session ) -> None: """``POST /{id}/share`` is guest-reachable (phase 55, task 01): 200 + ``share_url`` (token shape), the token persists on the row, and a second guest share returns the SAME token (idempotent).""" created = client.post("/api/chats", json={"messages": _simple_conversation()}).json() r1 = client.post(f"/api/chats/{created['id']}/share") assert r1.status_code == 200 body1 = r1.json() assert set(body1) == {"chat_id", "share_url"} assert body1["chat_id"] == created["id"] assert SHARE_URL_RE.fullmatch(body1["share_url"]), ( f"share_url must be /shared/: {body1['share_url']}" ) token = uuid.UUID(body1["share_url"].removeprefix("/shared/")) assert _stored_token(db, created["id"]) == token # persisted on the row r2 = client.post(f"/api/chats/{created['id']}/share") assert r2.status_code == 200 assert r2.json() == body1, "a guest re-share returns the SAME token" assert _stored_token(db, created["id"]) == token def test_guest_create_with_share_saves_and_shares_in_one_action( client: TestClient, db: Session ) -> None: """The save-then-share contract, now guest-reachable (phase 55, task 01): ONE request — 201 + ``share_url``, the token persisted in the SAME commit (no second request), and the link reads anonymously the moment the 201 lands.""" r = client.post( "/api/chats", json={"messages": _simple_conversation(), "share": True} ) assert r.status_code == 201 body = r.json() assert set(body) == OUT_KEYS | {"share_url"} assert body["title"] == FIRST_QUESTION # auto-title applies as for admin assert SHARE_URL_RE.fullmatch(body["share_url"]), ( f"share_url must be /shared/: {body['share_url']}" ) token = uuid.UUID(body["share_url"].removeprefix("/shared/")) assert _stored_token(db, body["id"]) == token # same commit, one INSERT anon = TestClient(fastapi_app) # fresh jar: truly anonymous got = anon.get(f"/api{body['share_url']}") assert got.status_code == 200 assert set(got.json()) == SHARED_OUT_KEYS assert got.json()["messages"] == _expect(_simple_conversation()) def test_guest_is_403_only_on_the_management_surface(client: TestClient) -> None: """The router-wide phase-16 gate MOVED, it did not disappear: a guest (no session cookie) is 403 ``admin only`` on exactly the four management routes — list / detail / delete / unshare (the owner's History surface). The public ``/api/shared/`` read is NOT in this list (it is anonymous by design, as before).""" anon = TestClient(fastapi_app) # fresh jar: truly anonymous unknown = uuid.uuid4() cases = [ ("GET", "/api/chats", None), ("GET", f"/api/chats/{unknown}", None), ("DELETE", f"/api/chats/{unknown}", None), ("POST", f"/api/chats/{unknown}/unshare", 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 a guest" assert r.json() == {"detail": "admin only"} def test_guest_unshare_403s_but_admin_revocation_still_works( admin_client: TestClient, ) -> None: """Revocation end-to-end across the split gate (phase 55, task 01): the guest who created + shared the chat cannot unshare (403 — the link stays live), but the admin's unshare revokes it — the public ``GET /api/shared/`` read 404s afterwards for guest AND admin (the public read itself is unaffected by this task).""" guest = TestClient(fastapi_app) # fresh jar: truly anonymous created = guest.post("/api/chats", json={"messages": _simple_conversation()}).json() share_url = guest.post(f"/api/chats/{created['id']}/share").json()["share_url"] anon = TestClient(fastapi_app) # a second guest, to prove the read assert anon.get(f"/api{share_url}").status_code == 200 # live r = guest.post(f"/api/chats/{created['id']}/unshare") assert r.status_code == 403 # management surface — a guest cannot revoke assert r.json() == {"detail": "admin only"} assert anon.get(f"/api{share_url}").status_code == 200 # still live assert ( admin_client.post(f"/api/chats/{created['id']}/unshare").status_code == 200 ) assert anon.get(f"/api{share_url}").status_code == 404, "guest: revoked" assert admin_client.get(f"/api{share_url}").status_code == 404, "admin: revoked" # ---------- 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/related/deflected/suggestions/thinking/ # tools/stopped/failed/error/budget_exhausted — the phase-160 # additive marker, explicit null where absent) survives identical # (the _expect full stored shape). assert r.json()["messages"][1] == _expect([_user(FIRST_QUESTION), FULL_BRAIN])[1] 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": [], "total": 0} 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] # ---------- list — pagination (phase 175, task 01) ---------- def _seed_staggered_chats(db: Session, n: int) -> list[str]: """``n`` saved chats with distinctive titles and DISTINCT, staggered ``updated_at`` (the ``test_list_orders_by_updated_at_desc`` idiom, scaled up): the ordered list is fully determined by ``updated_at desc`` (the uuid4 id tie-break never fires). Returns the titles in ORDERED order (newest first); ``clean_chats`` truncates around the test.""" base = datetime.now(UTC) db.add_all( [ SavedChat( title=f"page175 row {i:02d}", messages=[_user(f"question {i}")], updated_at=base + timedelta(hours=i), # i=0 oldest ) for i in range(n) ] ) db.commit() return [f"page175 row {i:02d}" for i in range(n - 1, -1, -1)] def _ordered_chat_ids(db: Session) -> list[str]: """The full ordered id sequence (``updated_at desc, id desc``) via raw SQL — the ground truth a page concatenation is checked against (bypassing the API itself).""" return [ str(r[0]) for r in db.execute( text( "SELECT id FROM saved_chats ORDER BY updated_at DESC, " "id DESC" ) ) ] def test_list_default_page_is_25_and_reports_total( admin_client: TestClient, db: Session ) -> None: ordered = _seed_staggered_chats(db, 30) r = admin_client.get("/api/chats") assert r.status_code == 200 body = r.json() assert set(body) == {"chats", "total"} assert body["total"] == 30 # the FULL count, not the page length assert len(body["chats"]) == 25 # The page is the FIRST 25 of the full ordered list # (``updated_at desc`` — the staggered fixture pins the order). assert [c["title"] for c in body["chats"]] == ordered[:25] for c in body["chats"]: assert set(c) == ROW_KEYS uuid.UUID(c["id"]) def test_list_limit_offset_slice(admin_client: TestClient, db: Session) -> None: ordered = _seed_staggered_chats(db, 30) r = admin_client.get("/api/chats", params={"limit": 10, "offset": 20}) assert r.status_code == 200 body = r.json() assert body["total"] == 30 # Exactly the LAST 10 of the ordered list (the fixture's ten # oldest), pinned by title. assert [c["title"] for c in body["chats"]] == ordered[20:] def test_list_order_preserved_across_pages( admin_client: TestClient, db: Session ) -> None: _seed_staggered_chats(db, 30) pages: list[dict[str, Any]] = [] for offset in (0, 10, 20): body = admin_client.get( "/api/chats", params={"limit": 10, "offset": offset} ).json() assert body["total"] == 30 pages.extend(body["chats"]) # The three pages concatenated == the full ordered id sequence # (the raw-SQL ground truth) — no duplicates, no gaps. assert [c["id"] for c in pages] == _ordered_chat_ids(db) assert len({c["id"] for c in pages}) == 30 def test_list_rejects_out_of_range_params(admin_client: TestClient) -> None: # The house fixed-422 contract (FastAPI field-message validation # — no hand-rolled checks): one request per case, status # asserted. for params in ({"limit": 0}, {"limit": 101}, {"offset": -1}): assert admin_client.get("/api/chats", params=params).status_code == 422 def test_list_offset_beyond_total_returns_empty_with_total( admin_client: TestClient, db: Session ) -> None: _seed_staggered_chats(db, 30) r = admin_client.get("/api/chats", params={"offset": 100}) assert r.status_code == 200 # An honest count, not an error: the list is empty but ``total`` # still reports the fixture's 30 rows. assert r.json() == {"chats": [], "total": 30} # ---------- 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/related/ # 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": [], "total": 0} 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 # ---------- share / unshare / public read (phase 51, task 01) ---------- def _share(admin_client: TestClient, chat_id: str) -> dict[str, Any]: r = admin_client.post(f"/api/chats/{chat_id}/share") assert r.status_code == 200 return r.json() def _stored_token(db: Session, chat_id: str) -> uuid.UUID | None: """The row's ``share_token`` as seen by a fresh DB read.""" row = db.get(SavedChat, uuid.UUID(chat_id)) assert row is not None, "the chat row must exist" return row.share_token def test_share_returns_200_with_share_url_and_is_idempotent( admin_client: TestClient, db: Session ) -> None: created = admin_client.post("/api/chats", json={"messages": _simple_conversation()}).json() body1 = _share(admin_client, created["id"]) assert set(body1) == {"chat_id", "share_url"} assert body1["chat_id"] == created["id"] assert SHARE_URL_RE.fullmatch(body1["share_url"]), ( f"share_url must be /shared/: {body1['share_url']}" ) token = uuid.UUID(body1["share_url"].removeprefix("/shared/")) # Persisted on the row (the A10 extension unchanged: same row, one # new column — no new table). assert _stored_token(db, created["id"]) == token # Idempotent: a re-share returns the SAME token, unchanged. body2 = _share(admin_client, created["id"]) assert body2 == body1 assert _stored_token(db, created["id"]) == token def test_share_leaves_updated_at_unchanged(admin_client: TestClient) -> None: """Sharing is not a content edit — the token is written with a Core ``update()`` that skips the ORM ``onupdate``, so the History page's "latest activity first" order follows content edits only.""" created = admin_client.post("/api/chats", json={"messages": _simple_conversation()}).json() updated_before = created["updated_at"] time.sleep(0.1) # now() has µs resolution — make a bump observable _share(admin_client, created["id"]) r = admin_client.get(f"/api/chats/{created['id']}") assert r.json()["updated_at"] == updated_before, ( "share must not bump updated_at" ) def test_unshare_revokes_the_link_and_is_idempotent( admin_client: TestClient, db: Session ) -> None: created = admin_client.post("/api/chats", json={"messages": _simple_conversation()}).json() share_url = _share(admin_client, created["id"])["share_url"] anon = TestClient(fastapi_app) # fresh jar: truly anonymous assert anon.get(f"/api{share_url}").status_code == 200 # live, pre-revoke r = admin_client.post(f"/api/chats/{created['id']}/unshare") assert r.status_code == 200 assert r.json() == {"chat_id": created["id"], "shared": False} # The token is NULL in the DB and the public read now 404s. assert _stored_token(db, created["id"]) is None revoked = anon.get(f"/api{share_url}") assert revoked.status_code == 404 assert revoked.json() == {"detail": "unknown or revoked share link"} # Idempotent: unsharing an unshared chat is a clean 200 (no write). r2 = admin_client.post(f"/api/chats/{created['id']}/unshare") assert r2.status_code == 200 assert r2.json() == {"chat_id": created["id"], "shared": False} assert _stored_token(db, created["id"]) is None def test_unshare_leaves_updated_at_unchanged(admin_client: TestClient) -> None: created = admin_client.post("/api/chats", json={"messages": _simple_conversation()}).json() updated_before = created["updated_at"] _share(admin_client, created["id"]) time.sleep(0.1) admin_client.post(f"/api/chats/{created['id']}/unshare") r = admin_client.get(f"/api/chats/{created['id']}") assert r.json()["updated_at"] == updated_before, ( "unshare must not bump updated_at" ) def test_public_read_returns_snapshot_without_private_keys( admin_client: TestClient, ) -> None: """A fresh anonymous client reads the shared chat: title + messages round-trip, and the body carries NONE of the admin-surface keys (no id, no timestamps, no token — a content snapshot, not a handle).""" created = admin_client.post( "/api/chats", json={ "title": EXPLICIT_TITLE, "messages": [_user(FIRST_QUESTION), FULL_BRAIN], }, ).json() share_url = _share(admin_client, created["id"])["share_url"] anon = TestClient(fastapi_app) # fresh jar: truly anonymous r = anon.get(f"/api{share_url}") assert r.status_code == 200 body = r.json() assert set(body) == SHARED_OUT_KEYS assert body["title"] == EXPLICIT_TITLE assert body["messages"] == _expect([_user(FIRST_QUESTION), FULL_BRAIN]) # --------------------------------------------------------------------------- # Phase 123 (tasks 03/04): the question's attached image — the saved # and shared shape carries the stored PATH (LOCKED A5: never base64), # on the USER record only (the attachment belongs to the question — a # brain record never carries the key). Text-only records stay WITHOUT # the key (absent, never null — the phase-50 byte-identical contract, # pinned by ``_expect`` above; the ``ChatMessage`` wrap serializer does # the dropping, so every surface — the stored JSONB, the admin GET, # the public shared read — inherits it). # --------------------------------------------------------------------------- #: A well-formed stored path (the ``POST /api/chat-images`` response's #: shape — the chat-images router names the file #: ``.``; the API boundary itself only bounds the #: string to 500 chars, the pattern gate is the chat request's). IMAGE_PATH = "/api/chat-images/" + "a" * 32 + ".png" def _user_with_image(text: str) -> dict[str, Any]: return {"who": "user", "text": text, "image": IMAGE_PATH} def test_create_round_trips_the_user_image_path( admin_client: TestClient, db: Session ) -> None: """The user record carries the PATH at every boundary: the 201 body, the RAW stored JSONB (never base64 — the A5 contract), and the admin GET — and the brain record stays WITHOUT the key. A text-only record in the SAME chat stays key-free too.""" r = admin_client.post( "/api/chats", json={ "messages": [ _user(FIRST_QUESTION), # text-only — stays WITHOUT the key _user_with_image("What is in this image?"), {"who": "brain", "text": "A cat, by the look of it."}, ] }, ) assert r.status_code == 201, r.text body = r.json() assert "image" not in body["messages"][0] assert body["messages"][1]["image"] == IMAGE_PATH assert "image" not in body["messages"][2] # The RAW stored JSONB (the DB is the durable boundary — the # served bodies could in principle re-derive it): the path, and # NOTHING base64 anywhere in the payload. raw = db.execute( text("SELECT messages FROM saved_chats WHERE id = :id"), {"id": body["id"]}, ).scalar_one() assert "image" not in raw[0] assert raw[1]["image"] == IMAGE_PATH assert "image" not in raw[2] assert "base64" not in json.dumps(raw) got = admin_client.get(f"/api/chats/{body['id']}") assert got.status_code == 200 assert "image" not in got.json()["messages"][0] assert got.json()["messages"][1]["image"] == IMAGE_PATH assert "image" not in got.json()["messages"][2] def test_shared_serve_includes_the_user_image_path( admin_client: TestClient, ) -> None: """The shared view is faithful (task 03's pin): the PUBLIC snapshot's user record carries the image path — the public ``messages`` shape already gains the one optional key, so no new shared-shape field exists — and the brain / text-only records stay WITHOUT it. The image bytes themselves ride the public serve route (phase 55 A1 — the image is part of the chat's content, the token is the credential): pinned in ``test_chat_api.py`` alongside the upload.""" r = admin_client.post( "/api/chats", json={ "messages": [ _user(FIRST_QUESTION), # text-only — stays WITHOUT the key _user_with_image("What is in this image?"), {"who": "brain", "text": "A cat, by the look of it."}, ] }, ) assert r.status_code == 201, r.text share_url = _share(admin_client, r.json()["id"])["share_url"] anon = TestClient(fastapi_app) # fresh jar: truly anonymous got = anon.get(f"/api{share_url}") assert got.status_code == 200 body = got.json() assert set(body) == SHARED_OUT_KEYS # no new shared-shape field messages = body["messages"] assert "image" not in messages[0] assert messages[1]["image"] == IMAGE_PATH assert "image" not in messages[2] # --------------------------------------------------------------------------- # Phase 124 (task 04): the brain answer's first-class image — the # shown document's ref (the LLM's ``show_image`` pick) rides the BRAIN # record's ``answer_image`` key (distinct from the user record's # phase-123 ``image`` attachment path — the attachment belongs to the # question, the answer image to the brain's reply). The phase-113 # lesson applied up front: the done-time auto-save (``rememberBrainTurn``) # carries the key, and the ``extra="forbid"`` boundary silently 422'd # any key the model was missing (the A2 quiet-failure path swallowed # it — grounded turns silently unsaved). These round-trips are that # regression pin. # --------------------------------------------------------------------------- #: The shown image doc's full ref — the done frame's ``answer_image`` #: shape (source/path/title + the ``image_url`` bytes route). ANSWER_IMAGE_REF: dict[str, Any] = { "source": "Homelab", "path": "kubernetes-cluster.png", "title": "Kubernetes Cluster Diagram", "image_url": "/api/documents/0e8a6e5d-7b6c-4f2a-9c1d-3e5f7a9b1c2d/image", } def test_create_round_trips_the_brain_answer_image_ref( admin_client: TestClient, db: Session ) -> None: """The phase-113 regression pin (the A2 quiet-failure path): a brain record carrying ``answer_image`` → ``POST /api/chats`` is 201 (NOT a silent 422) and the served chat round-trips the ref BYTE-FOR-BYTE (the restore path re-renders the answer figure from it). The user record carries NO ``answer_image`` key, and the user's phase-123 ``image`` attachment key stays distinct.""" brain = {**FULL_BRAIN, "answer_image": ANSWER_IMAGE_REF} r = admin_client.post( "/api/chats", json={"messages": [_user(FIRST_QUESTION), brain]}, ) assert r.status_code == 201, f"expected 201, got {r.status_code}: {r.text}" body = r.json() assert body["messages"][1]["answer_image"] == ANSWER_IMAGE_REF assert "answer_image" not in body["messages"][0] assert "image" not in body["messages"][1] # the brain's image is a REF # The RAW stored JSONB (the DB is the durable boundary): the full # ref, byte-for-byte. raw = db.execute( text("SELECT messages FROM saved_chats WHERE id = :id"), {"id": body["id"]}, ).scalar_one() assert raw[1]["answer_image"] == ANSWER_IMAGE_REF assert "answer_image" not in raw[0] # The admin GET round-trips the ref losslessly. got = admin_client.get(f"/api/chats/{body['id']}") assert got.status_code == 200 assert got.json()["messages"][1]["answer_image"] == ANSWER_IMAGE_REF def test_create_without_answer_image_round_trips_unchanged( admin_client: TestClient, ) -> None: """A record WITHOUT the key (every pre-phase-124 chat, every image-less turn) round-trips UNCHANGED: the response carries no ``answer_image`` key at all — ABSENT, never ``null`` (the byte-identical-off contract; the phase-50 round-trip contract). ``_expect`` (the stored shape) is unchanged by phase 124 — the omission serializer inherits it on every surface.""" brain = {**FULL_BRAIN} r = admin_client.post( "/api/chats", json={"messages": [_user(FIRST_QUESTION), brain]}, ) assert r.status_code == 201, r.text body = r.json() assert body["messages"] == _expect([_user(FIRST_QUESTION), brain]) assert "answer_image" not in body["messages"][0] assert "answer_image" not in body["messages"][1] got = admin_client.get(f"/api/chats/{body['id']}") assert got.status_code == 200 assert "answer_image" not in got.json()["messages"][1] def test_shared_serve_includes_the_brain_answer_image_ref( admin_client: TestClient, ) -> None: """The shared view is faithful (task 06's render depends on it): the PUBLIC snapshot's brain record carries the answer ref (the bytes route is public — a shared chat is faithful, the phase-123 user-image precedent), and the user record stays WITHOUT the key.""" brain = {**FULL_BRAIN, "answer_image": ANSWER_IMAGE_REF} r = admin_client.post( "/api/chats", json={"messages": [_user(FIRST_QUESTION), brain]}, ) assert r.status_code == 201, r.text share_url = _share(admin_client, r.json()["id"])["share_url"] anon = TestClient(fastapi_app) # fresh jar: truly anonymous got = anon.get(f"/api{share_url}") assert got.status_code == 200 messages = got.json()["messages"] assert messages[1]["answer_image"] == ANSWER_IMAGE_REF assert "answer_image" not in messages[0] # --------------------------------------------------------------------------- # Phase 160 (task 04, TODO L4): the budget-exhausted marker — the # persisted answer that ran out of tool calls. The done-time # auto-save (``rememberBrainTurn``) persists the done frame's # ``budget_exhausted`` into the brain record's ``budget_exhausted`` # key (the ``answer_image`` precedent — the field must exist so the # ``extra="forbid"`` boundary does NOT 422 those saves; the # phase-113 lesson, applied up front), and the restore path re- # renders the "Continue researching" button from it (task 04 — the # marker is a client restore hint only, never a server behavior; no # migration: ``saved_chats.messages`` is raw JSONB, phase 50, and # ``app/api/chats.py``'s logic is unchanged). These round-trips are # that regression pin. # --------------------------------------------------------------------------- def test_create_round_trips_the_budget_exhausted_marker( admin_client: TestClient, ) -> None: """The phase-113 regression pin (the A2 quiet-failure path): a brain record carrying ``budget_exhausted: true`` → ``POST /api/chats`` is 201 (NOT a silent 422) and the admin GET round-trips the record BYTE-FOR-BYTE through Postgres (the ``_expect`` full bor.chat.v1 shape — the restore path re-renders the Continue button from the marker). The user record carries the key as an explicit null (it never did, and the stored shape is explicit-null for every optional key).""" brain = {**FULL_BRAIN, "budget_exhausted": True} r = admin_client.post( "/api/chats", json={"messages": [_user(FIRST_QUESTION), brain]}, ) assert r.status_code == 201, f"expected 201, got {r.status_code}: {r.text}" body = r.json() assert body["messages"][1]["budget_exhausted"] is True assert body["messages"][0]["budget_exhausted"] is None got = admin_client.get(f"/api/chats/{body['id']}") assert got.status_code == 200 assert got.json()["messages"] == _expect( [_user(FIRST_QUESTION), {**FULL_BRAIN, "budget_exhausted": True}] ) def test_create_without_budget_exhausted_marker_round_trips_unchanged( admin_client: TestClient, ) -> None: """A record WITHOUT the marker (every non-cap turn, every pre- phase record) round-trips with the key as an explicit null — the stored shape never gains a value (the byte-identical contract, the phase-50 precedent: the key is absent from the client's ``bor.chat.v1`` record via the ``undefined``-drops-the-key pattern, and explicit-null here keeps the restore path null-safe). The ``FULL_BRAIN`` shape (no marker) is the pin: its stored and served form is byte-identical to pre-phase-160 apart from the one new null key.""" r = admin_client.post( "/api/chats", json={"messages": [_user(FIRST_QUESTION), FULL_BRAIN]}, ) assert r.status_code == 201, r.text body = r.json() assert body["messages"][1]["budget_exhausted"] is None got = admin_client.get(f"/api/chats/{body['id']}") assert got.status_code == 200 assert got.json()["messages"] == _expect([_user(FIRST_QUESTION), FULL_BRAIN]) def test_shared_serve_includes_the_budget_exhausted_marker( admin_client: TestClient, ) -> None: """The shared view is faithful (the data is in the public snapshot), but the control is NOT: the shared page (``/shared/``, rendered server-side by ``shared.js`` — never by ``app.js``, owner-locked zero interactive controls) IGNORES the key by construction — no Continue control renders for a flagged record (work item 8; pinned in ``test_frontend_tool_budget.py::test_shared_page_never_shows_ the_continue_control``). The user record stays with the explicit null.""" brain = {**FULL_BRAIN, "budget_exhausted": True} r = admin_client.post( "/api/chats", json={"messages": [_user(FIRST_QUESTION), brain]}, ) assert r.status_code == 201, r.text share_url = _share(admin_client, r.json()["id"])["share_url"] anon = TestClient(fastapi_app) # fresh jar: truly anonymous got = anon.get(f"/api{share_url}") assert got.status_code == 200 messages = got.json()["messages"] assert messages[1]["budget_exhausted"] is True # faithful snapshot assert messages[0]["budget_exhausted"] is None def test_put_round_trips_the_budget_exhausted_marker( admin_client: TestClient, ) -> None: """The re-Save upsert (the auto-save path the client's restore relies on) round-trips the marker byte-identically too: a PUT with the flagged record replaces the stored JSONB and the 200 body + the GET both carry it (a refreshed chat page restores the Continue affordance from the persisted record).""" created = admin_client.post( "/api/chats", json={"messages": [_user(FIRST_QUESTION), FULL_BRAIN]}, ).json() brain = {**FULL_BRAIN, "budget_exhausted": True} r = admin_client.put( f"/api/chats/{created['id']}", json={"messages": [_user(FIRST_QUESTION), brain]}, ) assert r.status_code == 200, r.text assert r.json()["messages"][1]["budget_exhausted"] is True got = admin_client.get(f"/api/chats/{created['id']}") assert got.status_code == 200 assert got.json()["messages"] == _expect( [_user(FIRST_QUESTION), {**FULL_BRAIN, "budget_exhausted": True}] ) # --------------------------------------------------------------------------- # Phase 159 (task 02): edit-and-resend — the SAVE shape. The client # edits the last question: the record is replaced in place, EVERY # record after it is dropped, and the edited question becomes the LAST # record. The auto-save then PUTs that shortened list. This pins the # claim "no schema change is needed": a shortened ``messages`` array # with the edited last user record is ALREADY legal on the existing # ``SavedChatUpdate`` path (no new field, no migration — A10 stays # stateless; the ≤4000 / ≤32 000 caps already cover the edited text). # --------------------------------------------------------------------------- #: The driver case's question (the phase-114 "Question too long" turn) #: — long, near the 4 000-char ``ChatRequest.message`` cap, so the #: edit trims it down to a short re-askable question. LONG_QUESTION = "How do I prune deleted docs " + "and rotate the certificates " * 90 TRIMMED_QUESTION = "How do I prune deleted docs?" def test_put_edited_truncated_tail_round_trips_byte_identical( admin_client: TestClient, db: Session ) -> None: """The phase-159 client shape round-trips byte-identically through the EXISTING PUT path: the conversation was [user Q1, brain A1, user Q2-long, brain A2]; the owner trims Q2 → Q2', and the auto-save PUTs [user Q1, brain A1, user Q2'] — the tail (A2) is dropped, the last record is the EDITED user record, and the array is SHORTER. The PUT is a plain re-Save (no new field): 200 + ``message_count`` drops to 3, the stored JSONB + the admin GET + the 200 body all round-trip the edited record + the truncated list byte-identically (``_expect``'s full bor.chat.v1 shape), and the title keeps the FIRST question (title-keep on re-Save — the edited second question never re-titles the row).""" q1 = "What is my Traefik port?" a1 = {"who": "brain", "text": "8080."} created = admin_client.post( "/api/chats", json={ "messages": [ _user(q1), a1, _user(LONG_QUESTION), {"who": "brain", "text": "A long answer."}, ] }, ).json() assert created["message_count"] == 4 edited = [_user(q1), a1, _user(TRIMMED_QUESTION)] r = admin_client.put(f"/api/chats/{created['id']}", json={"messages": edited}) assert r.status_code == 200, r.text # a shortened array is already legal body = r.json() assert body["message_count"] == 3, "the tail (A2) is dropped — 2 + the edit" assert body["title"] == q1 # title-keep: the edit re-titles nothing assert body["messages"] == _expect(edited) # The RAW stored JSONB (the DB is the durable boundary — the # phase-123/124 precedent): the edited last user record + the # truncated list, byte-for-byte, explicit nulls included. raw = db.execute( text("SELECT messages FROM saved_chats WHERE id = :id"), {"id": created["id"]}, ).scalar_one() assert raw == _expect(edited) # The admin GET round-trips the same shape (the restore path reads # from this surface — a refresh shows the edited question and no # stale tail). got = admin_client.get(f"/api/chats/{created['id']}") assert got.status_code == 200 assert got.json()["messages"] == _expect(edited) def test_put_edited_record_keeps_the_user_image_key( admin_client: TestClient, ) -> None: """The image survives the edit (the phase-49/123 A7 rule's record side): the record KEEPS its ``image`` key when the question is replaced in place + the tail dropped — the re-ask sends text only, but the saved shape (and the bubble's attachment) carry the attachment through the round-trip. The key is present-and-carrying (the phase-123 shape — ``_expect`` covers the base keys, the optional ``image`` key is asserted on top, the phase-123 precedent).""" edited = [ _user_with_image("What is in this image?"), # edited text, image kept # (the tail — a brain answer — is dropped by the edit) ] created = admin_client.post( "/api/chats", json={ "messages": [ _user_with_image("What is in this picture?"), {"who": "brain", "text": "A cat, by the look of it."}, ] }, ).json() r = admin_client.put(f"/api/chats/{created['id']}", json={"messages": edited}) assert r.status_code == 200, r.text msgs = r.json()["messages"] assert len(msgs) == 1, "the tail is dropped — one record (the edit)" expected = _expect(edited)[0] expected["image"] = IMAGE_PATH assert msgs[0] == expected, ( "the attachment key survives the in-place edit (text-only " "re-ask, attachment kept)" ) def test_shared_serve_carries_the_edited_truncated_shape( admin_client: TestClient, ) -> None: """The phase-159 claim pinned on the PUBLIC surface: the edited/truncated conversation needs NO schema change on the shared serve either. The conversation is [user Q1, brain A1, user Q2, brain A2], shared BEFORE the edit (the token predates the shape change), then the owner trims Q2 → Q2' and the auto-save PUTs the post-edit shape [user Q1, brain A1, user Q2'] (the tail dropped, the array shorter, the last record the edited user record). The anonymous ``GET /api/shared/`` still serves exactly the public shape (``title`` + ``messages`` — no id, no timestamps, no token) with the CURRENT row content: the edited record + the truncated list, byte-identical (the read is a point-in-time read of the live row, so the pre-share token rides the post-edit shape — the shared page renders the edited question, no stale tail, no new field).""" q1 = "What is my Traefik port?" a1 = {"who": "brain", "text": "8080."} created = admin_client.post( "/api/chats", json={ "messages": [ _user(q1), a1, _user(LONG_QUESTION), {"who": "brain", "text": "A long answer."}, ] }, ).json() share_url = _share(admin_client, created["id"])["share_url"] # The edit + the auto-save's PUT (the post-edit shape — the same # shape as the admin-surface pin above, now served publicly). edited = [_user(q1), a1, _user(TRIMMED_QUESTION)] r = admin_client.put(f"/api/chats/{created['id']}", json={"messages": edited}) assert r.status_code == 200, r.text # The public read: the token (issued BEFORE the shape change) serves # the CURRENT row — the edited user record as the LAST message, # the tail gone, the array shorter, the byte-identical shape. anon = TestClient(fastapi_app) # fresh jar: truly anonymous got = anon.get(f"/api{share_url}") assert got.status_code == 200, got.text body = got.json() assert set(body) == SHARED_OUT_KEYS # no new shared-shape field assert body["title"] == q1 # title-keep: the edit re-titles nothing assert len(body["messages"]) == 3, "the tail (A2) is dropped — 2 + the edit" assert body["messages"] == _expect(edited), ( "the public surface serves the edited/truncated shape " "byte-identically — no schema change, no stale tail" ) def test_public_read_wrong_and_revoked_tokens_404_with_one_detail( admin_client: TestClient, ) -> None: """Wrong (never issued) and revoked (unshared) tokens 404 with the SAME detail — no enumeration between the two cases.""" anon = TestClient(fastapi_app) wrong = anon.get(f"/api/shared/{uuid.uuid4()}") assert wrong.status_code == 404 assert wrong.json() == {"detail": "unknown or revoked share link"} created = admin_client.post("/api/chats", json={"messages": _simple_conversation()}).json() share_url = _share(admin_client, created["id"])["share_url"] assert admin_client.post(f"/api/chats/{created['id']}/unshare").status_code == 200 revoked = anon.get(f"/api{share_url}") assert revoked.status_code == 404 assert revoked.json() == wrong.json() # one message, both cases def test_public_read_malformed_token_returns_422() -> None: anon = TestClient(fastapi_app) assert anon.get("/api/shared/not-a-uuid").status_code == 422 def test_share_and_unshare_unknown_chat_return_404(admin_client: TestClient) -> None: unknown = uuid.uuid4() r = admin_client.post(f"/api/chats/{unknown}/share") assert r.status_code == 404 assert r.json() == {"detail": "unknown chat"} r = admin_client.post(f"/api/chats/{unknown}/unshare") assert r.status_code == 404 assert r.json() == {"detail": "unknown chat"} # ---------- create-with-share (phase 51, task 02 — the save-then-share # contract: one request saves AND shares; unshared shapes carry NO # ``share_url`` key at all — absent, not null) ---------- def test_create_with_share_sets_token_in_the_same_commit( admin_client: TestClient, db: Session ) -> None: """``POST /api/chats`` with ``share: true``: the 201 body carries ``share_url`` (the ONLY extra key — the shape is OUT_KEYS + ``share_url``), matching the token shape, and the row's ``share_token`` is persisted in the SAME commit (one INSERT — no second request, no window where the row is saved but unshared).""" r = admin_client.post( "/api/chats", json={"messages": _simple_conversation(), "share": True} ) assert r.status_code == 201 body = r.json() assert set(body) == OUT_KEYS | {"share_url"} assert SHARE_URL_RE.fullmatch(body["share_url"]), ( f"share_url must be /shared/: {body['share_url']}" ) token = uuid.UUID(body["share_url"].removeprefix("/shared/")) assert _stored_token(db, body["id"]) == token def test_create_with_share_is_immediately_publicly_readable( admin_client: TestClient, ) -> None: """The save-then-share contract's payoff: the row is readable ANONYMOUSLY the moment the 201 lands (no second step).""" created = admin_client.post( "/api/chats", json={"messages": _simple_conversation(), "share": True} ).json() anon = TestClient(fastapi_app) # fresh jar: truly anonymous r = anon.get(f"/api{created['share_url']}") assert r.status_code == 200 body = r.json() assert set(body) == SHARED_OUT_KEYS assert body["messages"] == _expect(_simple_conversation()) def test_create_without_share_has_no_share_url(admin_client: TestClient) -> None: """The default (``share`` absent or false) is byte-for-byte the phase-50 shape: NO ``share_url`` key in the create body, the get body, or the list row — absent, not ``null``.""" r = admin_client.post("/api/chats", json={"messages": _simple_conversation()}) assert r.status_code == 201 created = r.json() assert "share_url" not in created assert set(created) == OUT_KEYS got = admin_client.get(f"/api/chats/{created['id']}").json() assert "share_url" not in got and set(got) == OUT_KEYS row = admin_client.get("/api/chats").json()["chats"][0] assert "share_url" not in row and set(row) == ROW_KEYS def test_create_share_false_is_explicitly_unshared(admin_client: TestClient) -> None: r = admin_client.post( "/api/chats", json={"messages": _simple_conversation(), "share": False} ) assert r.status_code == 201 assert "share_url" not in r.json(), "share: false is a plain Save (phase-50 shape)" def test_list_rows_carry_share_url_only_when_shared( admin_client: TestClient, ) -> None: """The list endpoint populates ``share_url`` — so the History column renders straight from ``GET /api/chats`` (no second fetch per row): shared rows carry it (token shape), unshared rows omit it (the row shape is exactly ROW_KEYS).""" shared = admin_client.post( "/api/chats", json={"title": "Shared one", "messages": _simple_conversation(), "share": True}, ).json() plain = admin_client.post( "/api/chats", json={"title": "Plain one", "messages": _simple_conversation()}, ).json() rows = {c["id"]: c for c in admin_client.get("/api/chats").json()["chats"]} assert SHARE_URL_RE.fullmatch(rows[shared["id"]]["share_url"]) assert set(rows[shared["id"]]) == ROW_KEYS | {"share_url"} assert "share_url" not in rows[plain["id"]] assert set(rows[plain["id"]]) == ROW_KEYS def test_get_carry_share_url_and_unshare_drops_it(admin_client: TestClient) -> None: """``GET /{chat_id}`` carries ``share_url`` while shared (the same path as the create body) and drops the key after ``unshare`` — the full-payload shape returns to the phase-50 OUT_KEYS.""" created = admin_client.post( "/api/chats", json={"messages": _simple_conversation(), "share": True} ).json() got = admin_client.get(f"/api/chats/{created['id']}").json() assert got["share_url"] == created["share_url"] assert set(got) == OUT_KEYS | {"share_url"} assert admin_client.post(f"/api/chats/{created['id']}/unshare").status_code == 200 got2 = admin_client.get(f"/api/chats/{created['id']}").json() assert "share_url" not in got2 assert set(got2) == OUT_KEYS # ---------- sources-version stamp + stale flag (phase 53, task 03) ---------- def test_create_stamps_current_sources_version( admin_client: TestClient, db: Session ) -> None: """``POST`` stamps the PENDING row with the current generation — it ships in the same INSERT (the ``share_token`` precedent), and the 201 body reports ``stale: false`` (a fresh save is by definition current).""" created = admin_client.post( "/api/chats", json={"messages": _simple_conversation()} ).json() assert created["stale"] is False, "a fresh save is never stale" assert _stored_sources_version(db, created["id"]) == 0 # seed generation # After a KB-changing sync (one bump), the NEXT save stamps the # new generation and is still fresh. assert _bump(db) == 1 created2 = admin_client.post( "/api/chats", json={"messages": [_user("another question")]} ).json() assert _stored_sources_version(db, created2["id"]) == 1 assert created2["stale"] is False def test_pre_counter_row_goes_stale_on_the_first_bump( admin_client: TestClient, db: Session ) -> None: """Recorded assumption: pre-existing rows (saved before the counter existed) stamp 0 — "the pre-counter KB" — and go stale on the first bump (0 < 1). A row inserted directly with the column's server default mirrors such a legacy row.""" db.add(SavedChat(title="legacy", messages=[_user("old question")])) db.commit() # no sources_version supplied → server default 0 listing = admin_client.get("/api/chats").json()["chats"][0] assert listing["stale"] is False, "0 == 0: current at the pre-counter KB" assert _bump(db) == 1 listing = admin_client.get("/api/chats").json()["chats"][0] assert listing["stale"] is True, "0 < 1: the first bump stale-s it" def test_list_and_detail_report_stale_after_bump(admin_client: TestClient, db: Session) -> None: """The staleness flag is computed server-side in BOTH admin read shapes: after a bump, the list row and the detail payload of a row saved at the older generation report ``stale: true`` (and a second bump — still behind — stays stale).""" created = admin_client.post( "/api/chats", json={"messages": _simple_conversation()} ).json() assert created["stale"] is False assert _bump(db) == 1 row = admin_client.get("/api/chats").json()["chats"][0] assert row["stale"] is True assert set(row) == ROW_KEYS # stale sits in the standard row shape detail = admin_client.get(f"/api/chats/{created['id']}").json() assert detail["stale"] is True assert set(detail) == OUT_KEYS assert _bump(db) == 2 # still behind (stamp 0 < 2) → still stale assert admin_client.get(f"/api/chats/{created['id']}").json()["stale"] is True def test_resave_restamps_and_clears_stale(admin_client: TestClient, db: Session) -> None: """A Re-Save (``PUT``) re-stamps the row to the CURRENT generation unconditionally — the owner is affirming this content against the current KB — so the 200 body reports ``stale: false`` again and the stored stamp advances (the manual escape hatch for a false-positive stale row).""" created = admin_client.post( "/api/chats", json={"messages": _simple_conversation()} ).json() assert _bump(db) == 1 assert admin_client.get(f"/api/chats/{created['id']}").json()["stale"] is True r = admin_client.put( f"/api/chats/{created['id']}", json={"messages": _simple_conversation()} ) assert r.status_code == 200 assert r.json()["stale"] is False, "the re-Save affirms against generation 1" assert _stored_sources_version(db, created["id"]) == 1 # A later bump stale-s it again — the stamp is a point in time, not # a sticky flag. assert _bump(db) == 2 assert admin_client.get(f"/api/chats/{created['id']}").json()["stale"] is True def test_share_and_unshare_leave_sources_version_untouched( admin_client: TestClient, db: Session ) -> None: """Share/unshare write ONLY ``share_token`` (raw SQL, the phase-51 contract) — the version stamp, like ``updated_at``, is immune: neither action can (un-)stale a chat, and staleness stays a pure function of the saved generation vs the current one.""" assert _bump(db) == 1 # a non-zero stamp makes the assert observable created = admin_client.post( "/api/chats", json={"messages": _simple_conversation()} ).json() assert _stored_sources_version(db, created["id"]) == 1 _share(admin_client, created["id"]) assert _stored_sources_version(db, created["id"]) == 1 assert admin_client.get(f"/api/chats/{created['id']}").json()["stale"] is False assert admin_client.post(f"/api/chats/{created['id']}/unshare").status_code == 200 assert _stored_sources_version(db, created["id"]) == 1 assert admin_client.get(f"/api/chats/{created['id']}").json()["stale"] is False def test_public_read_snapshot_has_no_stale_surface( admin_client: TestClient, db: Session, ) -> None: """``/api/shared/`` is a FROZEN snapshot by design (phase 51): even after a bump, the anonymous body keeps exactly its title+messages key set — no ``stale`` flag, no staleness surface (an owner who regenerates can re-share afterwards).""" created = admin_client.post( "/api/chats", json={"messages": _simple_conversation(), "share": True} ).json() assert _bump(db) == 1 # the saved chat is now stale (admin surface) assert admin_client.get(f"/api/chats/{created['id']}").json()["stale"] is True anon = TestClient(fastapi_app) # fresh jar: truly anonymous r = anon.get(f"/api{created['share_url']}") assert r.status_code == 200 body = r.json() assert set(body) == SHARED_OUT_KEYS # no stale key — frozen snapshot assert "stale" not in body # ---------- the /shared/ page route (phase 51, task 01) ---------- def test_page_route_missing_shared_html_returns_same_404_json( client: TestClient, monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: """Stale-deploy guard: a static dir WITHOUT ``shared.html`` (the page lands in task 03) 404s with the SAME JSON as the API — never a 500, regardless of the token.""" monkeypatch.setattr( "app.api.chats.get_settings", lambda: Settings(static_dir=str(tmp_path)) ) r = client.get(f"/shared/{uuid.uuid4()}") assert r.status_code == 404 assert r.json() == {"detail": "unknown or revoked share link"} def test_page_route_serves_shared_html_when_present( client: TestClient, monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: """Once the file exists (task 03), the route serves it for any well- formed token — token validity is the page's own concern (it fetches the API and renders the "invalid or revoked" state itself).""" (tmp_path / "shared.html").write_text("shared page", encoding="utf-8") monkeypatch.setattr( "app.api.chats.get_settings", lambda: Settings(static_dir=str(tmp_path)) ) r = client.get(f"/shared/{uuid.uuid4()}") assert r.status_code == 200 assert r.text == "shared page" assert "text/html" in r.headers["content-type"] # ---------- payload boundary caps (phase 83, SEC-05) — the anonymous # write surface 422s on oversized bodies and stores NOTHING (the caps # live in app/schemas.py, A2 boundary-only: FastAPI rejects before the # handler runs — no route change, nothing ever lands in the JSONB) ---------- def test_post_rejects_text_over_600000_and_stores_nothing( client: TestClient, admin_client: TestClient ) -> None: """The audit vector (SEC-05): one 600_001-char message 422s at the boundary (``ChatMessage.text`` mirrors ``HistoryTurn``'s 600 000 cap — raised TOGETHER in phase 164, locked A1) and NOTHING is stored — the admin list is unchanged (the happy path for in-cap bodies stays the existing guest/admin pins, incl. the full-cap-answer save below).""" baseline = admin_client.get("/api/chats").json()["chats"] r = client.post( "/api/chats", json={"messages": [_user("a" * 600_001), _user("follow-up")]}, ) assert r.status_code == 422 listing = admin_client.get("/api/chats").json()["chats"] assert listing == baseline, "an oversized POST must not store a row" def test_post_accepts_a_full_cap_answer_and_round_trips( client: TestClient, admin_client: TestClient ) -> None: """Phase 164 task 01 (the live report's save side): a conversation carrying ONE full-length brain answer (525k chars — a ``BOR_MAX_OUTPUT_TOKENS=131_072`` answer at ~4 chars/token) saves 201 and round-trips byte-for-byte through the JSONB — the whole ``done``-time auto-save of a long answer must not 422 into the phase-113 quiet-failure. No column change: ``messages`` is raw JSONB, so the only gate is the schema boundary.""" answer = "a" * 525_000 r = client.post( "/api/chats", json={ "messages": [ _user("Write the whole runbook"), {"who": "brain", "text": answer}, _user("And the rollback?"), {"who": "brain", "text": "Short rollback."}, ] }, ) assert r.status_code == 201, r.text chat_id = r.json()["id"] assert r.json()["messages"][1]["text"] == answer stored = admin_client.get(f"/api/chats/{chat_id}").json() assert stored["messages"][1]["text"] == answer assert len(stored["messages"]) == 4 def test_post_rejects_over_total_char_budget_and_stores_nothing( client: TestClient, admin_client: TestClient ) -> None: """The ONE new anonymous bound (phase 164, locked A1): messages that are individually legal but total over 8 000 000 characters 422 with the budget named in the ``detail`` (the model-level validator, so ``loc`` is empty) — and nothing lands. 16 full-cap answers = 8 388 800 chars.""" baseline = len(admin_client.get("/api/chats").json()["chats"]) r = client.post( "/api/chats", json={"messages": [{"who": "brain", "text": "a" * 524_300} for _ in range(16)]}, ) assert r.status_code == 422 assert "8,000,000-character budget" in json.dumps(r.json()) assert len(admin_client.get("/api/chats").json()["chats"]) == baseline def test_put_rejects_over_total_char_budget_and_leaves_row_unchanged( client: TestClient, admin_client: TestClient ) -> None: """The re-Save path carries the SAME validator (phase 164): an over-budget replacement 422s and the row keeps its original payload.""" created = client.post("/api/chats", json={"messages": _simple_conversation()}).json() r = client.put( f"/api/chats/{created['id']}", json={"messages": [{"who": "brain", "text": "a" * 524_300} for _ in range(16)]}, ) assert r.status_code == 422 assert "8,000,000-character budget" in json.dumps(r.json()) kept = admin_client.get(f"/api/chats/{created['id']}").json() assert kept["messages"] == _expect(_simple_conversation()) def test_post_rejects_more_than_500_messages_and_stores_nothing( client: TestClient, admin_client: TestClient ) -> None: """The unbounded message list is the second DoS lever (SEC-05): 501 minimally-valid messages 422 (``SavedChatCreate.messages`` ``max_items=500`` — phase 164 raised it WITH :attr:`ChatRequest.history`) and nothing lands.""" baseline = len(admin_client.get("/api/chats").json()["chats"]) r = client.post( "/api/chats", json={"messages": [_user(f"question {i}") for i in range(501)]} ) assert r.status_code == 422 assert len(admin_client.get("/api/chats").json()["chats"]) == baseline def test_post_rejects_sources_over_20_and_stores_nothing( client: TestClient, admin_client: TestClient ) -> None: """One brain message carrying a 21-item ``sources`` list (all valid ``SourceRef`` shapes) 422 (``ChatMessage.sources`` ``max_items=20`` — top-N docs + agent reads) and nothing lands.""" baseline = len(admin_client.get("/api/chats").json()["chats"]) sources = [ {"source": f"src{i}", "path": f"docs{i}.md", "title": f"Doc {i}"} for i in range(21) ] r = client.post( "/api/chats", json={ "messages": [ _user("hi"), {"who": "brain", "text": "answer", "sources": sources}, ] }, ) assert r.status_code == 422 assert len(admin_client.get("/api/chats").json()["chats"]) == baseline def test_put_rejects_oversized_message_and_leaves_row_unchanged( client: TestClient, admin_client: TestClient ) -> None: """The re-Save path is gated by the SAME caps (A1: create AND update carry the bounds): a 600_001-char message 422s and the row keeps its original payload byte-for-byte (the stored shape is untouched — the rejected body never reaches the JSONB).""" created = client.post( "/api/chats", json={"messages": _simple_conversation()} ).json() r = client.put( f"/api/chats/{created['id']}", json={"messages": [_user("b" * 600_001)]}, ) assert r.status_code == 422 got = admin_client.get(f"/api/chats/{created['id']}") assert got.status_code == 200 assert got.json()["messages"] == _expect(_simple_conversation()) assert got.json()["message_count"] == 2 # original count, not the rejected 1 # ---------- failed-turn records (phase 120, task 01 — locked A1: a # failed chat turn persists as a BRAIN record with the `failed` marker # + the capped `error` detail, the phase-48 `stopped` precedent — no # separate error table, no new API) ---------- #: The failed record shape exactly as the client persists it (the #: zero-frame network-error case — ``finalizeFailedTurn``'s #: FAILED_TURN_TEXT bubble + the terminal error detail). FAILED_BRAIN: dict[str, Any] = { "who": "brain", "text": "My answer didn't make it — the connection dropped. Use Retry to ask again.", "failed": True, "error": "The chat model dropped the connection — try again?", } def test_create_round_trips_failed_record_byte_identical( admin_client: TestClient, ) -> None: """``POST /api/chats`` with a failed brain record returns it byte-identically (the phase-50 contract through the new keys), ``GET`` survives the trip to Postgres and back, and a ``PUT`` re-Save round-trips it too (the re-Save upsert keeps the marker + detail — the auto-save rides exactly this path).""" records = [_user(FIRST_QUESTION), FAILED_BRAIN] r = admin_client.post("/api/chats", json={"messages": records}) assert r.status_code == 201 body = r.json() assert body["messages"] == _expect(records) assert body["messages"][1]["failed"] is True assert body["messages"][1]["error"] == FAILED_BRAIN["error"] got = admin_client.get(f"/api/chats/{body['id']}") assert got.status_code == 200 assert got.json()["messages"] == _expect(records) r2 = admin_client.put( f"/api/chats/{body['id']}", json={"messages": records} ) assert r2.status_code == 200 assert r2.json()["messages"] == _expect(records) def test_post_rejects_error_over_500_and_stores_nothing( client: TestClient, admin_client: TestClient ) -> None: """The phase-83 style value bound on the new key: a 501-char ``error`` 422s at the boundary (``ChatMessage.error`` ``max_length=500``) and NOTHING is stored — the hostile detail string never lands in the JSONB.""" baseline = admin_client.get("/api/chats").json()["chats"] bad = dict(FAILED_BRAIN) bad["error"] = "e" * 501 r = client.post( "/api/chats", json={"messages": [_user("hi"), bad]}, ) assert r.status_code == 422 assert admin_client.get("/api/chats").json()["chats"] == baseline def test_put_rejects_error_over_500_and_leaves_row_unchanged( client: TestClient, admin_client: TestClient ) -> None: """The re-Save path is gated by the SAME bound: a 501-char ``error`` 422s and the row keeps its original payload byte-for-byte.""" created = client.post( "/api/chats", json={"messages": _simple_conversation()} ).json() bad = dict(FAILED_BRAIN) bad["error"] = "e" * 501 r = client.put( f"/api/chats/{created['id']}", json={"messages": [bad]}, ) assert r.status_code == 422 got = admin_client.get(f"/api/chats/{created['id']}") assert got.status_code == 200 assert got.json()["messages"] == _expect(_simple_conversation()) def test_shared_chat_with_failed_record_serves_public_shape( admin_client: TestClient, ) -> None: """The phase-51 public read is UNCHANGED by the failed record: ``GET /api/shared/`` still serves exactly the public shape (``title`` + ``messages`` — no id, no timestamps, no token) and the failed record rides the snapshot verbatim (the shared page renders its ``text`` as-is — no note, no Retry, read-only by design).""" records = [_user(FIRST_QUESTION), FAILED_BRAIN] created = admin_client.post( "/api/chats", json={"title": EXPLICIT_TITLE, "messages": records, "share": True}, ).json() anon = TestClient(fastapi_app) # fresh jar: truly anonymous r = anon.get(f"/api{created['share_url']}") assert r.status_code == 200 body = r.json() assert set(body) == SHARED_OUT_KEYS # title + messages — the public shape assert body["title"] == EXPLICIT_TITLE assert body["messages"] == _expect(records) assert body["messages"][1]["failed"] is True assert body["messages"][1]["error"] == FAILED_BRAIN["error"] # --------------------------------------------------------------------------- # Phase 158 (task 01): the validated ChatMessage.segments field — the # turn's chronological run order (D4/D6, owner direction 2026-10-04): # the discriminated thinking/tool union (ChatSegment), capped # phase-83-style, OMITTED when absent (the phase-50 byte-identical # round-trip contract — ABSENT, never null), and the extra="forbid" # boundary INTACT around the new key. The router is UNCHANGED (task # 01's spec): POST/PUT validate through ChatMessage and store # m.model_dump(); the admin GET and the public shared read re-validate # the stored JSONB through ChatMessage — so every surface inherits the # field + the omission rule from the schema alone. # --------------------------------------------------------------------------- #: The full segment array (task 01's spec): one ``thinking`` entry + #: two ``tool`` entries carrying EVERY additive field (offset/limit, #: path, truncated/chars_shown/chars_total — the phase-95/139/146 #: shapes; the tool entries are byte-for-byte the ToolCall record #: shape, the ChatToolSegment mirror). SEGMENTS: list[dict[str, Any]] = [ {"kind": "thinking", "text": "Step 1: list the documents to find the reference server config."}, { "kind": "tool", "name": "read", "argument": "Homelab/kubernetes.md", "truncated": True, "chars_shown": 128_000, "chars_total": 204_000, "offset": 42, "limit": 30, "path": "search_docs/reese-notes.md", }, { "kind": "tool", "name": "grep", "argument": "reese-sentinel-42", "truncated": False, "chars_shown": None, "chars_total": None, "offset": 1, "limit": 20, "path": "deployments/traefik.yaml", }, ] def _segmented_brain() -> dict[str, Any]: """A full ``bor.chat.v1`` brain record (phase 113 shape) PLUS the ``segments`` timeline (D4 — ``thinking`` + ``tools`` KEPT, the runs' order ADDITIVE).""" return {**FULL_BRAIN, "segments": SEGMENTS} def test_create_round_trips_segments(admin_client: TestClient, db: Session) -> None: """A record WITH ``segments`` (both kinds, every additive tool field) is ACCEPTED by ``POST /api/chats`` (the phase-113 lesson, applied up front: the field exists so the ``extra="forbid"`` boundary does not 422 the done-time auto-save carrying it — NOT a silent 422) and round-trips: the 201 body, the RAW stored JSONB (byte-for-byte), and the admin GET all carry the array with the entry ORDER preserved; the user record stays WITHOUT the key. The PUT upsert carries it too (the re-Save path's ``m.model_dump()`` stores it unchanged).""" r = admin_client.post( "/api/chats", json={"messages": [_user(FIRST_QUESTION), _segmented_brain()]} ) assert r.status_code == 201, f"expected 201, got {r.status_code}: {r.text}" body = r.json() assert body["messages"][1]["segments"] == SEGMENTS assert "segments" not in body["messages"][0] # The RAW stored JSONB (the DB is the durable boundary): the full # array, byte-for-byte. raw = db.execute( text("SELECT messages FROM saved_chats WHERE id = :id"), {"id": body["id"]}, ).scalar_one() assert raw[1]["segments"] == SEGMENTS assert "segments" not in raw[0] # The admin GET round-trips the array losslessly (order included). got = admin_client.get(f"/api/chats/{body['id']}") assert got.status_code == 200 assert got.json()["messages"][1]["segments"] == SEGMENTS # The re-Save upsert keeps the array (the stored shape is # re-validated through ChatMessage on every write). updated = admin_client.put( f"/api/chats/{body['id']}", json={"messages": [_user(FIRST_QUESTION), _segmented_brain()]}, ) assert updated.status_code == 200 assert updated.json()["messages"][1]["segments"] == SEGMENTS def test_shared_serve_includes_segments(admin_client: TestClient) -> None: """The shared view is faithful (the shared page's ``addSegments`` parity copy, task 03, renders from it): the PUBLIC snapshot's brain record carries the segments (the public shape gains no new top-level key — ``SHARED_OUT_KEYS`` is unchanged), with the entry order preserved; the user record stays WITHOUT the key.""" created = admin_client.post( "/api/chats", json={"title": EXPLICIT_TITLE, "messages": [_user(FIRST_QUESTION), _segmented_brain()]}, ).json() share_url = _share(admin_client, created["id"])["share_url"] anon = TestClient(fastapi_app) # fresh jar: truly anonymous r = anon.get(f"/api{share_url}") assert r.status_code == 200 body = r.json() assert set(body) == SHARED_OUT_KEYS # no new shared-shape field assert body["title"] == EXPLICIT_TITLE assert body["messages"][1]["segments"] == SEGMENTS assert "segments" not in body["messages"][0] def test_segments_over_50_entries_rejected(admin_client: TestClient) -> None: """``max_length=50`` (the phase-83 voice, the ``tools`` cap): 51 entries 422 at the boundary — far past any realistic turn (the round cap is 10 → ≤ 20 entries).""" r = admin_client.post( "/api/chats", json={ "messages": [ { "who": "brain", "text": "ok", "segments": [{"kind": "thinking", "text": "x"}] * 51, } ] }, ) assert r.status_code == 422 _assert_no_chats(admin_client) def test_thinking_segment_text_caps_rejected(admin_client: TestClient) -> None: """The thinking entry's ``text`` cap (HistoryTurn's 600 000 — the phase-164 raise, mirrored so a long-thinking turn never 422s its own auto-save): 600 001 chars 422; the ``min_length=1`` bound — an EMPTY text (the zero-char run the client never stores) 422s too.""" over = admin_client.post( "/api/chats", json={ "messages": [ { "who": "brain", "text": "ok", "segments": [{"kind": "thinking", "text": "x" * 600_001}], } ] }, ) assert over.status_code == 422 empty = admin_client.post( "/api/chats", json={ "messages": [ { "who": "brain", "text": "ok", "segments": [{"kind": "thinking", "text": ""}], } ] }, ) assert empty.status_code == 422 _assert_no_chats(admin_client) def test_tool_segment_caps_rejected(admin_client: TestClient) -> None: """The tool entry mirrors ToolCall's caps (mirrored, not weakened): a ``name`` over 100 and an ``offset`` of 0 (the ``ge=1`` whole-line bound) both 422 — the same values that 422 on the ``tools`` entry.""" bad_name = admin_client.post( "/api/chats", json={ "messages": [ { "who": "brain", "text": "ok", "segments": [ {"kind": "tool", "name": "n" * 101, "argument": None} ], } ] }, ) assert bad_name.status_code == 422 bad_offset = admin_client.post( "/api/chats", json={ "messages": [ { "who": "brain", "text": "ok", "segments": [{"kind": "tool", "name": "read", "offset": 0}], } ] }, ) assert bad_offset.status_code == 422 _assert_no_chats(admin_client) def test_unknown_segment_kind_rejected(admin_client: TestClient) -> None: """The discriminator (``kind`` = ``"thinking"`` | ``"tool"``): an unknown ``kind`` 422s at the boundary — nothing outside the union crosses (the ``extra="forbid"`` voice, applied to the variant pick).""" r = admin_client.post( "/api/chats", json={ "messages": [ { "who": "brain", "text": "ok", "segments": [{"kind": "bogus", "text": "x"}], } ] }, ) assert r.status_code == 422 _assert_no_chats(admin_client) def test_stray_top_level_key_still_rejected_with_segments( admin_client: TestClient, ) -> None: """The ``extra="forbid"`` boundary is INTACT around the new key: a record that carries a well-formed ``segments`` AND a stray top-level key still 422s — adding the field changed nothing about which keys are accepted (the phase-83 voice).""" bad = _segmented_brain() bad["foo"] = 1 r = admin_client.post("/api/chats", json={"messages": [bad]}) assert r.status_code == 422 _assert_no_chats(admin_client) def test_record_without_segments_round_trips_key_absent( admin_client: TestClient, db: Session ) -> None: """The phase-50 byte-identical contract, pinned: a record WITHOUT ``segments`` (the full phase-113 brain shape — every pre-phase chat) round-trips with the key ABSENT from every surface — the 201 body, the RAW stored JSONB, and the admin GET — NEVER ``null`` (the ``_drop_image_when_absent`` serializer's phase-158 pop). An EXPLICIT empty list is a different thing: ``[]`` is a value, so it round-trips as ``[]`` (present, empty — the pop is ``None``-gated only).""" r = admin_client.post( "/api/chats", json={"messages": [_user(FIRST_QUESTION), FULL_BRAIN]} ) assert r.status_code == 201, r.text body = r.json() assert body["messages"] == _expect([_user(FIRST_QUESTION), FULL_BRAIN]) assert "segments" not in body["messages"][0] assert "segments" not in body["messages"][1] raw = db.execute( text("SELECT messages FROM saved_chats WHERE id = :id"), {"id": body["id"]}, ).scalar_one() assert "segments" not in raw[0] assert "segments" not in raw[1] got = admin_client.get(f"/api/chats/{body['id']}") assert got.status_code == 200 assert "segments" not in got.json()["messages"][0] assert "segments" not in got.json()["messages"][1] # An explicit empty list round-trips as [] (present, not popped). r2 = admin_client.post( "/api/chats", json={"messages": [{"who": "brain", "text": "ok", "segments": []}]}, ) assert r2.status_code == 201, r2.text got2 = admin_client.get(f"/api/chats/{r2.json()['id']}") assert got2.json()["messages"][0]["segments"] == []