Files
billo-release-agent/tests/api/test_models.py
Yaojia Wang f5c2733cfb feat: initial commit — Billo Release Agent (LangGraph)
LangGraph-based release automation agent with:
- PR discovery (webhook + polling)
- AI code review via Claude Code CLI (subscription-based)
- Auto-create Jira tickets for PRs without ticket ID
- Jira ticket lifecycle management (code review -> staging -> done)
- CI/CD pipeline trigger, polling, and approval gates
- Slack interactive messages with approval buttons
- Per-repo semantic versioning
- PostgreSQL persistence (threads, staging, releases)
- FastAPI API (webhooks, approvals, status, manual triggers)
- Docker Compose deployment

1069 tests, 95%+ coverage.
2026-03-24 17:38:23 +01:00

295 lines
10 KiB
Python

"""Tests for API request/response models. Written FIRST (TDD RED phase)."""
from datetime import datetime, timezone
import pytest
from pydantic import ValidationError
from release_agent.api.models import (
ApprovalDecision,
ApprovalResponse,
ErrorResponse,
HealthResponse,
ManualReleaseRequest,
ManualTriggerResponse,
PendingApproval,
PendingApprovalsResponse,
ReleaseVersionListResponse,
StagingResponse,
WebhookResponse,
)
# ---------------------------------------------------------------------------
# WebhookResponse
# ---------------------------------------------------------------------------
class TestWebhookResponse:
def test_valid_construction(self) -> None:
resp = WebhookResponse(thread_id="thread-123", message="scheduled")
assert resp.thread_id == "thread-123"
assert resp.message == "scheduled"
def test_frozen_immutable(self) -> None:
resp = WebhookResponse(thread_id="t1", message="ok")
with pytest.raises((TypeError, ValidationError)):
resp.thread_id = "other" # type: ignore[misc]
def test_missing_thread_id_raises(self) -> None:
with pytest.raises(ValidationError):
WebhookResponse(message="ok") # type: ignore[call-arg]
def test_missing_message_raises(self) -> None:
with pytest.raises(ValidationError):
WebhookResponse(thread_id="t1") # type: ignore[call-arg]
# ---------------------------------------------------------------------------
# ApprovalDecision
# ---------------------------------------------------------------------------
class TestApprovalDecision:
def test_merge_decision(self) -> None:
d = ApprovalDecision(decision="merge")
assert d.decision == "merge"
def test_cancel_decision(self) -> None:
d = ApprovalDecision(decision="cancel")
assert d.decision == "cancel"
def test_approve_decision(self) -> None:
d = ApprovalDecision(decision="approve")
assert d.decision == "approve"
def test_skip_decision(self) -> None:
d = ApprovalDecision(decision="skip")
assert d.decision == "skip"
def test_trigger_decision(self) -> None:
d = ApprovalDecision(decision="trigger")
assert d.decision == "trigger"
def test_invalid_decision_raises(self) -> None:
with pytest.raises(ValidationError):
ApprovalDecision(decision="invalid") # type: ignore[arg-type]
def test_frozen_immutable(self) -> None:
d = ApprovalDecision(decision="merge")
with pytest.raises((TypeError, ValidationError)):
d.decision = "cancel" # type: ignore[misc]
# ---------------------------------------------------------------------------
# ApprovalResponse
# ---------------------------------------------------------------------------
class TestApprovalResponse:
def test_valid_construction(self) -> None:
resp = ApprovalResponse(
thread_id="t1", status="resumed", message="Graph resumed"
)
assert resp.thread_id == "t1"
assert resp.status == "resumed"
assert resp.message == "Graph resumed"
def test_frozen_immutable(self) -> None:
resp = ApprovalResponse(thread_id="t1", status="ok", message="m")
with pytest.raises((TypeError, ValidationError)):
resp.status = "bad" # type: ignore[misc]
# ---------------------------------------------------------------------------
# PendingApproval
# ---------------------------------------------------------------------------
class TestPendingApproval:
def test_full_construction(self) -> None:
now = datetime.now(tz=timezone.utc)
pa = PendingApproval(
thread_id="t1",
graph_name="pr_completed",
interrupt_value="Confirm merge?",
created_at=now,
repo_name="my-repo",
pr_id="42",
version="v1.2.3",
)
assert pa.thread_id == "t1"
assert pa.graph_name == "pr_completed"
assert pa.repo_name == "my-repo"
assert pa.pr_id == "42"
assert pa.version == "v1.2.3"
def test_optional_fields_none(self) -> None:
now = datetime.now(tz=timezone.utc)
pa = PendingApproval(
thread_id="t1",
graph_name="release",
interrupt_value="Confirm?",
created_at=now,
)
assert pa.repo_name is None
assert pa.pr_id is None
assert pa.version is None
def test_frozen_immutable(self) -> None:
now = datetime.now(tz=timezone.utc)
pa = PendingApproval(
thread_id="t1",
graph_name="g",
interrupt_value="v",
created_at=now,
)
with pytest.raises((TypeError, ValidationError)):
pa.thread_id = "other" # type: ignore[misc]
# ---------------------------------------------------------------------------
# PendingApprovalsResponse
# ---------------------------------------------------------------------------
class TestPendingApprovalsResponse:
def test_empty_list(self) -> None:
resp = PendingApprovalsResponse(items=[], count=0)
assert resp.items == []
assert resp.count == 0
def test_with_items(self) -> None:
now = datetime.now(tz=timezone.utc)
item = PendingApproval(
thread_id="t1",
graph_name="g",
interrupt_value="v",
created_at=now,
)
resp = PendingApprovalsResponse(items=[item], count=1)
assert resp.count == 1
assert len(resp.items) == 1
def test_frozen_immutable(self) -> None:
resp = PendingApprovalsResponse(items=[], count=0)
with pytest.raises((TypeError, ValidationError)):
resp.count = 5 # type: ignore[misc]
# ---------------------------------------------------------------------------
# HealthResponse
# ---------------------------------------------------------------------------
class TestHealthResponse:
def test_ok_status(self) -> None:
resp = HealthResponse(status="ok", version="0.1.0", uptime_seconds=123.4)
assert resp.status == "ok"
assert resp.version == "0.1.0"
assert resp.uptime_seconds == pytest.approx(123.4)
def test_degraded_status(self) -> None:
resp = HealthResponse(status="degraded", version="0.1.0", uptime_seconds=0.0)
assert resp.status == "degraded"
def test_invalid_status_raises(self) -> None:
with pytest.raises(ValidationError):
HealthResponse(status="unknown", version="0.1.0", uptime_seconds=0.0) # type: ignore[arg-type]
def test_frozen_immutable(self) -> None:
resp = HealthResponse(status="ok", version="0.1.0", uptime_seconds=1.0)
with pytest.raises((TypeError, ValidationError)):
resp.status = "degraded" # type: ignore[misc]
# ---------------------------------------------------------------------------
# ReleaseVersionListResponse
# ---------------------------------------------------------------------------
class TestReleaseVersionListResponse:
def test_valid_construction(self) -> None:
resp = ReleaseVersionListResponse(repo="my-repo", versions=["v1.0.0", "v1.1.0"])
assert resp.repo == "my-repo"
assert resp.versions == ["v1.0.0", "v1.1.0"]
def test_empty_versions(self) -> None:
resp = ReleaseVersionListResponse(repo="r", versions=[])
assert resp.versions == []
def test_frozen_immutable(self) -> None:
resp = ReleaseVersionListResponse(repo="r", versions=[])
with pytest.raises((TypeError, ValidationError)):
resp.repo = "other" # type: ignore[misc]
# ---------------------------------------------------------------------------
# StagingResponse
# ---------------------------------------------------------------------------
class TestStagingResponse:
def test_with_staging(self) -> None:
staging_data = {"version": "v1.0.0", "repo": "my-repo", "tickets": []}
resp = StagingResponse(repo="my-repo", staging=staging_data)
assert resp.repo == "my-repo"
assert resp.staging is not None
assert resp.staging["version"] == "v1.0.0"
def test_without_staging(self) -> None:
resp = StagingResponse(repo="my-repo", staging=None)
assert resp.staging is None
def test_frozen_immutable(self) -> None:
resp = StagingResponse(repo="r", staging=None)
with pytest.raises((TypeError, ValidationError)):
resp.repo = "other" # type: ignore[misc]
# ---------------------------------------------------------------------------
# ManualTriggerResponse
# ---------------------------------------------------------------------------
class TestManualTriggerResponse:
def test_valid_construction(self) -> None:
resp = ManualTriggerResponse(thread_id="t1", message="triggered")
assert resp.thread_id == "t1"
assert resp.message == "triggered"
def test_frozen_immutable(self) -> None:
resp = ManualTriggerResponse(thread_id="t1", message="m")
with pytest.raises((TypeError, ValidationError)):
resp.thread_id = "other" # type: ignore[misc]
# ---------------------------------------------------------------------------
# ManualReleaseRequest
# ---------------------------------------------------------------------------
class TestManualReleaseRequest:
def test_valid_construction(self) -> None:
req = ManualReleaseRequest(repo="my-repo")
assert req.repo == "my-repo"
def test_missing_repo_raises(self) -> None:
with pytest.raises(ValidationError):
ManualReleaseRequest() # type: ignore[call-arg]
def test_frozen_immutable(self) -> None:
req = ManualReleaseRequest(repo="r")
with pytest.raises((TypeError, ValidationError)):
req.repo = "other" # type: ignore[misc]
# ---------------------------------------------------------------------------
# ErrorResponse
# ---------------------------------------------------------------------------
class TestErrorResponse:
def test_error_only(self) -> None:
resp = ErrorResponse(error="Something went wrong")
assert resp.error == "Something went wrong"
assert resp.detail is None
def test_error_with_detail(self) -> None:
resp = ErrorResponse(error="Not found", detail="Thread t1 not found")
assert resp.detail == "Thread t1 not found"
def test_frozen_immutable(self) -> None:
resp = ErrorResponse(error="e")
with pytest.raises((TypeError, ValidationError)):
resp.error = "other" # type: ignore[misc]