Files
rfc-app/backend/tests/test_docs_sessions_vertical.py
T

385 lines
15 KiB
Python

"""v0.19.0 / roadmap item #30 — `/api/docs/sessions/*` endpoints.
The framework mediates reads against the public
`wiggleverse/ohm-session-history` gitea repo so the rendered
`/docs/sessions/*` surface inherits the same chrome as `/docs/user-guide`.
This test suite covers the four endpoints + the in-process TTL cache,
mocking the upstream HTTP via `httpx.MockTransport` (the same shape the
rest of the test suite uses for Gitea).
The tests do NOT spin up the full FakeGitea — they only need to mock
the gitea raw URL surface (and the contents API for the session-index
endpoint). Each test owns its mock transport so we can dial in 200 /
404 / 5xx / timeout responses per case.
Path-validation tests intentionally bypass the network — a malformed
`nnnn` or `filename` MUST be rejected at the route layer before any
upstream call is made.
"""
from __future__ import annotations
import json
import httpx
import pytest
from fastapi.testclient import TestClient
from app import docs_sessions
# Reuse the proven app-construction fixtures from the proposal vertical
# (same shape every test file in this repo uses).
from test_propose_vertical import ( # noqa: F401
app_with_fake_gitea,
tmp_env,
)
# ---------------------------------------------------------------------------
# Test scaffolding
# ---------------------------------------------------------------------------
class _UpstreamHandler:
"""Records every URL the docs_sessions module fetched and returns
canned responses keyed by URL substring. Allows the test to assert
on call count (for cache verification) without needing a full Gitea
simulator.
`calls` tracks only URLs that hit the session-history host (the
`OHM_SESSION_HISTORY_*` bases) so reconciler/Gitea-side calls — which
also pass through this handler because `httpx.AsyncClient` is a
shared attribute the gitea-side fixture also monkeypatches — don't
inflate the count we use for cache-hit assertions.
"""
_SESSION_HOST_MARKERS = ("ohm-session-history", "wiggleverse/ohm-session-history")
def __init__(self, responses: dict[str, tuple[int, str]]):
self.responses = responses
self.calls: list[str] = []
def __call__(self, request: httpx.Request) -> httpx.Response:
url = str(request.url)
if any(m in url for m in self._SESSION_HOST_MARKERS):
self.calls.append(url)
for key, (status, body) in self.responses.items():
if key in url:
return httpx.Response(status, text=body)
# Default: 404. Lets tests skip declaring "the rest is 404".
return httpx.Response(404, text="not found")
@pytest.fixture
def patched_httpx(monkeypatch, app_with_fake_gitea): # noqa: F811
"""Provide a hook the test can call to install a MockTransport.
Returns a closure: `install(handler)` patches `httpx.AsyncClient`
(via `app.docs_sessions.httpx.AsyncClient`) so every constructed
client uses the handler's transport.
M3 note: lifespan now calls `refresh_registry` which hits FakeGitea
via the gitea transport. Since `httpx` is a module singleton, installing
the docs transport would clobber the FakeGitea mock already installed
by `app_with_fake_gitea`. We use a COMPOSITE handler: Gitea API
requests (to `http://gitea.test/`) are delegated to FakeGitea; all
other requests go to the test-specific handler.
"""
from httpx._client import AsyncClient as RealAsyncClient
_fake = app_with_fake_gitea[1]
def install(handler):
def composite(request: httpx.Request) -> httpx.Response:
if "gitea.test" in str(request.url):
return _fake.handle(request)
return handler(request)
def patched(*args, **kwargs):
kwargs["transport"] = httpx.MockTransport(composite)
return RealAsyncClient(*args, **kwargs)
monkeypatch.setattr("app.docs_sessions.httpx.AsyncClient", patched)
return handler
yield install
@pytest.fixture
def app(app_with_fake_gitea):
"""Wrap the shared app fixture, resetting the docs-sessions cache so
cross-test state can't leak. Returns just the FastAPI app — the
fake-Gitea handle is irrelevant for the docs-sessions surface.
"""
docs_sessions.reset_cache()
fastapi_app, _fake = app_with_fake_gitea
return fastapi_app
# ---------------------------------------------------------------------------
# Manifest endpoint
# ---------------------------------------------------------------------------
def test_manifest_happy_path(app, patched_httpx):
manifest_body = json.dumps(
{
"0001": {"title": "Bootstrap"},
"0014": {"title": "Wave 7 driver"},
}
)
patched_httpx(
_UpstreamHandler({"sessions.json": (200, manifest_body)})
)
with TestClient(app) as client:
r = client.get("/api/docs/sessions/manifest")
assert r.status_code == 200, r.text
payload = r.json()
assert payload == {
"0001": {"title": "Bootstrap"},
"0014": {"title": "Wave 7 driver"},
}
def test_manifest_empty_state(app, patched_httpx):
"""A 404 from gitea means the manifest hasn't been published yet.
The endpoint returns HTTP 200 + `{}` so the frontend can render the
no-sessions-yet state without an error banner.
"""
patched_httpx(_UpstreamHandler({"sessions.json": (404, "not found")}))
with TestClient(app) as client:
r = client.get("/api/docs/sessions/manifest")
assert r.status_code == 200, r.text
assert r.json() == {}
def test_manifest_upstream_5xx_returns_502(app, patched_httpx):
patched_httpx(_UpstreamHandler({"sessions.json": (500, "internal")}))
with TestClient(app) as client:
r = client.get("/api/docs/sessions/manifest")
assert r.status_code == 502, r.text
body = r.json()
assert body["detail"]["error"] == "session-history fetch failed"
# ---------------------------------------------------------------------------
# About endpoint
# ---------------------------------------------------------------------------
def test_about_happy_path(app, patched_httpx):
readme = "# OHM session history\n\nWelcome.\n"
patched_httpx(_UpstreamHandler({"README.md": (200, readme)}))
with TestClient(app) as client:
r = client.get("/api/docs/sessions/about")
assert r.status_code == 200, r.text
assert "text/markdown" in r.headers["content-type"]
assert r.text == readme
def test_about_404(app, patched_httpx):
patched_httpx(_UpstreamHandler({"README.md": (404, "")}))
with TestClient(app) as client:
r = client.get("/api/docs/sessions/about")
assert r.status_code == 404, r.text
def test_about_upstream_5xx_returns_502(app, patched_httpx):
patched_httpx(_UpstreamHandler({"README.md": (503, "down")}))
with TestClient(app) as client:
r = client.get("/api/docs/sessions/about")
assert r.status_code == 502, r.text
# ---------------------------------------------------------------------------
# Transcript endpoint
# ---------------------------------------------------------------------------
def test_transcript_happy_path(app, patched_httpx):
body = "# Session 0017.1 — Transcript\n\nbody.\n"
fname = "SESSION-0017.1-TRANSCRIPT-2026-05-28T08-50--2026-05-28T11-20.md"
patched_httpx(_UpstreamHandler({fname: (200, body)}))
with TestClient(app) as client:
r = client.get(f"/api/docs/sessions/0017/{fname}")
assert r.status_code == 200, r.text
assert "text/markdown" in r.headers["content-type"]
assert r.text == body
def test_transcript_404(app, patched_httpx):
fname = "SESSION-9999.0-TRANSCRIPT-2026-01-01T00-00--2026-01-01T00-01.md"
patched_httpx(_UpstreamHandler({})) # everything 404s
with TestClient(app) as client:
r = client.get(f"/api/docs/sessions/9999/{fname}")
assert r.status_code == 404, r.text
def test_transcript_rejects_invalid_session_dir(app, patched_httpx):
"""`nnnn` must be exactly 4 digits. `abcd` fails before any
network call.
"""
handler = _UpstreamHandler({})
patched_httpx(handler)
with TestClient(app) as client:
r = client.get(
"/api/docs/sessions/abcd/"
"SESSION-0001.0-TRANSCRIPT-2026-01-01T00-00--2026-01-01T00-01.md"
)
assert r.status_code == 400, r.text
assert handler.calls == [], "rejected path must not hit the network"
def test_transcript_rejects_path_traversal(app, patched_httpx):
"""A filename that doesn't match the SESSION-NNNN.M-TRANSCRIPT regex
is rejected. `../etc/passwd` doesn't match; neither does the legacy
flat-root `SESSION-A-TRANSCRIPT.md`.
"""
handler = _UpstreamHandler({})
patched_httpx(handler)
with TestClient(app) as client:
# Path traversal — but FastAPI normalizes `..` in the path before
# routing, so this resolves to /api/docs/sessions/0001/etc/passwd
# which routes to the same handler with filename=etc/passwd, and
# gets rejected as an invalid transcript filename. Even if the
# normalization didn't apply (some intermediary), the regex
# check rejects anything not matching the SESSION- prefix.
r = client.get(
"/api/docs/sessions/0001/etc%2Fpasswd"
)
# 400 (filename validation) or 404 (path didn't match the
# route); both reject before any network call. Either is
# acceptable — what matters is that we never fetched it.
assert r.status_code in (400, 404), r.text
assert handler.calls == [], "rejected path must not hit the network"
def test_transcript_rejects_legacy_flat_filename(app, patched_httpx):
"""Legacy `SESSION-A-TRANSCRIPT.md` (letter form) doesn't match the
numeric regex — by design, since post-#23 transcripts live in
`NNNN/` folders with numeric names. Reject 400.
"""
handler = _UpstreamHandler({})
patched_httpx(handler)
with TestClient(app) as client:
r = client.get("/api/docs/sessions/0001/SESSION-A-TRANSCRIPT.md")
assert r.status_code == 400, r.text
assert handler.calls == [], "rejected path must not hit the network"
def test_transcript_upstream_5xx_returns_502(app, patched_httpx):
fname = "SESSION-0001.0-TRANSCRIPT-2026-01-01T00-00--2026-01-01T00-01.md"
patched_httpx(_UpstreamHandler({fname: (502, "bad gateway")}))
with TestClient(app) as client:
r = client.get(f"/api/docs/sessions/0001/{fname}")
assert r.status_code == 502, r.text
# ---------------------------------------------------------------------------
# Session-index endpoint
# ---------------------------------------------------------------------------
def test_session_index_happy_path(app, patched_httpx):
"""The contents API returns a JSON list of file entries. The
endpoint filters to entries that match the transcript regex and
sorts them.
"""
# Two transcripts (driver + subagent) + a non-transcript sibling
# that must be filtered out.
listing = json.dumps(
[
{
"name": "SESSION-0017.0-TRANSCRIPT-"
"2026-05-28T08-30--2026-05-28T12-00.md",
"type": "file",
},
{
"name": "SESSION-0017.1-TRANSCRIPT-"
"2026-05-28T08-50--2026-05-28T11-20.md",
"type": "file",
},
{"name": "notes.md", "type": "file"}, # not a transcript
{"name": "attached-dir", "type": "dir"}, # not a file
]
)
patched_httpx(_UpstreamHandler({"/contents/0017": (200, listing)}))
with TestClient(app) as client:
r = client.get("/api/docs/sessions/0017/index")
assert r.status_code == 200, r.text
files = r.json()["files"]
assert files == [
"SESSION-0017.0-TRANSCRIPT-2026-05-28T08-30--2026-05-28T12-00.md",
"SESSION-0017.1-TRANSCRIPT-2026-05-28T08-50--2026-05-28T11-20.md",
]
def test_session_index_404(app, patched_httpx):
patched_httpx(_UpstreamHandler({})) # everything 404s
with TestClient(app) as client:
r = client.get("/api/docs/sessions/9999/index")
assert r.status_code == 404, r.text
def test_session_index_rejects_invalid_session_dir(app, patched_httpx):
handler = _UpstreamHandler({})
patched_httpx(handler)
with TestClient(app) as client:
r = client.get("/api/docs/sessions/abc/index")
assert r.status_code == 400, r.text
assert handler.calls == [], "rejected path must not hit the network"
# ---------------------------------------------------------------------------
# Cache behavior
# ---------------------------------------------------------------------------
def test_manifest_cache_hits_within_ttl(app, patched_httpx, monkeypatch):
"""Two consecutive manifest calls within the TTL window should
issue exactly one HTTP request to gitea.
"""
# Generous TTL so the test never races.
monkeypatch.setenv("OHM_DOCS_SESSIONS_MANIFEST_TTL_SEC", "60")
handler = _UpstreamHandler(
{"sessions.json": (200, json.dumps({"0001": {"title": "x"}}))}
)
patched_httpx(handler)
with TestClient(app) as client:
r1 = client.get("/api/docs/sessions/manifest")
r2 = client.get("/api/docs/sessions/manifest")
assert r1.status_code == 200
assert r2.status_code == 200
assert len(handler.calls) == 1, (
f"expected one upstream call, got {handler.calls}"
)
def test_transcript_cache_hits_within_ttl(app, patched_httpx, monkeypatch):
monkeypatch.setenv("OHM_DOCS_SESSIONS_CONTENT_TTL_SEC", "300")
fname = "SESSION-0001.0-TRANSCRIPT-2026-01-01T00-00--2026-01-01T00-01.md"
handler = _UpstreamHandler({fname: (200, "# body\n")})
patched_httpx(handler)
with TestClient(app) as client:
r1 = client.get(f"/api/docs/sessions/0001/{fname}")
r2 = client.get(f"/api/docs/sessions/0001/{fname}")
assert r1.status_code == 200
assert r2.status_code == 200
assert len(handler.calls) == 1
def test_transcript_404_is_cached(app, patched_httpx, monkeypatch):
"""Negative caching: a 404 result is cached at the content TTL so a
deployment with no published transcripts doesn't hammer gitea on
every navigation. Documented in `docs_sessions.fetch_transcript`.
"""
monkeypatch.setenv("OHM_DOCS_SESSIONS_CONTENT_TTL_SEC", "300")
fname = "SESSION-9999.0-TRANSCRIPT-2026-01-01T00-00--2026-01-01T00-01.md"
handler = _UpstreamHandler({}) # everything 404s
patched_httpx(handler)
with TestClient(app) as client:
r1 = client.get(f"/api/docs/sessions/9999/{fname}")
r2 = client.get(f"/api/docs/sessions/9999/{fname}")
assert r1.status_code == 404
assert r2.status_code == 404
assert len(handler.calls) == 1, "negative caching should suppress the 2nd call"