Files
brain-of-reese/tests/integration/test_api.py
T
ducoterra a19d78d284
Build and Push Containers / build-and-push-app (push) Successful in 1m57s
Build and Push Containers / build-and-push-db (push) Failing after 13s
phase: 122_image_documents
**Phase 122 (image documents) — final verification pass: all green. No code changes were needed; defects found: none.**

**Verified (implementation already complete in working tree, reviewed end-to-end):**
- Toggle (`BOR_IMAGES`/`BOR_IMAGE_EXTENSIONS`/`BOR_IMAGE_DIR`, off by default) + `GET /api/config` `images` flag
- Ingest: bytes digest, `image_dir` persistent copy, `content = summary = vision description` (chat-model call; only text embedded), fail-soft skip + `images_failed` counter
- Serve/display: `/api/documents/{id}/image` route (404 matrix), viewer `<img>` + description, Sources 48px lazy thumbnails, chat inline source figure (alt = summary), agent `read` marker
- Prune guard: images-off syncs never prune `is_image` docs

**Test / lint / coverage (exact commands & outcomes):**
- `uv run pytest` → exit 0 (green; note: pytest 9.1.1 `-q` omits the final count line in output — exit code authoritative)
- `uv run pytest --cov=app --cov-report=term-missing` → **2715 passed, exit 0, TOTAL 99%** (>90% gate)
- `uv run ruff check . && uv run pyright` → "All checks passed!" / "0 errors, 0 warnings, 0 informations"
- `uv run pytest tests/e2e/test_image_documents.py -v --no-cov` → **4 passed, exit 0** (isolation)

**Completion criteria:** (1) images=true → described/embedded/displayed docs: ✅ (E2E + integration) · (2) images=false byte-identical + image docs survive sync: ✅ (E2E negative app + unit/integration) · (3) viewer + chat rendering with alt text; failed description skips + logs, sync completes: ✅ · (4) test/lint/coverage gates: ✅ · (5) commit + phase move: deferred to harness per this pass's rules (working tree left uncommitted).

**Notable deviation (pre-existing, documented in code):** image route uses `require_user` (phase-79 posture, same gate as the document content endpoint) rather than the phase text's "public" parenthetical — matches the endpoint it mirrors.

**Next pending phase:** `123_chat_image_questions`.
2026-09-25 01:54:23 -04:00

