Files
brain-of-reese/tests/integration/test_chats_api.py
T
ducoterra bef24e05e2
Build and Push Containers / build-and-push-app (push) Successful in 1m54s
Build and Push Containers / build-and-push-db (push) Failing after 13s
phase: 123_chat_image_questions
All gates green. Verification complete.

**Phase 123 — final verification pass (all 4 tasks already in `complete/`)**

- Verified the full implementation is in the working tree: `app/api/chat_images.py` (upload/serve pair), `ChatRequest.image`/`ChatMessage.image` (path-validated, omitted-when-None), toggle-off + stale-file hinted error frames, `build_user_content` multimodal build at both sites (chat.py deflected branch + `run_agent`), config-gated composer attach/preview/upload-then-send, restore + shared rendering, CSP `img-src 'self' data:` carve-out, mock-LLM capture buffer.
- `uv run pytest` → **2796 passed**, exit 0 (unit + integration).
- `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (29/4615 missed; phase-123 modules 99–100%).
- `uv run pytest tests/e2e/test_chat_image_questions.py -v --no-cov` → **5 passed** in isolation.
- `uv run ruff check . && uv run pyright` → clean (0 errors).

**Completion criteria:** (1) attach→send→multimodal text+image to the model, bubble/reload/shared all render it, saved chat stores the PATH with `"base64" not in json.dumps(stored)` — **verified** (E2E tests 1–4 + integration round-trip); (2) `BOR_IMAGES=false` — control hidden, exact hinted error frame, zero model calls / no query_log row — **verified** (E2E test 5 + integration); (3) text-only byte-identical (`content` stays a plain `str`) — **verified** (unit + integration); (4) all gates green — **verified**; (5) commit + phase move — left to the harness per pipeline rules (no `git add`/`commit` run).

No defects found; no live-infrastructure changes (repo + local dev DB only). **Next pending phase: none** — 123 is the last phase in `todo/`.
2026-09-25 05:19:18 -04:00

1409 lines
54 KiB
Python

"""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``), 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).
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…",
"tools": [
{
"name": "read",
"argument": "Homelab/kubernetes.md",
"truncated": True,
"chars_shown": 128_000,
"chars_total": 204_000,
},
{
"name": "ls",
"argument": None,
"truncated": False,
"chars_shown": None,
"chars_total": 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,
},
],
"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"),
}
for m in records
]
def _assert_no_chats(admin_client: TestClient) -> None:
assert admin_client.get("/api/chats").json() == {"chats": []}
# ---------- 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/<lowercase uuid>: {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/<lowercase uuid>: {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/<token>`` 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/<token>`` 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) 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"] = "<b>not allowed</b>"
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/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"] = "<script>alert(1)</script>"
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
# ---------- 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/<lowercase uuid>: {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
#: ``<uuid4().hex>.<ext>``; 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]
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/<lowercase uuid>: {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/<token>`` 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/<token> 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("<html>shared page</html>", 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 == "<html>shared page</html>"
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_32000_and_stores_nothing(
client: TestClient, admin_client: TestClient
) -> None:
"""The audit vector (SEC-05): one 32_001-char message 422s at the
boundary (``ChatMessage.text`` mirrors ``HistoryTurn``'s 32 000
cap) and NOTHING is stored — the admin list is unchanged (the happy
path for in-cap bodies stays the existing guest/admin pins)."""
baseline = admin_client.get("/api/chats").json()["chats"]
r = client.post(
"/api/chats",
json={"messages": [_user("a" * 32_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_rejects_more_than_200_messages_and_stores_nothing(
client: TestClient, admin_client: TestClient
) -> None:
"""The unbounded message list is the second DoS lever (SEC-05):
201 minimally-valid messages 422 (``SavedChatCreate.messages``
``max_items=200``) 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(201)]}
)
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 32_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" * 32_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/<token>`` 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"]