"""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"