Files
brain-of-reese/tests/unit/test_schemas.py
T
ducoterra fa189dede7 phase: 83_chat_save_payload_limits
All verification criteria are confirmed green. Final report:

**Phase 83 — final verification pass (all tasks already complete; verified, nothing to fix)**

- Verified `app/schemas.py` carries every A1 cap (text/thinking 32 000, sources 20, suggestions 50×200, tools 50, SourceRef 120/1000/500, ToolCall 100/2000, messages ≤200) — boundary-only, `app/api/chats.py` untouched
- Verified all test pins exist and pass: 32 unit boundary tests (both edges of every cap + round-trip), 4 oversized-422 integration pins (text/201-msgs/21-sources/PUT, each with nothing-stored/row-unchanged assertions), A3 SSE done-event pin at column maxima, dedicated E2E (anon 40 000-char POST → 422 + small save → 201)
- `uv run pytest tests/unit/test_schemas.py -v` — 32 passed
- `uv run pytest tests/integration/test_chats_api.py` — exit 0; A3 pin — passed
- `uv run pytest tests/e2e/test_chat_save_payload_limits.py -v --no-cov` — 2 passed (isolation); `uv run pytest tests/e2e/test_chat_history.py -v --no-cov` — 5 passed
- `uv run pytest` — exit 0 (~1 704 tests, 0 fail/skip); `uv run pytest --cov=app` — TOTAL 99%, `app/schemas.py` 100% (>90% ✓)
- `uv run ruff check . && uv run pyright` — clean (0 errors)
- `git diff --stat` — only `app/schemas.py`, 3 test files (+`test_chat_api.py` A3 pin, sanctioned by task 02), phase files; no `app/api/chats.py`/`alembic`/`frontend`/`pyproject`/`uv.lock` diff ✓
- All completion criteria met; commit + phase move left to the harness per pipeline rules (changes stay in working tree)
- Deviation note: list caps use `max_length` instead of `max_items` — identical in pydantic 2.13 (`max_items` is a deprecated alias); both boundaries behaviorally pinned
- Next pending phase: `84_docs_push_error_sanitization`
2026-09-08 00:33:21 -04:00

