feat(docs): save chat answers as docs — edit screen, commit + push to the .env docs branch

This commit is contained in:
2026-09-01 03:52:03 -04:00
parent 7b7a834a1a
commit 725af9fac1
32 changed files with 4356 additions and 107 deletions
+5 -4
View File
@@ -116,9 +116,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, /login.html and /history.html (phase 50): each
document revalidates, and all three pages' stylesheet requests carry
the same process token."""
"""/sources.html, /login.html, /history.html (phase 50) and
/doc-edit.html (phase 59): each document revalidates, and all four
pages' stylesheet requests carry the same process token."""
token = _expected_token()
assert token
@@ -134,7 +134,8 @@ def test_other_pages_share_the_token(page: Page, app_url: str) -> None:
sources_token = navigate("/sources.html")
login_token = navigate("/login.html")
history_token = navigate("/history.html") # phase 50: the new page
assert sources_token == login_token == history_token == token
docedit_token = navigate("/doc-edit.html") # phase 59: the doc edit screen
assert sources_token == login_token == history_token == docedit_token == token
def test_shared_page_is_no_cache_and_versioned(
+5 -1
View File
@@ -144,8 +144,12 @@ def test_api_config_serves_both_names(testy_server: str, app_server: str) -> Non
r = httpx.get(f"{TESTY_URL}/api/config", timeout=5)
assert r.status_code == 200
body = r.json()
assert set(body) == {"app_name", "version"}
# Phase 59 (task 05): the third key is the docs-push flag — the
# "Save as doc" gating; both instances run with BOR_DOCS_REPO
# empty, so it is the inert false here.
assert set(body) == {"app_name", "version", "docs_repo_configured"}
assert body["app_name"] == TESTY_NAME
assert body["docs_repo_configured"] is False
# The shared conftest instance keeps the default (the other
# suites' title/label contract rides on it).
+602
View File
@@ -0,0 +1,602 @@
"""Phase 59 story E2E (Playwright): the save → edit → push loop, with
the BARE REPO as source of truth.
Story: n/a (TODO-derived — "Convert response to documentation that gets
committed back to a repo specified in .env … allows you to modify the
new documentation before [pushing] to the specified repo").
Run in isolation (DB must be up: ``podman compose up -d db``; ``git``
on PATH — the suite skips without it):
uv run pytest tests/e2e/test_response_to_docs.py -v --no-cov
The loop under test: a completed brain bubble carries a bottom-right
"Save as doc" action (admin + a configured ``BOR_DOCS_REPO``) → it
opens ``/doc-edit.html?draft=<token>`` prefilled (auto-title from the
last question, path ``docs/<slug>.md``, body = the answer's MARKDOWN
SOURCE — never the rendered HTML) → Push commits + pushes to the
``.env``-configured branch of the ``.env``-configured repo. Every
success assertion reads the **bare repo itself** (``git show
<branch>:<path>``, ``git rev-list``, ``git rev-parse``) — the UI text
is only the entry point (D3: no PR is ever created or attempted — the
flow ends at the push to the branch).
App boots (the conftest pattern, module-scoped — as in
``test_git_sources_admin.py``):
* the module app boots with ``BOR_DOCS_REPO=<tmp>/docs.git`` (a local
BARE repo seeded with one commit on ``main``), ``BOR_DOCS_BRANCH=
bor-docs``, ``BOR_DOCS_BASE_BRANCH=main``, ``BOR_DOCS_WORK_DIR=
<tmp>/docs-work``;
* ``test_unconfigured_hides_button`` boots a SECOND app (separate
fixture, ``APP_PORT + 1``) with NO docs env — the inert default:
no button for anyone, draft creation still allowed (drafts are
repo-independent), push 409s naming ``BOR_DOCS_REPO``.
The mock LLM keeps every answer byte-deterministic: the suite replays
the same question through ``POST /api/chat`` (raw SSE, the
``test_chat_rag.py`` pattern) to recover the exact markdown source the
draft must carry — so "body == the answer's markdown source" is an
exact-byte assertion, not a contains check.
Test → story mapping (Playwright Mapping Rule):
1. ``test_save_edit_push``
2. ``test_second_push_fast_forwards``
3. ``test_guest_has_no_button``
4. ``test_unconfigured_hides_button``
"""
from __future__ import annotations
import asyncio
import json
import os
import re
import subprocess
import sys
from collections.abc import Iterator
from pathlib import Path
from types import SimpleNamespace
from typing import Any
from urllib.parse import parse_qs, urlsplit
import httpx
import pytest
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
from e2e.conftest import (
ADMIN_PASSWORD,
APP_PORT,
SESSION_SECRET,
USE_REAL_LLM,
_wait_http,
)
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
APP_URL = f"http://127.0.0.1:{APP_PORT}"
#: The unconfigured app's port (task 07: a second app boot WITHOUT
#: ``BOR_DOCS_REPO`` — a separate fixture on the next port, so it can
#: run alongside the module app).
UNCONF_URL = f"http://127.0.0.1:{APP_PORT + 1}"
BRANCH = "bor-docs"
BASE_BRANCH = "main"
#: On-topic fixture questions (the house phrasing — proven HIGH gate in
#: test_chat_rag.py / test_pinned_composer.py, so every turn renders a
#: grounded answer with the deterministic marker, never a deflection).
QUESTION_1 = "How is my Kubernetes cluster set up?"
QUESTION_2 = "What is in the new-service deployment?"
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
#: The distinctive line test 1 appends to the body before pushing —
#: ASCII on purpose (git's output must match byte-for-byte), and a
#: module constant so test 2 can reconstruct file 1's expected content
#: (deterministic: the mock answer + this exact suffix).
E2E_MARKER = "E2E-DOCS-MARKER (appended by the response-to-docs story suite)"
#: The edit screen's URL shape (task 05 navigates with the uuid4 token).
DRAFT_URL_RE = re.compile(r"/doc-edit\.html\?draft=[0-9a-f-]{36}")
#: The success line (task 06): `Pushed to <branch> — commit <sha7>.`
SUCCESS_SHA_RE = re.compile(r"commit ([0-9a-f]{7})\.$")
def _git_available() -> bool:
try:
return subprocess.run(
["git", "--version"], capture_output=True, timeout=10
).returncode == 0
except (FileNotFoundError, subprocess.TimeoutExpired):
return False
pytestmark = pytest.mark.skipif(
not _git_available(), reason="git is not on PATH (the docs push is real git)"
)
def _git(args: list[str], cwd: Path | None = None) -> str:
"""One git command (the bare repo is the source of truth); fail loud."""
proc = subprocess.run(
["git", *args], cwd=cwd, capture_output=True, text=True, timeout=60
)
assert proc.returncode == 0, f"git {' '.join(args)} failed: {proc.stderr}"
return proc.stdout
def doc_slug(title: str) -> str:
"""The app.js slug rule (phase 59 locked assumption), ported:
lowercase, runs of non-alphanumerics → ``-``, trimmed, ≤60 chars,
empty → ``note`` (the trailing trim survives a mid-dash 60-cut)."""
slug = (
re.sub(r"[^a-z0-9]+", "-", title.lower())
.strip("-")[:60]
.rstrip("-")
)
return slug or "note"
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_cache_busting.py`` pattern)."""
return {
c["name"]: c["value"]
for c in page.context.cookies()
if "name" in c and "value" in c
}
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def docs_repo(tmp_path_factory: pytest.TempPathFactory) -> SimpleNamespace:
"""The local BARE docs repo (the .env remote, D3-generic): one
seed commit (``README.md``) pushed as ``main``. ``work`` is where
the app's ``BOR_DOCS_WORK_DIR`` checkout lands (it persists for the
whole module — the second push exercises the existing-checkout
path)."""
base = tmp_path_factory.mktemp("docs-git")
bare = base / "docs.git"
_git(["init", "--bare", str(bare)])
seed = base / "seed"
_git(["init", "-b", "main", str(seed)])
(seed / "README.md").write_text("# e2e docs repo\n", encoding="utf-8")
_git(["add", "--", "README.md"], cwd=seed)
# -c identity + no GPG signing: the machine's global git config
# (gpgsign=true here) must not leak into the fixture.
_git(
[
"-c", "user.name=E2E Seeder",
"-c", "user.email=e2e@local",
"-c", "commit.gpgsign=false",
"commit", "-m", "seed: README",
],
cwd=seed,
)
_git(["remote", "add", "origin", str(bare)], cwd=seed)
_git(["push", "origin", "main"], cwd=seed)
return SimpleNamespace(bare=bare, work=base / "docs-work")
def _spawn_app(port: int, mock_port: int, docs_env: dict[str, str] | None) -> subprocess.Popen:
"""One uvicorn boot (the conftest app_server env shape); ``None``
docs_env = NO docs variables at all (the unconfigured app)."""
env = dict(os.environ)
env.pop("DEBUGPY", None)
env["BOR_ENVIRONMENT"] = "e2e"
env["BOR_STATIC_DIR"] = str(REPO / "frontend")
env["BOR_LLM_BASE_URL"] = (
"https://aipi.reeseapps.com/v1" if USE_REAL_LLM else f"http://127.0.0.1:{mock_port}/v1"
)
# Mock-calibrated threshold (conftest pattern): the fixture questions
# gate HIGH, so every turn is a grounded answer with the marker.
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
env.setdefault(
"BOR_DATABASE_URL",
"postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese",
)
# Phase 16: admin auth must be set or create_app() refuses to boot.
env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
env["BOR_SESSION_SECRET"] = SESSION_SECRET
if docs_env is None:
for var in (
"BOR_DOCS_REPO",
"BOR_DOCS_BRANCH",
"BOR_DOCS_BASE_BRANCH",
"BOR_DOCS_WORK_DIR",
):
env.pop(var, None)
else:
env.update(docs_env)
return subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app.main:app",
"--host", "127.0.0.1", "--port", str(port), "--log-level", "warning"],
cwd=REPO,
env=env,
)
def _stop(proc: subprocess.Popen) -> None:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
@pytest.fixture(scope="module")
def app_server(mock_llm: int, docs_repo: SimpleNamespace) -> Iterator[str]:
"""The configured app under test (module scope — shadows the
conftest session app; an isolated run never starts two)."""
proc = _spawn_app(
APP_PORT,
mock_llm,
{
"BOR_DOCS_REPO": str(docs_repo.bare),
"BOR_DOCS_BRANCH": BRANCH,
"BOR_DOCS_BASE_BRANCH": BASE_BRANCH,
"BOR_DOCS_WORK_DIR": str(docs_repo.work),
},
)
try:
_wait_http(f"{APP_URL}/api/health")
yield APP_URL
finally:
_stop(proc)
@pytest.fixture(scope="module")
def app_url(app_server: str) -> str:
return app_server
@pytest.fixture()
def unconfigured_app(mock_llm: int) -> Iterator[str]:
"""The SECOND app boot (task 07): NO ``BOR_DOCS_REPO`` — the inert
default the suite must see as absent-for-everyone + 409 push."""
proc = _spawn_app(APP_PORT + 1, mock_llm, None)
try:
_wait_http(f"{UNCONF_URL}/api/health")
yield UNCONF_URL
finally:
_stop(proc)
# ---------------------------------------------------------------------------
# KB + table hygiene (the E2E isolation pattern — this suite owns the
# KB tables and doc_drafts; both are reset around every test)
# ---------------------------------------------------------------------------
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 (the Playwright sync API keeps
an asyncio loop on the test thread — the test_chat_rag.py helper)."""
import threading
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 = threading.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) -> None:
with SessionLocal() as db:
db.execute(text("TRUNCATE chunks, documents, query_log, doc_drafts"))
db.commit()
if seed:
summary = _run_in_thread(_import_fixtures(mock_port))
assert summary.added == 13 # the A9 fixture set (test_chat_rag.py)
@pytest.fixture(autouse=True)
def _kb_and_clean_drafts(mock_llm: int, db_ready: None) -> Iterator[None]:
"""Fresh KB (the deterministic mock embeddings — the grounded
questions gate HIGH) + an empty ``doc_drafts`` table per test."""
_reset_db(mock_llm, seed=True)
yield
_reset_db(mock_llm, seed=False)
# ---------------------------------------------------------------------------
# Story helpers
# ---------------------------------------------------------------------------
def _stream_chat_answer(app_url: str, message: str) -> str:
"""Replay one turn through the raw SSE endpoint (the
``test_chat_rag.py`` transport pattern) and return the EXACT answer
text — the markdown source the UI accumulates into ``m.text``,
byte-identical for the deterministic mock (same KB, same question)."""
frames: list[dict[str, Any]] = []
with httpx.stream(
"POST", f"{app_url}/api/chat", json={"message": message}, timeout=120.0
) as r:
assert r.status_code == 200
buf = ""
for part in r.iter_text():
buf += part
while "\n\n" in buf:
frame, buf = buf.split("\n\n", 1)
if frame.strip().startswith("data:"):
frames.append(
json.loads(frame.strip().removeprefix("data:").strip())
)
deltas = [f for f in frames if f.get("type") == "delta"]
assert deltas, "the SSE stream must deliver deltas"
return "".join(d["text"] for d in deltas)
def _ask(page: Page, app_url: str, question: str) -> None:
"""One grounded turn to its DONE state (marker in the bubble + the
send button re-enabled — the meta-row buttons land on done)."""
page.fill("#message-input", question)
page.click("#send-btn")
bubble = page.locator(".msg.brain .bubble:not(.typing)").first
expect(bubble).to_contain_text(question, timeout=30_000)
expect(bubble).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000)
expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000)
expect(page.locator("#send-label")).to_have_text("Send")
def _login_admin(page: Page, app_url: str) -> None:
"""Real form login landing on the chat (admin settled)."""
login(page, app_url, next="/")
expect(page).to_have_url(app_url + "/", timeout=30_000)
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=30_000)
def _open_edit_screen(page: Page) -> str:
"""Click the save action, wait for the navigation, return the draft
token from the URL (the uuid4 credential)."""
page.click(".save-as-doc-btn")
page.wait_for_url(DRAFT_URL_RE, timeout=30_000)
token = parse_qs(urlsplit(page.url).query).get("draft", [""])[0]
assert re.fullmatch(r"[0-9a-f-]{36}", token), f"no draft token in {page.url}"
expect(page.locator("#doc-edit-gate")).to_be_hidden(timeout=30_000)
expect(page.locator("#doc-edit-content")).to_be_visible(timeout=30_000)
return token
def _push_and_read_sha(page: Page) -> tuple[str, str]:
"""Submit the edit screen's push; wait for the success line and
return (branch, sha7) exactly as the live region reported them."""
page.click("#push-doc-btn")
status = page.locator("#push-status")
expect(status).to_contain_text(f"Pushed to {BRANCH}", timeout=60_000)
line = status.inner_text().strip()
m = SUCCESS_SHA_RE.search(line)
assert m, f"the success line carries no commit sha: {line!r}"
return BRANCH, m.group(1)
# ---------------------------------------------------------------------------
# 1. The whole loop: save → edit → push → the bare repo agrees
# ---------------------------------------------------------------------------
def test_save_edit_push(
page: Page,
app_url: str,
mock_llm: int,
db_ready: None,
docs_repo: SimpleNamespace,
) -> None:
page.set_default_timeout(30_000)
_login_admin(page, app_url)
_ask(page, app_url, QUESTION_1)
# The "Save as doc" action is on the completed brain bubble…
btn = page.locator(".msg.brain .save-as-doc-btn")
expect(btn).to_have_count(1)
expect(btn).to_contain_text("Save as doc")
# …bottom-right: its left edge sits past the bubble's midline
# (margin-inline-start: auto in the meta row).
msg_box = page.locator(".msg.brain").bounding_box()
btn_box = btn.bounding_box()
assert msg_box is not None and btn_box is not None
midline = msg_box["x"] + msg_box["width"] / 2
assert btn_box["x"] > midline, (
f"save button x={btn_box['x']:.0f} is not past the bubble midline "
f"{midline:.0f} — it must sit bottom-right"
)
# Click → /doc-edit.html?draft=<uuid4>, prefilled.
_open_edit_screen(page)
expect(page.locator("#draft-title")).to_have_value(QUESTION_1) # auto-title
expect(page.locator("#draft-path")).to_have_value(
f"docs/{doc_slug(QUESTION_1)}.md"
)
# Body == the rendered answer's MARKDOWN SOURCE: the SSE replay
# recovers the exact bytes the UI accumulated (the mock is
# byte-deterministic on the same KB + question) — and they are
# plain markdown, not rendered HTML.
raw = _stream_chat_answer(app_url, QUESTION_1)
assert MOCK_ANSWER_MARKER in raw and QUESTION_1 in raw
assert "<" not in raw and ">" not in raw, "the draft body must be markdown, not HTML"
expect(page.locator("#draft-body")).to_have_value(raw)
# Modify the doc (the story's "modify before [pushing]"): a
# distinctive marker line the bare repo must show after the push.
edited = f"{raw}\n\n{E2E_MARKER}"
page.fill("#draft-body", edited)
# Push → the live region reports the branch + a 7-char commit sha…
branch, sha7 = _push_and_read_sha(page)
assert branch == BRANCH
# …and the BARE REPO agrees (the source of truth — not the UI):
# the file on the branch is exactly the edited body…
path = f"docs/{doc_slug(QUESTION_1)}.md"
shown = _git(["-C", str(docs_repo.bare), "show", f"{BRANCH}:{path}"])
assert shown == edited
# …and the branch tip's first 7 chars are the sha the UI reported.
tip = _git(["-C", str(docs_repo.bare), "rev-parse", BRANCH]).strip()
assert tip.startswith(sha7), f"UI sha {sha7} != bare repo tip {tip}"
# First push: the branch exists and is exactly one commit beyond
# main (created by the push — the remote had no bor-docs before).
assert (
_git(["-C", str(docs_repo.bare), "rev-list", "--count", f"{BASE_BRANCH}..{BRANCH}"])
.strip()
== "1"
)
# ---------------------------------------------------------------------------
# 2. A second save fast-forwards: two commits, file 1 untouched
# ---------------------------------------------------------------------------
def test_second_push_fast_forwards(
page: Page,
app_url: str,
mock_llm: int,
db_ready: None,
docs_repo: SimpleNamespace,
) -> None:
page.set_default_timeout(30_000)
_login_admin(page, app_url)
_ask(page, app_url, QUESTION_2)
# Save the second answer (different question → different slug)…
expect(page.locator(".msg.brain .save-as-doc-btn")).to_have_count(1)
_open_edit_screen(page)
expect(page.locator("#draft-title")).to_have_value(QUESTION_2)
expect(page.locator("#draft-path")).to_have_value(
f"docs/{doc_slug(QUESTION_2)}.md"
)
raw2 = _stream_chat_answer(app_url, QUESTION_2)
expect(page.locator("#draft-body")).to_have_value(raw2)
# …and push WITHOUT editing — a new commit on the same branch.
_push_and_read_sha(page)
# The bare repo: exactly two commits beyond main (fast-forward,
# never a force-push or a reset)…
assert (
_git(["-C", str(docs_repo.bare), "rev-list", "--count", f"{BASE_BRANCH}..{BRANCH}"])
.strip()
== "2"
)
# …file 2 landed with its unedited body…
path2 = f"docs/{doc_slug(QUESTION_2)}.md"
assert _git(["-C", str(docs_repo.bare), "show", f"{BRANCH}:{path2}"]) == raw2
# …and file 1 from test 1 is still at its path, byte-for-byte
# (deterministic reconstruction: the mock answer + the marker line).
path1 = f"docs/{doc_slug(QUESTION_1)}.md"
expected_first = f"{_stream_chat_answer(app_url, QUESTION_1)}\n\n{E2E_MARKER}"
assert _git(["-C", str(docs_repo.bare), "show", f"{BRANCH}:{path1}"]) == expected_first
# ---------------------------------------------------------------------------
# 3. Guest: no button, 403 on the draft API, the edit screen gates
# ---------------------------------------------------------------------------
def test_guest_has_no_button(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
page.set_default_timeout(30_000)
# No login (the conftest page fixture is a fresh context). Track
# every /api/doc-drafts request the PAGES make — the guest flow
# must never reach the admin API.
drafts_calls: list[str] = []
page.on(
"request",
lambda r: drafts_calls.append(r.url) if "/api/doc-drafts" in r.url else None,
)
page.goto(app_url)
expect(page.locator("#sign-in-link")).to_be_visible(timeout=30_000)
_ask(page, app_url, QUESTION_1) # the grounded answer streams for guests too
# The "Save as doc" action is admin-only: ABSENT (not hidden) on
# the completed bubble, whatever the docs config says.
expect(page.locator(".save-as-doc-btn")).to_have_count(0)
# The draft API 403s anonymous callers (httpx, no cookie at all).
r = httpx.post(
f"{app_url}/api/doc-drafts",
json={"title": "guest", "path": "docs/guest.md", "body": "nope"},
timeout=10,
)
assert r.status_code == 403
# The edit screen renders the admin gate with NO draft data in the
# DOM, and the page itself made zero draft API calls.
page.goto(app_url + "/doc-edit.html")
expect(page.locator("#doc-edit-gate")).to_be_visible(timeout=30_000)
expect(page.locator("#doc-edit-content")).to_be_hidden()
assert page.input_value("#draft-title") == ""
assert page.input_value("#draft-path") == ""
assert page.input_value("#draft-body") == ""
assert drafts_calls == [], f"guest pages called the draft API: {drafts_calls}"
# ---------------------------------------------------------------------------
# 4. Unconfigured (BOR_DOCS_REPO empty): inert for everyone, 409 push
# ---------------------------------------------------------------------------
def test_unconfigured_hides_button(
page: Page, unconfigured_app: str, mock_llm: int, db_ready: None
) -> None:
page.set_default_timeout(30_000)
_login_admin(page, unconfigured_app)
_ask(page, unconfigured_app, QUESTION_1)
# docs_repo_configured false → the button is hidden for EVERYONE,
# admin included (the optional-feature pattern — inert by default).
expect(page.locator(".save-as-doc-btn")).to_have_count(0)
# Drafts are repo-independent: an admin can still create one…
cookies = _admin_cookies(page)
r = httpx.post(
f"{unconfigured_app}/api/doc-drafts",
json={
"title": "Unconfigured draft",
"path": "docs/unconfigured.md",
"body": "A draft while no docs repo is configured.",
},
timeout=10,
cookies=cookies,
)
assert r.status_code == 201, r.text
token = r.json()["token"]
# …but pushing 409s, naming the missing variable (D3: fail loud,
# inert by default).
r = httpx.post(
f"{unconfigured_app}/api/doc-drafts/{token}/push", timeout=10, cookies=cookies
)
assert r.status_code == 409
assert "BOR_DOCS_REPO" in r.json()["detail"]
+29 -3
View File
@@ -18,13 +18,16 @@ def test_health_reports_ok(client) -> None:
def test_config_returns_default_app_metadata(client) -> None:
"""GET /api/config is public (anonymous) and returns exactly two keys."""
"""GET /api/config is public (anonymous) and returns exactly three
keys — the phase-39 app metadata + the phase-59 docs flag (inert
false while BOR_DOCS_REPO is empty — the "Save as doc" gating)."""
r = client.get("/api/config")
assert r.status_code == 200
body = r.json()
assert set(body) == {"app_name", "version"}
assert set(body) == {"app_name", "version", "docs_repo_configured"}
assert body["app_name"] == "Brain of Reese"
assert body["version"] == get_settings().app_version
assert body["docs_repo_configured"] is False
def test_config_follows_overridden_app_name(client) -> None:
@@ -39,9 +42,32 @@ def test_config_follows_overridden_app_name(client) -> None:
r = client.get("/api/config")
assert r.status_code == 200
body = r.json()
assert set(body) == {"app_name", "version"}
assert set(body) == {"app_name", "version", "docs_repo_configured"}
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_docs_flag_tracks_settings(client) -> 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
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()
+536
View File
@@ -0,0 +1,536 @@
"""Integration: doc-drafts API (phase 59, task 02) — create / get / update.
The draft lifecycle the edit screen runs on: create (from a response),
fetch by token, update (modify before push) — all admin-only
(router-wide ``require_admin``), all path-guard-railed (no path that can
escape the repo root).
Real Postgres (``podman compose up -d db``); no LLM involved — drafts
are plain rows, so the suite is deterministic without a fake.
Requires: podman compose up -d db
"""
from __future__ import annotations
import subprocess
import uuid
from collections.abc import Iterator
from datetime import datetime
from pathlib import Path
from typing import Any
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select, text
from app.config import Settings, get_settings
from app.main import app as fastapi_app
from app.models import DocDraft
TITLE = "How do I deploy a new service?"
PATH = "docs/note.md"
BODY = "# Answer\n\nSome **markdown** body."
BASE_BRANCH = "main"
DOCS_BRANCH = "bor-docs"
def _settings(**kwargs: Any) -> Settings:
"""Build Settings without reading a .env file (deterministic tests)."""
kwargs.setdefault("_env_file", None)
return Settings(**kwargs) # pyright: ignore[reportCallIssue] (kwarg exists at runtime)
def _git_available() -> bool:
try:
proc = subprocess.run(["git", "--version"], capture_output=True, check=False)
return proc.returncode == 0
except (FileNotFoundError, OSError):
return False
#: The push tests drive a real local git repo — skipped (not failed) on a
#: machine without the git CLI (the task-03 unit-suite guard).
GIT = _git_available()
def _git(cwd: Path, *argv: str) -> str:
"""Run git for the tests themselves (fixture setup + assertions)."""
proc = subprocess.run(["git", *argv], cwd=cwd, capture_output=True, text=True, check=False)
assert proc.returncode == 0, f"git {' '.join(argv)} failed: {proc.stderr}"
return proc.stdout
@pytest.fixture(autouse=True)
def clean_drafts(db) -> Iterator[None]:
"""doc_drafts is global state: reset around every test."""
db.execute(text("TRUNCATE doc_drafts"))
db.commit()
yield
db.execute(text("TRUNCATE doc_drafts"))
db.commit()
def _create(admin_client: TestClient, **overrides) -> dict:
"""POST a well-formed draft (201) and return the response body."""
payload = {"title": TITLE, "path": PATH, "body": BODY, **overrides}
r = admin_client.post("/api/doc-drafts", json=payload)
assert r.status_code == 201, r.text
return r.json()
def _backdate_updated_at(db, token: uuid.UUID) -> None:
"""Push the row's ``updated_at`` one hour back (raw SQL — a hand-
written UPDATE does not trigger the column's onupdate default), so
a subsequent API write's bump is observable deterministically."""
db.execute(
text("UPDATE doc_drafts SET updated_at = now() - interval '1 hour' WHERE token = :t"),
{"t": token},
)
db.commit()
# ---------- create (POST) ----------
def test_create_returns_201_with_all_fields_and_draft_status(
admin_client: TestClient, db
) -> None:
r = admin_client.post("/api/doc-drafts", json={"title": TITLE, "path": PATH, "body": BODY})
assert r.status_code == 201
body = r.json()
assert body["title"] == TITLE
assert body["path"] == PATH
assert body["body"] == BODY
assert body["status"] == "draft"
# The push feedback columns are NULL while still a draft.
assert body["branch"] is None
assert body["commit_sha"] is None
# The token: present, non-NULL, a valid (unguessable) UUID.
assert body["token"]
tok = uuid.UUID(body["token"])
assert body["created_at"]
assert body["updated_at"]
# The row is in Postgres under the same token (the URL credential).
row = db.execute(select(DocDraft).where(DocDraft.token == tok)).scalars().one()
assert row.title == TITLE
assert row.path == PATH
assert row.body == BODY
assert row.status == "draft"
def test_create_strips_title_body_and_path(admin_client: TestClient) -> None:
body = _create(
admin_client, title=f" {TITLE} ", path=f" {PATH} ", body=f"\n{BODY}\n"
)
assert body["title"] == TITLE
assert body["path"] == PATH
assert body["body"] == BODY
def test_create_rejects_blank_title_body_path(admin_client: TestClient) -> None:
# Whitespace-only values: past pydantic's min_length=1, caught by the
# API's non-empty-after-strip rule (422), nothing stored.
for overrides in ({"title": " "}, {"body": " \t\n "}, {"path": " "}):
payload = {"title": TITLE, "path": PATH, "body": BODY, **overrides}
assert admin_client.post("/api/doc-drafts", json=payload).status_code == 422
# Truly empty title/body: pydantic 422 (min_length=1).
empty_title = {"title": "", "path": PATH, "body": BODY}
assert admin_client.post("/api/doc-drafts", json=empty_title).status_code == 422
empty_body = {"title": TITLE, "path": PATH, "body": ""}
assert admin_client.post("/api/doc-drafts", json=empty_body).status_code == 422
# ---------- path guard-rails (shared with the push endpoint) ----------
@pytest.mark.parametrize(
("bad_path", "rule_in_detail"),
[
("/etc/passwd", "absolute"),
("../x.md", "'..'"),
("a/b/../c.md", "'..'"),
("no-suffix", "suffix"),
(" ", "empty"),
],
)
def test_create_path_guard_rejects_each_rule_with_422(
admin_client: TestClient, bad_path: str, rule_in_detail: str
) -> None:
r = admin_client.post(
"/api/doc-drafts", json={"title": TITLE, "path": bad_path, "body": BODY}
)
assert r.status_code == 422
assert rule_in_detail in r.json()["detail"]
def test_create_accepts_repo_relative_path_with_suffix(admin_client: TestClient) -> None:
body = _create(admin_client, path="docs/note.md")
assert body["path"] == "docs/note.md"
def test_put_path_guard_rejects_traversal(admin_client: TestClient) -> None:
created = _create(admin_client)
for bad in ("/etc/passwd", "../x.md", "a/b/../c.md", "no-suffix"):
r = admin_client.put(
f"/api/doc-drafts/{created['token']}", json={"path": bad}
)
assert r.status_code == 422, bad
# The row is untouched by the rejected updates.
body = admin_client.get(f"/api/doc-drafts/{created['token']}").json()
assert body["path"] == PATH
# ---------- get (by token) ----------
def test_get_round_trips_created_draft(admin_client: TestClient) -> None:
created = _create(admin_client)
r = admin_client.get(f"/api/doc-drafts/{created['token']}")
assert r.status_code == 200
assert r.json() == created
def test_get_unknown_token_returns_404(admin_client: TestClient) -> None:
r = admin_client.get(f"/api/doc-drafts/{uuid.uuid4()}")
assert r.status_code == 404
assert r.json() == {"detail": "draft not found"}
def test_get_malformed_token_returns_422(admin_client: TestClient) -> None:
assert admin_client.get("/api/doc-drafts/not-a-uuid").status_code == 422
# ---------- update (PUT) ----------
def test_put_partial_body_only_keeps_title_and_path(admin_client: TestClient, db) -> None:
created = _create(admin_client)
token = uuid.UUID(created["token"])
_backdate_updated_at(db, token)
r = admin_client.put(f"/api/doc-drafts/{token}", json={"body": "# v2\n\nEdited."})
assert r.status_code == 200
body = r.json()
assert body["title"] == TITLE # absent → unchanged
assert body["path"] == PATH # absent → unchanged
assert body["body"] == "# v2\n\nEdited."
assert body["status"] == "draft"
assert body["created_at"] == created["created_at"] # editing does not redate creation
# updated_at was bumped past the backdated value.
assert datetime.fromisoformat(body["updated_at"]) > datetime.fromisoformat(
created["updated_at"]
)
def test_put_replaces_all_fields_when_supplied(admin_client: TestClient) -> None:
created = _create(admin_client)
r = admin_client.put(
f"/api/doc-drafts/{created['token']}",
json={"title": "New title", "path": "docs/other.md", "body": "New body."},
)
assert r.status_code == 200
body = r.json()
assert body["title"] == "New title"
assert body["path"] == "docs/other.md"
assert body["body"] == "New body."
assert body["status"] == "draft"
def test_put_noop_still_bumps_updated_at(admin_client: TestClient, db) -> None:
"""A PUT whose supplied values are all identical (or empty body)
changes no stored value — the ORM flushes nothing — yet the contract
is that a PUT bumps ``updated_at`` (the raw-UPDATE fallback)."""
created = _create(admin_client)
token = uuid.UUID(created["token"])
_backdate_updated_at(db, token)
r = admin_client.put(f"/api/doc-drafts/{token}", json={"body": BODY}) # identical
assert r.status_code == 200
assert r.json()["body"] == BODY
assert datetime.fromisoformat(r.json()["updated_at"]) > datetime.fromisoformat(
created["updated_at"]
)
# And an empty partial body (no fields at all) does the same.
_backdate_updated_at(db, token)
r2 = admin_client.put(f"/api/doc-drafts/{token}", json={})
assert r2.status_code == 200
assert datetime.fromisoformat(r2.json()["updated_at"]) > datetime.fromisoformat(
created["updated_at"]
)
def test_put_resets_pushed_draft_to_draft(admin_client: TestClient, db) -> None:
created = _create(admin_client)
token = uuid.UUID(created["token"])
# Mark the draft pushed directly in the DB (the push endpoint's job
# lands in task 04 — here we pin the edit-side consequence).
row = db.execute(select(DocDraft).where(DocDraft.token == token)).scalars().one()
row.status = "pushed"
row.branch = "bor-docs"
row.commit_sha = "a" * 40
db.commit()
r = admin_client.put(f"/api/doc-drafts/{token}", json={"body": "Edited after push."})
assert r.status_code == 200
body = r.json()
assert body["status"] == "draft" # the stored sha no longer describes the body
assert body["body"] == "Edited after push."
assert body["title"] == TITLE # absent → unchanged
assert body["path"] == PATH # absent → unchanged
# The last push stays visible until the next push overwrites it.
assert body["branch"] == "bor-docs"
assert body["commit_sha"] == "a" * 40
def test_put_unknown_token_returns_404(admin_client: TestClient) -> None:
r = admin_client.put(f"/api/doc-drafts/{uuid.uuid4()}", json={"body": "x"})
assert r.status_code == 404
assert r.json() == {"detail": "draft not found"}
def test_put_malformed_token_returns_422(admin_client: TestClient) -> None:
assert admin_client.put("/api/doc-drafts/not-a-uuid", json={"body": "x"}).status_code == 422
def test_put_rejects_blank_fields_and_leaves_row_unchanged(admin_client: TestClient) -> None:
created = _create(admin_client)
for bad in ({"title": " "}, {"body": " \t "}, {"path": " "}):
assert admin_client.put(f"/api/doc-drafts/{created['token']}", json=bad).status_code == 422
assert admin_client.get(f"/api/doc-drafts/{created['token']}").json() == created
# ---------- auth: anonymous gets 403 on every route ----------
def test_anonymous_gets_403_on_all_routes(admin_client: TestClient, db) -> None:
created = _create(admin_client, body="admin-created")
anon = TestClient(fastapi_app) # fresh jar: truly anonymous (no cookie)
r = anon.post("/api/doc-drafts", json={"title": "x", "path": "docs/x.md", "body": "y"})
assert r.status_code == 403
assert r.json() == {"detail": "admin only"}
assert anon.get(f"/api/doc-drafts/{created['token']}").status_code == 403
assert anon.put(f"/api/doc-drafts/{created['token']}", json={"body": "nope"}).status_code == 403
# The anonymous attempts changed nothing: exactly the admin's draft
# exists, untouched.
rows = db.execute(select(DocDraft)).scalars().all()
assert len(rows) == 1
assert rows[0].body == "admin-created"
# ---------- push (POST /{token}/push — task 04) ----------
#
# The push tests run against a **real local bare repo** (the task-03
# unit pattern) and inject the endpoint's settings via the app's
# ``Depends(get_settings)`` override (the house pattern —
# ``app/api/config.py`` takes ``settings: Settings = Depends(
# get_settings)``). Result assertions read the bare repo's state
# (``git show <branch>:<path>``, ``git rev-parse``), not the response
# alone.
@pytest.fixture()
def bare_docs_repo(tmp_path: Path) -> Path:
"""A bare origin seeded with one commit on ``main`` (``README.md``)."""
if not GIT:
pytest.skip("git is not available on this machine")
bare = tmp_path / "bare.git"
_git(tmp_path, "init", "--bare", str(bare))
seed = tmp_path / "seed"
_git(tmp_path, "clone", str(bare), str(seed))
(seed / "README.md").write_text("# docs\n", encoding="utf-8")
_git(seed, "checkout", "-B", BASE_BRANCH)
_git(
seed,
"-c", "commit.gpgsign=false",
"-c", "user.name=Test",
"-c", "user.email=t@example.com",
"add", "README.md",
)
_git(
seed,
"-c", "commit.gpgsign=false",
"-c", "user.name=Test",
"-c", "user.email=t@example.com",
"commit", "-m", "seed README",
)
_git(seed, "push", "origin", BASE_BRANCH)
return bare
@pytest.fixture()
def docs_push_settings(bare_docs_repo: Path, tmp_path: Path) -> Iterator[Settings]:
"""Settings pointing at the fixture bare repo, injected into the
endpoint's settings dependency; the override is removed after the
test (no leak into other tests' settings)."""
settings = _settings(
docs_repo=str(bare_docs_repo),
docs_branch=DOCS_BRANCH,
docs_base_branch=BASE_BRANCH,
docs_work_dir=str(tmp_path / "docs-workdir"),
)
fastapi_app.dependency_overrides[get_settings] = lambda: settings
try:
yield settings
finally:
fastapi_app.dependency_overrides.pop(get_settings, None)
def test_push_success_commits_and_records_branch_and_sha(
admin_client: TestClient, db, docs_push_settings: Settings, bare_docs_repo: Path
) -> None:
created = _create(admin_client)
_backdate_updated_at(db, uuid.UUID(created["token"]))
r = admin_client.post(f"/api/doc-drafts/{created['token']}/push")
assert r.status_code == 200, r.text
body = r.json()
assert body["status"] == "pushed"
assert body["branch"] == DOCS_BRANCH
sha = body["commit_sha"]
assert len(sha) == 40
# The source of truth is the bare repo's state — not the response:
# the file landed on the branch at exactly the returned sha, with
# the draft's body, under the fixed per-invocation identity.
assert _git(bare_docs_repo, "rev-parse", DOCS_BRANCH).strip() == sha
assert _git(bare_docs_repo, "show", f"{DOCS_BRANCH}:{PATH}") == BODY
ident = _git(bare_docs_repo, "log", "-1", DOCS_BRANCH, "--format=%an <%ae>").strip()
assert ident == "Brain of Reese <bor@local>"
assert _git(bare_docs_repo, "log", "-1", DOCS_BRANCH, "--format=%s").strip() == (
f"docs: {TITLE}"
)
# The DB row records the outcome (status + branch + sha), and the
# GET endpoint reports it.
row = db.execute(
select(DocDraft).where(DocDraft.token == uuid.UUID(created["token"]))
).scalars().one()
assert row.status == "pushed"
assert row.branch == DOCS_BRANCH
assert row.commit_sha == sha
got = admin_client.get(f"/api/doc-drafts/{created['token']}").json()
assert got["status"] == "pushed"
assert got["branch"] == DOCS_BRANCH
assert got["commit_sha"] == sha
# The success bumped updated_at (past the backdated value).
assert datetime.fromisoformat(got["updated_at"]) > datetime.fromisoformat(
created["updated_at"]
)
def test_push_unconfigured_returns_409_naming_variable(admin_client: TestClient, db) -> None:
"""Default settings (``docs_repo=""``) → 409 naming the variable;
the row stays a draft (D3: inert by default)."""
created = _create(admin_client)
fastapi_app.dependency_overrides[get_settings] = lambda: _settings() # docs_repo=""
try:
r = admin_client.post(f"/api/doc-drafts/{created['token']}/push")
finally:
fastapi_app.dependency_overrides.pop(get_settings, None)
assert r.status_code == 409
assert r.json() == {"detail": "docs repo not configured (BOR_DOCS_REPO)"}
body = admin_client.get(f"/api/doc-drafts/{created['token']}").json()
assert body["status"] == "draft"
assert body["branch"] is None
assert body["commit_sha"] is None
def test_push_non_repo_dir_returns_502_with_git_stderr(
admin_client: TestClient, db, tmp_path: Path
) -> None:
"""A configured repo that is not a git repo → 502 with git's stderr
in the detail (the ``GitSyncError`` → ``detail`` mapping);
the row stays a draft (only a success mutates)."""
plain = tmp_path / "not-a-repo"
plain.mkdir()
(plain / "file.txt").write_text("not a repo\n", encoding="utf-8")
fastapi_app.dependency_overrides[
get_settings
] = lambda: _settings(
docs_repo=str(plain),
docs_branch=DOCS_BRANCH,
docs_base_branch=BASE_BRANCH,
docs_work_dir=str(tmp_path / "docs-workdir"),
)
created = _create(admin_client)
try:
r = admin_client.post(f"/api/doc-drafts/{created['token']}/push")
finally:
fastapi_app.dependency_overrides.pop(get_settings, None)
assert r.status_code == 502
detail = r.json()["detail"]
# git's stderr is surfaced (the clone refusal of a non-repo dir).
assert "failed" in detail
assert "fatal: repository" in detail
body = admin_client.get(f"/api/doc-drafts/{created['token']}").json()
assert body["status"] == "draft"
assert body["branch"] is None
assert body["commit_sha"] is None
def test_push_unknown_token_returns_404(
admin_client: TestClient, docs_push_settings: Settings
) -> None:
r = admin_client.post(f"/api/doc-drafts/{uuid.uuid4()}/push")
assert r.status_code == 404
assert r.json() == {"detail": "draft not found"}
def test_push_rejects_bad_stored_path_with_422(
admin_client: TestClient, db, docs_push_settings: Settings
) -> None:
"""A row whose stored path no longer passes the guard-rails must not
be pushable (422 naming the rule — re-validated on push, task 02
helper); the row is untouched."""
bad = DocDraft(token=uuid.uuid4(), title=TITLE, path="../evil.md", body=BODY)
db.add(bad)
db.commit()
db.refresh(bad)
r = admin_client.post(f"/api/doc-drafts/{bad.token}/push")
assert r.status_code == 422
assert "'..'" in r.json()["detail"]
row = db.get(DocDraft, bad.id)
assert row is not None
assert row.status == "draft"
assert row.branch is None
assert row.commit_sha is None
def test_push_anonymous_returns_403(
admin_client: TestClient,
db,
docs_push_settings: Settings,
bare_docs_repo: Path,
) -> None:
created = _create(admin_client)
anon = TestClient(fastapi_app) # fresh jar: truly anonymous (no cookie)
r = anon.post(f"/api/doc-drafts/{created['token']}/push")
assert r.status_code == 403
assert r.json() == {"detail": "admin only"}
# The anonymous push attempt changed nothing: no branch on the bare
# repo, the row is still a draft (guest reads 403 too).
assert _git(bare_docs_repo, "branch", "--list", DOCS_BRANCH).strip() == ""
assert anon.get(f"/api/doc-drafts/{created['token']}").status_code == 403
row = db.execute(
select(DocDraft).where(DocDraft.token == uuid.UUID(created["token"]))
).scalars().one()
assert row.status == "draft"
+322
View File
@@ -0,0 +1,322 @@
"""Integration: migration 0011 (doc_drafts) schema contract.
Drives the **real Alembic engine** against the live dev database
(``podman compose up -d db``), mirroring the house pattern of
``test_migration_0010.py`` (information_schema / pg_indexes assertions
on the state the migration must leave). The tests target revision
``0011`` explicitly so later migrations cannot break them:
* upgrade 0010 → 0011 → the ``doc_drafts`` table exists with the full
column contract (``id`` UUID PK; ``token`` UUID NOT NULL + the UNIQUE
index ``ix_doc_drafts_token`` — the URL credential; ``title`` /
``path`` / ``body`` TEXT NOT NULL; ``status`` TEXT NOT NULL default
'draft'; ``branch`` / ``commit_sha`` TEXT NULL; ``created_at`` /
``updated_at`` TIMESTAMPTZ NOT NULL default now());
* inserted rows round-trip: an omitted ``status`` defaults to 'draft'
with NULL ``branch`` / ``commit_sha`` (the pre-push state) and both
timestamps are stamped server-side; explicit push-state values
round-trip verbatim;
* two identical tokens are rejected by the unique index (the token is
a unique handle — the share-token precedent, phase 51);
* downgrade to 0010 → the table and index are gone (A13 — reversible),
the rest of the schema (e.g. ``saved_chats.share_token``) survives;
* upgrade back to 0011 → the table and the unique index are 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 uuid
from collections.abc import Iterator
from typing import Any
import pytest
from alembic.config import Config
from sqlalchemy import text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from alembic import command
from app.db import db_available
@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 _version(db: Session) -> str | None:
return db.execute(text("SELECT version_num FROM alembic_version")).scalar()
def _table_exists(db: Session, table: str) -> bool:
count: Any = db.execute(
text(
"SELECT count(*) FROM information_schema.tables"
" WHERE table_schema = 'public' AND table_name = :t"
),
{"t": table},
).scalar()
assert count is not None, "information_schema count must be an int"
return int(count) == 1
def _column(db: Session, table: str, column: str) -> tuple[Any, ...] | None:
"""(data_type, is_nullable, column_default) for one table column."""
row = db.execute(
text(
"SELECT data_type, is_nullable, column_default"
" FROM information_schema.columns"
" WHERE table_name = :t AND column_name = :c"
),
{"t": table, "c": column},
).fetchone()
return tuple(row) if row is not None else None
def _unique_token_index(db: Session) -> int:
"""1 iff ``ix_doc_drafts_token`` exists as a UNIQUE index."""
count: Any = db.execute(
text(
"SELECT count(*) FROM pg_indexes"
" WHERE tablename = 'doc_drafts'"
" AND indexname = 'ix_doc_drafts_token'"
),
).scalar()
assert count is not None, "pg_indexes count must be an int"
is_unique: Any = db.execute(
text(
"SELECT indisunique FROM pg_index"
" WHERE indexrelid = (SELECT oid FROM pg_class WHERE relname = 'ix_doc_drafts_token')"
),
).scalar()
return int(count) if is_unique else 0
def _insert(
db: Session,
*,
token: uuid.UUID | None = None,
status: str | None = None,
branch: str | None = None,
commit_sha: str | None = None,
) -> uuid.UUID:
"""Insert one doc_drafts row. ``status=None`` omits the column
(server-default path); a ``token`` is always supplied — the
migration carries no server default (the ORM/API supplies it)."""
cols = ["id", "token", "title", "path", "body"]
params: dict[str, Any] = {
"t": "Mig 0011",
"p": "docs/mig-0011.md",
"b": "# Phase 59 migration probe\n",
}
if token is not None:
params["tok"] = token
if status is not None:
cols.append("status")
params["s"] = status
if branch is not None:
cols.append("branch")
params["br"] = branch
if commit_sha is not None:
cols.append("commit_sha")
params["sha"] = commit_sha
sql = (
f"INSERT INTO doc_drafts ({', '.join(cols)}) VALUES ("
"gen_random_uuid(), :tok, :t, :p, :b"
+ (", :s" if status is not None else "")
+ (", :br" if branch is not None else "")
+ (", :sha" if commit_sha is not None else "")
+ ") RETURNING id"
)
draft_id: uuid.UUID = db.execute(text(sql), params).scalar_one()
db.commit()
return draft_id
def _delete(db: Session, draft_id: uuid.UUID) -> None:
db.execute(text("DELETE FROM doc_drafts WHERE id = :i"), {"i": draft_id})
db.commit()
def test_upgrade_to_0011_adds_doc_drafts(db: Session, alembic: Config) -> None:
"""Upgrade 0010 → 0011: the table + the unique token index exist
with the full column contract; the table is absent at 0010."""
command.downgrade(alembic, "0010") # start from the pre-0011 state
assert _version(db) == "0010"
assert not _table_exists(db, "doc_drafts"), "doc_drafts must be absent at 0010"
assert _unique_token_index(db) == 0, "the token index must be absent at 0010"
command.upgrade(alembic, "0011")
assert _version(db) == "0011", "alembic_version must be at 0011"
assert _table_exists(db, "doc_drafts"), "doc_drafts must exist at 0011"
id_col = _column(db, "doc_drafts", "id")
assert id_col is not None, "doc_drafts.id is missing"
assert id_col[0] == "uuid", "doc_drafts.id must be UUID"
assert id_col[1] == "NO", "doc_drafts.id must be NOT NULL (PK)"
token = _column(db, "doc_drafts", "token")
assert token is not None, "doc_drafts.token is missing"
assert token[0] == "uuid", "doc_drafts.token must be UUID"
assert token[1] == "NO", "doc_drafts.token must be NOT NULL (no un-drafted state)"
assert _unique_token_index(db) == 1, "the unique token index is missing"
for name in ("title", "path", "body"):
col = _column(db, "doc_drafts", name)
assert col is not None, f"doc_drafts.{name} is missing"
assert col[0] == "text", f"doc_drafts.{name} must be TEXT"
assert col[1] == "NO", f"doc_drafts.{name} must be NOT NULL"
status = _column(db, "doc_drafts", "status")
assert status is not None, "doc_drafts.status is missing"
assert status[0] == "text", "doc_drafts.status must be TEXT"
assert status[1] == "NO", "doc_drafts.status must be NOT NULL"
assert str(status[2]).startswith("'draft'"), (
"doc_drafts.status must have server default 'draft'"
)
for name in ("branch", "commit_sha"):
col = _column(db, "doc_drafts", name)
assert col is not None, f"doc_drafts.{name} is missing"
assert col[0] == "text", f"doc_drafts.{name} must be TEXT"
assert col[1] == "YES", f"doc_drafts.{name} must be NULL until pushed"
for name in ("created_at", "updated_at"):
col = _column(db, "doc_drafts", name)
assert col is not None, f"doc_drafts.{name} is missing"
assert col[0] == "timestamp with time zone", (
f"doc_drafts.{name} must be TIMESTAMPTZ"
)
assert col[1] == "NO", f"doc_drafts.{name} must be NOT NULL"
assert str(col[2]).startswith("now("), (
f"doc_drafts.{name} must have server default now()"
)
def test_inserted_rows_round_trip_the_pre_push_and_pushed_states(
db: Session, alembic: Config
) -> None:
"""At 0011, an omitted status defaults to 'draft' with NULL
branch/commit_sha (the pre-push state) and both timestamps are
stamped server-side; explicit push-state values round-trip
verbatim."""
command.upgrade(alembic, "head")
draft_token = uuid.uuid4()
draft_id = _insert(db, token=draft_token)
pushed_token = uuid.uuid4()
pushed_id = _insert(
db,
token=pushed_token,
status="pushed",
branch="bor-docs",
commit_sha="a" * 40,
)
try:
row = db.execute(
text(
"SELECT token, status, branch, commit_sha, created_at, updated_at"
" FROM doc_drafts WHERE id = :i"
),
{"i": draft_id},
).fetchone()
assert row is not None, "the draft row must exist"
assert row[0] == draft_token, "the token must round-trip verbatim"
assert row[1] == "draft", "an omitted status must default to 'draft'"
assert row[2] is None and row[3] is None, (
"branch/commit_sha must be NULL before the push endpoint runs"
)
assert row[4] is not None and row[5] is not None, (
"created_at/updated_at must be stamped server-side"
)
pushed = db.execute(
text(
"SELECT status, branch, commit_sha FROM doc_drafts WHERE id = :i"
),
{"i": pushed_id},
).fetchone()
assert pushed is not None, "the pushed row must exist"
assert tuple(pushed) == ("pushed", "bor-docs", "a" * 40), (
"explicit push-state values must round-trip verbatim"
)
finally:
_delete(db, draft_id)
_delete(db, pushed_id)
def test_unique_index_rejects_duplicate_tokens(db: Session, alembic: Config) -> None:
"""Two identical tokens are rejected by the unique index — the
token is the unique URL credential (the share-token precedent,
phase 51); a distinct token still lands."""
command.upgrade(alembic, "head")
dup_token = uuid.uuid4()
first_id = _insert(db, token=dup_token)
other_id: uuid.UUID | None = None
try:
try:
_insert(db, token=dup_token)
except IntegrityError:
db.rollback() # the aborted transaction must not leak
else:
pytest.fail("a duplicate doc_drafts.token must be rejected")
# A different token is fine — only the exact duplicate is unique.
other_id = _insert(db, token=uuid.uuid4())
finally:
_delete(db, first_id)
if other_id is not None:
_delete(db, other_id)
def test_downgrade_to_0010_drops_the_table(db: Session, alembic: Config) -> None:
"""Downgrade to 0010: the table and the unique index are gone
(A13 — reversible) while the rest of the schema survives."""
command.downgrade(alembic, "0010")
assert _version(db) == "0010"
assert not _table_exists(db, "doc_drafts"), "doc_drafts must be dropped"
assert _unique_token_index(db) == 0, "the token index must be dropped"
token_col = _column(db, "saved_chats", "share_token")
assert token_col is not None and token_col[0] == "uuid", (
"saved_chats.share_token must survive the downgrade"
)
meta = _column(db, "sources_meta", "version")
assert meta is not None and meta[0] == "integer", (
"sources_meta.version must survive the downgrade"
)
def test_upgrade_round_trip_restores_the_table(db: Session, alembic: Config) -> None:
"""Downgrade to 0010, then upgrade back to 0011: the table and the
unique index are back."""
command.downgrade(alembic, "0010")
command.upgrade(alembic, "0011")
assert _version(db) == "0011", "round-trip upgrade must land at 0011"
assert _table_exists(db, "doc_drafts"), "doc_drafts must be back"
assert _unique_token_index(db) == 1, "the unique token index must be back"
status = _column(db, "doc_drafts", "status")
assert status is not None and status[1] == "NO", (
"status must be TEXT NOT NULL after the round-trip"
)
assert str(status[2]).startswith("'draft'"), (
"status must default to 'draft' after the round-trip"
)
+1
View File
@@ -212,6 +212,7 @@ def test_html_pages_include_history() -> None:
"/git-sources.html",
"/history.html",
"/shared.html", # phase 51: the shared page's static path
"/doc-edit.html", # phase 59: the doc edit screen (task 06)
):
assert path in caching.HTML_PAGES, f"{path} must be in HTML_PAGES"
+118
View File
@@ -279,3 +279,121 @@ def test_effective_api_key_fallback(monkeypatch) -> None:
monkeypatch.setenv("AIPI_KEY", "sk-from-env")
s2 = _settings()
assert s2.effective_api_key == "sk-from-env"
# --- Docs push (phase 59) ---
def test_docs_push_defaults_are_inert() -> None:
"""Phase 59, D3: no docs repo by default — the feature is
inert-by-default (button hidden, push endpoint 409s — the
optional-feature pattern of the git-sources env fallback), and the
branch/base defaults + raw work-dir string are in place."""
s = _settings()
assert s.docs_repo == ""
assert s.docs_configured is False
assert s.docs_branch == "bor-docs"
assert s.docs_base_branch == "main"
# Raw string on purpose — Path.expanduser() is applied by the push
# service, not the setting (the sources_dir/upload_dir convention).
assert s.docs_work_dir == "~/bor-docs"
def test_docs_repo_set_is_configured(monkeypatch: pytest.MonkeyPatch) -> None:
"""A non-empty ``BOR_DOCS_REPO`` turns the feature on — a URL or a
local path (D3: generic remote, no scheme parsing here)."""
for repo in ("/path/to/docs-repo", "https://git.example.com/docs.git"):
monkeypatch.setenv("BOR_DOCS_REPO", repo)
s = _settings()
assert s.docs_configured is True
assert s.docs_repo == repo
# Whitespace-only behaves like empty: still inert.
monkeypatch.setenv("BOR_DOCS_REPO", " ")
assert _settings().docs_configured is False
def test_docs_branch_env_override(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("BOR_DOCS_BRANCH", raising=False)
monkeypatch.delenv("BOR_DOCS_BASE_BRANCH", raising=False)
assert _settings().docs_branch == "bor-docs"
assert _settings().docs_base_branch == "main"
monkeypatch.setenv("BOR_DOCS_BRANCH", "docs-pr")
monkeypatch.setenv("BOR_DOCS_BASE_BRANCH", "master")
s = _settings()
assert s.docs_branch == "docs-pr"
assert s.docs_base_branch == "master"
def test_docs_work_dir_env_override_is_raw_string(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("BOR_DOCS_WORK_DIR", "/data/bor/docs")
s = _settings()
assert s.docs_work_dir == "/data/bor/docs"
def test_docs_branch_whitespace_fails_loudly_when_repo_set(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A whitespace-bearing branch would corrupt a ``git checkout``
argument — fail loud at startup, naming the field (the
``agent_max_rounds`` pattern)."""
monkeypatch.setenv("BOR_DOCS_REPO", "/path/to/docs-repo")
monkeypatch.setenv("BOR_DOCS_BRANCH", "bor docs")
with pytest.raises(ValidationError, match="docs_branch"):
_settings()
def test_docs_branch_dotdot_fails_loudly_when_repo_set(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""``..`` is a path-traversal token, never part of a branch name.
A blank branch is rejected too (empty while a repo is set)."""
monkeypatch.setenv("BOR_DOCS_REPO", "/path/to/docs-repo")
monkeypatch.setenv("BOR_DOCS_BRANCH", "a..b")
with pytest.raises(ValidationError, match="docs_branch"):
_settings()
monkeypatch.setenv("BOR_DOCS_BRANCH", " ")
with pytest.raises(ValidationError, match="docs_branch"):
_settings()
def test_docs_base_branch_invalid_fails_loudly_naming_field(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The base branch gets the same token shape check — the error
names ``docs_base_branch``, not the sibling field."""
monkeypatch.setenv("BOR_DOCS_REPO", "/path/to/docs-repo")
monkeypatch.setenv("BOR_DOCS_BASE_BRANCH", "bad branch")
with pytest.raises(ValidationError, match="docs_base_branch"):
_settings()
monkeypatch.setenv("BOR_DOCS_BASE_BRANCH", "a..b")
with pytest.raises(ValidationError, match="docs_base_branch"):
_settings()
def test_docs_branchs_valid_when_repo_set(monkeypatch: pytest.MonkeyPatch) -> None:
"""Repo set + well-formed branch tokens boot cleanly and the
feature is configured (dash/dot/slash branch names are legal git
refs and stay accepted)."""
monkeypatch.setenv("BOR_DOCS_REPO", "/path/to/docs-repo")
s = _settings() # defaults bor-docs / main
assert s.docs_configured is True
monkeypatch.setenv("BOR_DOCS_BRANCH", "feature/docs-update")
monkeypatch.setenv("BOR_DOCS_BASE_BRANCH", "develop")
s2 = _settings()
assert s2.docs_configured is True
assert s2.docs_branch == "feature/docs-update"
assert s2.docs_base_branch == "develop"
def test_docs_branchs_garbage_ignored_when_repo_unset(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""All-or-nothing: while the repo is empty the feature is inert, so
the (ignored) branch values must NOT block startup — only a
configured repo makes the shape check apply."""
monkeypatch.delenv("BOR_DOCS_REPO", raising=False)
monkeypatch.setenv("BOR_DOCS_BRANCH", "bor docs..")
monkeypatch.setenv("BOR_DOCS_BASE_BRANCH", "..")
s = _settings()
assert s.docs_configured is False
assert s.docs_branch == "bor docs.." # stored verbatim, never used
+545
View File
@@ -0,0 +1,545 @@
"""Unit: the phase-59 task-06 doc edit screen (``/doc-edit.html``).
No Python logic exists for this task — the behavior lives in
``frontend/doc-edit.html`` + ``frontend/assets/doc-edit.js`` +
``styles.css``, and it is E2E-gated by the story suite (task 07). Like
the other frontend-adjacent unit files (``test_history_page.py``,
``test_save_as_doc_button.py``), this module pins the HTML/JS/CSS
markers the edit loop depends on, so a silent regression is caught
without a browser:
* the house shell (AGENTS.md rule 5 + the login.html/shared.html
minimal-flow-page lineage): skip-link, the SLIM header (brand +
"Back to chat" — no nav), the 46rem base column (hard-coded — a form
column, NOT ``--chat-column``), the ``container`` frame;
* the form contract: ``#draft-title`` / ``#draft-path`` /
``#draft-body`` with visible labels, ``#push-doc-btn`` (the exact
"Push to docs branch" copy) + the back link, ``#push-status``
(``role="status" aria-live="polite"``) and the hidden ``#push-error``
(``role="alert"``);
* the admin gate — the ``sources-gate`` pattern, ship-hidden, with the
no-JS ``?next=`` fallback (the page is static; the API is the
authority — the draft endpoints are admin-only regardless);
* the JS: the whoami gate (anonymous branch makes NO ``/api/doc-drafts``
call), the token handling (missing → "No draft specified.",
non-uuid → "Draft not found." with no fetch), the three API paths
(GET draft / PUT edits / POST push — the PUT runs BEFORE the push:
the endpoint commits the row, so unsaved edits would push stale
text), the §7.4 never-stale lifecycle (disable + "Pushing…",
re-enable in the finally), the success line
(``Pushed to <branch> — commit <sha7>.``), the failure banner
(git's stderr trimmed to its first meaningful lines, fields
preserved), and VALUES-not-innerHTML everywhere.
The Containerfile stage-1 coverage (doc-edit.html copied, doc-edit.js
bundled) and the cache-busting registration (``/doc-edit.html`` in
``HTML_PAGES``) are pinned by ``test_containerfile_assets.py`` /
``test_caching.py``.
"""
from __future__ import annotations
import re
from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
ASSETS = FRONTEND / "assets"
DOC_EDIT_HTML = FRONTEND / "doc-edit.html"
DOC_EDIT_JS = ASSETS / "doc-edit.js"
STYLES_CSS = ASSETS / "styles.css"
def _html() -> str:
assert DOC_EDIT_HTML.is_file(), "frontend/doc-edit.html is missing"
return DOC_EDIT_HTML.read_text(encoding="utf-8")
def _js() -> str:
assert DOC_EDIT_JS.is_file(), "frontend/assets/doc-edit.js is missing"
return DOC_EDIT_JS.read_text(encoding="utf-8")
def _css() -> str:
return STYLES_CSS.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 doc-edit.js"
return js[start : js.find("\n}\n", start) + 4]
# ---------- the house shell (AGENTS.md rule 5) ----------
def test_page_scaffold_slim_header_and_landmarks() -> None:
"""The minimal-flow-page scaffold (the login.html/shared.html
lineage): skip-link, the SLIM header (brand + the "Back to chat"
link to / — and NO nav: this is a flow page, not one of the app's
pages), ``<main id="main" class="app-main" tabindex="-1">`` with
the ``container`` frame, and the house footer."""
html = _html()
assert '<a class="skip-link" href="#main">Skip to content</a>' in html
assert 'class="app-header"' in html
# The slim header: the brand + the back link.
assert '<span class="brand-text">Brain of <strong>Reese</strong></span>' in html
back = re.search(r'<a[^>]*class="doc-edit-back"[^>]*href="/"[^>]*>', html)
assert back, "the header must carry the 'Back to chat' link to /"
assert "<span>Back to chat</span>" in html
# And NO nav — the flow-page lineage (no hamburger, no app-nav).
assert 'id="app-nav"' not in html, "the slim header ships no nav"
assert 'id="nav-toggle"' not in html, "the slim header ships no hamburger"
assert "<main id=\"main\" class=\"app-main\" tabindex=\"-1\">" in html
assert '<div class="container doc-edit-shell">' in html
assert 'class="app-footer"' in html
def test_page_title_and_description() -> None:
"""The page's identity: the house title shape (<name> · Brain of
Reese) + a description naming the admin-only flow."""
html = _html()
assert "<title>Edit doc · Brain of Reese</title>" in html
desc = re.search(r'<meta name="description" content="([^"]+)"', html)
assert desc, "the page must carry a meta description"
assert "admin" in desc.group(1).lower()
# ---------- the form contract ----------
def test_form_fields_have_labels_and_ids() -> None:
"""The three fields (task 06): #draft-title (text), #draft-path
(text), #draft-body (textarea) — each with a VISIBLE
``<label for=…>`` (WCAG input-label rule), the text inputs
``required`` (the browser's native prompt is the first line of
sanity), the body a <textarea>."""
html = _html()
for field_id, tag in (
("draft-title", "input"),
("draft-path", "input"),
("draft-body", "textarea"),
):
assert f'<label for="{field_id}">' in html, (
f"#{field_id} needs a visible label"
)
field = re.search(rf"<{tag}[^>]*id=\"{field_id}\"[^>]*>", html)
assert field, f"#{field_id} is missing"
title = re.search(r"<input[^>]*id=\"draft-title\"[^>]*>", html)
path = re.search(r"<input[^>]*id=\"draft-path\"[^>]*>", html)
body = re.search(r"<textarea[^>]*id=\"draft-body\"[^>]*>", html)
assert title and path and body, "the draft field tags are missing"
title, path, body = title.group(0), path.group(0), body.group(0)
for f in (title, path, body):
assert "required" in f, "the native `required` is the first line"
assert "type=\"text\"" in title and "type=\"text\"" in path
def test_push_button_and_back_link_actions() -> None:
"""The actions (task 06): #push-doc-btn — the primary, exact copy
"Push to docs branch" — and the back link to / (the form's second
action; the header carries its own copy)."""
html = _html()
btn = re.search(r"<button[^>]*id=\"push-doc-btn\"[^>]*>", html)
assert btn, "#push-doc-btn is missing"
assert "type=\"submit\"" in btn.group(0), (
"the push button submits the form (the handler preventDefaults)"
)
assert ">Push to docs branch</button>" in html, (
"the exact house copy: 'Push to docs branch'"
)
# A back link inside the actions row (href="/").
actions = html[html.find('class="doc-edit-actions"'):]
actions = actions[: actions.find("</form>")]
assert re.search(r'<a[^>]*class="doc-edit-back"[^>]*href="/"[^>]*>', actions), (
"the actions row carries its own back link to /"
)
def test_feedback_live_region_and_error_banner() -> None:
"""The "never stale" feedback contract (phase 55 convention,
task 06): #push-status is the polite live region
(role="status" aria-live="polite"); #push-error is the alert
banner — SHIPS hidden (role="alert")."""
html = _html()
status = re.search(r'<[a-z]+[^>]*id="push-status"[^>]*>', html)
assert status, "#push-status is missing"
assert 'role="status"' in status.group(0)
assert 'aria-live="polite"' in status.group(0)
error = re.search(r'<[a-z]+[^>]*id="push-error"[^>]*>', html)
assert error, "#push-error is missing"
assert 'role="alert"' in error.group(0)
assert "hidden" in error.group(0), "the error banner ships hidden"
# ---------- the admin gate (the sources-gate pattern) ----------
def test_admin_gate_ships_hidden_with_no_js_fallback() -> None:
"""The gate: the EXACT .sources-gate pattern (phase 16/35/50),
ship-hidden (the admin never sees it; the content div ships hidden
too — anonymous-safe), the labelled h2, and the Sign in link whose
static ?next= returns the admin to THIS page after login (the
no-JS fallback)."""
html = _html()
gate = re.search(r'<section[^>]*class="sources-gate"[^>]*id="doc-edit-gate"[^>]*>', html)
assert gate, "the #doc-edit-gate section (sources-gate pattern) is missing"
assert "hidden" in gate.group(0), "the gate ships hidden"
assert 'aria-labelledby="doc-edit-gate-title"' in gate.group(0)
assert '<h2 id="doc-edit-gate-title">' in html
assert '<a class="sources-gate-link" href="/login.html?next=/doc-edit.html">Sign in</a>' in html
# The content ships hidden too (the gate is what anonymous sees).
content = re.search(r'<div[^>]*id="doc-edit-content"[^>]*>', html)
assert content and "hidden" in content.group(0), (
"#doc-edit-content must ship hidden (anonymous-safe)"
)
# ---------- scripts + no CDN ----------
def test_script_load_order_and_no_cdn() -> None:
"""The house script order: brand.js (classic) FIRST, the doc-edit.js
module second; NO direct header.js <script> tag (single-evaluation
design — doc-edit.js imports it relatively); no external
src=/href= (AGENTS.md rule 6 — No CDN)."""
html = _html()
srcs = re.findall(r'<script[^>]*src="([^"]+)"', html)
assert srcs == ["assets/brand.js", "/assets/doc-edit.js"], (
f"doc-edit.html must load brand.js (classic, first) + the "
f"doc-edit.js module, got {srcs}"
)
js = _js()
assert 'from "./header.js"' in js, (
"doc-edit.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)"
)
# ---------- boot: the whoami gate ----------
def test_anonymous_boot_makes_no_drafts_request() -> None:
"""The whoami gate in the boot IIFE: ``fetchIsAdmin()`` (the
header.js cached whoami — the single /api/whoami call site) decides
the gate. Anonymous: the gate shows, the content stays hidden, a
bare return — and NO /api/doc-drafts call on the wire (the draft
API is admin-only regardless; the story E2E pins the request
log). Only the admin path reaches the token read + loadDraft."""
js = _js()
assert "fetchIsAdmin" in js, "the gate must run on the cached whoami"
boot = js[js.find("(async () => {"):]
assert boot, "the boot IIFE must exist"
gate_i = boot.find("const admin = await fetchIsAdmin();")
assert gate_i != -1, "boot must await fetchIsAdmin() first"
branch = boot[gate_i : boot.find("return;", gate_i)]
assert "fetch(" not in branch, (
"the anonymous branch must not fetch anything (no draft leak)"
)
assert "gateEl.hidden = false" in branch
assert "contentEl.hidden = true" in branch
# The admin path: the gate hides, the content reveals, the token
# is read, and only THEN does the draft load.
after = boot[boot.find("return;", gate_i):]
assert "gateEl.hidden = true" in after
assert "contentEl.hidden = false" in after
token_i = after.find('new URLSearchParams(window.location.search).get("draft")')
assert token_i != -1, "boot must read ?draft=<token>"
assert after.find("await loadDraft(token)") > token_i
def test_token_missing_and_malformed_copy() -> None:
"""The token handling: missing → the error banner "No draft
specified."; a non-uuid token → "Draft not found." with NO fetch
(the shared.js malformed-token precedent — a 422 validation line
is framework noise, not a house message)."""
js = _js()
boot = js[js.find("(async () => {"):]
missing_i = boot.find('showError("No draft specified.")')
assert missing_i != -1, "the missing-token banner copy is pinned"
# The uuid shape check gates the fetch (malformed → no request).
malformed_i = boot.find("UUID_RE.test(token)")
assert malformed_i != -1, "the uuid shape check must gate the fetch"
after_malformed = boot[malformed_i : malformed_i + 300]
assert 'showError("Draft not found.")' in after_malformed
assert "fetch(" not in after_malformed, ("a malformed token must not fetch")
# The regex is the 8-4-4-4-12 uuid shape (case-insensitive).
assert "/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i" in js
# And draftToken (the push's credential) is set only after the
# checks — the load follows.
assert boot.find("draftToken = token;") > malformed_i
assert boot.find("await loadDraft(token)") > boot.find("draftToken = token;")
# ---------- the three API paths ----------
def test_the_three_api_paths() -> None:
"""The edit loop's three draft API paths (task 06): GET
/api/doc-drafts/<token> (load — in loadDraft), PUT
/api/doc-drafts/<token> (persist the edits) and POST
/api/doc-drafts/<token>/push (the single mutation). The PUT runs
BEFORE the push: the push endpoint commits the ROW's
title/path/body, so an unsaved edit would push stale text."""
js = _js()
load = _fn(js, "loadDraft")
assert 'fetch(`/api/doc-drafts/${token}`)' in load, (
"loadDraft must GET the draft by token"
)
assert "push" not in load.lower().replace("pushing", ""), (
"loadDraft must not push (it only loads)"
)
push_fn = js[js.find("function wirePush() {"):]
put_i = push_fn.find('method: "PUT"')
post_i = push_fn.find("/push")
assert put_i != -1 and post_i != -1, "the PUT + the POST /push must both exist"
assert put_i < post_i, "the PUT (persist edits) must run BEFORE the push"
assert 'fetch(`/api/doc-drafts/${draftToken}/push`, {' in push_fn, (
"the push endpoint is POST /api/doc-drafts/<token>/push"
)
# Exactly one call per path — no duplicate fetch sites.
assert js.count("doc-drafts") >= 3
def test_load_fill_is_values_not_innerhtml() -> None:
"""The 200 body fills the three fields with VALUES
(``.value`` = textContent discipline) — NEVER innerHTML: the body
is user-derived markdown, and the title/path may contain anything
but markup. The whole file builds no HTML at all (the page markup
is static; JS only reads/sets values and hidden flags)."""
js = _js()
load = _fn(js, "loadDraft")
assert "titleInput.value = draft.title" in load
assert "pathInput.value = draft.path" in load
assert "bodyInput.value = draft.body" in load
assert "innerHTML" not in js, "doc-edit.js must never build HTML"
def test_load_outcome_copy() -> None:
"""loadDraft's failure lines: 404 → "Draft not found." (no
enumeration — one message for every unknown token), other non-2xx
→ the server's detail (422 shape-aware), a network failure → the
fixed one-line copy."""
js = _js()
load = _fn(js, "loadDraft")
assert 'showError("Draft not found.")' in load
assert "r.status === 404" in load
assert "apiDetail(" in load, "non-2xx must surface the server detail"
assert "is the app running?" in load, "the network-failure line"
# The 404 check runs before the generic non-2xx arm.
assert load.find("r.status === 404") < load.find("if (!r.ok)")
# ---------- push: the never-stale lifecycle ----------
def test_push_sanity_checks_before_any_request() -> None:
"""The client-side sanity (the server is the authority — it
re-runs the guard-rails): non-empty title, non-empty body, no
".." in the path. Each violation lands the error banner, focuses
the offending field, and returns BEFORE any fetch — and without a
token the banner says "No draft specified." (no fetch)."""
js = _js()
fn = js[js.find("function wirePush() {"):]
title_i = fn.find('showError("Enter a title for the doc.")')
body_i = fn.find('showError("The doc body must not be empty.")')
path_i = fn.find("path.includes(\"..\")")
assert title_i != -1 and body_i != -1 and path_i != -1, (
"the three sanity checks must exist"
)
assert title_i < body_i < path_i, "title, body, path — in field order"
# Each violation focuses its field (keyboard a11y).
assert "titleInput.focus()" in fn
assert "bodyInput.focus()" in fn
assert "pathInput.focus()" in fn
# No token → the banner, no fetch (the first fetch comes later).
notoken_i = fn.find('showError("No draft specified.")')
first_fetch = fn.find("await fetch(")
assert -1 < notoken_i < first_fetch
def test_push_disables_relabels_and_reenables() -> None:
"""The §7.4 never-stale lifecycle: the button disables +
relabels "Pushing…" AND the live region says "Pushing…" while the
request is out; the finally re-enables the button with its idle
label (IDLE_LABEL = the exact static copy) on EVERY outcome —
success OR failure, success AND failure."""
js = _js()
fn = js[js.find("function wirePush() {"):]
disable_i = fn.find("pushBtn.disabled = true")
relabel_i = fn.find('pushBtn.textContent = "Pushing…"')
status_i = fn.find('setStatus("Pushing…")')
assert disable_i != -1 and relabel_i != -1 and status_i != -1, (
"disable + relabel + status before the requests"
)
fetch_i = fn.find("await fetch(")
assert -1 < status_i < fetch_i, "the status line precedes the first request"
finally_i = fn.find("} finally {")
assert finally_i != -1, "the finally block is the never-stale guarantee"
after_finally = fn[finally_i:]
assert "pushBtn.disabled = false" in after_finally
assert "pushBtn.textContent = IDLE_LABEL" in after_finally
# The idle label IS the static button copy (a mismatch would
# relabel the button into an unknown state on success).
assert 'const IDLE_LABEL = "Push to docs branch";' in js
def test_push_success_line_branch_and_sha7() -> None:
"""The 200 outcome: the live region reads
`Pushed to <branch> — commit <sha7>.` — the branch from the API,
the commit sha TRUNCATED to its first seven chars for display (the
full value stays in the API/draft row), the exact em-dash shape.
The button re-enables (a re-push after further edits is a NEW
commit — the D3 ASSUMPTION)."""
js = _js()
fn = js[js.find("function wirePush() {"):]
assert (
"`Pushed to ${pushed.branch} — commit ${String(pushed.commit_sha).slice(0, 7)}.`"
in fn
), "the success line is 'Pushed to <branch> — commit <sha7>.'"
# The success line is set AFTER the push response is read.
json_i = fn.find("await r.json()")
ok_i = fn.find('`Pushed to ${pushed.branch}')
assert -1 < json_i < ok_i
def test_push_failure_banner_trims_git_detail_and_keeps_fields() -> None:
"""The failure outcome: the #push-error banner with the API's
detail — for a git 502 that is git's stderr, trimmed to its first
meaningful lines (trimGitDetail: blank lines + the "hint:" chatter
dropped, at most three lines, single-line details untouched) — the
fields are PRESERVED (no input is cleared anywhere in the file)
and the stale success line is cleared so only the error claims the
outcome. Network failure → the fixed one-line copy."""
js = _js()
fn = js[js.find("function wirePush() {"):]
assert "trimGitDetail(" in fn, "the failure detail must pass the trimmer"
assert "apiDetail(" in fn, "the detail must be the API's (422-shape-aware)"
# No field is ever cleared: the user's edits survive a failed push.
for field in ('titleInput.value = ""', 'pathInput.value = ""',
'bodyInput.value = ""', "titleInput.value=''",
"pathInput.value=''", "bodyInput.value=''"):
assert field not in js, f"a failed push must keep the edits, not {field!r}"
assert "is the app running?" in fn, "the network-failure line"
# The stale success line is cleared on failure (one claim at a
# time — the PUT-failure arm clears it too).
fail_i = fn.find("trimGitDetail(")
assert fn.rfind('setStatus("")', 0, fail_i) > 0, (
"a failed push clears the status line before the banner"
)
put_fail_i = fn.find('showError(await apiDetail(put')
assert put_fail_i != -1
assert fn.rfind('setStatus("")', 0, put_fail_i) > 0, (
"a failed PUT also clears the stale status line"
)
trim = _fn(js, "trimGitDetail")
assert 'l.startsWith("hint:")' in trim, "the 'hint:' chatter is dropped"
assert "slice(0, 3)" in trim, "at most three meaningful lines"
assert "filter(" in trim and ".trim()" in trim
def test_trim_git_detail_behavior_is_pinned_by_the_markers() -> None:
"""The trimmer's contract in one place: non-empty lines that are
not hint: lines, up to three, space-joined, with a fallback for an
all-hint/empty detail (the banner must never be blank)."""
js = _js()
trim = _fn(js, "trimGitDetail")
assert "split(\"\\n\")" in trim
assert 'join(" ")' in trim
assert '|| "The push failed."' in trim, "the empty-detail fallback"
# ---------- styles.css: the new classes ----------
def test_doc_edit_shell_is_the_hardcoded_46rem_column() -> None:
""".doc-edit-shell: the 46rem base column — HARD-CODED 46rem (a
form column, not a reading column — it must NOT ride
--chat-column, so phase 58's wide-desktop doubling never stretches
the form), centered, a flex column on the container frame."""
css = _css()
block = re.search(r"\.doc-edit-shell \{([\s\S]*?)\n\}", css)
assert block, "styles.css must style .doc-edit-shell"
body = block.group(1)
assert "max-width: 46rem" in body, "the 46rem base column (hard-coded)"
assert "--chat-column" not in body, (
"the form column does not ride --chat-column (phase 58 must "
"not stretch it)"
)
assert "margin-inline: auto" in body
assert "flex-direction: column" in body
def test_back_link_and_push_button_css() -> None:
""".doc-edit-back: the ghost language (>=44px target, --line
border, ink-soft on the --surface bar), pushed right
(margin-left: auto); #push-doc-btn: the brand primary (dark ink on
brand 5.2:1 — never white on brand), >=44px, a :disabled state
(the "Pushing…" affordance)."""
css = _css()
back = re.search(r"\.doc-edit-back \{([\s\S]*?)\n\}", css)
assert back, "styles.css must style .doc-edit-back"
bbody = back.group(1)
assert "min-height: 44px" in bbody
assert "border: 1px solid var(--line)" in bbody
assert "var(--ink-soft)" in bbody
assert "margin-left: auto" in bbody
btn = re.search(r"#push-doc-btn \{([\s\S]*?)\n\}", css)
assert btn, "styles.css must style #push-doc-btn"
tbody = btn.group(1)
assert "background: var(--brand)" in tbody
assert "color: var(--bg)" in tbody, "dark ink on brand (never white)"
assert "min-height: 44px" in tbody
assert re.search(r"#push-doc-btn:disabled \{[^}]*opacity[^}]*\}", css), (
"the disabled (Pushing…) state must be styled"
)
def test_form_fields_css_mono_and_min_height() -> None:
"""#draft-path and #draft-body are MONO (the path is machine data;
the body is markdown) on the inset bg fill; #draft-body carries
the pinned min-height: 20rem; the inputs keep the 44px floor."""
css = _css()
pair = re.search(
r"#draft-title,\n#draft-path \{([\s\S]*?)\n\}", css
)
assert pair, "styles.css must style the two text inputs"
assert "min-height: 44px" in pair.group(1)
# The DEDICATED #draft-path rule (the pair above shares the name in
# its selector list — search past the pair's closing brace).
path = re.search(
r"#draft-path \{([\s\S]*?)\n\}", css[pair.end():]
)
assert path and "var(--mono)" in path.group(1), "#draft-path must be mono"
body = re.search(r"#draft-body \{([\s\S]*?)\n\}", css)
assert body, "styles.css must style #draft-body"
bbody = body.group(1)
assert "var(--mono)" in bbody, "#draft-body must be mono"
assert "min-height: 20rem" in bbody, "the pinned 20rem body floor"
assert "resize: vertical" in bbody
def test_status_and_error_css_families() -> None:
""".doc-edit-status: the ok family (ok-ink on ok-bg 10.6:1) when
a push outcome has landed, the dashed placeholder when empty;
.doc-edit-error: the err family (err-ink on err-bg 9.3:1,
err-line border) with long-word breaking (git paths). The global
3px :focus-visible ring covers the new controls (AGENTS.md rule 5)."""
css = _css()
status = re.search(r"\.doc-edit-status \{([\s\S]*?)\n\}", css)
assert status, "styles.css must style .doc-edit-status"
sbody = status.group(1)
assert "var(--ok-bg)" in sbody and "var(--ok-ink)" in sbody
assert re.search(r"\.doc-edit-status:empty \{", css), (
"the empty status must be the dashed placeholder"
)
error = re.search(r"\.doc-edit-error \{([\s\S]*?)\n\}", css)
assert error, "styles.css must style .doc-edit-error"
ebody = error.group(1)
assert "var(--err-bg)" in ebody and "var(--err-ink)" in ebody
assert "var(--err-line)" in ebody
assert "overflow-wrap: anywhere" in ebody
assert ":focus-visible" in css, "the global focus ring (AGENTS.md rule 5)"
+200
View File
@@ -0,0 +1,200 @@
"""Unit tests: docs-push service (phase 59, task 03).
``push_document`` is exercised against a **real local git repo** — a
bare origin in ``tmp_path`` plus the working clones the service creates
itself — and every result assertion reads the bare repo's state
directly (``git show <branch>:<path>``, ``git rev-list``), not the
return value alone. The remote is a plain local path, so no network is
ever involved.
The module skips (``pytest.skip``) when ``git --version`` fails — a
machine without git must not see hard failures.
"""
from __future__ import annotations
import subprocess
from pathlib import Path
import pytest
from app.core.docs_push import DocsPushError, push_document
BASE = "main"
BRANCH = "bor-docs"
REL = "docs/note.md"
IDENTITY = ("-c", "commit.gpgsign=false", "-c", "user.name=Test", "-c", "user.email=t@example.com")
def _git_available() -> bool:
try:
proc = subprocess.run(["git", "--version"], capture_output=True, check=False)
return proc.returncode == 0
except (FileNotFoundError, OSError):
return False
@pytest.fixture(scope="module", autouse=True)
def _require_git() -> None:
"""Skip the whole module when the git CLI is missing."""
if not _git_available():
pytest.skip("git is not available on this machine")
def _git(cwd: Path, *argv: str) -> str:
"""Run git for the tests themselves (setup + assertions); loud on failure."""
proc = subprocess.run(["git", *argv], cwd=cwd, capture_output=True, text=True, check=False)
assert proc.returncode == 0, f"git {' '.join(argv)} failed: {proc.stderr}"
return proc.stdout
def _push(bare: Path, work: Path, content: str, message: str = "docs: note") -> tuple[str, str]:
"""push_document against the fixture bare repo (plain local path)."""
return push_document(
repo=str(bare),
base_branch=BASE,
branch=BRANCH,
work_dir=str(work),
rel_path=REL,
content=content,
commit_message=message,
)
@pytest.fixture()
def bare_repo(tmp_path: Path) -> Path:
"""A bare origin seeded with one commit on ``main`` (``README.md``)."""
bare = tmp_path / "bare.git"
_git(tmp_path, "init", "--bare", str(bare))
seed = tmp_path / "seed"
_git(tmp_path, "clone", str(bare), str(seed))
(seed / "README.md").write_text("# docs\n", encoding="utf-8")
_git(seed, "checkout", "-B", BASE)
_git(seed, *IDENTITY, "add", "README.md")
_git(seed, *IDENTITY, "commit", "-m", "seed README")
_git(seed, "push", "origin", BASE)
return bare
def test_first_push_creates_branch_and_returns_sha(bare_repo: Path, tmp_path: Path) -> None:
"""First push: clones the base, creates the branch, lands the file."""
work = tmp_path / "work" # absent — push_document clones it
branch, sha = _push(bare_repo, work, "# Note\n\nbody one\n")
assert branch == BRANCH
assert (work / ".git").is_dir()
assert (work / REL).read_text(encoding="utf-8") == "# Note\n\nbody one\n"
# The file lands on the branch of the BARE repo, at the returned sha.
assert _git(bare_repo, "show", f"{BRANCH}:{REL}") == "# Note\n\nbody one\n"
assert _git(bare_repo, "rev-parse", BRANCH).strip() == sha
assert len(sha) == 40
# Exactly one commit beyond main.
assert _git(bare_repo, "rev-list", "--count", f"main..{BRANCH}").strip() == "1"
# Fixed per-invocation identity + message (no global git config reliance).
ident = _git(bare_repo, "log", "-1", BRANCH, "--format=%an <%ae>").strip()
assert ident == "Brain of Reese <bor@local>"
assert _git(bare_repo, "log", "-1", BRANCH, "--format=%s").strip() == "docs: note"
def test_second_push_fast_forwards_same_branch(bare_repo: Path, tmp_path: Path) -> None:
"""Second push (edited content, same path): fast-forward, 2 commits."""
work = tmp_path / "work"
sha1 = _push(bare_repo, work, "v1\n")[1]
sha2 = _push(bare_repo, work, "v2 edited\n")[1]
assert sha1 != sha2
assert _git(bare_repo, "show", f"{BRANCH}:{REL}") == "v2 edited\n"
assert _git(bare_repo, "rev-list", "--count", f"main..{BRANCH}").strip() == "2"
# Fast-forward, no force: the first commit is still an ancestor.
_git(bare_repo, "merge-base", "--is-ancestor", sha1, sha2)
def test_fresh_checkout_reattaches_onto_remote_branch(bare_repo: Path, tmp_path: Path) -> None:
"""An absent checkout re-attaches onto the existing remote branch
(its history) so the push still fast-forwards."""
work1 = tmp_path / "work1"
sha1 = _push(bare_repo, work1, "v1\n")[1]
work2 = tmp_path / "work2" # different dir — push_document clones anew
branch, sha2 = _push(bare_repo, work2, "v2\n")
assert branch == BRANCH
assert _git(bare_repo, "rev-list", "--count", f"main..{BRANCH}").strip() == "2"
assert _git(bare_repo, "rev-parse", BRANCH).strip() == sha2
# work2's commit sits on work1's commit (re-attach, not a fork).
_git(bare_repo, "merge-base", "--is-ancestor", sha1, sha2)
def test_concurrently_advanced_remote_fails_loudly(bare_repo: Path, tmp_path: Path) -> None:
"""Remote advanced by a second clone → the first clone's push is a
non-fast-forward: DocsPushError carrying git's stderr, remote kept."""
work_a = tmp_path / "work_a"
_push(bare_repo, work_a, "from A\n")
# A second clone advances the branch on the bare repo.
work_b = tmp_path / "work_b"
_git(tmp_path, "clone", "--depth", "1", "--branch", BRANCH, str(bare_repo), str(work_b))
(work_b / "docs" / "other.md").write_text("from B\n", encoding="utf-8")
_git(work_b, *IDENTITY, "add", "docs/other.md")
_git(work_b, *IDENTITY, "commit", "-m", "docs: other")
_git(work_b, "push", "origin", BRANCH)
remote_tip_before = _git(bare_repo, "rev-parse", BRANCH).strip()
with pytest.raises(DocsPushError) as excinfo:
_push(bare_repo, work_a, "from A again\n")
msg = str(excinfo.value)
# git's stderr is surfaced (the non-fast-forward refusal).
assert "non-fast-forward" in msg
assert "rejected" in msg
# The remote branch was NOT touched (no force-push, no merge).
assert _git(bare_repo, "rev-parse", BRANCH).strip() == remote_tip_before
assert _git(bare_repo, "show", f"{BRANCH}:{REL}") == "from A\n"
def test_missing_repo_path_fails_loudly(tmp_path: Path) -> None:
"""No such repo → DocsPushError naming the failed git step."""
with pytest.raises(DocsPushError, match="git clone .* failed"):
push_document(
repo=str(tmp_path / "no-such-repo"),
base_branch=BASE,
branch=BRANCH,
work_dir=str(tmp_path / "w"),
rel_path=REL,
content="x\n",
commit_message="docs: x",
)
# No fake checkout is left behind.
assert not (tmp_path / "w" / ".git").exists()
def test_non_repo_dir_fails_loudly(tmp_path: Path) -> None:
"""A plain directory (not a git repo) as the remote → DocsPushError."""
plain = tmp_path / "plain"
plain.mkdir()
(plain / "file.txt").write_text("not a repo\n", encoding="utf-8")
with pytest.raises(DocsPushError, match="failed"):
push_document(
repo=str(plain),
base_branch=BASE,
branch=BRANCH,
work_dir=str(tmp_path / "w"),
rel_path=REL,
content="x\n",
commit_message="docs: x",
)
def test_unsafe_rel_path_is_refused_before_any_git(bare_repo: Path, tmp_path: Path) -> None:
"""The defensive parts re-assertion refuses traversal paths."""
for bad in ("../evil.md", "/etc/passwd", "a/b/../c.md"):
with pytest.raises(DocsPushError, match="unsafe rel_path"):
push_document(
repo=str(bare_repo),
base_branch=BASE,
branch=BRANCH,
work_dir=str(tmp_path / "w"),
rel_path=bad,
content="x\n",
commit_message="docs: x",
)
# No checkout was even attempted.
assert not (tmp_path / "w").exists()
+1
View File
@@ -30,6 +30,7 @@ HTML_PAGES = (
"git-sources.html",
"history.html", # phase 50: the admin saved-chats page
"shared.html", # phase 51: the anonymous shared-conversation page
"doc-edit.html", # phase 59: the admin doc edit screen (flow page)
)
+3 -3
View File
@@ -143,11 +143,11 @@ def test_missing_git_raises_named_error(
clone_or_pull("https://example.com/homelab.git", tmp_path / "homelab")
def test_run_captures_and_returns_stdout(
def test_run_git_captures_and_returns_stdout(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""_run returns the captured stdout on success (git output is not lost)."""
"""run_git returns the captured stdout on success (git output is not lost)."""
calls = _fake_run(monkeypatch, stdout="From example.com\n + abc..def main")
assert git_sync._run(["git", "status"], cwd=tmp_path) == "From example.com\n + abc..def main"
assert git_sync.run_git(["git", "status"], cwd=tmp_path) == "From example.com\n + abc..def main"
assert len(calls) == 1
+33
View File
@@ -44,3 +44,36 @@ def test_documents_unique_source_path() -> None:
and {col.name for col in c.columns} == {"source", "path"}
]
assert uq, "documents must be unique on (source, path) — the upsert key"
def test_doc_drafts_token_is_unique_not_null() -> None:
"""Phase 59: the draft's URL credential — an unguessable uuid4,
UNIQUE + NOT NULL (no "un-drafted" state, unlike the NULLable
``saved_chats.share_token``)."""
drafts = Base.metadata.tables["doc_drafts"]
token = drafts.c["token"]
assert token.nullable is False, "doc_drafts.token must be NOT NULL"
uq = [
c
for c in drafts.constraints
if isinstance(c, UniqueConstraint)
and {col.name for col in c.columns} == {"token"}
]
assert uq, "doc_drafts must be unique on (token) — the URL credential"
def test_doc_drafts_column_contract() -> None:
"""Phase 59: the editable triple (title/path/body) + status +
timestamps are NOT NULL; ``branch`` / ``commit_sha`` are NULL
until the push endpoint records them."""
drafts = Base.metadata.tables["doc_drafts"]
assert set(drafts.c.keys()) == {
"id", "token", "title", "path", "body", "status",
"branch", "commit_sha", "created_at", "updated_at",
}
for name in ("title", "path", "body", "status", "created_at", "updated_at"):
assert drafts.c[name].nullable is False, f"{name} must be NOT NULL"
for name in ("branch", "commit_sha"):
assert drafts.c[name].nullable is True, f"{name} must be NULL until pushed"
assert drafts.c["status"].default is not None, "status needs an ORM default (draft)"
assert drafts.c["token"].default is not None, "token needs an ORM default (uuid4)"
+303
View File
@@ -0,0 +1,303 @@
"""Unit: the phase-59 "Save as doc" button (task 05).
No Python logic exists beyond the one-line ``app/api/config.py`` flag —
the behavior lives in ``frontend/assets/app.js`` + ``brand.js`` +
``styles.css``, and it is E2E-gated by the story suite (task 07). Like
the other frontend-adjacent unit files (``test_frontend_brand.py``),
this module pins the JS/CSS markers the story depends on, so a silent
regression in the button layer is caught without a browser — plus the
``app/api/config.py`` unit pin (the response dict's
``docs_repo_configured`` bool tracks ``settings.docs_configured``).
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import Any
from app.config import Settings
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
BRAND_JS = FRONTEND / "assets" / "brand.js"
APP_JS = FRONTEND / "assets" / "app.js"
STYLES_CSS = FRONTEND / "assets" / "styles.css"
def _text(path: Path) -> str:
return path.read_text(encoding="utf-8")
def _settings(**kwargs: Any) -> Settings:
"""Build Settings without reading a .env file (deterministic tests).
Same house pattern as tests/integration/test_doc_drafts_api.py —
``_env_file`` exists at runtime (pydantic-settings) but is not in the
static signature, hence the ignore on the call.
"""
kwargs.setdefault("_env_file", None)
return Settings(**kwargs) # pyright: ignore[reportCallIssue] (kwarg exists at runtime)
# ---------------------------------------------------------------------------
# app/api/config.py — the unit pin (the response dict gains the flag)
# ---------------------------------------------------------------------------
def test_app_config_dict_carries_the_docs_flag() -> None:
"""The ``app_config`` response dict gains ``docs_repo_configured`` —
a real bool that tracks ``settings.docs_configured``: false (inert)
while BOR_DOCS_REPO is empty, true the moment it is non-empty."""
from app.api.config import app_config
s = _settings()
body = app_config(s)
assert set(body) == {"app_name", "version", "docs_repo_configured"}
assert body["docs_repo_configured"] is s.docs_configured
assert body["docs_repo_configured"] is False
s2 = _settings(docs_repo="/srv/docs-repo")
assert app_config(s2)["docs_repo_configured"] is True
# ---------------------------------------------------------------------------
# brand.js — the flag + promise are surfaced the way app_name is
# ---------------------------------------------------------------------------
def test_brand_js_surfaces_the_docs_flag_inert_by_default() -> None:
"""window.BOR_DOCS_REPO_CONFIGURED is a classic-script global: false
at parse time (inert — hidden for everyone until proven), BEFORE the
/api/config fetch starts (the same ordering pin as window.BOR_BRAND)."""
js = _text(BRAND_JS)
assert "window.BOR_DOCS_REPO_CONFIGURED = false;" in js
default_idx = js.find("window.BOR_DOCS_REPO_CONFIGURED = false;")
# The real fetch statement (the file-header comment mentions the
# fetch too — anchor on the parse-time const, not the comment).
fetch_idx = js.find('BOR_CONFIG_PROMISE = fetch("/api/config"')
assert 0 <= default_idx < fetch_idx, (
"the inert flag default must be set at top level before the fetch"
)
def test_brand_js_exposes_the_config_promise_and_sets_the_flag() -> None:
"""The SAME boot fetch's promise is exposed at parse time
(window.BOR_CONFIG_PROMISE — app.js's boot awaits it), the flag lands
the moment the answer arrives, and the promise NEVER rejects (the
error arm warns + resolves null — the loadHealth house style)."""
js = _text(BRAND_JS)
assert "window.BOR_CONFIG_PROMISE = BOR_CONFIG_PROMISE;" in js
assert "window.BOR_DOCS_REPO_CONFIGURED = cfg?.docs_repo_configured === true;" in js
# The flag is a strict boolean: only the literal JSON true flips it.
assert "=== true" in js
assert "console.warn" in js
assert "return null;" in js
# ---------------------------------------------------------------------------
# app.js — boot wiring: the flag is final before any bubble renders
# ---------------------------------------------------------------------------
def test_app_js_boot_awaits_config_before_capturing_the_flag() -> None:
"""The boot IIFE awaits brand.js's parse-time promise (never
rejecting — a defensive fallback covers a missing global) and then
captures docsRepoConfigured — BEFORE any bubble renders
(restoreConversation), so a restored conversation of a configured
admin gets the button exactly once: no flash, no re-render, no
second fetch."""
js = _text(APP_JS)
assert "let docsRepoConfigured = false;" in js
await_idx = js.find("await (window.BOR_CONFIG_PROMISE ?? Promise.resolve());")
capture_idx = js.find("docsRepoConfigured = window.BOR_DOCS_REPO_CONFIGURED === true;")
restore_idx = js.find("restoreConversation();")
assert await_idx >= 0 and await_idx < capture_idx, (
"the flag capture must follow the config-promise await"
)
assert restore_idx > 0 and capture_idx < restore_idx, (
"the flag must be final BEFORE the restored conversation renders"
)
# ---------------------------------------------------------------------------
# app.js — the button: gating, ARIA, one per bubble
# ---------------------------------------------------------------------------
def test_app_js_button_gates_on_admin_and_configured() -> None:
"""The single guard: admin (the whoami gate Tune uses) AND
docs_repo_configured — otherwise the function injects NOTHING
(anonymous, or unconfigured admin, or deflected scope — same as
Tune). One button per bubble; the .msg-meta row is reused (or
created plain) and a role=list row gets a listitem button (ARIA)."""
js = _text(APP_JS)
fn_idx = js.find("function appendSaveAsDocButton(wrap, markdown) {")
assert fn_idx != -1, "appendSaveAsDocButton missing"
fn_end = js.find("async function saveAsDoc", fn_idx)
fn_body = js[fn_idx:fn_end]
assert "if (!isAdmin || !docsRepoConfigured) return;" in fn_body
assert 'meta.querySelector(".save-as-doc-btn")' in fn_body, (
"the one-button-per-bubble guard is missing"
)
assert "meta.getAttribute(\"role\") === \"list\"" in fn_body
assert "btn.setAttribute(\"role\", \"listitem\")" in fn_body
def test_app_js_button_carries_the_class_and_label() -> None:
"""The .save-as-doc-btn class (the CSS right-alignment hook) + the
house label "Save as doc" (an accessible button name — the icon is
aria-hidden decoration)."""
js = _text(APP_JS)
fn_idx = js.find("function appendSaveAsDocButton(wrap, markdown) {")
fn_body = js[fn_idx : js.find("async function saveAsDoc", fn_idx)]
assert 'btn.className = "save-as-doc-btn"' in fn_body
assert 'btn.type = "button"' in fn_body
assert "<span>Save as doc</span>" in fn_body
# The file glyph is aria-hidden decoration (the label carries the
# accessible name) — the icon constant, which the function consumes.
icon_idx = js.find("const SAVE_AS_DOC_ICON")
icon_body = js[icon_idx : js.find("const DOC_TITLE_MAX", icon_idx)]
assert 'aria-hidden="true"' in icon_body, "the icon must be aria-hidden"
assert 'SAVE_AS_DOC_ICON + "<span>Save as doc</span>"' in fn_body
def test_app_js_call_sites_pass_the_raw_markdown() -> None:
"""Three call sites, each passing the RAW persisted markdown (never
the rendered HTML): the live `done` branch (exactly the string
rememberBrainTurn stores, so a reload offers the identical draft),
the empty-answer fallback bubble (parity with the done path), and
the restore path (m.text). A stopped partial is a note, not an
answer — the restore gates on !m.stopped; the live stop path and
the pagehide partial never call the helper at all."""
js = _text(APP_JS)
assert 'appendSaveAsDocButton(wrap, finalText || acc || "…");' in js, (
"the live done branch must pass the raw persisted text"
)
assert "appendSaveAsDocButton(fwrap, fallback);" in js, (
"the empty-answer fallback bubble must get the button too"
)
assert "if (!m.stopped) appendSaveAsDocButton(wrap, m.text);" in js, (
"the restore path must pass m.text and skip stopped records"
)
# The live call sits next to the Tune button (same meta row scope).
tune_idx = js.find("appendTuneButton(wrap); // every completed brain bubble is tunable")
save_idx = js.find('appendSaveAsDocButton(wrap, finalText || acc || "…");')
assert tune_idx > 0 and tune_idx < save_idx
# The stop finalize keeps its Tune button but gains NO save button
# (a stopped partial is a note, not an answer) — none between the
# stop call site and the pagehide handler (which persists, it does
# not render).
stop_idx = js.find("appendTuneButton(wrap); // admin-only; parity with the restore path")
pagehide_idx = js.find("pagehide", stop_idx)
assert stop_idx > 0 and stop_idx < pagehide_idx
assert "appendSaveAsDocButton" not in js[stop_idx:pagehide_idx], (
"the stopped partial (note, not answer) must not get the button"
)
# ---------------------------------------------------------------------------
# app.js — the click: payload, slug rule, navigation, failure copy
# ---------------------------------------------------------------------------
def test_app_js_default_title_is_the_last_user_question() -> None:
"""The default title: the LAST user question's text,
whitespace-collapsed, ≤120 chars (the phase-50 auto-title
convention — the chat auto-title targets the FIRST question, the
docs default the LAST). Defensive "Note" with no user record."""
js = _text(APP_JS)
assert "const DOC_TITLE_MAX = 120;" in js
fn_idx = js.find("function defaultDocTitle() {")
assert fn_idx != -1, "defaultDocTitle missing"
fn_body = js[fn_idx : js.find("function docSlug", fn_idx)]
assert "conversation.length - 1" in fn_body, (
"the LAST user record wins (iterate backwards)"
)
assert 'conversation[i].who === "user"' in fn_body
assert 'question.replace(/\\s+/g, " ").trim().slice(0, DOC_TITLE_MAX)' in fn_body
assert '|| "Note"' in fn_body
def test_app_js_slug_rule() -> None:
"""The default in-repo path slug: lowercase → runs of
non-alphanumerics → "-" → trimmed → ≤60 chars → empty → "note"
(the phase-59 locked assumption; a 60-cut mid dash-run is trimmed
again so the path never dangles)."""
js = _text(APP_JS)
fn_idx = js.find("function docSlug(title) {")
assert fn_idx != -1, "docSlug missing"
fn_body = js[fn_idx : js.find("function appendSaveAsDocButton", fn_idx)]
assert ".toLowerCase()" in fn_body
assert '.replace(/[^a-z0-9]+/g, "-")' in fn_body
assert '.replace(/^-+|-+$/g, "")' in fn_body
assert ".slice(0, 60)" in fn_body
assert '|| "note"' in fn_body
# The default in-repo path is docs/<slug>.md.
assert "docs/${docSlug(title)}.md" in js
def test_app_js_post_payload_and_navigation() -> None:
"""Click → POST /api/doc-drafts {title, path, body: markdown} (the
raw markdown is the body — never HTML) → 201 →
location.assign("/doc-edit.html?draft=" + token). A double-click
guard disables the button until the outcome (released in the
finally — never stale); failure shows the neutral one-line banner
(phase-55 convention) and never navigates."""
js = _text(APP_JS)
fn_idx = js.find("async function saveAsDoc(btn, markdown) {")
assert fn_idx != -1, "saveAsDoc missing"
fn_body = js[fn_idx : fn_idx + 3000]
assert 'fetch("/api/doc-drafts"' in fn_body
assert 'JSON.stringify({ title, path, body: markdown })' in fn_body
assert 'location.assign("/doc-edit.html?draft=" + draft.token)' in fn_body
assert "btn.disabled = true" in fn_body
assert "btn.disabled = false" in fn_body
assert "showErrorBanner(" in fn_body
# The neutral one-line failure copy (phase-55 convention).
assert "Couldn't save the answer as a doc" in fn_body
def test_app_js_retry_landing_keeps_save_rightmost() -> None:
"""markLastRetryable re-appends the save button AFTER the Retry
button lands on the same (last) bubble — the auto-margined buttons
split the row's free space between them, so DOM order decides the
right edge: "Save as doc" stays the bottom-right action even on the
last bubble (which also carries Retry)."""
js = _text(APP_JS)
fn_idx = js.find("function markLastRetryable() {")
assert fn_idx != -1
fn_end = js.find("/* Phase 59 (owner-locked 2026-08-31, TODO.md L3): the bottom-right", fn_idx)
fn_body = js[fn_idx:fn_end]
assert "appendRetryButton(lastBrainWrap);" in fn_body
assert 'lastBrainWrap.querySelector(".save-as-doc-btn")' in fn_body
# "saveDocBtn" — NOT "saveBtn": phase 55 pins the Save pill's
# identifier gone from app.js (substring), so the local stays distinct.
assert "saveDocBtn.parentElement.appendChild(saveDocBtn)" in fn_body
assert "saveBtn" not in _text(APP_JS), (
"the phase-55 pin: no saveBtn identifier in app.js"
)
# ---------------------------------------------------------------------------
# styles.css — the .tune-btn visual family + the right alignment
# ---------------------------------------------------------------------------
def test_styles_css_save_as_doc_btn_is_right_aligned() -> None:
""".save-as-doc-btn exists, carries the bottom-right declaration
(margin-inline-start: auto) and the .tune-btn visual family (pill,
>=44px target, line border, ink-soft palette); :focus-visible is
the global rule, the hover rule is per-class."""
css = _text(STYLES_CSS)
m = re.search(r"\.save-as-doc-btn \{[^}]*\}", css)
assert m, "the .save-as-doc-btn rule is missing"
block = m.group(0)
assert "margin-inline-start: auto;" in block, (
"the bottom-right requirement lives on the button's class"
)
assert "min-height: 44px;" in block # WCAG touch target (the family)
assert "border-radius: 999px;" in block
assert "border: 1px solid var(--line);" in block
assert "var(--ink-soft)" in block
assert ".save-as-doc-btn:hover" in css
assert ".save-as-doc-btn svg" in css # the 14px house glyph sizing
assert ":focus-visible" in css # the global focus ring (AGENTS §5)
+17 -10
View File
@@ -16,10 +16,11 @@ without a browser:
``.shared-shell``, ``.doc-md``, ``.doc-summary:has(+ .doc-md)`` —
each capped with ``max-width: var(--chat-column)`` and NOTHING else
in the file uses the token (exactly four rules);
* the negative pin — ``.tuning-shell`` (a form, not a reading
surface) keeps its hard-coded ``max-width: 46rem`` at every width,
and it is the only literal ``max-width: 46rem`` rule left in the
file;
* the negative pin — the form columns (``.tuning-shell``; and from
phase 59, task 06, ``.doc-edit-shell`` — forms, not reading
surfaces) are the only literal ``max-width: 46rem`` rules left in
the file, kept hard-coded so the wide-desktop doubling never
stretches a form;
* the "46rem column contract" block comments were updated to name the
base value + the wide override (the stale "≤46rem" contract claims
are gone from the reading-column comments).
@@ -166,15 +167,21 @@ def test_tuning_shell_stays_hardcoded_46rem() -> None:
def test_no_other_hardcoded_46rem_rule_remains() -> None:
"""After the switch, the .tuning-shell rule is the ONLY rule with
a literal max-width: 46rem — every reading column rides the
token (the --chat-column base declaration is the other
non-rule occurrence of 46rem)."""
"""After the switch, the form columns are the ONLY rules with a
literal max-width: 46rem: .tuning-shell (phase 27) and
.doc-edit-shell (phase 59, task 06 — the doc edit screen is a
FORM column, not a reading column, so it must not ride
--chat-column and phase 58's wide-desktop doubling must never
stretch the form). Every reading column rides the token (the
--chat-column base declaration is the other non-rule occurrence
of 46rem)."""
css = _css()
assert css.count("max-width: 46rem") == 1, (
"only .tuning-shell may keep a literal max-width: 46rem"
assert css.count("max-width: 46rem") == 2, (
"only the form columns (.tuning-shell, .doc-edit-shell) may "
"keep a literal max-width: 46rem"
)
assert "max-width: 46rem" in _rule_block(css, ".tuning-shell")
assert "max-width: 46rem" in _rule_block(css, ".doc-edit-shell")
def test_comments_cite_the_wide_override_with_provenance() -> None: