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.
295 lines
10 KiB
Python
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]
|