"""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 ``.`` 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). Phase 132 (task 01, TODO L8) pins the PRE-turn uploading state in the same chat-frontend surface: the preview strip ships a HIDDEN status part (the house loading arc — decorative, aria-hidden — + the pinned "Uploading image…" text), and ``handleSend``'s attachedImage branch enters it exactly once per upload (after the ``attachUpload`` guard, before the ``await uploadAttachedImage``) and exits it on BOTH resolution paths (the helper never throws) — strip class ``is-uploading`` + status unhidden + remove ✕ disabled + the Send button disabled with "Uploading…" on entry; the mirror image on exit with the idle label restored byte-for-byte. ``setUiState`` / ``uiState`` / the turn loader stay untouched (the upload is pre-turn; ``uiState`` stays ``idle``). """ from __future__ import annotations import base64 import io import json import re import shutil import subprocess 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 ``.`` 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 ``.`` 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="") # 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 ( '