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

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

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

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

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

1171 lines
48 KiB
Python
Raw Permalink 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: image questions (phase 123, task 01 — attach an image to a
question; TODO L6).
The server-side contract pinned here:
* ``POST /api/chat-images`` (user-gated like the chat turn it feeds)
stores the question image as ``<uuid4().hex>.<ext>`` under
``chat_image_dir`` — the extension gate reuses the phase-122 image
frozenset (the extension is the source of truth, the Content-Type
header a hint), the bytes are streamed with the
``chat_image_max_mb`` cap (413, fixed detail naming the cap, never
echoing the filename) — and returns the served path, the ONLY value
``ChatRequest.image`` accepts (never a data URL).
* ``GET /api/chat-images/{filename}`` (PUBLIC — the saved chat's id is
already its credential, phase 55 A1; the uuid filename has no
enumeration value) serves the bytes with the phase-122 mime map +
``Cache-Control: private, max-age=3600``; a malformed name OR a
missing file is one fixed 404 (no traversal, no shape-hinting 422).
* ``ChatRequest.image`` (stored path, pattern-validated with a fixed
422 detail — no echo) and ``ChatMessage.image`` (on the USER record
only; omitted when ``None`` — a text-only saved payload stays
byte-identical to pre-phase, the phase-50 contract).
* The turn pipeline: image + toggle OFF → the phase-114 error frame
WITH the hint (no model call, no record); image + the stored file
deleted out-of-band → the same frame shape, no hint; a valid image →
the multimodal user content list (text part + image_url data URL) at
BOTH construction sites (this module's deflected-branch ``messages``
list and the grounded branch's ``run_agent`` — the flow pinned in
``app.rag.agent``); ``image=None`` → the plain-string content,
byte-identical to pre-phase.
Endpoint-level chat tests use the ``test_embed_question_length.py``
wiring (a recording fake LLM, a fake retriever, a fake DB session,
patched settings) so the whole validate → build contract runs without
a stack; the upload/serve tests point ``chat_image_dir`` at a tmp
directory (the raw-string ``expanduser`` convention).
"""
from __future__ import annotations
import base64
import io
import json
import re
import uuid
from collections.abc import Iterator
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any
import pytest
from fastapi.testclient import TestClient
from pydantic import ValidationError
from app.api import chat as chat_api
from app.api import chat_images
from app.config import Settings
from app.main import app as fastapi_app
from app.models import Document
from app.rag import summarizer
from app.rag.llm import StreamPiece
from app.rag.retriever import RetrievedChunk
from app.schemas import ChatMessage, ChatRequest
from tests.conftest import ADMIN_PASSWORD
if TYPE_CHECKING:
from app.rag.scaffolding import ScaffoldingFilter
#: A real 1×1 transparent PNG (the phase-122 fixture bytes) — the
#: server is content-agnostic (extension + size gates only), but a
#: well-formed fixture keeps the data-URL pin honest.
PNG_1X1 = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGP4DwQACfsD/fteaysAAAAASUVORK5CYII="
)
#: A well-formed stored path (the upload response's shape) for one hex
#: per test module — the validators key on the SHAPE, not a specific
#: uuid, so a fixed value pins the pattern deterministically.
GOOD_PATH = "/api/chat-images/" + "a" * 32 + ".png"
GOOD_PATHS = {
GOOD_PATH,
"/api/chat-images/" + "B" * 32 + ".jpeg", # uppercase hex is accepted too
"/api/chat-images/" + "0" * 32 + ".jpg",
"/api/chat-images/" + "1" * 32 + ".webp",
"/api/chat-images/" + "2" * 32 + ".gif",
"/api/chat-images/" + "3" * 32 + ".bmp",
}
BAD_PATHS = {
"data:image/png;base64,iVBORw0KGgo", # a data URL — the anti-pattern
"/api/chat-images/" + "a" * 31 + ".png", # 31 hex chars — not a uuid4().hex
"/api/chat-images/" + "a" * 33 + ".png", # 33 hex chars
"/api/chat-images/" + "g" * 32 + ".png", # non-hex chars
"/api/chat-images/" + "a" * 32 + ".txt", # a wrong extension
"/api/chat-images/" + "a" * 32 + ".giff", # near-miss extension
"/api/chat-images/" + "a" * 32, # no extension at all
"photo.png", # a bare filename — not a served path
"/api/chat-images/../etc/passwd", # a traversal
"/api/chat-images/" + "a" * 32 + ".png?x=1", # a query string is not part of a path
}
# ---------- settings (phase 123 task 01, work item 1) ----------
def test_chat_image_settings_defaults() -> None:
"""The ``image_dir`` convention: a sibling of phase 122's document-
image dir, and the A5 ~10 MB cap."""
assert Settings.model_fields["chat_image_dir"].default == (
"~/bor-sources/chat-images"
)
assert Settings.model_fields["chat_image_max_mb"].default == 10
s = Settings(_env_file=None) # pyright: ignore[reportCallIssue]
assert s.chat_image_dir == "~/bor-sources/chat-images"
assert s.chat_image_max_mb == 10
@pytest.mark.parametrize("bad", [0, -1, -10])
def test_chat_image_max_mb_rejects_zero_and_negative(bad: int) -> None:
"""``<= 0`` would reject every question-image upload — a typo that
must fail loudly at startup (the ``upload_max_mb`` precedent)."""
with pytest.raises(ValidationError, match="chat_image_max_mb must be > 0"):
Settings(_env_file=None, chat_image_max_mb=bad) # pyright: ignore[reportCallIssue]
@pytest.mark.parametrize("good", [1, 10, 512])
def test_chat_image_max_mb_accepts_positive(good: int) -> None:
"""A deployment with a smaller-cap vision model lowers the cap via
``BOR_CHAT_IMAGE_MAX_MB`` — env-tunable, no code change."""
s = Settings(_env_file=None, chat_image_max_mb=good) # pyright: ignore[reportCallIssue]
assert s.chat_image_max_mb == good
def test_chat_image_settings_env_overrides(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The ``BOR_``-prefixed env vars drive the pair (the house
convention): a deployment moves the store (a shared volume) or
lowers the cap through ``.env`` alone — no code change."""
monkeypatch.setenv("BOR_CHAT_IMAGE_DIR", "/srv/bor/chat-images")
monkeypatch.setenv("BOR_CHAT_IMAGE_MAX_MB", "42")
s = Settings(_env_file=None) # pyright: ignore[reportCallIssue]
assert s.chat_image_dir == "/srv/bor/chat-images"
assert s.chat_image_max_mb == 42
# ---------- the shared data-URL helper (assert the import, not a copy)
def test_the_data_url_helper_is_imported_not_copied() -> None:
"""Phase 123 (task 04): the multimodal builder and the phase-122
``describe_image`` path share ONE data-URL helper —
``app.rag.summarizer.image_data_url``. The identity pin catches a
regression that would silently fork the construction (a local
copy is a different function object): one map, one truth, both
call sites."""
assert chat_api.image_data_url is summarizer.image_data_url
# and the describe path really calls it (the phase-122 call site
# inside summarizer.py's describe_image — a re-implementation would
# build the string inline and this line would fail).
src = (Path(__file__).resolve().parents[2] / "app" / "rag" / "summarizer.py").read_text(
encoding="utf-8"
)
assert re.search(r"data_url = image_data_url\(data, mime\)", src)
# ---------- ChatRequest.image (the stored-path pattern guard) ----------
def test_image_validator_accepts_well_formed_paths() -> None:
"""Every well-formed stored path parses — the six extensions,
lower- AND uppercase hex (``uuid4().hex`` is lowercase; the schema
pattern allows both, per the pinned design regex)."""
for path in GOOD_PATHS:
request = ChatRequest(message="What is in this image?", image=path)
assert request.image == path
@pytest.mark.parametrize("bad", sorted(BAD_PATHS))
def test_image_validator_rejects_wrong_shapes(bad: str) -> None:
"""A data URL, a wrong extension, a traversal, a mistyped uuid —
all 422 with ONE fixed detail (no echo of the input)."""
with pytest.raises(ValidationError, match="image must be an uploaded chat image path"):
ChatRequest(message="What is in this image?", image=bad) # type: ignore[call-arg]
def test_image_validator_defaults_to_none() -> None:
"""``image=None`` (every pre-phase / text-only request) stays
untouched — no validation, byte-identical request shape. An
EXPLICIT ``null`` (a client that always sends the key) passes the
validator as ``None`` too (the null-safe restore convention)."""
request = ChatRequest(message="How is my homelab set up?")
assert request.image is None
explicit = ChatRequest.model_validate({"message": "hi", "image": None})
assert explicit.image is None
def test_image_over_500_chars_is_rejected() -> None:
"""The path bound (500) — a data URL smuggled past the pattern
would be caught by it, but the bound is the size ceiling anyway."""
with pytest.raises(ValidationError, match="at most 500 characters"):
ChatRequest(message="hi", image="x" * 501) # type: ignore[call-arg]
def test_chat_endpoint_422s_a_non_path_image(client: TestClient) -> None:
"""The schema guard is the boundary: a data URL never reaches the
turn (422 JSON — not an SSE frame)."""
r = client.post(
"/api/chat",
json={"message": "What is this?", "image": "data:image/png;base64,AAAA"},
)
assert r.status_code == 422
# ---------- build_user_content (the multimodal builder, unit level) ----------
def _stored_image(
tmp_path: Path, name: str = GOOD_PATH.rsplit("/", 1)[-1], data: bytes = PNG_1X1
) -> Path:
"""One stored question image in a tmp ``chat_image_dir``."""
root = tmp_path / "chat-images"
root.mkdir(parents=True, exist_ok=True)
(root / name).write_bytes(data)
return root
def test_build_user_content_none_is_the_plain_string(tmp_path: Path) -> None:
"""``image=None`` → the plain question string (byte-identical to
pre-phase — the multimodal branch is inert)."""
settings = Settings(_env_file=None) # pyright: ignore[reportCallIssue]
assert chat_api.build_user_content("How is my homelab set up?", None, settings) == (
"How is my homelab set up?"
)
def test_build_user_content_set_is_the_multimodal_list(tmp_path: Path) -> None:
"""A stored path → the OpenAI-compatible content list: the text
part + the image part (a data URL built server-side from the stored
bytes + the phase-122 mime map — one map, one truth)."""
root = _stored_image(tmp_path)
settings = Settings(_env_file=None, chat_image_dir=str(root)) # pyright: ignore[reportCallIssue]
content = chat_api.build_user_content("What is in this image?", GOOD_PATH, settings)
expected_b64 = base64.b64encode(PNG_1X1).decode("ascii")
assert content == [
{"type": "text", "text": "What is in this image?"},
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{expected_b64}"}},
]
@pytest.mark.parametrize(
("ext", "mime"),
[
(".jpg", "image/jpeg"),
(".jpeg", "image/jpeg"),
(".webp", "image/webp"),
(".gif", "image/gif"),
(".bmp", "image/bmp"),
],
)
def test_build_user_content_mime_follows_the_phase_122_map(
tmp_path: Path, ext: str, mime: str
) -> None:
"""Every accepted extension rides its phase-122 data-URL mime (the
``IMAGE_MIMES`` map — the describe path's one map, one truth)."""
name = "c" * 32 + ext
root = _stored_image(tmp_path, name=name)
settings = Settings(_env_file=None, chat_image_dir=str(root)) # pyright: ignore[reportCallIssue]
content = chat_api.build_user_content("q", f"/api/chat-images/{name}", settings)
assert isinstance(content, list)
url = content[1]["image_url"]["url"] # type: ignore[index]
assert url.startswith(f"data:{mime};base64,")
# ---------- POST /api/chat-images (the upload endpoint) ----------
@pytest.fixture()
def image_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Point the store at a tmp dir (the raw-string ``expanduser``
convention — the setting is patched, not the filesystem home; the
monkeypatch fixture reverts it)."""
root = tmp_path / "chat-images"
monkeypatch.setattr(
chat_images,
"get_settings",
lambda: Settings(_env_file=None, chat_image_dir=str(root)), # pyright: ignore[reportCallIssue]
)
return root
def _upload(client: TestClient, filename: str, data: bytes, mime: str = "image/png") -> Any:
return client.post(
"/api/chat-images",
files={"file": (filename, io.BytesIO(data), mime)},
)
def test_upload_stores_a_uuid_named_file_and_returns_its_path(
client: TestClient, image_dir: Path
) -> None:
"""The happy path: a well-formed upload lands as
``<uuid4().hex>.<ext>`` in the store (created on demand) and the
response is exactly the served path — the value the chat request
accepts."""
r = _upload(client, "my photo.png", PNG_1X1)
assert r.status_code == 200
path = r.json()["path"]
assert re.fullmatch(r"/api/chat-images/[0-9a-f]{32}\.png", path)
assert set(r.json()) == {"path"} # the response shape — nothing else
stored = image_dir / path.rsplit("/", 1)[-1]
assert stored.is_file()
assert stored.read_bytes() == PNG_1X1 # the exact bytes
# the store holds exactly the one file (no temp litter)
assert [p.name for p in image_dir.iterdir()] == [stored.name]
def test_upload_normalizes_an_uppercase_extension(
client: TestClient, image_dir: Path
) -> None:
"""The extension is lowercased into the stored name (``Photo.JPG``
→ ``.jpg``) — the serve route's mime map is lowercase-keyed."""
r = _upload(client, "Photo.JPG", PNG_1X1, mime="image/jpeg")
assert r.status_code == 200
assert r.json()["path"].endswith(".jpg")
assert (image_dir / r.json()["path"].rsplit("/", 1)[-1]).is_file()
def test_upload_rejects_a_non_image_extension_with_the_fixed_detail(
client: TestClient, image_dir: Path
) -> None:
"""The EXTENSION is the source of truth (the Content-Type header is
a hint): a non-image extension is a 422 naming the accepted set —
a fixed detail, no filename echo — and NOTHING is written (not
even the store dir)."""
r = _upload(client, "evil.exe", b"MZ", mime="application/octet-stream")
assert r.status_code == 422
assert r.json()["detail"] == (
"only .bmp, .gif, .jpeg, .jpg, .png, .webp images are accepted"
)
assert not image_dir.exists() # the dir is created on the accepted path only
def test_upload_rejects_a_name_without_an_extension(
client: TestClient, image_dir: Path
) -> None:
"""No extension → not in the phase-122 set → the same 422."""
r = _upload(client, "README", b"not an image")
assert r.status_code == 422
assert not image_dir.exists()
def test_upload_413s_over_cap_with_the_fixed_detail(
client: TestClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The byte cap (``chat_image_max_mb`` — 1 MiB here) is enforced
while streaming: over-cap is a 413 naming the cap (never the
filename), no file is stored, and no temp litter survives."""
root = tmp_path / "chat-images"
monkeypatch.setattr(
chat_images,
"get_settings",
lambda: Settings(
_env_file=None, # pyright: ignore[reportCallIssue]
chat_image_dir=str(root),
chat_image_max_mb=1,
),
)
r = _upload(client, "big.png", b"\x00" * (1024 * 1024 + 1))
assert r.status_code == 413
assert r.json()["detail"] == "the image exceeds the 1 MB limit"
if root.exists(): # nothing stored, no temp litter
assert list(root.iterdir()) == []
# at-or-under the cap passes (the boundary is inclusive)
r2 = _upload(client, "ok.png", b"\x00" * (1024 * 1024))
assert r2.status_code == 200
def test_upload_requires_a_user(client: TestClient, image_dir: Path) -> None:
"""The upload is user-gated exactly like the chat turn it feeds —
an anonymous caller gets the 401 before any byte is received."""
anon = TestClient(fastapi_app)
r = _upload(anon, "a.png", PNG_1X1)
assert r.status_code == 401
assert not image_dir.exists()
@pytest.mark.parametrize(
("ext", "mime"),
[
(".png", "image/png"),
(".jpg", "image/jpeg"),
(".jpeg", "image/jpeg"),
(".webp", "image/webp"),
(".gif", "image/gif"),
(".bmp", "image/bmp"),
],
)
def test_upload_accepts_every_one_of_the_six_extensions(
client: TestClient, image_dir: Path, ext: str, mime: str
) -> None:
"""The FULL accepted set (the phase-122 frozenset — one set, one
truth with the serve route's mime map and the client pre-check):
every extension of the six lands stored + served, nothing in the
set is silently missing."""
r = _upload(client, f"photo{ext}", PNG_1X1, mime=mime)
assert r.status_code == 200, r.text
path = r.json()["path"]
assert path.endswith(ext)
assert (image_dir / path.rsplit("/", 1)[-1]).is_file()
# ---------- GET /api/chat-images/{filename} (the serve route) ----------
def test_serve_returns_the_exact_bytes_with_mime_and_cache(
client: TestClient, image_dir: Path
) -> None:
"""A stored file streams back byte-identical with the phase-122
mime map's Content-Type + the phase-122 serve-route cache header."""
r = _upload(client, "a.png", PNG_1X1)
path = r.json()["path"]
got = client.get(path)
assert got.status_code == 200
assert got.content == PNG_1X1
assert got.headers["content-type"].startswith("image/png")
assert got.headers["cache-control"] == "private, max-age=3600"
def test_serve_is_public_like_saved_chat_content(client: TestClient, image_dir: Path) -> None:
"""The image is part of a saved chat's content (phase 55 A1 — the
chat's id is already its credential): an anonymous visitor with the
path can render it (the shared page's fidelity)."""
r = _upload(client, "a.png", PNG_1X1)
anon = TestClient(fastapi_app)
got = anon.get(r.json()["path"])
assert got.status_code == 200
assert got.content == PNG_1X1
@pytest.mark.parametrize(
"bad",
[
"a" * 31 + ".png", # 31 hex chars — not the stored shape
"a" * 33 + ".png", # 33 hex chars
"a" * 32 + ".txt", # a wrong extension
"a" * 32, # no extension
"not-a-uuid.png", # not hex at all
"..png", # a traversal-shaped name
],
)
def test_serve_404s_a_malformed_filename(client: TestClient, image_dir: Path, bad: str) -> None:
"""The regex guard: anything that is not ``<uuid-hex>.<ext>`` 404s
with the ONE fixed detail — no 422 that would hint at accepted
shapes, no traversal by construction."""
r = client.get(f"/api/chat-images/{bad}")
assert r.status_code == 404
assert r.json() == {"detail": "chat image not found"}
def test_serve_404s_a_missing_file(client: TestClient, image_dir: Path) -> None:
"""Well-formed name, file deleted out-of-band (the stale-path
edge) → the same fixed 404 (the viewer's fallback renders the
"image unavailable" note — never a broken icon)."""
r = client.get(f"/api/chat-images/{'d' * 32}.png")
assert r.status_code == 404
assert r.json() == {"detail": "chat image not found"}
# ---------- ChatMessage.image (persistence shape) ----------
def test_chat_message_image_defaults_to_none_and_is_omitted() -> None:
"""A record without an image serializes WITHOUT the key (ABSENT,
never ``null``) — a text-only saved payload is byte-identical to
pre-phase-123 (the phase-50 round-trip contract)."""
m = ChatMessage(who="user", text="How is my homelab set up?")
assert m.image is None
dumped = m.model_dump()
assert "image" not in dumped # ABSENT, never ``null`` (the phase-50 contract)
# and every pre-phase key set is untouched
assert set(dumped) == {
"who",
"text",
"sources",
"related",
"deflected",
"suggestions",
"thinking",
"tools",
"stopped",
"failed",
"error",
}
def test_chat_message_carries_the_stored_path_on_the_user_record() -> None:
"""The user record carries the PATH (never base64, LOCKED A5) and
it round-trips losslessly through the saved/shared shape."""
m = ChatMessage(who="user", text="What is in this image?", image=GOOD_PATH)
dumped = m.model_dump()
assert dumped["image"] == GOOD_PATH
rt = ChatMessage.model_validate(dumped)
assert rt.image == GOOD_PATH
# an explicit ``null`` (a legacy/corrupted payload) parses too —
# the restore path is null-safe for every optional key
assert ChatMessage.model_validate({"who": "user", "text": "x", "image": None}).image is None
def test_chat_message_image_bound_is_500() -> None:
"""The path bound: 500 fits (the upload path is ~52 chars — the
bound is headroom, not a squeeze), 501 422s."""
assert ChatMessage(who="user", text="x", image="a" * 500).image == "a" * 500
with pytest.raises(ValidationError, match="at most 500 characters"):
ChatMessage(who="user", text="x", image="a" * 501)
def test_chat_message_extra_forbid_boundary_is_intact() -> None:
"""``extra="forbid"`` still rejects unknown keys (a corrupted or
HTML-shaped payload 422s at the boundary) — adding ``image``
changed nothing about the KEYS contract."""
with pytest.raises(ValidationError):
ChatMessage(who="user", text="x", bogus="<b>") # type: ignore[call-arg]
with pytest.raises(ValidationError):
ChatMessage(who="alien", text="x") # type: ignore[call-arg]
# ---------- the turn pipeline (validate → multimodal build) ----------
class _RecordingLLM:
"""Records every ``embed_one`` input and the messages of each
request (the ``test_embed_question_length.py`` fake) — streams a
canned answer, never emits tool calls (a grounded turn through the
agent loop ends after the single request)."""
def __init__(self, answer: str = "Here is what the image shows.") -> None:
self.settings = Settings(_env_file=None) # pyright: ignore[reportCallIssue]
self.embedded: list[str] = []
self.answer = answer
self.seen: list[list[dict[str, Any]]] = []
async def embed_one(self, text: str) -> list[float]:
self.embedded.append(text)
return [0.0] * 768
async def chat_stream(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
scaffolding: ScaffoldingFilter | None = None,
):
self.seen.append(messages)
for i in range(0, len(self.answer), 12):
yield StreamPiece("content", self.answer[i : i + 12])
class _FakeSteeringResult:
def all(self) -> list[Any]:
return []
class _FakeSession:
"""Stands in for the DB session (the ``test_embed_question_length``
fake) — records what was written (the error-path convention is a
NO-WRITE)."""
def __init__(self) -> None:
self.added: list[Any] = []
self.commits = 0
def __enter__(self) -> _FakeSession:
return self
def __exit__(self, *args: Any) -> None:
pass
def add(self, obj: Any) -> None:
self.added.append(obj)
def commit(self) -> None:
self.commits += 1
def scalars(self, _stmt: Any) -> _FakeSteeringResult:
return _FakeSteeringResult()
def get(self, model: Any, pk: Any) -> Any:
return None
def execute(self, *args: Any, **kwargs: Any) -> list[Any]:
return []
def _doc(title: str, content: str) -> Document:
return Document(
id=uuid.uuid4(),
source="Homelab",
path=f"{title.lower().replace(' ', '-')}.md",
full_path="/tmp/doc.md",
title=title,
content=content,
content_hash="0" * 64,
created_at=datetime(2024, 6, 15, 12, 0, 0, tzinfo=UTC),
)
def _chunk(doc: Document, score: float) -> RetrievedChunk:
return RetrievedChunk(
chunk_id=uuid.uuid4(),
position=0,
content=doc.content[:32],
score=score,
document=doc,
cosine=score,
fts_hit=False,
is_summary=False,
)
@pytest.fixture(autouse=True)
def _admin_signed_in(client: TestClient) -> None:
"""``POST /api/chat`` and ``POST /api/chat-images`` are user-gated
— every test here runs as the signed-in ADMIN (the TestClient
cookie jar carries the session; ``require_user`` passes the admin
session without a DB lookup)."""
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 204, f"admin login failed: {r.status_code} {r.text}"
@pytest.fixture()
def turn_env(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> Iterator[tuple[_FakeSession, _RecordingLLM, Path]]:
"""``POST /api/chat`` with the LLM, retriever, session, and
settings all faked — the images toggle ON by default here (each
test that needs it off patches the settings again), the store is a
tmp dir, and the code-default thresholds apply (0.9 chunk grounded,
0.29 chunk deflected, against the conftest-calibrated 0.30
floor... the gate threshold is patched to the mock's calibration)."""
session = _FakeSession()
llm = _RecordingLLM()
store = tmp_path / "chat-images"
store.mkdir()
(store / GOOD_PATH.rsplit("/", 1)[-1]).write_bytes(PNG_1X1)
settings = Settings(
_env_file=None, # pyright: ignore[reportCallIssue]
relevance_threshold=0.30,
images=True,
chat_image_dir=str(store),
llm_retry_delay=0.01,
)
monkeypatch.setattr(chat_api, "db_available", lambda: True)
monkeypatch.setattr(chat_api, "SessionLocal", lambda: session)
monkeypatch.setattr(chat_api, "get_settings", lambda: settings)
monkeypatch.setitem(fastapi_app.dependency_overrides, chat_api.get_llm, lambda: llm)
yield session, llm, store
fastapi_app.dependency_overrides.clear()
def _retrieve_with(cosine: float) -> Any:
doc = _doc("Kubernetes", "k" * 80)
chunks = [_chunk(doc, cosine)]
def retrieve(_db: Any, _question: str, _vec: list[float]) -> list[RetrievedChunk]:
return chunks
return retrieve
def _ask(
client: TestClient, message: str, image: str | None = None
) -> list[dict[str, Any]]:
body: dict[str, Any] = {"message": message}
if image is not None:
body["image"] = image
with client.stream("POST", "/api/chat", json=body) as r:
assert r.status_code == 200
frames: list[dict[str, Any]] = []
buf = ""
for part in r.iter_text():
buf += part
while "\n\n" in buf:
frame, buf = buf.split("\n\n", 1)
frame = frame.strip()
if frame.startswith("data:"):
frames.append(json.loads(frame.removeprefix("data:").strip()))
assert buf.strip() == ""
return frames
def test_image_with_toggle_off_settles_the_hinted_frame_without_any_model_call(
client: TestClient,
turn_env: tuple[_FakeSession, _RecordingLLM, Path],
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""LOCKED A5 gate: ``images`` false with an image set → the
phase-114 error frame with the EXACT detail + hint, ONE frame
(terminal — no ``done``), NO model call (zero embeds, zero
requests), NO record (the rejected turn saves nothing)."""
session, llm, _store = turn_env
monkeypatch.setattr(
chat_api,
"get_settings",
lambda: Settings(
_env_file=None, # pyright: ignore[reportCallIssue]
relevance_threshold=0.30,
images=False,
chat_image_dir=str(_store),
),
)
monkeypatch.setattr(chat_api, "retrieve", _retrieve_with(0.90))
frames = _ask(client, "What is in this image?", GOOD_PATH)
assert [f["type"] for f in frames] == ["error"]
assert frames[0] == {
"type": "error",
"detail": "Image support is turned off on this server.",
"hint": "Enable BOR_IMAGES in the server's .env (and restart) to ask with an image.",
}
assert llm.embedded == [] # NO model call (the embed never ran)
assert llm.seen == []
assert session.added == [] # NO query_log row (the error-path convention)
def test_image_with_a_missing_stored_file_settles_the_stale_frame(
client: TestClient,
turn_env: tuple[_FakeSession, _RecordingLLM, Path],
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The stale-path edge (the file was deleted out-of-band): the
SAME frame shape, a different detail, NO hint (the banner's
default copy is the honest fallback) — and again NO model call,
NO record."""
session, llm, _store = turn_env
stale = "/api/chat-images/" + "e" * 32 + ".png" # well-formed, absent
monkeypatch.setattr(chat_api, "retrieve", _retrieve_with(0.90))
frames = _ask(client, "What is in this image?", stale)
assert [f["type"] for f in frames] == ["error"]
assert frames[0] == {
"type": "error",
"detail": "That image is no longer available.",
"hint": None,
}
assert llm.embedded == []
assert llm.seen == []
assert session.added == []
def test_text_only_question_is_byte_identical_with_images_enabled(
client: TestClient,
turn_env: tuple[_FakeSession, _RecordingLLM, Path],
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""``image=None`` with the toggle ON: the user message is the PLAIN
STRING (not a list) — the multimodal branch is inert, and the turn
completes normally (the toggle only gates image turns)."""
_session, llm, _store = turn_env
monkeypatch.setattr(chat_api, "retrieve", _retrieve_with(0.29))
frames = _ask(client, "How is my Kubernetes cluster set up?")
assert [f["type"] for f in frames][-1] == "done"
assert llm.seen[0][-1] == {"role": "user", "content": "How is my Kubernetes cluster set up?"}
def test_deflected_image_turn_delivers_the_multimodal_list(
client: TestClient,
turn_env: tuple[_FakeSession, _RecordingLLM, Path],
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Construction site 1 (the deflected branch's ``messages`` list):
the mock client RECEIVES the content list — the text part + the
image_url part whose data URL decodes to the stored bytes (the
phase-122 mime map, the shared data-URL helper)."""
_session, llm, _store = turn_env
monkeypatch.setattr(chat_api, "retrieve", _retrieve_with(0.29))
frames = _ask(client, "What is in this image?", GOOD_PATH)
assert [f["type"] for f in frames][-1] == "done"
assert len(llm.seen) == 1
user = llm.seen[0][-1]
assert user["role"] == "user"
assert user["content"] == [
{"type": "text", "text": "What is in this image?"},
{
"type": "image_url",
"image_url": {
"url": f"data:image/png;base64,{base64.b64encode(PNG_1X1).decode('ascii')}"
},
},
]
def test_grounded_image_turn_delivers_the_multimodal_list_through_run_agent(
client: TestClient,
turn_env: tuple[_FakeSession, _RecordingLLM, Path],
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Construction site 2 (the grounded branch): ``run_agent`` builds
its own ``[system, *history, user]`` from the value chat.py passes
(the pinned flow) — the mock receives the SAME multimodal list,
with the tools offered (the agent loop ran)."""
_session, llm, _store = turn_env
monkeypatch.setattr(chat_api, "retrieve", _retrieve_with(0.90))
frames = _ask(client, "What is in this image?", GOOD_PATH)
assert frames[-1]["type"] == "done"
assert frames[-1]["deflected"] is False
assert len(llm.seen) == 1
user = llm.seen[0][-1]
assert user["role"] == "user"
assert user["content"] == [
{"type": "text", "text": "What is in this image?"},
{
"type": "image_url",
"image_url": {
"url": f"data:image/png;base64,{base64.b64encode(PNG_1X1).decode('ascii')}"
},
},
]
assert llm.seen[0][0]["role"] == "system"
# ---------------------------------------------------------------------------
# Frontend source pins (tasks 02 + 03) — the house-style source
# assertions (see tests/unit/test_pinned_composer.py): the composer's
# attach control (hidden by default, config-gated reveal, the A8
# upload-before-send ordering), the user record's `image` key (the
# stored PATH — A5: never base64), the ONE bubble-image renderer
# (live + restore + shared, the onerror degradation), and the redo's
# text-only re-ask (A7).
# ---------------------------------------------------------------------------
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
INDEX_HTML = FRONTEND / "index.html"
APP_JS = FRONTEND / "assets" / "app.js"
SHARED_JS = FRONTEND / "assets" / "shared.js"
STYLES_CSS = FRONTEND / "assets" / "styles.css"
def _index_html() -> str:
return INDEX_HTML.read_text(encoding="utf-8")
def _app_js() -> str:
return APP_JS.read_text(encoding="utf-8")
def _shared_js() -> str:
return SHARED_JS.read_text(encoding="utf-8")
def _css() -> str:
return STYLES_CSS.read_text(encoding="utf-8")
def test_the_attach_control_is_hidden_by_default_in_the_markup() -> None:
"""The default-off contract (A5): the paperclip button, the hidden
file input, and the preview strip all ship HIDDEN in the static
markup — with the flag off (the default) nothing is ever revealed,
so the rendered flag-off DOM is the pre-phase one. The accessible
names ride aria-label (the SVGs are decorative)."""
html = _index_html()
assert (
'<button type="button" class="attach-btn" id="attach-btn" hidden'
' aria-label="Attach an image">' in html
)
assert (
'<input type="file" id="attach-file"'
' accept="image/png,image/jpeg,image/webp,image/gif,image/bmp" hidden>'
in html
)
assert '<div class="attach-preview" id="attach-preview" hidden>' in html
assert (
'class="attach-preview-remove" id="attach-remove"'
' aria-label="Remove the attached image">' in html
)
# the thumbnail is decorative — the filename beside it is the
# readable label (the alt="" ships in the markup).
assert '<img class="attach-preview-img" alt="">' in html
def test_the_attach_reveal_is_gated_on_the_config_flag() -> None:
"""The reveal reads `images` from the boot /api/config — the brand
boot's ONE config request (no second round-trip). The `!== true`
guard degrades quietly: off (the default), a missing key, or an
unanswered fetch all leave the button hidden for good, and the API
contract still enforces the toggle server-side."""
js = _app_js()
assert "const bootConfig = await (window.BOR_CONFIG_PROMISE ?? Promise.resolve());" in js
m = re.search(r"if \(attachBtn\) attachBtn\.hidden = bootConfig\?\.images !== true;", js)
assert m, "the attach reveal must be gated on the settled config's images flag"
def test_the_client_precheck_matches_the_servers_six_extensions() -> None:
"""ONE list of the six, on both sides: the client pre-check (the
file NAME's extension — the accept attribute is advisory) and the
server's phase-122 frozenset (the upload endpoint's authority).
The client list must stay the code default's six — a drift here
would either block a valid pick or waste a round-trip on a 422."""
js = _app_js()
m = re.search(r"const ATTACHABLE_IMAGE_EXTENSIONS = \[(.*?)\];", js)
assert m, "the attachable-extension pre-check list must exist"
client_set = {tok.strip().strip('"') for tok in m.group(1).split(",")}
assert client_set == {
"bmp",
"gif",
"jpeg",
"jpg",
"png",
"webp",
}
# and it equals the server's default set (dots stripped — the
# client list is dotless, the server's is dotted).
server_set = {
e.lstrip(".")
for e in Settings(_env_file=None).image_extension_set # pyright: ignore[reportCallIssue]
}
assert client_set == server_set
# the file input's accept attribute names the same six (mime form).
m = re.search(r'<input type="file" id="attach-file"\s+accept="([^"]+)"', _index_html())
assert m, "the attach file input must carry the six-mime accept attribute"
accept_set = {mime.rsplit("/", 1)[-1] for mime in m.group(1).split(",")}
# image/jpeg appears twice (jpg + jpeg) — the extension SET is the six.
assert accept_set == {"png", "jpeg", "webp", "gif", "bmp"}
def test_the_upload_happens_before_the_send_and_blocks_on_failure() -> None:
"""LOCKED A8, ordered in ``handleSend``: the attached File goes to
``POST /api/chat-images`` as the FIRST await of the send — BEFORE
the input is cleared and BEFORE ``runTurn`` — and a null path (any
failure: 413 over-cap, 422 a bad extension, 5xx, network drop)
RETURNS immediately: the send is BLOCKED, the typed question stays
exactly where the user left it, and the attachment stays for the
retry (the banner says what failed)."""
js = _app_js()
start = js.index("async function handleSend")
end = js.index("async function runTurn")
body = js[start:end]
upload = body.index("await uploadAttachedImage(attachedImage.file)")
clear = body.index('input.value = ""')
turn = body.index("await runTurn(text, { reask: false, image });")
assert upload < clear < turn, (
"A8 ordering: the upload must precede the input clear and the turn"
)
# the block line sits between the upload and the clear — a null
# path never reaches the turn, and the double-fire guard means a
# second submit mid-upload is a no-op (the first owns the send).
block = body.index("if (path === null) return;")
assert upload < block < clear
assert "if (attachUpload) return;" in body
# the upload endpoint itself (multipart, the session cookie rides
# the browser) with the phase-114-style out-of-turn banner copy.
assert 'res = await fetch("/api/chat-images", { method: "POST", body: form });' in js
assert "Couldn't attach the image — " in js
assert "Couldn't attach the image — try again." in js
def test_the_user_record_carries_the_stored_path_never_base64() -> None:
"""A5 at the record level: save point 1 (the user append in
``runTurn``) joins the ``bor.chat.v1`` record with the `image` key
ONLY when an attachment exists — and the value is the upload
response's STORED PATH, never a data URL. The /api/chat body
follows the same omission rule (a text-only body is byte-identical
to pre-phase)."""
js = _app_js()
assert (
"conversation.push(\n"
" image ? { who: \"user\", text, image: image.path } : { who: \"user\", text }\n"
" );" in js
)
# the request body: the key is added ONLY when present.
m = re.search(
r"const payload = \{ message: text, history \};\s*\n"
r"\s*if \(image\) payload\.image = image\.path;",
js,
)
assert m, "the /api/chat body must gain `image` (the path) only when attached"
# the triple: the PATH for the record + body, the data URL for the
# LIVE bubble only, the filename for the alt.
m = re.search(
r"image = \{\s*\n\s*path,.*\n\s*src: attachedImage\.dataUrl,.*\n"
r"\s*alt: attachedImage\.name,",
js,
)
assert m, "the image triple must carry path / live data URL / filename"
def test_prior_turns_images_are_never_replayed_and_the_redo_is_text_only() -> None:
"""LOCKED A7: the `history` entries travel as {who, text,
thinking} ONLY — a prior turn's image is never re-sent (the text of
a prior turn stands alone; a 10 MB image per past turn would blow
every budget). And the redo (``retryLastTurn``) re-asks with
``reask: true`` and NO image argument — and the upload helper is
called from EXACTLY ONE site (the plain send's handleSend): a
re-ask of a question that had an image re-sends ``prev.text``
only."""
js = _app_js()
m = re.search(
r"const history = conversation\.slice\(0, -1\)\.map\(\(m\) => \(\{(.*?)\}\)\);",
js,
re.S,
)
assert m, "the history map (prior turns in the body) must exist"
assert "image" not in m.group(1), "A7: history entries must never carry an image"
assert "return runTurn(text, { reask: true });" in js
# the upload is reached ONLY from the plain send's handleSend —
# the def + the one call site, nowhere else (the redo, the
# stale-regen, and every other turn path are image-free).
assert js.count("uploadAttachedImage") == 2
def test_the_live_user_bubble_renders_the_attached_image() -> None:
"""The live user bubble carries the image through the ONE
renderer (``attachBubbleImage``): the img is created
createElement-style (no HTML strings — the house rule), prepends
the bubble (the attachment is part of the question — it sits
ABOVE the text), lazy-loads, alt = the filename, and the CSS caps
it (a tall portrait must not blow the 46rem chat column)."""
js = _app_js()
assert "image ? { src: image.src || image.path, alt: image.alt } : null" in js
m = re.search(r"function attachBubbleImage\(bubble, src, alt\) \{(.*?)\n\}", js, re.S)
assert m, "the bubble-image renderer must exist"
body = m.group(1)
for needle in (
"img.className = \"msg-image\";",
"img.alt = alt || \"attached image\";",
"img.loading = \"lazy\";",
"bubble.prepend(img);",
"img.onerror",
):
assert needle in body, f"attachBubbleImage must keep: {needle}"
css_block = re.search(r"\.msg-image \{(.*?)\}", _css(), re.S)
assert css_block, ".msg-image must exist in styles.css"
assert "max-width: 100%;" in css_block.group(1)
assert "max-height: 240px;" in css_block.group(1)
def test_the_restore_renders_the_stored_path_through_the_same_helper() -> None:
"""Task 03: ``renderStoredMessage``'s USER branch reads ``m.image``
(the stored PATH — null-safe: pre-phase / text-only records have
no key at all and render byte-identically, no img) and routes it
through the SAME ``attachBubbleImage`` helper the live send uses
(ONE renderer for live + restore — the live bubble passes the data
URL, the restore passes the path). The load failure degrades IN
PLACE to the small "image unavailable" line — never a broken
image icon (the stored file was deleted out-of-band; the record
keeps its path, the render degrades)."""
js = _app_js()
m = re.search(
r"if \(typeof m\.image === \"string\" && m\.image\) \{\s*\n"
r"\s*attachBubbleImage\(wrap\.querySelector\(\"\.bubble\"\),"
r" m\.image, m\.text \|\| \"attached image\"\);",
js,
)
assert m, "the restore's user branch must render m.image through attachBubbleImage"
assert 'note.textContent = "image unavailable";' in js
assert "img.replaceWith(note);" in js
# the degradation's small note is styled (the italic one-liner).
assert re.search(r"\.msg-image-unavailable \{", _css())
def test_the_shared_page_renders_the_user_image_the_same_way() -> None:
"""Task 03: the shared page's ``renderSharedMessage`` user branch
carries the SAME image render (the per-page duplication house
style: shared.js keeps its own ``addBubbleImage`` copy — the
.msg-image treatment itself is ONE, styles.css is shared by both
pages, so the rule appears exactly once). The serve route is
public (the token is the shared chat's credential — the image is
part of that content), and the load-failure degradation is
identical to the chat page."""
js = _shared_js()
m = re.search(
r"if \(typeof m\.image === \"string\" && m\.image\) \{\s*\n"
r"\s*addBubbleImage\(wrap, m\.image, m\.text \|\| \"attached image\"\);",
js,
)
assert m, "the shared page's user branch must render m.image"
m = re.search(r"function addBubbleImage\(wrap, src, alt\) \{(.*?)\n\}", js, re.S)
assert m, "the shared page's bubble-image helper must exist"
body = m.group(1)
for needle in (
"img.className = \"msg-image\";",
"img.alt = alt || \"attached image\";",
"img.loading = \"lazy\";",
"bubble.prepend(img);",
):
assert needle in body, f"addBubbleImage must keep: {needle}"
assert 'note.textContent = "image unavailable";' in js
assert "img.replaceWith(note);" in js
# ONE .msg-image rule — both pages share it (no second copy).
assert len(re.findall(r"\.msg-image \{", _css())) == 1
def test_the_preview_strip_reveals_and_clears_with_the_attachment() -> None:
"""The preview strip (thumbnail ≤48px + the filename + the remove
✕): revealed with the data-URL thumbnail + the file NAME as the
readable label (the thumbnail is decorative, alt=""), and cleared
with the attachment (the remove button, a fresh pick, the send,
or a New chat — the strip must not linger into a turn)."""
js = _app_js()
m = re.search(r"function showAttachPreview\(\) \{(.*?)\n\}", js, re.S)
assert m, "the preview reveal helper must exist"
body = m.group(1)
for needle in (
'attachPreview.querySelector("img").src = attachedImage.dataUrl;',
'attachPreview.querySelector(".attach-preview-name").textContent = attachedImage.name;',
"attachPreview.hidden = false;",
):
assert needle in body, f"showAttachPreview must keep: {needle}"
m = re.search(r"function clearAttachedImage\(\) \{(.*?)\n\}", js, re.S)
assert m, "the attachment-clear helper must exist (idempotent)"
body = m.group(1)
assert "attachedImage = null;" in body
assert "attachPreview.hidden = true;" in body
# the send clears the strip AFTER the user bubble is rendered (the
# bubble already holds the image; a failed turn keeps both).
run_start = js.index("async function runTurn")
region = js[run_start:run_start + 4000]
push = region.index("conversation.push(")
clear = region.index("clearAttachedImage();")
assert push < clear, "the strip must clear only after the user bubble lands"
# the 44px touch floor + the thumbnail box (styles.css is shared
# with the shared page — the rules need no second copy there).
btn_block = re.search(r"\.attach-btn \{(.*?)\}", _css(), re.S)
assert btn_block and "width: 44px;" in btn_block.group(1)
assert "min-height: 44px;" in btn_block.group(1)
thumb_block = re.search(r"\.attach-preview-img \{(.*?)\}", _css(), re.S)
assert thumb_block and "width: 48px;" in thumb_block.group(1)
assert "height: 48px;" in thumb_block.group(1)
def test_the_remove_button_clears_the_state_and_refocuses_the_trigger() -> None:
"""The remove ✕ (or a new selection) clears the state + hides the
strip, and focus returns to the paperclip trigger (the keyboard
path stays inside the composer — WCAG 2.1 AA)."""
js = _app_js()
m = re.search(
r'attachRemove\?\.addEventListener\("click", \(\) => \{(.*?)\}\);', js, re.S
)
assert m, "the remove button's click binding must exist"
body = m.group(1)
assert "clearAttachedImage();" in body
assert "attachBtn?.focus();" in body
# a fresh pick re-arms the input (the same file re-picked must
# fire change again).
m = re.search(r'attachFile\?\.addEventListener\("change", \(\) => \{(.*?)\n\}\);', js, re.S)
assert m, "the file input's change binding must exist"
assert 'attachFile.value = "";' in m.group(1)
def test_the_bad_pick_and_the_reader_failure_keep_the_banner_copy() -> None:
"""Client-side failure copy (the out-of-turn banner — no state
change: a previous attachment, if any, survives a bad pick): the
extension pre-check names the six in prose, and a file whose bytes
never became readable (evicted mid-pick) gets the same copy as an
upload failure."""
js = _app_js()
m = re.search(
r'showErrorBanner\(\s*\n\s*"Only PNG, JPEG, WebP, GIF, and BMP'
r' images can be attached."\s*\);',
js,
)
assert m, "the bad-pick banner copy must name the six in prose"
m = re.search(r'reader\.onerror = \(\) => \{(.*?)\n \};', js, re.S)
assert m, "the FileReader's onerror branch must exist"
assert 'showErrorBanner("Couldn\'t attach the image — try again.");' in m.group(1)