"""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). """ 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 ``.`` 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 ( '