492 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Integration tests: HTTP API surface (mostly no database required).
Phase 79 note: the user-gated endpoints (chat, suggestions) are driven
here by a signed-in ADMIN client — the admin path of ``require_user``
short-circuits before any DB touch, so this module stays database-free
(the 401 auth contract itself is pinned in ``test_auth_api.py``).
Phase 80 note: the suggestions pins are the exception — the chips are
the last 3 questions asked once any are saved, so the env-override
pin (the override is the SEED) needs an empty ``saved_chats``;
the full state matrix lives in ``test_suggestions_api.py``.
Phase 91 (task 01) note: the ``/api/config`` pins are now DB-backed —
the three UI strings are the EFFECTIVE values (the ``ui_settings`` row
over the env values, B1), resolved through a short-lived session, so
the pins take the ``db`` fixture (skip when the stack is down) and
start from an empty ``ui_settings`` table (the env-only-deployment
state; the DB-over-env behaviour itself is pinned in
test_ui_settings_api.py).
"""
from __future__ import annotations
import json
import pytest
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.config import get_settings
from tests.conftest import ADMIN_PASSWORD
def _clear_ui_settings(db: Session) -> None:
"""The env-only-deployment state for the /api/config pins: no
ui_settings row, so the effective strings are the env values
(phase 91, task 01)."""
db.execute(text("DELETE FROM ui_settings"))
db.commit()
def test_health_reports_ok(client) -> None:
r = client.get("/api/health")
assert r.status_code == 200
body = r.json()
assert body["status"] == "ok"
assert body["db"] in {"up", "down"}
assert body["version"]
def test_config_returns_default_app_metadata(client, db: Session) -> None:
"""GET /api/config is public (anonymous) and returns exactly six
keys — the phase-39 app metadata, the phase-59 docs flag (inert
false while BOR_DOCS_REPO is empty — the "Save as doc" gating),
the phase-122 images flag (default false in the test env —
LOCKED A3: off by default), and the phase-62 UI customization
strings (composer placeholder, footer line). Phase 91: with an
empty ui_settings table the effective strings are the env defaults
(B1 — DB-over-env, the row absent here); the retired CSS-file
theming's ``theme`` key is gone (task 03 — the six keys are the
entire contract)."""
_clear_ui_settings(db)
r = client.get("/api/config")
assert r.status_code == 200
body = r.json()
assert set(body) == {
"app_name", "version", "docs_repo_configured",
"images", "input_placeholder", "footer_text",
}
assert body["app_name"] == "Brain of Reese"
assert body["version"] == get_settings().app_version
assert body["docs_repo_configured"] is False
# Phase 122 (task 01): the images flag is the default off (the
# test env sets no BOR_IMAGES) — a real bool, not a truthy string.
assert body["images"] is False
# Phase 62: UNSET => the phase-61 neutral copy stands (the
# byte-identical contract).
assert body["input_placeholder"] == "Ask me anything…"
assert body["footer_text"] == "Powered by self-hosted models"
def test_config_follows_overridden_app_name(client, db: Session) -> None:
"""GET /api/config reflects a Settings override (e.g. BOR_APP_NAME).
Phase 91: the override is the ENV side of the DB-over-env resolver —
with an empty ui_settings row the effective app_name is the
overridden env value."""
from app.config import Settings
from app.main import app as fastapi_app
_clear_ui_settings(db)
fastapi_app.dependency_overrides[get_settings] = lambda: Settings(
app_name="Brain of Testy"
)
try:
r = client.get("/api/config")
assert r.status_code == 200
body = r.json()
assert set(body) == {
"app_name", "version", "docs_repo_configured",
"images", "input_placeholder", "footer_text",
}
assert body["app_name"] == "Brain of Testy"
assert body["version"] == "0.1.0"
assert body["docs_repo_configured"] is False
finally:
fastapi_app.dependency_overrides.clear()
def test_config_serves_ui_customization_overrides(client, db: Session) -> None:
"""Phase 62: the UI customization string keys mirror Settings
overrides (``BOR_INPUT_PLACEHOLDER`` / ``BOR_FOOTER_TEXT``) — the
values the frontend brand layer applies at boot, so this dict is
the whole contract. Phase 91: placeholder + footer are the
EFFECTIVE strings — the env overrides win with an empty
ui_settings row (B1); the retired theming's ``theme`` key is gone
(task 03)."""
from app.config import Settings
from app.main import app as fastapi_app
_clear_ui_settings(db)
fastapi_app.dependency_overrides[get_settings] = lambda: Settings(
input_placeholder="Ask the vault…",
footer_text="Powered by my own models",
)
try:
r = client.get("/api/config")
assert r.status_code == 200
body = r.json()
assert set(body) == {
"app_name", "version", "docs_repo_configured",
"images", "input_placeholder", "footer_text",
}
assert body["input_placeholder"] == "Ask the vault…"
assert body["footer_text"] == "Powered by my own models"
finally:
fastapi_app.dependency_overrides.clear()
def test_config_docs_flag_tracks_settings(client, db: Session) -> None:
"""Phase 59 (task 05): ``docs_repo_configured`` mirrors
``settings.docs_configured`` — a real bool (never a truthy string)
that flips true the moment BOR_DOCS_REPO is non-empty: that flag is
the entire frontend gating of the "Save as doc" button."""
from app.config import Settings
from app.main import app as fastapi_app
_clear_ui_settings(db)
fastapi_app.dependency_overrides[get_settings] = lambda: Settings(
app_name="Brain of Testy",
docs_repo="/srv/docs-repo",
)
try:
r = client.get("/api/config")
assert r.status_code == 200
body = r.json()
assert isinstance(body["docs_repo_configured"], bool)
assert body["docs_repo_configured"] is True
finally:
fastapi_app.dependency_overrides.clear()
def test_config_images_flag_tracks_settings(client, db: Session) -> None:
"""Phase 122 (task 01): ``images`` mirrors ``settings.images``
(``BOR_IMAGES``) — a real bool (never a truthy string) that flips
true the moment the toggle is on: that flag is the entire frontend
gating of the image affordances (the phase-123 attach control,
optionally the Sources page hint)."""
from app.config import Settings
from app.main import app as fastapi_app
_clear_ui_settings(db)
fastapi_app.dependency_overrides[get_settings] = lambda: Settings(
images=True,
)
try:
r = client.get("/api/config")
assert r.status_code == 200
body = r.json()
assert isinstance(body["images"], bool)
assert body["images"] is True
finally:
fastapi_app.dependency_overrides.clear()
def test_suggestions_returns_list(client) -> None:
# Phase 79: the chips are user-gated — sign in as the admin first
# (the test's purpose is the list shape, not the auth contract).
# Phase 80: the chips are the last 3 questions asked OR the seed —
# the per-state exact lists are pinned in test_suggestions_api.py;
# here the DB-free shape pin holds in EVERY state: a list of
# non-blank strings (1–3 chips once questions exist, the seed
# while none do).
assert client.post("/api/login", json={"password": ADMIN_PASSWORD}).status_code == 204
r = client.get("/api/suggestions")
assert r.status_code == 200
suggestions = r.json()["suggestions"]
assert isinstance(suggestions, list)
assert all(isinstance(s, str) and s.strip() for s in suggestions)
def test_suggestions_honors_bor_suggestions_env_override(
monkeypatch, db: Session
) -> None:
"""GET /api/suggestions reflects the BOR_SUGGESTIONS JSON env
override — as the SEED (phase 80): it appears while ZERO questions
have been saved, so the pin needs an empty ``saved_chats`` (the
full state matrix is test_suggestions_api.py)."""
from fastapi.testclient import TestClient
from app.main import create_app
override = [
"How do I back up with Borg?",
"How is my K3S cluster set up?",
"How do I deploy a service?",
"What proxy fronts reeseapps.com?",
]
db.execute(text("TRUNCATE saved_chats"))
db.commit()
get_settings.cache_clear()
try:
monkeypatch.setenv("BOR_SUGGESTIONS", json.dumps(override))
fresh_client = TestClient(create_app())
finally:
get_settings.cache_clear()
db.execute(text("TRUNCATE saved_chats"))
db.commit()
# Phase 79: sign the fresh client in as the admin (the chips are
# user-gated; the override's value is what this test pins).
assert fresh_client.post(
"/api/login", json={"password": ADMIN_PASSWORD}
).status_code == 204
r = fresh_client.get("/api/suggestions")
assert r.status_code == 200
assert r.json() == {"suggestions": override}
@pytest.mark.parametrize(
("path", "marker"),
[
("/", "Brain of Reese"),
# Phase 76 (task 02): /sources.html + /git-sources.html are SHELL
# routes — the body is the shell (index.html) whose static
# <title> is "Brain of Reese" (the per-view title is set
# CLIENT-side by the router, invisible to httpx). The marker
# asserts the shell body (the view section is inside it) instead
# of the old page's title.
("/sources.html", 'id="view-rag"'), # phase 76: shell route (was "Knowledge base")
("/document.html", "Brain of Reese"), # phase 10: viewer page
("/login.html", "Sign in"), # phase 16: admin sign-in page
# Phase 76 (task 01): /tuning.html is a SHELL route — same
# shell-body marker pattern as the two task-02 paths above.
("/tuning.html", 'id="view-tuning"'), # phase 76: shell route (was "Global Tuning")
("/git-sources.html", 'id="view-git-sources"'), # phase 76: shell route (was "Git sources")
# Phase 76 (task 03): /history.html is a SHELL route too — the
# shell-body marker (the History view section is inside the
# shell; the old standalone page's title is client-side now).
("/history.html", 'id="view-history"'), # phase 76: shell route (was "Saved chats")
# Phase 79 (task 06): /tokens.html is a SHELL route too — the
# shell-body marker (the Tokens view section is inside the
# shell; the per-view title is client-side now).
("/tokens.html", 'id="view-tokens"'), # phase 79: shell route
# Phase 91 (task 04): /theme.html is a SHELL route too — the
# shell-body marker (the Theme view section is inside the
# shell; the per-view title is client-side now).
("/theme.html", 'id="view-theme"'), # phase 91: shell route
("/shared.html", "Shared conversation"), # phase 51: anonymous shared page
],
)
def test_html_pages_served_locally_no_cdn(client, path: str, marker: str) -> None:
"""No-CDN check (PLAN §7.3, re-verified on BOTH pages in phase 07 and
on the viewer page in phase 10): each page is served by FastAPI and
references only same-origin assets (no https:// script/link tags)."""
r = client.get(path)
assert r.status_code == 200
assert marker in r.text
assert 'src="https://' not in r.text
assert 'href="https://' not in r.text
# Phase 33 (cache busting): the five HTML pages revalidate (no-cache) with
# ?v=<token> asset refs; /assets/* is immutable for a year; /api/* is
# untouched. The token itself is unit-tested in tests/unit/test_caching.py.
def test_index_page_no_cache_with_versioned_asset_refs(client) -> None:
"""GET / — always revalidated, and the stylesheet reference carries
the process version token (non-empty, matching asset_version())."""
from app.core.caching import asset_version
token = asset_version()
assert token # non-empty in every supported environment
r = client.get("/")
assert r.status_code == 200
assert r.headers["cache-control"] == "no-cache"
assert f'href="/assets/styles.css?v={token}"' in r.text
# The unversioned reference is gone from the served body.
assert 'href="/assets/styles.css">' not in r.text
@pytest.mark.parametrize(
"path",
["/sources.html", "/document.html", "/login.html", "/tuning.html",
"/git-sources.html", "/history.html", # phase 50: + History (shell route, task 03)
"/tokens.html", # phase 79 task 06: + Tokens (shell route)
"/theme.html", # phase 91 task 04: + Theme (shell route)
"/shared.html"], # phase 51: + the anonymous shared page
)
def test_html_pages_no_cache_with_versioned_refs(client, path: str) -> None:
"""Each of the other four pages revalidates and carries at least one
versioned asset reference."""
from app.core.caching import asset_version
r = client.get(path)
assert r.status_code == 200
assert r.headers["cache-control"] == "no-cache"
assert f"?v={asset_version()}" in r.text
@pytest.mark.parametrize(
("path", "view_id", "old_title"),
[
("/tuning.html", 'id="view-tuning"', "Global Tuning · Brain of Reese"),
("/sources.html", 'id="view-rag"', "Sources · Brain of Reese"),
("/git-sources.html", 'id="view-git-sources"', "Git sources · Brain of Reese"),
("/history.html", 'id="view-history"', "Saved chats · Brain of Reese"), # phase 76 task 03
# phase 79 task 06: the sixth view — there was never a
# standalone tokens.html, so "old_title" is the router's
# client-side title: the pin asserts the shell never carries
# the per-view title statically (the router writes it).
("/tokens.html", 'id="view-tokens"', "Access tokens · Brain of Reese"), # phase 79 task 06
# phase 91 task 04: the seventh view — there was never a
# standalone theme.html, so "old_title" is the router's
# client-side title: the pin asserts the shell never carries
# the per-view title statically (the router writes it).
("/theme.html", 'id="view-theme"', "Theme · Brain of Reese"), # phase 91 task 04
],
)
def test_shell_routes_serve_the_shell_no_cache_versioned(
client, path: str, view_id: str, old_title: str
) -> None:
"""Phase 76 (task 01: /tuning.html; task 02: /sources.html +
/git-sources.html; task 03: /history.html — all four non-chat
navbar views): every shell route serves the shell
(``frontend/index.html``), so the phase-33 page contract applies to
it exactly as to a static page: 200, text/html, ``Cache-Control:
no-cache``, ``?v=<token>`` asset refs, no validators (the
middleware wraps the whole app and lists the path in ``HTML_PAGES``
— unchanged). The body IS the shell: the view section is inside it,
the old standalone page's title is gone. The static catch-all stays
intact: an unknown path still 404s. (The conditional-GET
revalidation contract for these paths is pinned by
``tests/integration/test_caching_revalidation.py``.)"""
from app.core.caching import asset_version
r = client.get(path)
assert r.status_code == 200
assert r.headers["content-type"].split(";", 1)[0] == "text/html"
assert r.headers["cache-control"] == "no-cache"
assert "etag" not in r.headers
assert "last-modified" not in r.headers
assert f"?v={asset_version()}" in r.text
# The body is the SHELL: the chat view AND the route's view section
# are inside it…
assert 'id="view-chat"' in r.text
assert view_id in r.text
# …with the shell's static title (the per-view title is the
# router's client-side job), and the old page's own <title> is gone.
assert "<title>Brain of Reese</title>" in r.text
assert f"{old_title}</title>" not in r.text
# The catch-all is intact: an unknown path still 404s.
assert client.get("/nonexistent.html").status_code == 404
def test_index_html_variant_no_cache_versioned(client) -> None:
"""/index.html is the same page as / — same caching treatment."""
from app.core.caching import asset_version
r = client.get("/index.html")
assert r.status_code == 200
assert r.headers["cache-control"] == "no-cache"
assert f"?v={asset_version()}" in r.text
def test_assets_served_immutable_for_a_year(client) -> None:
r = client.get("/assets/styles.css")
assert r.status_code == 200
cc = r.headers["cache-control"]
assert "public" in cc
assert "max-age=31536000" in cc
assert "immutable" in cc
# The asset body is untouched (header-only middleware).
assert client.get("/assets/app.js?v=whichever").status_code == 200
def test_api_health_gets_no_cache_control_injected(client) -> None:
"""Baseline (pre-middleware) behavior for /api/*: FastAPI's JSON
responses ship no Cache-Control header — the middleware must not
inject one."""
r = client.get("/api/health")
assert r.status_code == 200
assert "cache-control" not in r.headers
def test_styles_and_js_served(client) -> None:
assert client.get("/assets/styles.css").status_code == 200
assert client.get("/assets/app.js").status_code == 200
assert client.get("/assets/sources.js").status_code == 200
assert client.get("/assets/markdown.js").status_code == 200 # phase 10: shared renderer
assert client.get("/assets/document.js").status_code == 200 # phase 10: viewer page
assert client.get("/assets/login.js").status_code == 200 # phase 16: login page
assert client.get("/assets/document-modal.js").status_code == 200 # phase 26: modal module
assert client.get("/assets/tuning.js").status_code == 200 # phase 27: tuning page
assert client.get("/assets/git-sources.js").status_code == 200 # phase 35: git sources page
assert client.get("/assets/shared.js").status_code == 200 # phase 51: shared page module
# Emoji code points banned from UI chrome (phase 08): the pictograph
# blocks, VS-16/ZWJ, plus the exact glyphs the old light theme used
# (🧠 🧑 👋 📂 ⚠).
_EMOJI_GLYPHS = "\U0001F9E0\U0001F9D1\U0001F44B\U0001F4C2\u26A0"
def _find_emoji(text: str) -> list[str]:
hits: list[str] = []
for ch in text:
cp = ord(ch)
if (
0x1F300 <= cp <= 0x1FAFF
or 0x2600 <= cp <= 0x27BF
or 0x2B00 <= cp <= 0x2BFF
or cp in (0xFE0F, 0x200D)
or ch in _EMOJI_GLYPHS
):
hits.append(ch)
return hits
@pytest.mark.parametrize(
"path",
[
"/",
"/sources.html",
"/document.html",
"/login.html", # phase 16
"/tuning.html", # phase 27
"/git-sources.html", # phase 35
"/assets/app.js",
"/assets/sources.js",
"/assets/markdown.js",
"/assets/document.js",
"/assets/login.js", # phase 16
"/assets/document-modal.js", # phase 26: the document modal module
"/assets/git-sources.js", # phase 35: the git sources page module
"/shared.html", # phase 51: the anonymous shared page
"/assets/shared.js", # phase 51: the shared page module
"/assets/styles.css",
],
)
def test_ui_chrome_has_no_emoji(client, path: str) -> None:
"""Permanent regression guard (phase 08): the UI chrome — all pages,
the JS that renders it, and the stylesheet — is emoji-free.
Phase 37 revision (owner permission 2026-08-26, PLAN §4): the agent's
``.tool-call`` line carries the CONTENT marks — 🔎 (list) and 📄
(read) — the only emoji in the whole frontend, and only as the exact
tool-line template strings in app.js. Phase 68 revision: the search
tool line (the ``grep`` tool, phase 70) adds the third template
literal ("🔎 Searching for "). Phase 70 revision: the scoped ``ls``
tool line adds the fourth ("🔎 Listing documents in "). The guard strips
precisely those four literals; any other emoji, or those marks
anywhere else, still fails."""
r = client.get(path)
assert r.status_code == 200
text = r.text
if path in ("/assets/app.js", "/assets/shared.js"):
text = text.replace('"🔎 Listing documents"', "")
text = text.replace('"📄 Reading "', "")
text = text.replace('"🔎 Searching for "', "")
text = text.replace('"🔎 Listing documents in "', "")
assert _find_emoji(text) == [], f"emoji found in {path}: {_find_emoji(text)!r}"
def test_chat_requires_message(client) -> None:
"""The empty-message 422 validation pin (phase 79: the anonymous
caller now 401s BEFORE validation — sign in as the admin so this
test keeps testing validation, not the auth contract)."""
assert client.post("/api/login", json={"password": ADMIN_PASSWORD}).status_code == 204
r = client.post("/api/chat", json={"message": ""})
assert r.status_code == 422