feat(docs): save chat answers as docs — edit screen, commit + push to the .env docs branch
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)"
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user