pr-reviewer/tests/test_endpoints.py
Adam Moussa 89494c1710
Ground reviews in the engineering-handbook
Feed the reviewer a distilled digest of the Sea Haven engineering-handbook
so findings reflect our naming, commit, PR, secrets, and IaC conventions
instead of generic code-review judgment.

A new handbook module keeps an app-managed shallow clone of the (private)
handbook, distills the review-relevant pages into a compact conventions
checklist via the Fireworks model once a day, caches it under ~/.cache,
and hands it to the reviewer to inject into every review's system prompt.
The refresh runs in-process at the start of each worker cycle; failures
keep the last good digest and back off, so a handbook outage never blocks
reviews. Set HANDBOOK_ENABLED=false to disable.

Extract a shared fireworks_complete helper used by both the reviewer and
the distiller, so the handbook provider needs no reviewer reference and
the guidance callable is set once at construction. Clone auth uses a
Basic http.extraHeader (GitHub git-over-HTTPS rejects Bearer), and the
distiller wraps its answer in delimiters to strip a reasoning model's
chain-of-thought preamble. Adds GET /api/handbook and a header status
line. Stdlib-only, no new dependencies.
2026-07-01 17:08:21 -04:00

303 lines
9.3 KiB
Python

"""Endpoint tests via FastAPI TestClient.
The lazy singletons ``app.main.gh`` / ``app.main.reviewer`` are monkeypatched to
return fakes, so no network and no secrets are required. TestClient uses the
already-installed httpx2, so no extra test deps.
"""
from __future__ import annotations
import json
from typing import Any
import pytest
from fastapi.testclient import TestClient
from app import main as main_mod
from app.github_client import GitHubError
from app.store import ReviewStore
@pytest.fixture
def client() -> TestClient:
return TestClient(main_mod.app)
class FakeGH:
def __init__(self) -> None:
self.posted: dict[str, Any] | None = None
def search_prs(self) -> list[dict[str, Any]]:
return [{"owner": "o", "repo": "r", "number": 1, "title": "t"}]
def whoami(self) -> str:
return "octocat"
def pr_diff(self, owner: str, repo: str, number: int) -> str:
return "diff --git a/x b/x\n+hi\n"
def submit_review(self, owner, repo, number, body, event) -> dict[str, Any]:
self.posted = {"event": event, "body": body}
return {"html_url": "https://github.com/o/r/pull/1#review"}
class FakeReviewer:
def review(self, pr: dict[str, Any], diff: str) -> dict[str, Any]:
return {
"summary": "ok",
"recommended_event": "COMMENT",
"_body_markdown": "**Summary:** ok",
"_diff_seen": diff,
}
def _patch(monkeypatch, gh=None, reviewer=None):
if gh is not None:
monkeypatch.setattr(main_mod, "gh", lambda: gh)
if reviewer is not None:
monkeypatch.setattr(main_mod, "reviewer", lambda: reviewer)
# --------------------------------------------------------------------------- #
# /api/config
# --------------------------------------------------------------------------- #
def test_api_config(client: TestClient) -> None:
r = client.get("/api/config")
assert r.status_code == 200
data = r.json()
assert set(data) == {"org", "filter", "model", "mention_authors"}
# --------------------------------------------------------------------------- #
# /api/prs
# --------------------------------------------------------------------------- #
def test_api_prs_ok(client: TestClient, monkeypatch) -> None:
_patch(monkeypatch, gh=FakeGH())
r = client.get("/api/prs")
assert r.status_code == 200
data = r.json()
assert data["me"] == "octocat"
assert data["prs"][0]["number"] == 1
def test_api_prs_github_error_maps_to_400(client: TestClient, monkeypatch) -> None:
class BoomGH(FakeGH):
def search_prs(self):
raise GitHubError("GitHub 401: Bad credentials")
_patch(monkeypatch, gh=BoomGH())
r = client.get("/api/prs")
assert r.status_code == 400
assert "Bad credentials" in r.json()["detail"]
# --------------------------------------------------------------------------- #
# /api/review
# --------------------------------------------------------------------------- #
def _review_body() -> dict[str, Any]:
return {
"owner": "o",
"repo": "r",
"number": 1,
"title": "t",
"author": "octocat",
"body": "b",
}
def test_api_review_ok(client: TestClient, monkeypatch) -> None:
_patch(monkeypatch, gh=FakeGH(), reviewer=FakeReviewer())
r = client.post("/api/review", json=_review_body())
assert r.status_code == 200
assert r.json()["review"]["summary"] == "ok"
def test_api_review_github_error_maps_to_400(client: TestClient, monkeypatch) -> None:
class BoomGH(FakeGH):
def pr_diff(self, *a):
raise GitHubError("GitHub 404: Not Found")
_patch(monkeypatch, gh=BoomGH(), reviewer=FakeReviewer())
r = client.post("/api/review", json=_review_body())
assert r.status_code == 400
assert "Not Found" in r.json()["detail"]
def test_api_review_generic_error_maps_to_500(client: TestClient, monkeypatch) -> None:
class BoomReviewer:
def review(self, pr, diff):
raise ValueError("fireworks exploded")
_patch(monkeypatch, gh=FakeGH(), reviewer=BoomReviewer())
r = client.post("/api/review", json=_review_body())
assert r.status_code == 500
assert "Review failed" in r.json()["detail"]
# --------------------------------------------------------------------------- #
# /api/revise
# --------------------------------------------------------------------------- #
def test_api_revise_folds_notes_into_diff(client: TestClient, monkeypatch) -> None:
reviewer = FakeReviewer()
_patch(monkeypatch, gh=FakeGH(), reviewer=reviewer)
body = _review_body()
body["notes"] = "be harsher on error handling"
r = client.post("/api/revise", json=body)
assert r.status_code == 200
seen = r.json()["review"]["_diff_seen"]
assert "REVIEWER REVISION NOTES" in seen
assert "be harsher on error handling" in seen
def test_api_revise_generic_error_maps_to_500(client: TestClient, monkeypatch) -> None:
class BoomReviewer:
def review(self, pr, diff):
raise RuntimeError("nope")
_patch(monkeypatch, gh=FakeGH(), reviewer=BoomReviewer())
body = _review_body()
body["notes"] = "x"
r = client.post("/api/revise", json=body)
assert r.status_code == 500
assert "Revision failed" in r.json()["detail"]
# --------------------------------------------------------------------------- #
# /api/post
# --------------------------------------------------------------------------- #
def test_api_post_ok(client: TestClient, monkeypatch) -> None:
fake = FakeGH()
_patch(monkeypatch, gh=fake)
r = client.post(
"/api/post",
json={
"owner": "o",
"repo": "r",
"number": 1,
"body": "lgtm",
"event": "APPROVE",
},
)
assert r.status_code == 200
assert r.json()["ok"] is True
assert fake.posted == {"event": "APPROVE", "body": "lgtm"}
def test_api_post_github_error_maps_to_400(client: TestClient, monkeypatch) -> None:
class BoomGH(FakeGH):
def submit_review(self, *a):
raise GitHubError("Invalid event: LGTM")
_patch(monkeypatch, gh=BoomGH())
r = client.post(
"/api/post",
json={"owner": "o", "repo": "r", "number": 1, "body": "b", "event": "LGTM"},
)
assert r.status_code == 400
assert "Invalid event" in r.json()["detail"]
# --------------------------------------------------------------------------- #
# /api/reviews and /api/refresh (background-worker cache)
# --------------------------------------------------------------------------- #
def test_api_reviews_returns_cached(client: TestClient, monkeypatch, tmp_path) -> None:
s = ReviewStore(str(tmp_path / "cache.db"))
s.upsert(
"o",
"r",
1,
status="ready",
review_json=json.dumps({"summary": "hi"}),
pr_title="t",
pr_author="a",
pr_url="u",
pr_updated_at="2026-01-01",
)
monkeypatch.setattr(main_mod, "_store", s)
r = client.get("/api/reviews")
assert r.status_code == 200
data = r.json()
assert data["poll_interval"] > 0
item = data["reviews"][0]
assert item["status"] == "ready"
assert item["review"]["summary"] == "hi"
def test_api_reviews_503_without_store(client: TestClient, monkeypatch) -> None:
monkeypatch.setattr(main_mod, "_store", None)
assert client.get("/api/reviews").status_code == 503
def test_api_refresh_triggers_worker(client: TestClient, monkeypatch) -> None:
class FakeWorker:
def __init__(self) -> None:
self.triggered = False
def trigger(self) -> None:
self.triggered = True
w = FakeWorker()
monkeypatch.setattr(main_mod, "_worker", w)
r = client.post("/api/refresh")
assert r.status_code == 202
assert r.json()["scheduled"] is True
assert w.triggered is True
def test_api_refresh_503_without_worker(client: TestClient, monkeypatch) -> None:
monkeypatch.setattr(main_mod, "_worker", None)
assert client.post("/api/refresh").status_code == 503
def test_api_handbook_disabled_without_provider(
client: TestClient, monkeypatch
) -> None:
monkeypatch.setattr(main_mod, "_handbook", None)
r = client.get("/api/handbook")
assert r.status_code == 200
assert r.json()["enabled"] is False
def test_api_handbook_reports_status(client: TestClient, monkeypatch) -> None:
class FakeHB:
def status(self) -> dict:
return {
"enabled": True,
"head_sha": "abc1234",
"distilled_at": 123.0,
"digest_chars": 42,
}
monkeypatch.setattr(main_mod, "_handbook", FakeHB())
r = client.get("/api/handbook")
assert r.status_code == 200
body = r.json()
assert body["head_sha"] == "abc1234"
assert body["digest_chars"] == 42
assert "digest" not in body # never expose the digest text
def test_api_review_writes_through_to_cache(
client: TestClient, monkeypatch, tmp_path
) -> None:
s = ReviewStore(str(tmp_path / "cache.db"))
monkeypatch.setattr(main_mod, "_store", s)
_patch(monkeypatch, gh=FakeGH(), reviewer=FakeReviewer())
r = client.post("/api/review", json=_review_body())
assert r.status_code == 200
row = s.get("o", "r", 1)
assert row is not None
assert row["status"] == "ready"
assert json.loads(row["review_json"])["summary"] == "ok"