All green. Final report: **Phase 160 completion pass — verified (final verification; all 5 tasks already in `complete/`)** - **Verified, no code defects found:** budget trailers (`TOOL_BUDGET_REMAINING/LAST/SPENT`), cap-path rejection + `ROUND_CAP_FALLBACK_ANSWER` + `ROUND_CAP_WRAPUP_INSTRUCTION`, `done.budget_exhausted` (ABSENT-when-false), `rounds=/cap_hit=` log fields, `agent_max_rounds=10` default, frontend Continue button + restore + shared-page exclusion, mock markers. - **Tests/lint:** `uv run pytest` 153/153 green (EXIT 0); `uv run pytest --cov=app --cov-report=term-missing` TOTAL **99%** (>90%); `uv run ruff check .` clean; `uv run pyright` 0 errors/0 warnings. - **Criteria:** ① decreasing trailer pinned by unit tests (9→…→spent) ✅ ② cap path ends readable, no tool lines after cap (E2E) ✅ ③ `budget_exhausted` true-only-on-cap, absent otherwise (integration) ✅ ④ Continue button → one fresh `continue` turn, fresh budget, record intact (E2E) ✅ ⑤ flag survives refresh; shared chat never renders it (`shared.js` has no `budget_exhausted` path) ✅ ⑥ `max_rounds=0` byte-identical single `tools=None` request; default still 10 ✅ ⑦ full suite/coverage/lint green ✅ ⑧ `test_tool_budget_telemetry.py` **7/7** in isolation; all 6 regression E2E suites green in isolation ✅ ⑨ commit + phase move = harness (I make no git history/index changes). - **Docs:** README.md — fixed: the "Continue researching" button and cap behavior were undocumented (added a Chat-section paragraph + an Agent-tools-section paragraph, incl. the phase-71 recorded-revision note); env-var table, commands, and architecture all verified consistent otherwise. AGENTS.md — verified consistent, no changes needed. - **Decisions:** no code changes required — phase was fully implemented; only README docs added. No live-infrastructure touched. - **Next pending phase:** `161_reasoning_bubble`.
2122 lines
84 KiB
Python
2122 lines
84 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).
|
||
#: 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": []}
|
||
|
||
|
||
# ---------- 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/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"] = "<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]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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/<token>``, 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/<token>`` 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/<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"]
|
||
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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 32 000, phase
|
||
83): 32 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" * 32_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"] == []
|