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