import json from pathlib import Path from typing import Any import httpx import pytest import respx from app.config import SourceCraftSettings from app.sourcecraft.client import SourceCraftClient, SourceCraftError from app.sourcecraft.models import EventType, RunStatus FIXTURES = Path(__file__).resolve().parent.parent / "fixtures" / "sourcecraft" API = "https://api.example.test" REPO = "org/repo" SHA = "932f4b1a144cfed4fed8eafd94433e0995e5b12d" def fixture(name: str) -> Any: return json.loads((FIXTURES / name).read_text(encoding="utf-8")) class Sleeps: def __init__(self) -> None: self.delays: list[float] = [] async def __call__(self, delay: float) -> None: self.delays.append(delay) @pytest.fixture def sleeps() -> Sleeps: return Sleeps() @pytest.fixture async def client(sleeps: Sleeps) -> Any: settings = SourceCraftSettings(token="secret", api_url=API, max_attempts=3) # type: ignore[arg-type] async with SourceCraftClient(settings, sleep=sleeps) as c: yield c @respx.mock async def test_list_runs_parses_real_response(client: SourceCraftClient) -> None: route = respx.get(f"{API}/repos/{REPO}/cicd/runs").respond(json=fixture("runs.json")) runs = await client.list_runs(REPO, page_size=3) assert route.calls.last.request.headers["Authorization"] == "Bearer secret" assert route.calls.last.request.url.params["page_size"] == "3" assert [r.slug for r in runs] == ["1000002", "1000001", "1000000"] assert runs[1].event_type is EventType.MANUAL @respx.mock async def test_failed_run_points_at_failed_cube(client: SourceCraftClient) -> None: respx.get(f"{API}/repos/{REPO}/cicd/runs/1000001").respond(json=fixture("run_failed.json")) run = await client.get_run(REPO, "1000001") assert run.status is RunStatus.FAILED and run.status.finished assert [(t, c.slug) for _, t, c in run.failed_cubes()] == [("check", "lint")] assert all(not c.is_service for _, _, c in run.cubes()) assert run.dates.finished_at is not None @respx.mock async def test_unfinished_run_has_no_finish_date(client: SourceCraftClient) -> None: respx.get(f"{API}/repos/{REPO}/cicd/runs/1000002").respond(json=fixture("run_processing.json")) run = await client.get_run(REPO, "1000002") assert not run.status.finished assert run.dates.finished_at is None # API sends 1970-01-01 for "not yet" check = run.task("check-and-deploy", "check") assert check is not None and check.status is RunStatus.SUCCESS assert run.task("check-and-deploy", "nope") is None @respx.mock async def test_commit_sha_from_meta_cube(client: SourceCraftClient) -> None: respx.get(f"{API}/repos/{REPO}/cicd/runs/1000002").respond(json=fixture("run_processing.json")) respx.get(f"{API}/repos/{REPO}/cicd/logs/1000002/check-and-deploy/check/meta").respond( json=fixture("log_meta.json") ) run = await client.get_run(REPO, "1000002") assert await client.run_commit_sha(REPO, run) == SHA @respx.mock async def test_commit_sha_none_when_meta_log_missing(client: SourceCraftClient) -> None: respx.get(f"{API}/repos/{REPO}/cicd/runs/1000002").respond(json=fixture("run_processing.json")) respx.get(f"{API}/repos/{REPO}/cicd/logs/1000002/check-and-deploy/check/meta").respond( 404, json={"error_code": "NotFound", "message": "no logs"} ) run = await client.get_run(REPO, "1000002") assert await client.run_commit_sha(REPO, run) is None @respx.mock async def test_logs_are_read_page_by_page(client: SourceCraftClient) -> None: url = f"{API}/repos/{REPO}/cicd/logs/1/wf/task/cube" respx.get(url, params={"page": "1"}).respond(json={"logs": "a\n", "page_complete": True, "done": False}) respx.get(url, params={"page": "2"}).respond(json={"logs": "b\n", "page_complete": True, "done": True}) respx.get(url, params={"page": "3"}).respond(404, json={"error_code": "NotFound", "message": ""}) logs = await client.get_cube_logs(REPO, "1", "wf", "task", "cube") assert logs.text == "a\nb\n" and logs.done @respx.mock async def test_real_failed_lint_log(client: SourceCraftClient) -> None: respx.get(f"{API}/repos/{REPO}/cicd/logs/1000001/check-and-deploy/check/lint").respond( json=fixture("log_lint_failed.json") ) logs = await client.get_cube_logs(REPO, "1000001", "check-and-deploy", "check", "lint") assert "F821 Undefined name" in logs.text and logs.done @respx.mock async def test_retries_transport_errors_and_5xx(client: SourceCraftClient, sleeps: Sleeps) -> None: respx.get(f"{API}/repos/{REPO}/cicd/runs").mock( side_effect=[httpx.ConnectError("tls eof"), httpx.Response(503), httpx.Response(200, json={"runs": []})] ) assert await client.list_runs(REPO) == [] assert sleeps.delays == [0.5, 1.0] @respx.mock async def test_gives_up_after_max_attempts(client: SourceCraftClient) -> None: respx.get(f"{API}/repos/{REPO}/cicd/runs").mock(side_effect=httpx.ConnectError("down")) with pytest.raises(httpx.ConnectError): await client.list_runs(REPO) @respx.mock async def test_persistent_5xx_raises_api_error(client: SourceCraftClient) -> None: respx.get(f"{API}/repos/{REPO}/cicd/runs").respond(502, text="bad gateway") with pytest.raises(SourceCraftError) as exc: await client.list_runs(REPO) assert exc.value.status == 502 @respx.mock async def test_4xx_is_not_retried(client: SourceCraftClient, sleeps: Sleeps) -> None: route = respx.get(f"{API}/repos/{REPO}/cicd/runs/9").respond( 403, json={"error_code": "PermissionDenied", "message": "no access"} ) with pytest.raises(SourceCraftError) as exc: await client.get_run(REPO, "9") assert (exc.value.status, exc.value.code) == (403, "PermissionDenied") assert route.call_count == 1 and sleeps.delays == [] async def test_run_url(client: SourceCraftClient) -> None: assert client.run_url(REPO, "7") == "https://sourcecraft.dev/org/repo/cicd/runs/7"