feat(chat): save and view chat history — admin-only saved_chats, History page, open-a-chat return

This commit is contained in:
2026-08-29 21:22:25 -04:00
parent 6832957ab0
commit ece93a7c8f
29 changed files with 3099 additions and 20 deletions
+5 -3
View File
@@ -102,8 +102,9 @@ def test_html_pages_are_no_cache_and_versioned(page: Page, app_url: str) -> None
def test_other_pages_share_the_token(page: Page, app_url: str) -> None:
"""/sources.html and /login.html: each document revalidates, and both
pages' stylesheet requests carry the same process token."""
"""/sources.html, /login.html and /history.html (phase 50): each
document revalidates, and all three pages' stylesheet requests carry
the same process token."""
token = _expected_token()
assert token
@@ -118,7 +119,8 @@ def test_other_pages_share_the_token(page: Page, app_url: str) -> None:
sources_token = navigate("/sources.html")
login_token = navigate("/login.html")
assert sources_token == login_token == token
history_token = navigate("/history.html") # phase 50: the new page
assert sources_token == login_token == history_token == token
def test_api_responses_unaffected(page: Page, app_url: str, db_ready: None) -> None:
+434
View File
@@ -0,0 +1,434 @@
"""Phase 50 E2E (Playwright): save & view chat history.
TODO.md L5 (owner 2026-08-29): "Need a way to save and view chat
history in a new page, then return to that history with a click"
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_chat_history.py -v --no-cov
The owner-locked loop under test (A10 extension, 2026-08-29):
* **Save** — on the chat page, admin-only (the pill ships hidden and
whoami reveals it): the current conversation POSTs to ``/api/chats``
(auto-title = the first question, whitespace-collapsed, 120-char cap)
and links to the created row; a re-Save PUTs the SAME row (upsert);
"New chat" unlinks, so the next Save creates again;
* **History** — ``/history.html`` lists the saved chats in a full-width
table (Title | Messages | Updated | Actions); the Title cell IS the
Open link (``/?chat=<id>`` — "return to that history with a click"),
and Delete is the inline two-step confirm (owner-locked: no native
confirm dialog — a real ``window.confirm`` would hang Playwright, so
the inline pair appearing is itself pinned);
* **Open** — ``/?chat=<id>`` (valid uuid + admin) boots into the saved
conversation through the SAME restore path as the phase-14 local
session (pixel-identical), links it, and a subsequent Save updates
that row; a deleted/unknown id degrades to the local restore with the
error banner;
* **Anonymous** — no Save button, no History nav link, the History page
shows the gated state WITHOUT ever fetching ``/api/chats`` (the router
403s them — pinned via the request log), and the API 403s.
DB isolation: the shared e2e Postgres keeps ``saved_chats`` rows across
suites, so every test here uses a DISTINCTIVE question text (its
auto-title is therefore unique), never asserts on absolute row counts,
and deletes the rows it creates in a ``finally`` (admin cookie). The KB
tables are truncated + re-seeded the house way (deterministic mock
embeddings); ``saved_chats`` is never touched by the reset.
"""
from __future__ import annotations
import asyncio
from pathlib import Path
from threading import Thread
from typing import Any
import httpx
from playwright.sync_api import Page, expect
from sqlalchemy import text
from app.config import Settings
from app.db import SessionLocal
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
from e2e.auth_helpers import login
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
#: Phase 10 viewer URL + phase 13 back=/ (the restored chip must be
#: byte-identical to the live-rendered one).
CHIP_HREF = "/document.html?source=docs&path=homelab%2Fkubernetes.md&back=%2F"
async def _import_fixtures(mock_port: int) -> ImportSummary:
kwargs: dict[str, Any] = {"_env_file": None, "llm_base_url": f"http://127.0.0.1:{mock_port}/v1"}
settings = Settings(**kwargs) # pyright: ignore[reportCallIssue]
return await import_sources([FIXTURES], LLMClient(settings))
def _run_in_thread(coro: Any) -> Any:
"""Run a coroutine on a worker thread.
Playwright's sync API keeps an asyncio loop running on the test thread,
so ``asyncio.run`` cannot be called directly from a test body.
"""
box: dict[str, Any] = {}
def runner() -> None:
try:
box["value"] = asyncio.run(coro)
except BaseException as e: # noqa: BLE001 — re-raised on the test thread
box["error"] = e
t = Thread(target=runner)
t.start()
t.join()
if "error" in box:
raise box["error"]
return box["value"]
def _reset_db(mock_port: int, seed: bool) -> ImportSummary | None:
"""Truncate the KB (and query log + steering notes — deterministic
mock answers), then optionally re-import fixtures. ``saved_chats``
is deliberately NOT touched: rows persist across suites and every
test here cleans up after itself."""
with SessionLocal() as db:
db.execute(
text("TRUNCATE chunks, documents, query_log, steering_notes")
)
db.commit()
if not seed:
return None
return _run_in_thread(_import_fixtures(mock_port))
def _ask(page: Page, question: str) -> None:
"""Send one turn and wait until the grounded answer has fully
landed (the ``done`` event restored the Send button)."""
page.fill("#message-input", question)
page.click("#send-btn")
expect(page.locator(".msg.user .bubble").last).to_contain_text(question)
expect(page.locator(".msg.brain .bubble").last).to_contain_text(
MOCK_ANSWER_MARKER, timeout=30_000
)
expect(page.locator("#send-btn")).to_be_enabled()
expect(page.locator("#send-label")).to_have_text("Send")
def _admin_cookies(page: Page) -> dict[str, str]:
"""The signed session cookies the browser holds after a form login —
used to call the admin API with plain httpx (the test's API side
sees exactly what the signed-in browser sees)."""
return {
c["name"]: c["value"]
for c in page.context.cookies()
if "name" in c and "value" in c
}
def _chats(app_url: str, cookies: dict[str, str]) -> list[dict[str, Any]]:
r = httpx.get(f"{app_url}/api/chats", timeout=10, cookies=cookies)
assert r.status_code == 200
return r.json()["chats"]
def _find_row(
rows: list[dict[str, Any]], title: str
) -> dict[str, Any] | None:
return next((c for c in rows if c["title"] == title), None)
def _delete_chat(app_url: str, cookies: dict[str, str], chat_id: str) -> None:
"""Best-effort row cleanup (a 404 — already deleted — is fine)."""
httpx.delete(f"{app_url}/api/chats/{chat_id}", timeout=10, cookies=cookies)
def _save(page: Page) -> None:
"""Press Save and wait for the live-region confirmation (the
never-stale contract: the status line is the success feedback)."""
page.locator("#save-chat-btn").click()
expect(page.locator("#send-status")).to_have_text("Conversation saved.")
# ---------------------------------------------------------------------------
# 1. Save on the chat page → the row exists (UI + API agree)
# ---------------------------------------------------------------------------
def test_save_and_see_history(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
login(page, app_url, next="/")
expect(page).to_have_url(app_url + "/", timeout=30_000)
q = "How is my Kubernetes cluster set up? (hist-save)"
_ask(page, q)
# Admin: the Save pill is revealed (ship-hidden, whoami reveals it).
save = page.locator("#save-chat-btn")
expect(save).to_be_visible()
expect(save).to_have_attribute("aria-label", "Save chat")
save.click()
expect(page.locator("#send-status")).to_have_text("Conversation saved.")
cookies = _admin_cookies(page)
created: str | None = None
try:
# The API agrees: the row exists, auto-titled from the first
# question (whitespace-collapsed, <=120 chars), two messages.
row = _find_row(_chats(app_url, cookies), " ".join(q.split())[:120])
assert row is not None, "the saved chat row must exist"
assert row["message_count"] == 2
created = row["id"]
# The History page shows it: the row for THIS chat carries the
# auto-title and the message count.
page.goto(app_url + "/history.html")
link = page.locator(f"#history-tbody a[href='/?chat={created}']")
expect(link).to_be_visible(timeout=15_000)
expect(link).to_have_text(" ".join(q.split())[:120])
row_tr = page.locator(
"#history-tbody tr", has=page.locator(f"a[href='/?chat={created}']")
)
expect(row_tr.locator(".history-count-cell")).to_have_text("2")
finally:
if created is not None:
_delete_chat(app_url, cookies, created)
# ---------------------------------------------------------------------------
# 2. History → click the title → back in the saved conversation; the
# conversation continues and a re-Save updates the SAME row
# ---------------------------------------------------------------------------
def test_open_chat_returns_to_history(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
login(page, app_url, next="/")
expect(page).to_have_url(app_url + "/", timeout=30_000)
q = "How is my Kubernetes cluster set up? (hist-open)"
_ask(page, q)
# The answer text the History session saw (rendered bubble).
answer_before = page.locator(".msg.brain .bubble").first.inner_text()
_save(page)
cookies = _admin_cookies(page)
row = _find_row(_chats(app_url, cookies), q)
assert row is not None
chat_id = row["id"]
try:
# From the History page, the title IS the Open link…
page.goto(app_url + "/history.html")
link = page.locator(f"#history-tbody a[href='/?chat={chat_id}']")
expect(link).to_be_visible(timeout=15_000)
doc_requests: list[str] = []
page.on(
"request",
lambda r: doc_requests.append(r.url)
if r.resource_type == "document"
else None,
)
link.click()
# …and the click navigates to /?chat=<uuid> ("return to that
# history with a click"). app.js then normalizes the one-shot
# ?chat= param back to /, so the navigation target itself is
# what gets pinned here.
assert any(u == app_url + "/?chat=" + chat_id for u in doc_requests), (
f"the title link must navigate to /?chat={chat_id}: {doc_requests}"
)
# The chat rendered the saved conversation…
expect(page.locator(".msg.user .bubble")).to_have_count(1)
expect(page.locator(".msg.user .bubble")).to_contain_text(q)
bubble = page.locator(".msg.brain .bubble").first
expect(bubble).to_contain_text(q)
expect(bubble).to_contain_text(MOCK_ANSWER_MARKER)
# …the SAME answer text the History session saw (pixel-identical
# restore through renderStoredMessage)…
assert bubble.inner_text() == answer_before
# …with its source chip restored byte-identically.
chip = page.locator(".msg.brain .source-chip", has_text="kubernetes.md")
expect(chip).to_have_count(1)
expect(chip.first).to_have_attribute("href", CHIP_HREF)
# The conversation continues: a new turn streams fine…
_ask(page, "How is my Kubernetes cluster set up? (hist-open-2)")
expect(page.locator(".msg.user .bubble")).to_have_count(2)
# …and a re-Save UPSERTS: the same single row, count grown to 4.
_save(page)
mine = [c for c in _chats(app_url, cookies) if c["title"] == q]
assert len(mine) == 1, "the re-Save must not spawn a second row"
assert mine[0]["id"] == chat_id, "the re-Save updates the SAME row"
assert mine[0]["message_count"] == 4
finally:
_delete_chat(app_url, cookies, chat_id)
# ---------------------------------------------------------------------------
# 3. "New chat" unlinks: the next Save is a fresh create, not an update
# of the previously saved conversation
# ---------------------------------------------------------------------------
def test_new_chat_unlinks(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
login(page, app_url, next="/")
expect(page).to_have_url(app_url + "/", timeout=30_000)
q1 = "How is my Kubernetes cluster set up? (hist-unlink)"
_ask(page, q1)
_save(page)
cookies = _admin_cookies(page)
cleanup: list[str] = []
try:
row1 = _find_row(_chats(app_url, cookies), q1)
assert row1 is not None
cleanup.append(row1["id"])
# New chat clears the conversation AND unlinks it from the row.
page.locator("#new-chat-btn").click()
expect(page.locator("#send-status")).to_contain_text("New chat started")
expect(page.locator(".msg")).to_have_count(0)
# A fresh conversation, saved: a NEW row (a create, not the
# previous row's update) — the list now carries two of ours.
q2 = "How is my Kubernetes cluster set up? (hist-unlink-2)"
_ask(page, q2)
_save(page)
rows = _chats(app_url, cookies)
mine = [c for c in rows if c["title"] in (q1, q2)]
assert len(mine) == 2, "Save after New chat must create a second row"
a = _find_row(rows, q1)
b = _find_row(rows, q2)
assert a is not None and b is not None
assert a["id"] != b["id"], "the fresh Save must not reuse the old row"
assert a["message_count"] == 2, "the unlinked conversation was untouched"
cleanup.append(b["id"])
finally:
for chat_id in cleanup:
_delete_chat(app_url, cookies, chat_id)
# ---------------------------------------------------------------------------
# 4. Delete: the inline two-step confirm (no native dialog), the row is
# gone (the API 404s), and /?chat=<id> degrades to the local restore
# ---------------------------------------------------------------------------
def test_delete_two_step(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
login(page, app_url, next="/")
expect(page).to_have_url(app_url + "/", timeout=30_000)
q = "How is my Kubernetes cluster set up? (hist-delete)"
_ask(page, q)
_save(page)
cookies = _admin_cookies(page)
row = _find_row(_chats(app_url, cookies), q)
assert row is not None
chat_id = row["id"]
try:
page.goto(app_url + "/history.html")
tr = page.locator(
"#history-tbody tr", has=page.locator(f"a[href='/?chat={chat_id}']")
)
expect(tr.locator("a.history-title-link")).to_be_visible(timeout=15_000)
# Step 1: the Delete button swaps, IN PLACE, for the "Delete?
# [Yes] [No]" pair — a real window.confirm would hang Playwright
# here, so the pair appearing is the pinned contract.
tr.locator("button.history-delete").click()
expect(tr.locator(".history-confirm-yes")).to_be_visible()
expect(tr.locator(".history-confirm-no")).to_be_visible()
# "No" cancels: the pair is gone, the Delete button returns, the
# row stays.
tr.locator(".history-confirm-no").click()
expect(tr.locator(".history-confirm-yes")).to_have_count(0)
expect(tr.locator("button.history-delete")).to_be_visible()
expect(page.locator(f"#history-tbody a[href='/?chat={chat_id}']")).to_have_count(1)
# Step 2: Delete → "Yes" removes the row + the live-region line.
tr.locator("button.history-delete").click()
tr.locator(".history-confirm-yes").click()
expect(page.locator(f"#history-tbody a[href='/?chat={chat_id}']")).to_have_count(0)
expect(page.locator("#history-status")).to_contain_text(f'Deleted "{q}".')
# The API agrees: the id is unknown now.
r = httpx.get(f"{app_url}/api/chats/{chat_id}", timeout=10, cookies=cookies)
assert r.status_code == 404
# Degradation: /?chat=<deleted id> shows the error banner and
# falls through to the local restore (this context's
# localStorage still holds the conversation from the ask above).
page.goto(app_url + f"/?chat={chat_id}")
banner = page.locator("#kb-banner")
expect(banner).to_be_visible(timeout=15_000)
expect(banner).to_contain_text("That saved chat isn't available")
expect(page.locator(".msg.user .bubble")).to_contain_text(q)
expect(page.locator(".msg.brain .bubble").first).to_contain_text(
MOCK_ANSWER_MARKER, timeout=15_000
)
finally:
# No-op when the delete above succeeded (the 404 is handled).
_delete_chat(app_url, cookies, chat_id)
# ---------------------------------------------------------------------------
# 5. Anonymous: no Save button, no History nav link, the History page is
# gated WITHOUT fetching /api/chats, and the API 403s
# ---------------------------------------------------------------------------
def test_anonymous_cannot(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
# A fresh context is anonymous by construction (no login).
requests: list[str] = []
page.on("request", lambda r: requests.append(r.url))
page.goto(app_url + "/")
# Settled anonymous state (the whoami round-trip has landed)…
expect(page.locator("#sign-in-link")).to_be_visible(timeout=15_000)
# …and the phase-50 surface is absent for anonymous: no Save pill,
# no History nav link (both ship hidden and stay hidden).
expect(page.locator("#save-chat-btn")).to_be_hidden()
expect(page.locator("#nav-history")).to_be_hidden()
# Direct visit to the History page: it loads and shows the gated
# state (the table wrapped away) — and NEVER calls /api/chats (the
# router 403s anonymous, so the page must not even try).
page.goto(app_url + "/history.html")
expect(page.locator("#history-gate")).to_be_visible(timeout=15_000)
expect(page.locator("#history-table-wrap")).to_be_hidden()
assert not any("/api/chats" in u for u in requests), (
f"the anonymous History page must not fetch /api/chats: {requests}"
)
# And the API gate itself: 403 without the admin cookie.
r = httpx.get(f"{app_url}/api/chats", timeout=10)
assert r.status_code == 403
+15 -4
View File
@@ -18,9 +18,11 @@ sources, document viewer, global tuning, login): one shared markup block
Per role, the VISIBLE inventory:
* admin: brand + nav [Chat, #nav-sources, #nav-git-sources, #nav-tuning]
(four links, that order — the "Sources" link joined in phase 35 as
"Git sources", owner permission 2026-08-26) + #sign-out-btn (with
* admin: brand + nav [Chat, #nav-sources, #nav-git-sources, #nav-tuning,
#nav-history] (five links, that order — the "Sources" link joined in
phase 35 as "Git sources", owner permission 2026-08-26; the History
link joined in phase 50, owner permission 2026-08-29) + #sign-out-btn
(with
#sign-in-link
hidden) — on all five pages, same id+class inventory, same DOM order.
The #sync-btn (Sources page only) and the #new-chat-btn (chat page
@@ -29,7 +31,7 @@ Per role, the VISIBLE inventory:
exactly those two); the #steering-toggle was removed from the navbar
the same day; note management lives on /tuning.html;
* anonymous: brand + nav [Chat] (#nav-sources / #nav-git-sources /
#nav-tuning hidden — locked A10 UI revision) + #sign-in-link (with
#nav-tuning / #nav-history hidden — locked A10 UI revision) + #sign-in-link (with
#sign-out-btn hidden; the Sources page's #sync-btn stays ship-hidden)
on all five pages — and the steering toggle (removed at owner
request, 2026-08-28) + panel are ABSENT from the DOM (the panel via
@@ -233,6 +235,9 @@ def _visit(page: Page, app_url: str, name: str, url: str, admin: bool) -> list[s
# RAG and Tuning.
expect(page.locator("#nav-git-sources")).to_be_visible()
expect(page.locator("#nav-tuning")).to_be_visible()
# Phase 50: the FIFTH admin-only nav link (History) is revealed
# on every page, after Tuning.
expect(page.locator("#nav-history")).to_be_visible()
# The steering toggle was removed from the navbar at owner
# request (2026-08-28) — absent on every page, admin included.
assert page.locator("#steering-toggle").count() == 0, (
@@ -255,6 +260,9 @@ def _visit(page: Page, app_url: str, name: str, url: str, admin: bool) -> list[s
expect(page.locator("#nav-sources")).to_be_hidden()
expect(page.locator("#nav-git-sources")).to_be_hidden()
expect(page.locator("#nav-tuning")).to_be_hidden()
# Phase 50: the History link ships hidden and stays hidden for
# anonymous (the same A10 UI revision).
expect(page.locator("#nav-history")).to_be_hidden()
expect(page.locator("#sync-btn")).to_be_hidden()
expect(page.locator("#sign-out-btn")).to_be_hidden()
expect(page.locator("#sign-in-link")).to_be_visible()
@@ -323,6 +331,9 @@ def _admin_login_page_inventory(page: Page, app_url: str) -> list[str]:
expect(page.locator("#nav-sources")).to_be_visible()
expect(page.locator("#nav-git-sources")).to_be_visible()
expect(page.locator("#nav-tuning")).to_be_visible()
# Phase 50: the History link joins the admin bar on the login
# page too (the one-bar contract).
expect(page.locator("#nav-history")).to_be_visible()
assert page.locator("#steering-toggle").count() == 0, (
"login: the steering toggle was removed from the navbar"
)
+3 -1
View File
@@ -88,6 +88,7 @@ def test_suggestions_honors_bor_suggestions_env_override(monkeypatch) -> None:
("/login.html", "Sign in"), # phase 16: admin sign-in page
("/tuning.html", "Global Tuning"), # phase 27: global tuning page
("/git-sources.html", "Git sources"), # phase 35: admin git sources page
("/history.html", "Saved chats"), # phase 50: admin saved-chats page
],
)
def test_html_pages_served_locally_no_cdn(client, path: str, marker: str) -> None:
@@ -124,7 +125,8 @@ def test_index_page_no_cache_with_versioned_asset_refs(client) -> None:
@pytest.mark.parametrize(
"path",
["/sources.html", "/document.html", "/login.html", "/tuning.html", "/git-sources.html"],
["/sources.html", "/document.html", "/login.html", "/tuning.html",
"/git-sources.html", "/history.html"], # phase 50: + the History 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
+446
View File
@@ -0,0 +1,446 @@
"""Integration: saved-chat CRUD (phase 50, task 02) — the ``/api/chats``
contract.
Real Postgres (``podman compose up -d db``). The router sits behind the
phase-16 ``require_admin`` gate exactly like ``/api/steering`` (the
house pattern of ``test_steering_api.py``): anonymous callers get 403 on
every route; the admin CRUD exercises the auto-title convention (first
user message, whitespace-collapsed, 120-char cap + the no-user-message
fallback), the list order (``updated_at desc, id desc``), the
full-payload round-trip (a ``bor.chat.v1``-shaped brain record carrying
``sources``/``thinking``/``tools``/``stopped`` survives losslessly),
the PUT upsert semantics (replacement + title-keep + title-set +
``updated_at`` bump), and the delete 404/204.
Requires: podman compose up -d db
"""
from __future__ import annotations
import time
import uuid
from collections.abc import Iterator
from datetime import UTC, datetime, timedelta
from typing import Any
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select, text
from sqlalchemy.orm import Session
from app.main import app as fastapi_app
from app.models import SavedChat
FIRST_QUESTION = "How did I install gitlab?"
EXPLICIT_TITLE = "My backup notes"
#: A full ``bor.chat.v1`` brain record (phase 14 shape) — every optional
#: key present; the round-trip test asserts it survives byte-identical.
FULL_BRAIN: dict[str, Any] = {
"who": "brain",
"text": "Your k3s cluster runs on three nodes — you've got this.",
"sources": [
{"source": "Homelab", "path": "kubernetes.md", "title": "Kubernetes Cluster"}
],
"deflected": False,
"suggestions": ["What ports does Traefik expose?"],
"thinking": "The kubernetes doc covers the cluster layout…",
"tools": [
{"name": "read_document", "argument": "Homelab/kubernetes.md"},
{"name": "list_documents", "argument": None},
],
"stopped": False,
}
OUT_KEYS = {"id", "title", "created_at", "updated_at", "message_count", "messages"}
ROW_KEYS = {"id", "title", "updated_at", "message_count"}
@pytest.fixture(autouse=True)
def clean_chats(db: Session) -> Iterator[None]:
"""``saved_chats`` is global state: reset around every test."""
db.execute(text("TRUNCATE saved_chats"))
db.commit()
yield
db.execute(text("TRUNCATE saved_chats"))
db.commit()
def _user(text: str) -> dict[str, Any]:
return {"who": "user", "text": text}
def _simple_conversation() -> list[dict[str, Any]]:
return [_user(FIRST_QUESTION), {"who": "brain", "text": "You've got this!"}]
def _expect(records: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""The stored shape of a record list (see app/api/chats.py):
every record carries all ``bor.chat.v1`` keys, explicit nulls where
an optional key does not apply (the restore path is null-safe).
A record that already carries every key (``FULL_BRAIN``) is
unchanged by this."""
return [
{
"who": m["who"],
"text": m["text"],
"sources": m.get("sources"),
"deflected": m.get("deflected"),
"suggestions": m.get("suggestions"),
"thinking": m.get("thinking"),
"tools": m.get("tools"),
"stopped": m.get("stopped"),
}
for m in records
]
def _assert_no_chats(admin_client: TestClient) -> None:
assert admin_client.get("/api/chats").json() == {"chats": []}
# ---------- anonymous: 403 on every route (phase 16 gate) ----------
def test_anonymous_every_route_returns_403(client: TestClient) -> None:
anon = TestClient(fastapi_app) # fresh jar: truly anonymous
unknown = uuid.uuid4()
cases = [
("GET", "/api/chats", None),
("POST", "/api/chats", {"messages": _simple_conversation()}),
("GET", f"/api/chats/{unknown}", None),
("PUT", f"/api/chats/{unknown}", {"messages": _simple_conversation()}),
("DELETE", f"/api/chats/{unknown}", None),
]
for method, path, body in cases:
r = anon.request(method, path, json=body)
assert r.status_code == 403, f"{method} {path} must be 403 for anonymous"
assert r.json() == {"detail": "admin only"}
# ---------- create ----------
def test_create_returns_201_and_auto_titles(
admin_client: TestClient, db: Session
) -> None:
r = admin_client.post("/api/chats", json={"messages": _simple_conversation()})
assert r.status_code == 201
body = r.json()
assert set(body) == OUT_KEYS
assert body["title"] == FIRST_QUESTION # auto-title = first user message
assert body["message_count"] == 2
assert body["messages"] == _expect(_simple_conversation())
uuid.UUID(body["id"]) # valid UUID
# Fresh row: nothing has updated it, so both stamps agree.
assert body["created_at"] and body["updated_at"]
assert abs(
datetime.fromisoformat(body["created_at"])
- datetime.fromisoformat(body["updated_at"])
).total_seconds() < 5
rows = db.scalars(select(SavedChat)).all()
assert [row.title for row in rows] == [FIRST_QUESTION]
def test_create_auto_title_collapses_whitespace_and_truncates_to_120(
admin_client: TestClient,
) -> None:
long_text = "How did I install " + "x" * 200
r = admin_client.post(
"/api/chats", json={"messages": [_user(long_text), {"who": "brain", "text": "ok"}]}
)
assert r.status_code == 201
assert len(r.json()["title"]) == 120
assert r.json()["title"] == long_text[:120]
# Multi-space / tab / newline runs collapse to single spaces.
r = admin_client.post(
"/api/chats", json={"messages": [_user("What is\nmy\tTraefik port?")]}
)
assert r.status_code == 201
assert r.json()["title"] == "What is my Traefik port?"
def test_create_honors_explicit_title(admin_client: TestClient) -> None:
r = admin_client.post(
"/api/chats",
json={"title": f" {EXPLICIT_TITLE} ", "messages": _simple_conversation()},
)
assert r.status_code == 201
assert r.json()["title"] == EXPLICIT_TITLE # trimmed, not auto-titled
def test_create_blank_title_falls_back_to_auto_title(admin_client: TestClient) -> None:
r = admin_client.post(
"/api/chats", json={"title": " \t\n ", "messages": _simple_conversation()}
)
assert r.status_code == 201
assert r.json()["title"] == FIRST_QUESTION
def test_create_without_user_message_falls_back_to_chat_id(admin_client: TestClient) -> None:
# Defensive — the UI cannot produce a conversation with no user
# message; the auto-title then names the row after its own id.
r = admin_client.post(
"/api/chats", json={"messages": [{"who": "brain", "text": "hello"}]}
)
assert r.status_code == 201
body = r.json()
assert body["title"] == f"Chat {body['id'][:8]}"
def test_create_round_trips_full_brain_record(admin_client: TestClient) -> None:
r = admin_client.post(
"/api/chats", json={"messages": [_user(FIRST_QUESTION), FULL_BRAIN]}
)
assert r.status_code == 201
# The bor.chat.v1-shaped payload round-trips losslessly: every
# optional key (sources/deflected/suggestions/thinking/tools/
# stopped) survives identical.
assert r.json()["messages"][1] == FULL_BRAIN
def test_create_rejects_empty_messages(admin_client: TestClient) -> None:
assert admin_client.post("/api/chats", json={"messages": []}).status_code == 422
_assert_no_chats(admin_client)
def test_create_rejects_unknown_who(admin_client: TestClient) -> None:
r = admin_client.post(
"/api/chats", json={"messages": [{"who": "alien", "text": "hi"}]}
)
assert r.status_code == 422
_assert_no_chats(admin_client)
def test_create_rejects_empty_text(admin_client: TestClient) -> None:
assert (
admin_client.post("/api/chats", json={"messages": [_user("")]})
).status_code == 422
_assert_no_chats(admin_client)
def test_create_rejects_extra_message_keys(admin_client: TestClient) -> None:
# A corrupted / HTML-shaped payload must not cross the boundary.
message = _user(FIRST_QUESTION)
message["html"] = "<b>not allowed</b>"
assert admin_client.post("/api/chats", json={"messages": [message]}).status_code == 422
_assert_no_chats(admin_client)
def test_create_rejects_title_over_500(admin_client: TestClient) -> None:
assert (
admin_client.post(
"/api/chats", json={"title": "t" * 501, "messages": _simple_conversation()}
)
).status_code == 422
# ---------- list ----------
def test_list_empty(admin_client: TestClient) -> None:
r = admin_client.get("/api/chats")
assert r.status_code == 200
assert r.json() == {"chats": []}
def test_list_orders_by_updated_at_desc(admin_client: TestClient, db: Session) -> None:
base = datetime.now(UTC)
db.add_all(
[
SavedChat(
title="oldest",
messages=[_user("one")],
updated_at=base,
),
SavedChat(
title="newest",
messages=[_user("two"), _user("three")],
updated_at=base + timedelta(hours=2),
),
SavedChat(
title="middle",
messages=[_user("four")],
updated_at=base + timedelta(hours=1),
),
]
)
db.commit()
r = admin_client.get("/api/chats")
assert r.status_code == 200
body = r.json()
assert [c["title"] for c in body["chats"]] == ["newest", "middle", "oldest"]
for c in body["chats"]:
assert set(c) == ROW_KEYS
uuid.UUID(c["id"])
assert "messages" not in c # no payloads in the list
def test_list_reports_message_count(admin_client: TestClient) -> None:
admin_client.post("/api/chats", json={"messages": _simple_conversation()})
body = admin_client.get("/api/chats").json()
assert [c["message_count"] for c in body["chats"]] == [2]
# ---------- get ----------
def test_get_returns_full_payload_round_trip(admin_client: TestClient) -> None:
created = admin_client.post(
"/api/chats",
json={"title": EXPLICIT_TITLE, "messages": [_user(FIRST_QUESTION), FULL_BRAIN]},
).json()
r = admin_client.get(f"/api/chats/{created['id']}")
assert r.status_code == 200
body = r.json()
assert set(body) == OUT_KEYS
assert body["id"] == created["id"]
assert body["title"] == EXPLICIT_TITLE
assert body["message_count"] == 2
# Byte-identical payload: the brain record with sources/thinking/
# tools/stopped (incl. the `argument: null` tool) survives the trip
# to Postgres and back.
assert body["messages"] == _expect([_user(FIRST_QUESTION), FULL_BRAIN])
def test_get_unknown_chat_returns_404(admin_client: TestClient) -> None:
r = admin_client.get(f"/api/chats/{uuid.uuid4()}")
assert r.status_code == 404
assert r.json() == {"detail": "unknown chat"}
def test_get_invalid_id_returns_422(admin_client: TestClient) -> None:
assert admin_client.get("/api/chats/not-a-uuid").status_code == 422
# ---------- update (PUT) — the re-Save upsert ----------
def test_put_replaces_messages_and_bumps_updated_at(admin_client: TestClient) -> None:
created = admin_client.post(
"/api/chats",
json={"title": EXPLICIT_TITLE, "messages": _simple_conversation()},
).json()
# A second, newer chat — it currently lists first.
other = admin_client.post(
"/api/chats", json={"messages": [_user("second question")], "title": "Other"}
).json()
assert admin_client.get("/api/chats").json()["chats"][0]["id"] == other["id"]
updated_before = created["updated_at"]
time.sleep(0.1) # now() has µs resolution — make the bump observable
new_messages = [
_user("How do I prune deleted docs?"),
{"who": "brain", "text": "Use --prune."},
]
r = admin_client.put(f"/api/chats/{created['id']}", json={"messages": new_messages})
assert r.status_code == 200
body = r.json()
assert body["id"] == created["id"]
assert body["title"] == EXPLICIT_TITLE # absent title keeps the current one
assert body["message_count"] == 2
assert body["messages"] == _expect(new_messages) # full replacement
assert datetime.fromisoformat(body["created_at"]) == datetime.fromisoformat(
created["created_at"]
) # editing does not redate creation
assert datetime.fromisoformat(body["updated_at"]) > datetime.fromisoformat(
updated_before
), "updated_at must bump on a re-Save (onupdate=func.now())"
# The list order follows the bump: this row is first again.
body_list = admin_client.get("/api/chats").json()["chats"]
assert body_list[0]["id"] == created["id"]
def test_put_sets_title_when_supplied(admin_client: TestClient) -> None:
created = admin_client.post(
"/api/chats", json={"messages": _simple_conversation()}
).json()
r = admin_client.put(
f"/api/chats/{created['id']}",
json={"title": "Renamed notes", "messages": _simple_conversation()},
)
assert r.status_code == 200
assert r.json()["title"] == "Renamed notes"
assert (
admin_client.get(f"/api/chats/{created['id']}").json()["title"] == "Renamed notes"
)
def test_put_blank_title_keeps_current(admin_client: TestClient) -> None:
created = admin_client.post(
"/api/chats",
json={"title": EXPLICIT_TITLE, "messages": _simple_conversation()},
).json()
r = admin_client.put(
f"/api/chats/{created['id']}", json={"title": " ", "messages": _simple_conversation()}
)
assert r.status_code == 200
assert r.json()["title"] == EXPLICIT_TITLE
def test_put_unknown_chat_returns_404(admin_client: TestClient) -> None:
r = admin_client.put(
f"/api/chats/{uuid.uuid4()}", json={"messages": _simple_conversation()}
)
assert r.status_code == 404
assert r.json() == {"detail": "unknown chat"}
def test_put_rejects_empty_messages(admin_client: TestClient) -> None:
created = admin_client.post(
"/api/chats", json={"messages": _simple_conversation()}
).json()
assert (
admin_client.put(f"/api/chats/{created['id']}", json={"messages": []})
).status_code == 422
# The original payload is untouched.
assert (
admin_client.get(f"/api/chats/{created['id']}").json()["messages"]
== _expect(_simple_conversation())
)
def test_put_rejects_extra_message_keys(admin_client: TestClient) -> None:
created = admin_client.post(
"/api/chats", json={"messages": _simple_conversation()}
).json()
bad = _user("hi")
bad["innerHTML"] = "<script>alert(1)</script>"
r = admin_client.put(
f"/api/chats/{created['id']}", json={"messages": [bad, FULL_BRAIN]}
)
assert r.status_code == 422
assert (
admin_client.get(f"/api/chats/{created['id']}").json()["messages"]
== _expect(_simple_conversation())
)
# ---------- delete ----------
def test_delete_returns_204_and_removes(admin_client: TestClient, db: Session) -> None:
created = admin_client.post("/api/chats", json={"messages": _simple_conversation()}).json()
assert admin_client.delete(f"/api/chats/{created['id']}").status_code == 204
assert admin_client.get(f"/api/chats/{created['id']}").status_code == 404
assert admin_client.get("/api/chats").json() == {"chats": []}
assert db.scalars(select(SavedChat)).all() == []
def test_delete_unknown_chat_returns_404(admin_client: TestClient) -> None:
r = admin_client.delete(f"/api/chats/{uuid.uuid4()}")
assert r.status_code == 404
assert r.json() == {"detail": "unknown chat"}
def test_delete_invalid_id_returns_422(admin_client: TestClient) -> None:
assert admin_client.delete("/api/chats/not-a-uuid").status_code == 422
+255
View File
@@ -0,0 +1,255 @@
"""Integration: migration 0008 (saved_chats) schema contract.
Drives the **real Alembic engine** against the live dev database
(``podman compose up -d db``), mirroring the house pattern of
``test_migration_0005.py`` / ``test_migration_0007.py``
(information_schema assertions on the state the migration must leave).
The tests target revision ``0008`` explicitly so later migrations
cannot break them:
* upgrade 0007 → 0008 → a ``saved_chats`` table exists with
``id UUID`` PK, ``title VARCHAR(500) NOT NULL``,
``messages JSONB NOT NULL`` (the ``bor.chat.v1`` record list), and
``created_at`` / ``updated_at TIMESTAMPTZ NOT NULL`` — both stamped
server-side by ``now()`` (an insert that omits them still lands
with both set);
* the ``updated_at`` ORM ``onupdate`` bumps the timestamp on a row
update while ``created_at`` stays put (the phase-50 History page
orders by it);
* downgrade to 0007 → the table is gone;
* upgrade back to 0008 → it is back (round-trip).
The ``alembic`` fixture guarantees the DB ends at head even if a test
fails or the process is interrupted.
"""
from __future__ import annotations
import json
import time
import uuid
from collections.abc import Iterator
from datetime import datetime
from typing import Any
import pytest
from alembic.config import Config
from sqlalchemy import text
from sqlalchemy.orm import Session
from alembic import command
from app.db import db_available
from app.models import SavedChat
TITLE_BASE = "Mig 0008"
MESSAGE_SHAPE = [{"who": "user", "text": "How did I install gitlab?"}]
@pytest.fixture()
def alembic(db: Session) -> Iterator[Config]:
"""Real Alembic config bound to the dev DB (URL from app settings).
Starts at head (repairs an interrupted earlier run); teardown upgrades
to head no matter what happened, so the dev DB is never left below
head.
"""
if not db_available():
pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
cfg = Config() # no alembic.ini file — env.py gets the URL from app config
cfg.set_main_option("script_location", "alembic")
command.upgrade(cfg, "head")
try:
yield cfg
finally:
command.upgrade(cfg, "head")
def _table_exists(db: Session) -> bool:
"""1 iff ``saved_chats`` is a table in this database."""
count: Any = db.execute(
text(
"SELECT count(*) FROM information_schema.tables"
" WHERE table_name = 'saved_chats'"
)
).scalar()
assert count is not None, "information_schema count must be an int"
return int(count) == 1
def _column(db: Session, column: str) -> tuple[Any, ...] | None:
"""(data_type, is_nullable, column_default) for one saved_chats column."""
row = db.execute(
text(
"SELECT data_type, is_nullable, column_default"
" FROM information_schema.columns"
" WHERE table_name = 'saved_chats' AND column_name = :c"
),
{"c": column},
).fetchone()
return tuple(row) if row is not None else None
def _pk_columns(db: Session) -> set[str]:
"""Primary-key columns of ``saved_chats`` (empty if it does not exist)."""
rows = db.execute(
text(
"SELECT kcu.column_name"
" FROM information_schema.table_constraints tc"
" JOIN information_schema.key_column_usage kcu"
" ON tc.constraint_name = kcu.constraint_name"
" AND tc.table_schema = kcu.table_schema"
" WHERE tc.table_name = 'saved_chats'"
" AND tc.constraint_type = 'PRIMARY KEY'"
)
).fetchall()
return {r[0] for r in rows}
def _version(db: Session) -> str | None:
return db.execute(text("SELECT version_num FROM alembic_version")).scalar()
def _raw_insert(db: Session, title: str) -> uuid.UUID:
"""Insert one saved_chats row omitting the timestamps (server-stamped)."""
id: uuid.UUID = db.execute(
text(
"INSERT INTO saved_chats (id, title, messages)"
" VALUES (gen_random_uuid(), :t, CAST(:m AS jsonb))"
" RETURNING id"
),
{"t": title, "m": json.dumps(MESSAGE_SHAPE)},
).scalar_one()
db.commit()
return id
def _delete(db: Session, id: uuid.UUID) -> None:
db.execute(text("DELETE FROM saved_chats WHERE id = :i"), {"i": id})
db.commit()
def test_upgrade_to_0008_creates_saved_chats(db: Session, alembic: Config) -> None:
"""Upgrade 0007 → 0008: ``saved_chats`` exists with the locked
columns, types, nullability, PK, and server ``now()`` defaults."""
command.downgrade(alembic, "0007") # start from the pre-0008 state
assert _version(db) == "0007"
assert not _table_exists(db), "saved_chats must not exist before 0008"
command.upgrade(alembic, "0008")
assert _version(db) == "0008", "alembic_version must be at 0008"
assert _table_exists(db), "saved_chats is missing after 0008"
assert _pk_columns(db) == {"id"}, "saved_chats must have a single id PK"
id_col = _column(db, "id")
assert id_col is not None, "saved_chats.id is missing"
assert id_col[0] == "uuid", "saved_chats.id must be UUID"
assert id_col[1] == "NO", "saved_chats.id must be NOT NULL"
title = _column(db, "title")
assert title is not None, "saved_chats.title is missing"
assert title[0] == "character varying", "saved_chats.title must be VARCHAR"
assert title[1] == "NO", "saved_chats.title must be NOT NULL"
messages = _column(db, "messages")
assert messages is not None, "saved_chats.messages is missing"
assert messages[0] == "jsonb", "saved_chats.messages must be JSONB"
assert messages[1] == "NO", "saved_chats.messages must be NOT NULL"
for column in ("created_at", "updated_at"):
col = _column(db, column)
assert col is not None, f"saved_chats.{column} is missing"
assert col[0] == "timestamp with time zone", (
f"saved_chats.{column} must be TIMESTAMPTZ"
)
assert col[1] == "NO", f"saved_chats.{column} must be NOT NULL"
assert col[2] is not None and "now()" in col[2], (
f"saved_chats.{column} must default to now()"
)
# Phase 51 (share_token) must not leak into this minimal migration.
assert _column(db, "share_token") is None, (
"0008 stays minimal — share_token lands in 0009 (phase 51)"
)
def test_server_timestamps_stamped_on_insert(db: Session, alembic: Config) -> None:
"""An insert that omits created_at/updated_at (the API's shape) still
lands with both stamped by the server defaults."""
command.upgrade(alembic, "head")
chat_id = _raw_insert(db, f"{TITLE_BASE}: server stamps")
try:
created_at, updated_at = db.execute(
text("SELECT created_at, updated_at FROM saved_chats WHERE id = :i"),
{"i": chat_id},
).one()
assert isinstance(created_at, datetime), "created_at must be server-stamped"
assert isinstance(updated_at, datetime), "updated_at must be server-stamped"
assert created_at.tzinfo is not None, "created_at must be timezone-aware"
# Fresh row: nothing has updated it, so both stamps agree (now).
assert (created_at - updated_at).total_seconds() < 5, (
"a fresh row must have created_at ≈ updated_at"
)
finally:
_delete(db, chat_id)
def test_updated_at_bumps_on_row_update(db: Session, alembic: Config) -> None:
"""The ORM ``onupdate=func.now()`` (the History page's Updated column)
bumps ``updated_at`` on a row update while ``created_at`` stays put."""
command.upgrade(alembic, "head")
chat = SavedChat(title=f"{TITLE_BASE}: before update", messages=MESSAGE_SHAPE)
db.add(chat)
db.commit()
try:
created_before: datetime = chat.created_at
updated_before: datetime = chat.updated_at
assert created_before is not None and updated_before is not None
time.sleep(0.1) # now() has µs resolution — make the bump observable
chat.title = f"{TITLE_BASE}: after update"
chat.messages = [
{"who": "user", "text": "How did I install gitlab?"},
{"who": "brain", "text": "You've got this!", "sources": []},
]
db.commit()
db.expire(chat)
created_after: datetime = chat.created_at
updated_after: datetime = chat.updated_at
assert created_after == created_before, "created_at must not move on update"
assert updated_after > updated_before, (
"updated_at must bump on a row update (onupdate=func.now())"
)
finally:
db.expire_all()
db.execute(text("DELETE FROM saved_chats WHERE id = :i"), {"i": chat.id})
db.commit()
def test_downgrade_to_0007_drops_table(db: Session, alembic: Config) -> None:
"""Downgrade to 0007: ``saved_chats`` is dropped (A13 — reversible)."""
command.downgrade(alembic, "0007")
assert _version(db) == "0007"
assert not _table_exists(db), "saved_chats must be dropped by the downgrade"
assert _column(db, "id") is None, "saved_chats.id must be gone"
def test_upgrade_round_trip_restores_table(db: Session, alembic: Config) -> None:
"""Downgrade to 0007, then upgrade back to 0008: the table is back
with its locked columns and PK."""
command.downgrade(alembic, "0007")
command.upgrade(alembic, "0008")
assert _version(db) == "0008", "round-trip upgrade must land at 0008"
assert _table_exists(db), "saved_chats must be back after the round-trip"
assert _pk_columns(db) == {"id"}, "saved_chats.id PK must be back"
messages = _column(db, "messages")
assert messages is not None and messages[0] == "jsonb", (
"saved_chats.messages must be JSONB after the round-trip"
)
col = _column(db, "updated_at")
assert col is not None and col[2] is not None and "now()" in col[2], (
"saved_chats.updated_at must keep its now() default after the round-trip"
)
+24
View File
@@ -185,6 +185,30 @@ def test_empty_static_dir_is_dev(tmp_path) -> None:
assert asset_version(str(empty)) == "dev"
# ---------------------------------------------------------------------------
# HTML_PAGES registration
# ---------------------------------------------------------------------------
def test_html_pages_include_history() -> None:
"""Phase 50: the History page is registered in HTML_PAGES — without
this entry it would serve unversioned asset refs, which the
immutable-for-a-year asset caching would pin to stale CSS after a
deploy (the phase-35 git-sources lesson). The entry is ADDED —
every pre-phase-50 page stays registered."""
for path in (
"/",
"/index.html",
"/sources.html",
"/document.html",
"/login.html",
"/tuning.html",
"/git-sources.html",
"/history.html",
):
assert path in caching.HTML_PAGES, f"{path} must be in HTML_PAGES"
# ---------------------------------------------------------------------------
# rewrite_asset_refs (task 02)
# ---------------------------------------------------------------------------
+1
View File
@@ -28,6 +28,7 @@ HTML_PAGES = (
"document.html",
"login.html",
"git-sources.html",
"history.html", # phase 50: the admin saved-chats page
)
+428
View File
@@ -0,0 +1,428 @@
"""Unit: the phase-50 task-04 History-page contract.
The browser behavior itself is E2E-gated by the story suite (task 05);
like the other frontend-adjacent unit files, this module pins the
JS/CSS/HTML markers the History page depends on, so a silent
regression is caught without a browser:
* the anonymous no-fetch gate (the gate in, the table out, and the
single ``GET /api/chats`` fetch lives ONLY in ``loadChats`` —
unreachable from the anonymous branch);
* the inline two-step Delete (the "Delete? [Yes] [No]" pair, focus to
Yes, the row kept on No / a failed request, ``Deleted "<title>".``
on success) and the ``window.confirm`` absence in ``history.js``
(owner-locked 2026-08-29: no native confirm dialog on this page);
* the ``/?chat=<id>`` Open-link href shape (TODO.md L5 — "return to
that history with a click");
* ``#nav-history`` on ALL SEVEN pages (the phase-34 one-bar contract)
+ ``header.js``'s reveal-for-admin block;
* the full-width table CSS (AGENTS.md rule 5) + the confirm pair +
the empty-state row.
The Containerfile stage-1 coverage (history.html copied, history.js
bundled) is pinned dynamically by
``tests/integration/test_containerfile_assets.py`` — a page or module
missing from stage 1 fails there.
"""
from __future__ import annotations
import re
from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
ASSETS = FRONTEND / "assets"
INDEX_HTML = FRONTEND / "index.html"
SOURCES_HTML = FRONTEND / "sources.html"
GIT_SOURCES_HTML = FRONTEND / "git-sources.html"
TUNING_HTML = FRONTEND / "tuning.html"
DOCUMENT_HTML = FRONTEND / "document.html"
LOGIN_HTML = FRONTEND / "login.html"
HISTORY_HTML = FRONTEND / "history.html"
HISTORY_JS = ASSETS / "history.js"
HEADER_JS = ASSETS / "header.js"
STYLES_CSS = ASSETS / "styles.css"
#: The phase-34 one-bar contract + the new History page: SEVEN pages.
ALL_PAGES = (
INDEX_HTML,
SOURCES_HTML,
GIT_SOURCES_HTML,
TUNING_HTML,
DOCUMENT_HTML,
LOGIN_HTML,
HISTORY_HTML,
)
def _text(path: Path) -> str:
assert path.is_file(), f"missing frontend file: {path}"
return path.read_text(encoding="utf-8")
def _js() -> str:
return _text(HISTORY_JS)
def _css() -> str:
return _text(STYLES_CSS)
def _fn(js: str, name: str) -> str:
"""The source of a top-level ``function <name>(...)`` (to its close)."""
start = js.find(f"function {name}(")
assert start != -1, f"{name}() must exist in history.js"
return js[start : js.find("\n}\n", start) + 4]
def _nav_history_tag(html: str) -> str:
tag = re.search(r'<a[^>]*id="nav-history"[^>]*>', html)
assert tag, "the #nav-history link is missing"
return tag.group(0)
# ---------- the #nav-history link: all seven pages ----------
def test_nav_history_present_on_all_seven_pages() -> None:
"""The phase-34 one-bar contract extended by phase 50: the admin-only
History link SHIPS hidden (revealed by header.js for admin) on every
page, after the Tuning link, pointing at /history.html. The page's
own link is the active one (is-active + aria-current)."""
for html in ALL_PAGES:
text = _text(html)
tag = _nav_history_tag(text)
assert 'href="/history.html"' in tag
assert "hidden" in tag, f"{html.name}: #nav-history must ship hidden"
# Placed after the Tuning link (the owner-locked position).
assert text.find('id="nav-tuning"') < text.find('id="nav-history"'), (
f"{html.name}: #nav-history must follow #nav-tuning"
)
# The history page is the only one whose link is active.
for html in ALL_PAGES:
tag = _nav_history_tag(_text(html))
if html.name == "history.html":
assert 'class="nav-link is-active"' in tag
assert 'aria-current="page"' in tag
else:
assert "is-active" not in tag, (
f"{html.name}: no nav link is current there"
)
def test_nav_history_count_is_exactly_seven_pages() -> None:
"""The pin counting occurrences across ``frontend/*.html`` — exactly
one ``id="nav-history"`` per page, seven pages, no duplicates and no
extra page that forgot (or added twice)."""
total = 0
for html in sorted(FRONTEND.glob("*.html")):
count = html.read_text(encoding="utf-8").count('id="nav-history"')
assert count in (0, 1), f"{html.name}: #nav-history appears {count} times"
total += count
assert total == 7, f"expected #nav-history on 7 pages, found {total}"
def test_header_js_reveals_nav_history_for_admin() -> None:
"""header.js reveals #nav-history for admin exactly like
#nav-tuning — the same ship-hidden / reveal-for-admin contract,
inside initSharedHeader (null-safe: a page without the link is a
no-op)."""
js = _text(HEADER_JS)
fn = js.find("function initSharedHeader")
assert fn != -1
body = js[fn : js.find("\n}", fn)]
assert 'querySelector("#nav-history")' in body
assert "navHistory.hidden = !admin" in body
# ---------- history.html: the page scaffold ----------
def test_history_page_scaffold_and_landmarks() -> None:
"""The standard page scaffold (AGENTS.md rule 5): skip link, the
shared header, the steering panel + announcer (phase 34 — ships on
every page), the page-head, the gate (ship-hidden), the
role="status" live region, and the table inside the
.table-wrap card. Footer with the version span (the index.html
shape)."""
html = _text(HISTORY_HTML)
assert '<a class="skip-link" href="#main">' in html
assert 'class="app-header"' in html
assert 'nav class="app-nav" id="app-nav" aria-label="Primary"' in html
tag = re.search(r'<section[^>]*id="steering-panel"[^>]*>', html)
assert tag and "hidden" in tag.group(0), "the steering panel ships hidden"
assert re.search(r'<p[^>]*id="steering-announcer"[^>]*role="status"[^>]*>', html)
assert "<main id=\"main\" class=\"app-main\" tabindex=\"-1\">" in html
assert '<h1>Saved chats</h1>' in html
# The anonymous gate — the #sources-gate pattern, ship-hidden.
gate = re.search(r'<section[^>]*id="history-gate"[^>]*>', html)
assert gate and "hidden" in gate.group(0), "#history-gate must ship hidden"
assert 'href="/login.html?next=/history.html"' in html, (
"the gate's Sign in returns to the History page (no-JS fallback)"
)
# The action-feedback live region.
assert re.search(r'<span[^>]*id="history-status"[^>]*role="status"[^>]*>', html)
# The table wrapper: the .table-wrap card (scrollable) with its
# own id, a labeled region, focusable.
wrap = re.search(r'<div[^>]*class="table-wrap history-table-wrap"[^>]*>', html)
assert wrap, "the table must live in the .table-wrap card"
assert 'id="history-table-wrap"' in wrap.group(0)
assert 'role="region"' in wrap.group(0) and 'tabindex="0"' in wrap.group(0)
# Footer with the version span.
assert 'class="footer-version" id="app-version"' in html
def test_history_table_skeleton() -> None:
"""The table skeleton: ``.history-table`` with the four columns —
Title | Messages | Updated | Actions (the Actions header text is
visually-hidden — the row buttons carry their own aria-labels) —
and the empty-state row (ship-hidden, the exact copy)."""
html = _text(HISTORY_HTML)
assert '<table class="history-table">' in html
for col in ('<th scope="col">Title</th>', '<th scope="col">Messages</th>',
'<th scope="col">Updated</th>'):
assert col in html
actions_th = re.search(
r'<th scope="col">([^<]*)<span class="visually-hidden">Actions</span></th>',
html,
)
assert actions_th, "the Actions column header must be visually-hidden text"
assert actions_th.group(1) == "", "no visible text beside the hidden header"
# The empty-state row: ship-hidden, colspan 4, the exact copy.
row = re.search(r'<tr[^>]*class="history-empty-row"[^>]*>', html)
assert row, "the empty-state row must ship in the skeleton"
assert "hidden" in row.group(0)
assert 'id="history-empty-row"' in row.group(0)
assert "<td colspan=\"4\">" in html
assert (
"No saved chats yet — finish a conversation and press"
" <strong>Save</strong> in the chat."
) in html
def test_history_page_scripts_and_no_cdn() -> None:
"""Script load order (the house pattern): brand.js classic FIRST,
the history.js module second, NO direct header.js <script> tag
(single-evaluation design — history.js imports it relatively).
No-CDN rule (AGENTS.md rule 6): no external script/link tags."""
html = _text(HISTORY_HTML)
srcs = re.findall(r'<script[^>]*src="([^"]+)"', html)
assert srcs == ["assets/brand.js", "/assets/history.js"], (
f"history.html must load brand.js (classic, first) + the history.js "
f"module, got {srcs}"
)
js = _js()
assert 'from "./header.js"' in js, (
"history.js must import the shared header module relatively"
)
assert '"/assets/header.js"' not in js
assert 'src="http' not in html and 'href="http' not in html, (
"no CDN: every asset is local (AGENTS.md rule 6)"
)
# ---------- the anonymous no-fetch gate ----------
def test_anonymous_boot_makes_no_chats_request() -> None:
"""The whoami gate in the boot IIFE: ``initSharedHeader()`` first
(shared-header contract), then the anonymous branch hides the
table, shows the gate, and RETURNS — no ``/api/chats`` request on
the wire (the router 403s anonymous; the story E2E pins the
request log). Only the admin path reaches ``loadChats()``. The
single ``fetch("/api/chats")`` in the file lives in loadChats."""
js = _js()
assert js.count('fetch("/api/chats")') == 1, (
"exactly ONE list fetch — the anonymous path must never add one"
)
load = _fn(js, "loadChats")
assert 'fetch("/api/chats")' in load, "the list fetch lives in loadChats"
boot = js[js.find("(async () => {"):]
assert boot, "the boot IIFE must exist"
assert "await initSharedHeader()" in boot
gate_i = boot.find("if (!(await fetchIsAdmin()))")
assert gate_i != -1, "the whoami gate must run in boot"
# The anonymous branch: gate in, table out, then a bare return —
# and NO fetch call anywhere inside it.
branch = boot[gate_i : boot.find("return;", gate_i)]
assert "fetch(" not in branch, "the anonymous branch must not fetch anything"
assert "tableWrap.hidden = true" in branch
assert "gateEl.hidden = false" in branch
# The admin path: the gate hides, then the list loads.
after = boot[boot.find("return;", gate_i):]
assert "gateEl.hidden = true" in after
assert "loadChats();" in after
def test_admin_load_renders_rows_or_empty_state() -> None:
"""loadChats: a 0-row fetch (and non-2xx / a network failure)
reveals the empty-state row; a populated fetch renders one row per
chat, in the server's order (latest activity first)."""
js = _js()
load = _fn(js, "loadChats")
# Every no-data outcome lands on the empty state.
assert load.count("showEmptyState()") == 3, (
"network failure, non-2xx and a 0-row list all show the empty state"
)
assert "chats.length" in load
assert "makeRow(chat)" in load
empty = _fn(js, "showEmptyState")
assert "emptyRow.hidden = false" in empty
# ---------- the /?chat=<id> Open link ----------
def test_open_link_is_the_title_with_chat_href() -> None:
"""makeRow: the Title cell is the Open link — ``/?chat=<id>``
("return to that history with a click", TODO.md L5) — rendered
through textContent (the auto-title is user-derived; never
innerHTML). The Updated cell carries the locale date+time with the
full ISO in the title attribute; Messages is the message_count."""
js = _js()
row = _fn(js, "makeRow")
assert 'link.href = "/?chat=" + chat.id' in row, (
"the Open link returns to /?chat=<id> (task 03's boot load)"
)
assert 'link.className = "history-title-link"' in row
assert "link.textContent = chat.title" in row, "XSS contract: textContent only"
assert 'innerHTML' not in row, "makeRow must never build HTML"
assert "String(chat.message_count)" in row
assert "updatedTd.title = chat.updated_at" in row, "full ISO on hover"
assert "fmtDate(chat.updated_at)" in row
assert "link.title" in row or "titleTd.title = chat.title" in row
# ---------- the inline two-step Delete ----------
def test_two_step_delete_confirm_pair() -> None:
"""makeDeleteControl: the first click swaps the Delete button for
the "Delete? [Yes] [No]" pair IN PLACE (keyboard-reachable — focus
moves to Yes); No restores the Delete button (focus returns); the
Delete button carries a labeled aria-name."""
js = _js()
# Owner-locked 2026-08-29: no native confirm dialog in the file.
assert "window.confirm" not in js, "history.js must use the inline two-step only"
fn = _fn(js, "makeDeleteControl")
assert 'del.className = "history-delete"' in fn
assert 'del.setAttribute("aria-label", `Delete saved chat: ${chat.title}`)' in fn
assert 'label.textContent = "Delete?"' in fn
assert 'yes.className = "history-confirm-yes"' in fn
assert 'no.className = "history-confirm-no"' in fn
# The shipped state of the actions cell IS the Delete button
# (before any click) — a cell that only gains the button on
# restore would render an empty Actions column.
append = fn.find("cell.appendChild(del)")
ret = fn.rfind("return cell")
assert -1 < append < ret, "the Delete button is appended before the return"
# The swap + the focus handoff.
assert "cell.replaceChildren(label, yes, no)" in fn
yes_swap = fn.find("cell.replaceChildren(label, yes, no)")
assert fn.find("yes.focus()", yes_swap) > 0, "focus moves to Yes after the swap"
# No (and the restore helper) bring the Delete button back, focused.
restore_start = fn.find("function restoreDelete")
restore_end = fn.find("\n }", restore_start)
restore = fn[restore_start:restore_end]
assert "cell.replaceChildren(del)" in restore
assert "del.focus()" in restore
assert 'no.addEventListener("click", restoreDelete)' in fn
def test_confirmed_delete_outcomes() -> None:
"""confirmDelete: double-fire guarded; 2xx → the row is removed +
the empty-state row reappears when it was the last + the live
region `Deleted "<title>".`; a 404 (already gone) drops the stale
row and says so; any other failure / a network error KEEPS the row
(restore) and lands the error line."""
js = _js()
fn = _fn(js, "confirmDelete")
assert "yesBtn.disabled = true" in fn
assert "fetch(`/api/chats/${chat.id}`, { method: \"DELETE\" })" in fn
# Success: remove + empty-state check + the exact live-region line.
assert "row.remove()" in fn
assert "showEmptyIfLast()" in fn
assert 'announce(`Deleted "${chat.title}".`)' in fn
# 404: the row is stale — drop it, no restore. (The branch slices
# stop at the NEXT branch boundary — a template-literal `}` inside
# an announce line must not end the slice early.)
nf = fn.find("r.status === 404")
assert nf != -1, "the 404 branch must be handled"
notok = fn.find("if (!r.ok)")
nf_branch = fn[nf:notok]
assert "row.remove()" in nf_branch
assert "already deleted" in nf_branch
assert "restoreDelete()" not in nf_branch
# !ok (non-404) and network: the row stays, the button is
# retryable, and the error line lands.
# !ok (non-404) and network: the row stays, the button is
# retryable, and the error line lands. (The try/catch wraps the
# FETCH, so it precedes the status branches; the !ok slice runs to
# the function's close — the success tail after it carries neither
# a restore nor that line.)
assert notok != -1
notok_branch = fn[notok:]
assert "restoreDelete()" in notok_branch
assert "try again" in notok_branch
# The network-error catch: the reachable? line + the restore (the
# catch wraps the fetch, so it precedes the status branches).
catch_i = fn.find("} catch {")
assert catch_i != -1
catch_branch = fn[catch_i : fn.find("if (r.status === 404)")]
assert "is the app reachable?" in catch_branch
assert "restoreDelete()" in catch_branch
# The empty-state row reappears exactly when the last data row is
# gone (the hidden empty row itself ships in the tbody).
empty = _fn(js, "showEmptyIfLast")
assert "querySelectorAll(\"tr\").length > 1" in empty
# ---------- the table CSS ----------
def test_history_table_css_full_width_and_palette() -> None:
"""styles.css: .history-table is the full-width sources-table family
(width 100%, --line borders, the brand-soft thead, row hover); the
title link is the accent link (brand-ink, focus-visible); the
confirm pair is Yes-on-error-rose + No-ghost; the empty-state row
is the muted centered message. Every pair is Phase-08 AA
(brand-ink/brand-soft 6.9:1, err 9.1:1, ink-soft >=6.9:1)."""
css = _css()
block = re.search(r"\.history-table \{([\s\S]*?)\n\}", css)
assert block, "styles.css must style .history-table"
body = block.group(1)
assert "width: 100%" in body, "the table is FULL-WIDTH (AGENTS.md rule 5)"
assert "min-width: 640px" in body
th = re.search(r"\.history-table th \{([\s\S]*?)\n\}", css)
assert th and "var(--brand-soft)" in th.group(1) and "var(--brand-ink)" in th.group(1)
hover = re.search(r"\.history-table tbody tr:hover \{([^}]*)\}", css)
assert hover, "row hover is part of the table family"
link = re.search(r"\.history-title-link \{([\s\S]*?)\n\}", css)
assert link and "var(--brand-ink)" in link.group(1), "the Open link is the accent link"
assert re.search(r"\.history-title-link:focus-visible \{[^}]*outline[^}]*3px", css), (
"the Open link keeps a :focus-visible outline"
)
yes = re.search(r"\.history-confirm-yes \{([\s\S]*?)\n\}", css)
assert yes, "the confirm Yes button must be styled"
ybody = yes.group(1)
assert "var(--err-bg)" in ybody and "var(--err-ink)" in ybody and "var(--err-line)" in ybody
no = re.search(r"\.history-confirm-no \{([\s\S]*?)\n\}", css)
assert no and "background: transparent" in no.group(1), "No is the ghost"
empty = re.search(r"\.history-empty-row td \{([\s\S]*?)\n\}", css)
assert empty, "the empty-state row must be styled"
ebody = empty.group(1)
assert "text-align: center" in ebody and "var(--ink-soft)" in ebody
def test_history_table_mobile_behavior() -> None:
"""≤640px (the phase-07 responsive contract): the table keeps its
full width (the .table-wrap's horizontal scroll already covers
it) and the actions cell wraps so the two-step confirm pair fits
the phone width."""
css = _css()
mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}\n", css)
assert mobile, "the mobile media query must exist"
mbody = mobile.group(1)
assert ".history-actions-cell { white-space: normal; }" in mbody
assert ".history-actions { flex-wrap: wrap; }" in mbody
+293
View File
@@ -0,0 +1,293 @@
"""Unit: the phase-50 task-03 save-chat contract on the chat page.
The browser behavior itself is E2E-gated by the story suite (task 05);
like the other frontend-adjacent unit files, this module pins the
JS/CSS/HTML markers the save/load contract depends on, so a silent
regression is caught without a browser:
* the ``currentChatId`` lifecycle (set on create/open, cleared by New
chat and by the 404-PUT fallback);
* the upsert branch (PUT when linked, POST when not, the 404→recreate
fallback, the live-region feedback strings);
* the boot-load precedence (a valid ``?chat=`` uuid + admin replaces the
local restore and mirrors it to localStorage; anonymous / invalid /
404 / network → the local restore);
* the ship-hidden / reveal-for-admin gate on ``#save-chat-btn``.
"""
from __future__ import annotations
import re
from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
APP_JS = FRONTEND / "assets" / "app.js"
INDEX_HTML = FRONTEND / "index.html"
SOURCES_HTML = FRONTEND / "sources.html"
GIT_SOURCES_HTML = FRONTEND / "git-sources.html"
DOCUMENT_HTML = FRONTEND / "document.html"
LOGIN_HTML = FRONTEND / "login.html"
TUNING_HTML = FRONTEND / "tuning.html"
STYLES_CSS = FRONTEND / "assets" / "styles.css"
def _js() -> str:
return APP_JS.read_text(encoding="utf-8")
def _css() -> str:
return STYLES_CSS.read_text(encoding="utf-8")
def _index() -> str:
return INDEX_HTML.read_text(encoding="utf-8")
def _fn(js: str, name: str) -> str:
"""The source of a top-level ``function <name>(...)`` (to its close)."""
start = js.find(f"function {name}(")
assert start != -1, f"{name}() must exist in app.js"
return js[start : js.find("\n}\n", start) + 4]
# ---------- the Save button on the chat page ----------
def test_save_button_ships_hidden_beside_new_chat() -> None:
"""#save-chat-btn: a real type=button with the accessible name
"Save chat", SHIPPED HIDDEN (app.js reveals it for admin only),
beside #new-chat-btn in .chat-shell inside <main>, above
#messages — the two chat-shell actions read as a pair. No other
page carries it (chat-page only, like New chat)."""
html = _index()
btn = re.search(r'<button[^>]*id="save-chat-btn"[^>]*>', html)
assert btn, "index.html must contain #save-chat-btn"
tag = btn.group(0)
assert 'type="button"' in tag
assert 'aria-label="Save chat"' in tag
assert "hidden" in tag, "the button ships hidden (reveal is app.js's job)"
# Beside New chat: after it, still inside .chat-shell, above #messages.
main_idx = html.find('main id="main"')
shell_idx = html.find('class="container chat-shell"')
new_idx = html.find('id="new-chat-btn"')
messages_idx = html.find('id="messages"')
assert -1 < main_idx < shell_idx < new_idx < btn.start() < messages_idx, (
"the button must sit beside #new-chat-btn in .chat-shell, above #messages"
)
for other in (SOURCES_HTML, GIT_SOURCES_HTML, DOCUMENT_HTML, LOGIN_HTML, TUNING_HTML):
assert 'id="save-chat-btn"' not in other.read_text(encoding="utf-8"), (
f"{other.name}: the Save button is chat-page only"
)
def test_save_button_css_is_the_exact_new_chat_family() -> None:
"""styles.css: .save-chat-btn carries the EXACT visual family of
.new-chat-btn — solid brand pill (--bg on --brand = 5.2:1, AA),
borderless, 999px radius, ≥44px target, hover lightens the brand
fill; the ≤640px block mirrors the New chat overrides (label stays
visible in .chat-shell, icon hidden there; icon-only elsewhere)."""
css = _css()
block = re.search(r"\.save-chat-btn \{([\s\S]*?)\n\}", css)
assert block, "styles.css must style .save-chat-btn"
body = block.group(1)
assert "min-height: 44px" in body
assert "border-radius: 999px" in body
assert "border: 0" in body
assert "background: var(--brand)" in body, "same solid brand fill as New chat"
assert "color: var(--bg)" in body, "--bg text on --brand = 5.2:1 (AA)"
hover = re.search(r"\.save-chat-btn:hover \{([\s\S]*?)\n\}", css)
assert hover and "#f55a72" in hover.group(1), "hover lightens the brand fill"
svg = re.search(r"\.save-chat-btn svg \{([\s\S]*?)\n\}", css)
assert svg and "display: none" in svg.group(1), "icon hidden on desktop (like New chat)"
mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}", css)
assert mobile, "mobile media query missing"
mbody = mobile.group(1)
assert ".save-chat-btn { padding: 0.4rem 0.3rem; }" in mbody, "squeezes with New chat"
assert ".save-chat-label { display: none; }" in mbody
assert ".save-chat-btn svg { display: block; }" in mbody
assert ".chat-shell .save-chat-label { display: inline; }" in mbody, (
"in .chat-shell the label stays visible, as for New chat"
)
assert ".chat-shell .save-chat-btn svg { display: none; }" in mbody
# ---------- currentChatId lifecycle ----------
def test_current_chat_id_module_scope_and_lifecycle() -> None:
"""currentChatId: module scope, string | null — set to the created
row's id on a fresh Save (201), set to the opened id on a
successful boot load, cleared by "New chat" AND by the 404-PUT
fallback (a stale link must never leave the conversation unsaved)."""
js = _js()
assert "let currentChatId = null" in js, "module-scope link, null = unlinked"
# Set on create: the 201 branch links to the created row's id.
save_body = _fn(js, "saveCurrentChat")
assert "res.status === 201" in save_body
assert "currentChatId = String(created.id)" in save_body, (
"a fresh Save links to the created row's id"
)
# Set on open: the boot load links to the fetched id.
load_body = _fn(js, "restoreSavedChatFromUrl")
assert "currentChatId = chatId" in load_body
# Cleared by New chat.
new_body = _fn(js, "startNewChat")
assert "currentChatId = null" in new_body, "New chat unlinks"
# Cleared by the 404-PUT fallback (see the upsert test for the branch).
assert "res.status === 404" in save_body
assert save_body.count("currentChatId = null") >= 1
# ---------- the upsert branch ----------
def test_save_upsert_put_when_linked_post_when_not() -> None:
"""saveCurrentChat: linked → PUT /api/chats/<id> with the messages
payload (re-Save updates the SAME row — no title in the body, so the
row keeps its current one); unlinked → POST /api/chats (the server
auto-titles). The 404 from the PUT unlinks and retries as a create.
Empty conversation → no request, live-region "Nothing to save
yet."; success → live-region "Conversation saved." (status text
only, no banner); 403/5xx/network → the error banner."""
js = _js()
body = _fn(js, "saveCurrentChat")
# No-op first: nothing to save → live-region line, no fetch.
noop = body.find('sendStatus.textContent = "Nothing to save yet."')
first_fetch = body.find("await fetch(")
assert 0 < noop < first_fetch, "the empty-conversation no-op precedes any fetch"
assert "if (!conversation.length)" in body
# The branch: PUT when linked, POST when not.
assert "if (currentChatId)" in body
assert '`/api/chats/${currentChatId}`' in body
assert 'method: "PUT"' in body
assert 'fetch("/api/chats"' in body
assert 'method: "POST"' in body
put_idx = body.find('method: "PUT"')
post_idx = body.find('method: "POST"')
assert -1 < put_idx < post_idx, "the PUT (linked) branch precedes the POST fallback"
assert '{ messages: conversation }' in body, "the messages payload — no title (keep current)"
# The 404→recreate fallback: unlink, then POST again.
notfound_idx = body.find("res.status === 404")
assert notfound_idx != -1, "the PUT 404 must be handled"
fallback = body[notfound_idx:post_idx]
assert "currentChatId = null" in fallback, "the stale link is dropped"
# Success is status text only — the live region, never stale — and
# nothing between the 201 link and the success line may raise a
# banner (the !res.ok branch returns before either).
assert body.count('sendStatus.textContent = "Conversation saved."') == 1
saved_line = 'sendStatus.textContent = "Conversation saved."'
between = body[body.find("res.status === 201") : body.find(saved_line)]
assert "showErrorBanner" not in between, "no banner on the success path"
# Failures raise an actionable banner (non-ok HTTP + network).
assert 'showErrorBanner("Couldn\'t save the conversation — is the app reachable?")' in body
assert "check you're still signed in and try again" in body, "403/5xx: actionable line"
# The double-click guard releases on EVERY outcome.
finally_idx = body.rfind("finally")
assert finally_idx != -1 and "saveBtn.disabled = false" in body[finally_idx:], (
"the button is re-enabled in the finally — never stale"
)
# ---------- boot-load precedence ----------
def test_boot_load_precedence_saved_chat_over_local_restore() -> None:
"""Inside the boot IIFE: after fetchIsAdmin() + the reveal gate,
restoreSavedChatFromUrl() runs; only when it returns false does the
phase-14 local restore run. Header init stays first (shared-module
contract)."""
js = _js()
boot_start = js.find("(async () => {")
assert boot_start != -1, "the boot IIFE must exist"
boot = js[boot_start:]
init_i = boot.find("await initSharedHeader();")
admin_i = boot.find("isAdmin = await fetchIsAdmin();")
reveal_i = boot.find("saveBtn.hidden = !isAdmin")
saved_i = boot.find("await restoreSavedChatFromUrl();")
local_i = boot.find("restoreConversation();")
assert -1 < init_i < admin_i < reveal_i < saved_i < local_i, (
"boot order: header init → whoami → Save reveal → ?chat= load → local fallback"
)
assert "if (!openedSaved) restoreConversation();" in boot, (
"the local restore runs ONLY when the saved-chat load did not open"
)
def test_boot_load_gates_valid_uuid_and_admin_only() -> None:
"""restoreSavedChatFromUrl: a VALID uuid + admin is the ONLY
fetch path — invalid/absent ?chat= and anonymous short-circuit to
false (no request: the gate would 403). On 200 the messages
REPLACE the local conversation, render through the SAME
renderStoredMessage loop (pixel-identical restore), link
currentChatId, and mirror to localStorage. 404/network/malformed/
empty → banner + false (the local restore then runs)."""
js = _js()
body = _fn(js, "restoreSavedChatFromUrl")
# The gates, in order: param present → valid uuid → admin.
assert '.get("chat")' in body, "the ?chat= param"
assert "UUID_RE.test(chatId)" in body, "a valid uuid only"
assert "!isAdmin" in body, "admin only (no fetch for anonymous)"
gate = body.find("!isAdmin")
fetch_i = body.find('fetch(`/api/chats/${chatId}`)')
assert -1 < gate < fetch_i, "the gates short-circuit BEFORE the fetch"
assert "const UUID_RE" in js, "the uuid pattern is module-level"
# On success: replace → render through the SAME loop → link → mirror.
assert "conversation = messages" in body, "the saved messages REPLACE the local conversation"
assert "renderStoredMessage(m)" in body, "the SAME renderStoredMessage path as local restore"
assert "markLastRetryable()" in body, "parity with local restore: Retry on the last bubble"
save_mir = body.find("saveConversation()")
link_i = body.find("currentChatId = chatId")
assert -1 < link_i < save_mir, "link first, then mirror to localStorage"
# Failure: the exact banner line, then false (→ local restore). The
# gate line returns false directly; the 404/network, malformed-body
# and empty-payload paths all route through the banner helper.
banner_line = 'showErrorBanner("That saved chat isn\'t available — it may have been deleted.")'
assert banner_line in body
assert "return false" in body, "invalid/absent param or anonymous → no fetch, local restore"
assert body.count("return unavailable()") == 4, (
"network, non-ok (404/403/5xx), malformed body and empty payload all fall back"
)
# The ?chat= param is a one-shot boot instruction: the success path
# normalizes the URL back to / so a later refresh (or "New chat" +
# refresh) restores the LOCAL session instead of re-opening the row.
assert 'history.replaceState(null, "", "/")' in body, (
"a consumed ?chat= must not linger in the URL"
)
# The defensive filter keeps a corrupted stored row from poisoning the
# restore (same shape check as loadStoredConversation).
assert 'm.who === "user" || m.who === "brain"' in body
assert 'typeof m.text === "string"' in body
# ---------- the reveal gate ----------
def test_save_button_revealed_only_for_admin() -> None:
"""The ship-hidden/reveal-for-admin contract: app.js queries
#save-chat-btn, binds the click to saveCurrentChat, and the boot
IIFE sets saveBtn.hidden = !isAdmin (phase 16 absent-not-hidden —
hidden is display:none, no trace for anonymous)."""
js = _js()
assert 'document.querySelector("#save-chat-btn")' in js
assert 'saveBtn?.addEventListener("click", saveCurrentChat)' in js
assert "saveBtn.hidden = !isAdmin" in js, "revealed for admin only, at boot"
# The reveal happens in the boot IIFE (after whoami), not at module
# evaluation (isAdmin is false there).
boot_start = js.find("(async () => {")
reveal = js.find("saveBtn.hidden = !isAdmin")
assert boot_start < reveal, "the reveal must run at boot, after whoami resolves"
def test_boot_load_adds_no_direct_storage_access() -> None:
"""The localStorage accesses stay EXACTLY the phase-14 three
(loadStoredConversation / saveConversation / clearStoredConversation)
— the saved-chat mirror goes through saveConversation(), so the
house failure-safety pin (exactly 3, all try-wrapped) holds."""
js = _js()
accesses = list(re.finditer(r"localStorage\.(?:getItem|setItem|removeItem)", js))
assert len(accesses) == 3, f"expected exactly 3 localStorage accesses, got {len(accesses)}"
def test_no_cdn_added() -> None:
"""AGENTS.md rule 6: the Save button adds no external script/link."""
index = _index()
assert 'src="http' not in index and 'href="http' not in index
+3 -2
View File
@@ -22,8 +22,8 @@ ASSETS = FRONTEND / "assets"
HEADER_JS = ASSETS / "header.js"
#: All six pages carry the shared header block (phase 34's five pages +
#: phase 35's git-sources page).
#: All seven pages carry the shared header block (phase 34's five pages
#: + phase 35's git-sources page + phase 50's History page).
PAGES = (
FRONTEND / "index.html",
FRONTEND / "sources.html",
@@ -31,6 +31,7 @@ PAGES = (
FRONTEND / "git-sources.html",
FRONTEND / "login.html",
FRONTEND / "tuning.html",
FRONTEND / "history.html",
)