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.
This commit is contained in:
294
tests/api/test_models.py
Normal file
294
tests/api/test_models.py
Normal file
@@ -0,0 +1,294 @@
|
||||
"""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]
|
||||
Reference in New Issue
Block a user