# #region Test.Api.CleanReleaseV2 [C:3] [TYPE Module] [SEMANTICS test,clean-release-v2,api] # @BRIEF Unit tests for clean release v2 API routes — register, approve, publish, revoke. # @RELATION BINDS_TO -> [CleanReleaseV2Api] # @TEST_EDGE: candidate_not_found -> 404 # @TEST_EDGE: approval_gate_error -> 409 # @TEST_EDGE: publication_gate_error -> 409 import os os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:") os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:") os.environ.setdefault("SECRET_KEY", "test-secret-key-for-tests") import sys from pathlib import Path from unittest.mock import MagicMock, patch import pytest from fastapi import FastAPI from fastapi.testclient import TestClient _src = str(Path(__file__).resolve().parent.parent.parent / "src") if _src not in sys.path: sys.path.insert(0, _src) def _make_repo(overrides: dict | None = None) -> MagicMock: repo = MagicMock() repo.get_candidate.return_value = None repo.save_candidate.side_effect = lambda c: c repo.save_manifest.side_effect = lambda m: m repo.get_artifact.return_value = None if overrides: for k, v in overrides.items(): setattr(repo, k, v) return repo def _make_client(repo: MagicMock | None = None) -> TestClient: from src.api.routes.clean_release_v2 import router from src.dependencies import get_clean_release_repository app = FastAPI() app.include_router(router) if repo is None: repo = _make_repo() app.dependency_overrides[get_clean_release_repository] = lambda: repo return TestClient(app) # ── register_candidate ── class TestRegisterCandidate: """POST /api/v2/clean-release/candidates""" def test_register_success(self): repo = _make_repo() client = _make_client(repo) resp = client.post("/api/v2/clean-release/candidates", json={ "id": "cand-v2-1", "version": "2.0.0", "source_snapshot_ref": "release/2.0", "created_by": "bot", }) assert resp.status_code == 201 data = resp.json() assert data["id"] == "cand-v2-1" assert data["status"] == "DRAFT" repo.save_candidate.assert_called_once() # ── import_artifacts ── class TestImportArtifactsV2: """POST /api/v2/clean-release/candidates/{candidate_id}/artifacts""" def test_import_success(self): repo = _make_repo() repo.get_candidate.return_value = MagicMock(id="cand-v2-1") client = _make_client(repo) resp = client.post("/api/v2/clean-release/candidates/cand-v2-1/artifacts", json={ "artifacts": [{"id": "a1", "path": "/p", "sha256": "s", "size": 1}] }) assert resp.status_code == 200 assert resp.json()["status"] == "success" def test_import_candidate_not_found(self): repo = _make_repo() repo.get_candidate.return_value = None client = _make_client(repo) resp = client.post("/api/v2/clean-release/candidates/cand-999/artifacts", json={ "artifacts": [{"id": "a1", "path": "/p", "sha256": "s", "size": 1}] }) assert resp.status_code == 404 # ── build_manifest ── class TestBuildManifestV2: """POST /api/v2/clean-release/candidates/{candidate_id}/manifests""" def test_build_manifest_success(self): candidate = MagicMock() candidate.id = "cand-v2-1" candidate.source_snapshot_ref = "main" repo = _make_repo() repo.get_candidate.return_value = candidate client = _make_client(repo) resp = client.post("/api/v2/clean-release/candidates/cand-v2-1/manifests") assert resp.status_code == 201 data = resp.json() assert data["candidate_id"] == "cand-v2-1" assert data["manifest_version"] == 1 def test_build_manifest_not_found(self): repo = _make_repo() repo.get_candidate.return_value = None client = _make_client(repo) resp = client.post("/api/v2/clean-release/candidates/cand-999/manifests") assert resp.status_code == 404 # ── approve_candidate_endpoint ── class TestApproveCandidate: """POST /api/v2/clean-release/candidates/{candidate_id}/approve""" @patch("src.api.routes.clean_release_v2.approve_candidate") def test_approve_success(self, mock_approve): mock_approve.return_value = MagicMock(decision="APPROVED", id="dec-1") repo = _make_repo() client = _make_client(repo) resp = client.post("/api/v2/clean-release/candidates/cand-v2-1/approve", json={ "report_id": "report-1", "decided_by": "admin", }) assert resp.status_code == 200 data = resp.json() assert data["status"] == "ok" assert data["decision"] == "APPROVED" @patch("src.api.routes.clean_release_v2.approve_candidate") def test_approve_gate_error(self, mock_approve): mock_approve.side_effect = ValueError("Not approved") repo = _make_repo() client = _make_client(repo) resp = client.post("/api/v2/clean-release/candidates/cand-v2-1/approve", json={ "report_id": "report-1", "decided_by": "admin", }) assert resp.status_code == 409 assert "APPROVAL_GATE_ERROR" in resp.text # ── reject_candidate_endpoint ── class TestRejectCandidate: """POST /api/v2/clean-release/candidates/{candidate_id}/reject""" @patch("src.api.routes.clean_release_v2.reject_candidate") def test_reject_success(self, mock_reject): mock_reject.return_value = MagicMock(decision="REJECTED", id="dec-2") repo = _make_repo() client = _make_client(repo) resp = client.post("/api/v2/clean-release/candidates/cand-v2-1/reject", json={ "report_id": "report-1", "decided_by": "admin", }) assert resp.status_code == 200 assert resp.json()["decision"] == "REJECTED" @patch("src.api.routes.clean_release_v2.reject_candidate") def test_reject_gate_error(self, mock_reject): mock_reject.side_effect = ValueError("Already approved") repo = _make_repo() client = _make_client(repo) resp = client.post("/api/v2/clean-release/candidates/cand-v2-1/reject", json={ "report_id": "report-1", "decided_by": "admin", }) assert resp.status_code == 409 # ── publish_candidate_endpoint ── class TestPublishCandidate: """POST /api/v2/clean-release/candidates/{candidate_id}/publish""" @patch("src.api.routes.clean_release_v2.publish_candidate") def test_publish_success(self, mock_publish): pub = MagicMock() pub.id = "pub-1" pub.candidate_id = "cand-v2-1" pub.report_id = "report-1" pub.published_by = "admin" pub.published_at = __import__("datetime").datetime.now() pub.target_channel = "production" pub.publication_ref = "ref-1" pub.status = "PUBLISHED" mock_publish.return_value = pub repo = _make_repo() client = _make_client(repo) resp = client.post("/api/v2/clean-release/candidates/cand-v2-1/publish", json={ "report_id": "report-1", "published_by": "admin", "target_channel": "production", }) assert resp.status_code == 200 data = resp.json() assert data["status"] == "ok" assert data["publication"]["id"] == "pub-1" @patch("src.api.routes.clean_release_v2.publish_candidate") def test_publish_gate_error(self, mock_publish): mock_publish.side_effect = ValueError("Not approved") repo = _make_repo() client = _make_client(repo) resp = client.post("/api/v2/clean-release/candidates/cand-v2-1/publish", json={ "report_id": "report-1", "published_by": "admin", "target_channel": "production", }) assert resp.status_code == 409 # ── revoke_publication_endpoint ── class TestRevokePublication: """POST /api/v2/clean-release/publications/{publication_id}/revoke""" @patch("src.api.routes.clean_release_v2.revoke_publication") def test_revoke_success(self, mock_revoke): pub = MagicMock() pub.id = "pub-1" pub.candidate_id = "cand-v2-1" pub.report_id = "report-1" pub.published_by = "admin" pub.published_at = __import__("datetime").datetime.now() pub.target_channel = "production" pub.publication_ref = "ref-1" pub.status = "REVOKED" mock_revoke.return_value = pub repo = _make_repo() client = _make_client(repo) resp = client.post("/api/v2/clean-release/publications/pub-1/revoke", json={ "revoked_by": "admin", }) assert resp.status_code == 200 data = resp.json() assert data["publication"]["id"] == "pub-1" assert data["publication"]["status"] == "REVOKED" @patch("src.api.routes.clean_release_v2.revoke_publication") def test_revoke_gate_error(self, mock_revoke): mock_revoke.side_effect = ValueError("Already revoked") repo = _make_repo() client = _make_client(repo) resp = client.post("/api/v2/clean-release/publications/pub-1/revoke", json={ "revoked_by": "admin", }) assert resp.status_code == 409 # #endregion Test.Api.CleanReleaseV2