396 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Unit: schema boundary caps for the saved-chat surface (phase 83, task 01).
The first schema-boundary suite: every A1 cap in ``app/schemas.py`` is
pinned at BOTH boundaries — a value exactly at the cap validates and one
past it raises a pydantic ``ValidationError`` naming the offending field
(the 422-shaped boundary response, house style, phase 56 precedent).
Plus the regression pin: a realistic ``bor.chat.v1`` payload validates
cleanly and round-trips ``model_dump()`` (the stored-shape contract —
the caps added value bounds only, no key/shape change).
"""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from app.schemas import (
ChatMessage,
SavedChatCreate,
SavedChatUpdate,
SourceRef,
ToolCall,
)
# --- boundary constants (phase 83, A1 — mirror sources in app/schemas.py) ---
TEXT_CAP = 32_000 # mirrors HistoryTurn.text / .thinking
SOURCES_CAP = 20
SUGGESTIONS_CAP = 50
CHIP_CAP = 200
TOOLS_CAP = 50
SOURCE_CAP = 120 # documents.source String(120)
PATH_CAP = 1000 # documents.path String(1000)
TITLE_CAP = 500 # documents.title String(500)
NAME_CAP = 100 # ToolCall.name
ARGUMENT_CAP = 2000 # ToolCall.argument
MESSAGES_CAP = 200 # SavedChatCreate/Update.messages
def _source_ref() -> dict:
return {"source": "Homelab", "path": "kubernetes.md", "title": "K8s"}
def _tool_call() -> dict:
return {"name": "read", "argument": "Homelab/kubernetes.md"}
def _user_message(text: str = "How did I install k3s?") -> ChatMessage:
return ChatMessage.model_validate({"who": "user", "text": text})
def _failed_loc(exc: ValidationError, *loc: object) -> None:
"""Assert the first ``ValidationError`` names exactly the given
field path (``loc`` tuple, e.g. ``("text",)`` or
``("messages", 0, "text")``) — the 422 must point at the offender."""
assert exc.errors()[0]["loc"] == tuple(loc), exc.errors()
# ---------------------------------------------------------------------------
# ChatMessage.text / .thinking
# ---------------------------------------------------------------------------
def test_chat_message_text_at_cap_validates() -> None:
msg = _user_message("a" * TEXT_CAP)
assert len(msg.text) == TEXT_CAP
def test_chat_message_text_one_over_cap_rejects() -> None:
with pytest.raises(ValidationError) as exc:
_user_message("a" * (TEXT_CAP + 1))
_failed_loc(exc.value, "text")
def test_chat_message_thinking_at_cap_validates() -> None:
msg = ChatMessage.model_validate({"who": "brain", "text": "ok", "thinking": "t" * TEXT_CAP})
assert len(msg.thinking or "") == TEXT_CAP
def test_chat_message_thinking_one_over_cap_rejects() -> None:
with pytest.raises(ValidationError) as exc:
ChatMessage.model_validate(
{"who": "brain", "text": "ok", "thinking": "t" * (TEXT_CAP + 1)}
)
_failed_loc(exc.value, "thinking")
def test_chat_message_thinking_none_still_valid() -> None:
assert _user_message().thinking is None
assert ChatMessage.model_validate(
{"who": "brain", "text": "ok", "thinking": None}
).thinking is None
# ---------------------------------------------------------------------------
# ChatMessage.sources / .suggestions / .tools
# ---------------------------------------------------------------------------
def test_chat_message_sources_at_cap_validates() -> None:
msg = ChatMessage.model_validate(
{"who": "brain", "text": "ok", "sources": [_source_ref() for _ in range(SOURCES_CAP)]}
)
assert len(msg.sources or []) == SOURCES_CAP
def test_chat_message_sources_one_over_cap_rejects() -> None:
with pytest.raises(ValidationError) as exc:
ChatMessage.model_validate(
{
"who": "brain",
"text": "ok",
"sources": [_source_ref() for _ in range(SOURCES_CAP + 1)],
}
)
_failed_loc(exc.value, "sources")
def test_chat_message_suggestions_at_cap_validates() -> None:
msg = ChatMessage.model_validate(
{"who": "brain", "text": "ok", "suggestions": [f"chip {i}" for i in range(SUGGESTIONS_CAP)]}
)
assert len(msg.suggestions or []) == SUGGESTIONS_CAP
def test_chat_message_suggestions_one_over_cap_rejects() -> None:
with pytest.raises(ValidationError) as exc:
ChatMessage.model_validate(
{
"who": "brain",
"text": "ok",
"suggestions": [f"chip {i}" for i in range(SUGGESTIONS_CAP + 1)],
}
)
_failed_loc(exc.value, "suggestions")
def test_chat_message_suggestion_chip_at_cap_validates() -> None:
msg = ChatMessage.model_validate(
{"who": "brain", "text": "ok", "suggestions": ["c" * CHIP_CAP]}
)
assert msg.suggestions is not None
assert len(msg.suggestions[0]) == CHIP_CAP
def test_chat_message_suggestion_chip_one_over_cap_rejects() -> None:
with pytest.raises(ValidationError) as exc:
ChatMessage.model_validate(
{"who": "brain", "text": "ok", "suggestions": ["c" * (CHIP_CAP + 1)]}
)
# the offender is the ITEM (the list itself is well under max_items).
_failed_loc(exc.value, "suggestions", 0)
def test_chat_message_tools_at_cap_validates() -> None:
msg = ChatMessage.model_validate(
{"who": "brain", "text": "ok", "tools": [_tool_call() for _ in range(TOOLS_CAP)]}
)
assert len(msg.tools or []) == TOOLS_CAP
def test_chat_message_tools_one_over_cap_rejects() -> None:
with pytest.raises(ValidationError) as exc:
ChatMessage.model_validate(
{"who": "brain", "text": "ok", "tools": [_tool_call() for _ in range(TOOLS_CAP + 1)]}
)
_failed_loc(exc.value, "tools")
# ---------------------------------------------------------------------------
# SourceRef (documents column-length mirrors)
# ---------------------------------------------------------------------------
def test_source_ref_source_at_cap_validates() -> None:
ref = SourceRef.model_validate(
{"source": "s" * SOURCE_CAP, "path": "a.md", "title": "A"}
)
assert len(ref.source) == SOURCE_CAP
def test_source_ref_source_one_over_cap_rejects() -> None:
with pytest.raises(ValidationError) as exc:
SourceRef.model_validate({"source": "s" * (SOURCE_CAP + 1), "path": "a.md", "title": "A"})
_failed_loc(exc.value, "source")
def test_source_ref_path_at_cap_validates() -> None:
ref = SourceRef.model_validate(
{"source": "Homelab", "path": "p" * PATH_CAP, "title": "A"}
)
assert len(ref.path) == PATH_CAP
def test_source_ref_path_one_over_cap_rejects() -> None:
with pytest.raises(ValidationError) as exc:
SourceRef.model_validate(
{"source": "Homelab", "path": "p" * (PATH_CAP + 1), "title": "A"}
)
_failed_loc(exc.value, "path")
def test_source_ref_title_at_cap_validates() -> None:
ref = SourceRef.model_validate(
{"source": "Homelab", "path": "a.md", "title": "t" * TITLE_CAP}
)
assert len(ref.title) == TITLE_CAP
def test_source_ref_title_one_over_cap_rejects() -> None:
with pytest.raises(ValidationError) as exc:
SourceRef.model_validate(
{"source": "Homelab", "path": "a.md", "title": "t" * (TITLE_CAP + 1)}
)
_failed_loc(exc.value, "title")
# ---------------------------------------------------------------------------
# ToolCall
# ---------------------------------------------------------------------------
def test_tool_call_name_at_cap_validates() -> None:
call = ToolCall.model_validate({"name": "n" * NAME_CAP, "argument": None})
assert len(call.name) == NAME_CAP
def test_tool_call_name_one_over_cap_rejects() -> None:
with pytest.raises(ValidationError) as exc:
ToolCall.model_validate({"name": "n" * (NAME_CAP + 1), "argument": None})
_failed_loc(exc.value, "name")
def test_tool_call_argument_at_cap_validates() -> None:
call = ToolCall.model_validate({"name": "read", "argument": "a" * ARGUMENT_CAP})
assert len(call.argument or "") == ARGUMENT_CAP
def test_tool_call_argument_one_over_cap_rejects() -> None:
with pytest.raises(ValidationError) as exc:
ToolCall.model_validate({"name": "read", "argument": "a" * (ARGUMENT_CAP + 1)})
_failed_loc(exc.value, "argument")
def test_tool_call_argument_none_still_valid() -> None:
assert ToolCall.model_validate({"name": "ls", "argument": None}).argument is None
assert ToolCall.model_validate({"name": "ls"}).argument is None
# ---------------------------------------------------------------------------
# SavedChatCreate / SavedChatUpdate .messages
# ---------------------------------------------------------------------------
def _minimal_messages(n: int) -> list[dict]:
return [{"who": "user" if i % 2 == 0 else "brain", "text": f"m{i}"} for i in range(n)]
def test_saved_chat_create_messages_at_cap_validates() -> None:
payload = SavedChatCreate.model_validate(
{"title": "big", "messages": _minimal_messages(MESSAGES_CAP)}
)
assert len(payload.messages) == MESSAGES_CAP
def test_saved_chat_create_messages_one_over_cap_rejects() -> None:
with pytest.raises(ValidationError) as exc:
SavedChatCreate.model_validate(
{"title": "bigger", "messages": _minimal_messages(MESSAGES_CAP + 1)}
)
_failed_loc(exc.value, "messages")
def test_saved_chat_update_messages_at_cap_validates() -> None:
payload = SavedChatUpdate.model_validate({"messages": _minimal_messages(MESSAGES_CAP)})
assert len(payload.messages) == MESSAGES_CAP
def test_saved_chat_update_messages_one_over_cap_rejects() -> None:
with pytest.raises(ValidationError) as exc:
SavedChatUpdate.model_validate({"messages": _minimal_messages(MESSAGES_CAP + 1)})
_failed_loc(exc.value, "messages")
# ---------------------------------------------------------------------------
# JSON-shape regression: same KEYS as before, value bounds only
# ---------------------------------------------------------------------------
def test_extra_keys_still_forbidden() -> None:
"""``extra="forbid"`` is untouched — a stray key is still a 422-shaped
rejection (the accepted/rejected KEYS did not change with phase 83)."""
with pytest.raises(ValidationError) as exc:
ChatMessage.model_validate({"who": "user", "text": "hi", "html": "<b>x</b>"})
_failed_loc(exc.value, "html")
def test_minimal_message_still_validates() -> None:
"""Optional keys may be ABSENT exactly as pre-phase-83."""
msg = ChatMessage.model_validate({"who": "user", "text": "hi"})
assert (msg.sources, msg.deflected, msg.suggestions, msg.thinking, msg.tools, msg.stopped) == (
None,
None,
None,
None,
None,
None,
)
# ---------------------------------------------------------------------------
# Realistic-payload round-trip (the stored-shape contract)
# ---------------------------------------------------------------------------
def test_realistic_bor_chat_v1_payload_round_trips() -> None:
"""A full ``bor.chat.v1``-shaped record (4–8 messages mixing
user/brain, one brain message with ``thinking`` + ``tools`` +
``sources``, one with ``suggestions`` + ``stopped``) validates
cleanly and ``model_dump()`` of the messages equals the input dict —
``None``-keys preserved (the phase-50/51 byte-identical stored shape;
the caps added value bounds only, no key/shape change)."""
messages: list[dict] = [
{
"who": "user",
"text": "How did I install k3s on the new node?",
"sources": None,
"deflected": None,
"suggestions": None,
"thinking": None,
"tools": None,
"stopped": None,
},
{
"who": "brain",
"text": "Your k3s cluster runs on three nodes — here's how it happened.",
"sources": [
{"source": "Homelab", "path": "kubernetes.md", "title": "Kubernetes Cluster"},
{"source": "Deployments", "path": "k3s-install.md", "title": "k3s Install Notes"},
],
"deflected": False,
"suggestions": None,
"thinking": "The kubernetes doc covers the cluster layout…",
"tools": [
{"name": "read", "argument": "Homelab/kubernetes.md"},
{"name": "ls", "argument": None},
],
"stopped": None,
},
{
"who": "user",
"text": "And what ports does Traefik expose?",
"sources": None,
"deflected": None,
"suggestions": None,
"thinking": None,
"tools": None,
"stopped": None,
},
{
"who": "brain",
"text": "Traefik exposes 80/443 on every node.",
"sources": None,
"deflected": None,
"suggestions": ["What is the Traefik dashboard password?"],
"thinking": None,
"tools": None,
"stopped": True, # the owner stopped the generation mid-answer
},
]
payload = SavedChatCreate.model_validate({"title": "k3s install", "messages": messages})
dumped = payload.model_dump()
# the stored shape is byte-identical: every message dict — None-keys
# and all — round-trips unchanged (and the top-level record keeps its
# ``share`` default).
assert dumped["messages"] == messages
assert dumped["share"] is False
def test_realistic_payload_round_trips_through_update_model() -> None:
"""Same record re-Saved through ``SavedChatUpdate`` (the re-Save
upsert surface) — identical stored shape."""
msg = {
"who": "brain",
"text": "answer",
"sources": [_source_ref()],
"deflected": None,
"suggestions": ["follow-up?"],
"thinking": "scratchpad",
"tools": [_tool_call()],
"stopped": None,
}
payload = SavedChatUpdate.model_validate({"messages": [msg]})
assert payload.model_dump()["messages"] == [msg]