# [DEF:backend.tests.services.clean_release.test_approval_service:Module] # @TIER: CRITICAL # @SEMANTICS: tests, clean-release, approval, lifecycle, gate # @PURPOSE: Define approval gate contracts for approve/reject operations over immutable compliance evidence. # @LAYER: Tests # @RELATION: TESTS -> src.services.clean_release.approval_service # @RELATION: TESTS -> src.services.clean_release.enums # @RELATION: TESTS -> src.services.clean_release.repository # @INVARIANT: Approval is allowed only for PASSED report bound to candidate; duplicate approve and foreign report must be rejected. from __future__ import annotations from datetime import datetime, timezone import pytest from src.models.clean_release import ComplianceReport, ReleaseCandidate from src.services.clean_release.enums import ApprovalDecisionType, CandidateStatus, ComplianceDecision from src.services.clean_release.exceptions import ApprovalGateError from src.services.clean_release.repository import CleanReleaseRepository # [DEF:_seed_candidate_with_report:Function] # @PURPOSE: Seed candidate and report fixtures for approval gate tests. # @PRE: candidate_id and report_id are non-empty. # @POST: Repository contains candidate and report linked by candidate_id. def _seed_candidate_with_report( *, candidate_id: str = "cand-approve-1", report_id: str = "CCR-approve-1", report_status: ComplianceDecision = ComplianceDecision.PASSED, ) -> tuple[CleanReleaseRepository, str, str]: repository = CleanReleaseRepository() repository.save_candidate( ReleaseCandidate( id=candidate_id, version="1.0.0", source_snapshot_ref="git:sha-approve-1", created_by="tester", created_at=datetime.now(timezone.utc), status=CandidateStatus.CHECK_PASSED.value, ) ) repository.save_report( ComplianceReport( id=report_id, run_id="run-approve-1", candidate_id=candidate_id, final_status=report_status.value, summary_json={ "operator_summary": "seed", "violations_count": 0, "blocking_violations_count": 0 if report_status == ComplianceDecision.PASSED else 1, }, generated_at=datetime.now(timezone.utc), immutable=True, ) ) return repository, candidate_id, report_id # [/DEF:_seed_candidate_with_report:Function] # [DEF:test_approve_rejects_blocked_report:Function] # @PURPOSE: Ensure approve is rejected when latest report final status is not PASSED. # @PRE: Candidate has BLOCKED report. # @POST: approve_candidate raises ApprovalGateError. def test_approve_rejects_blocked_report(): from src.services.clean_release.approval_service import approve_candidate repository, candidate_id, report_id = _seed_candidate_with_report( report_status=ComplianceDecision.BLOCKED, ) with pytest.raises(ApprovalGateError, match="PASSED"): approve_candidate( repository=repository, candidate_id=candidate_id, report_id=report_id, decided_by="approver", comment="blocked report cannot be approved", ) # [/DEF:test_approve_rejects_blocked_report:Function] # [DEF:test_approve_rejects_foreign_report:Function] # @PURPOSE: Ensure approve is rejected when report belongs to another candidate. # @PRE: Candidate exists, report candidate_id differs. # @POST: approve_candidate raises ApprovalGateError. def test_approve_rejects_foreign_report(): from src.services.clean_release.approval_service import approve_candidate repository, candidate_id, _ = _seed_candidate_with_report() foreign_report = ComplianceReport( id="CCR-foreign-1", run_id="run-foreign-1", candidate_id="cand-foreign-1", final_status=ComplianceDecision.PASSED.value, summary_json={"operator_summary": "foreign", "violations_count": 0, "blocking_violations_count": 0}, generated_at=datetime.now(timezone.utc), immutable=True, ) repository.save_report(foreign_report) with pytest.raises(ApprovalGateError, match="belongs to another candidate"): approve_candidate( repository=repository, candidate_id=candidate_id, report_id=foreign_report.id, decided_by="approver", comment="foreign report", ) # [/DEF:test_approve_rejects_foreign_report:Function] # [DEF:test_approve_rejects_duplicate_approve:Function] # @PURPOSE: Ensure repeated approve decision for same candidate is blocked. # @PRE: Candidate has already been approved once. # @POST: Second approve_candidate call raises ApprovalGateError. def test_approve_rejects_duplicate_approve(): from src.services.clean_release.approval_service import approve_candidate repository, candidate_id, report_id = _seed_candidate_with_report() first = approve_candidate( repository=repository, candidate_id=candidate_id, report_id=report_id, decided_by="approver", comment="first approval", ) assert first.decision == ApprovalDecisionType.APPROVED.value assert repository.get_candidate(candidate_id).status == CandidateStatus.APPROVED.value with pytest.raises(ApprovalGateError, match="already approved"): approve_candidate( repository=repository, candidate_id=candidate_id, report_id=report_id, decided_by="approver", comment="duplicate approval", ) # [/DEF:test_approve_rejects_duplicate_approve:Function] # [DEF:test_reject_persists_decision_without_promoting_candidate_state:Function] # @PURPOSE: Ensure reject decision is immutable and does not promote candidate to APPROVED. # @PRE: Candidate has PASSED report and CHECK_PASSED lifecycle state. # @POST: reject_candidate persists REJECTED decision; candidate status remains unchanged. def test_reject_persists_decision_without_promoting_candidate_state(): from src.services.clean_release.approval_service import reject_candidate repository, candidate_id, report_id = _seed_candidate_with_report() decision = reject_candidate( repository=repository, candidate_id=candidate_id, report_id=report_id, decided_by="approver", comment="manual rejection", ) candidate = repository.get_candidate(candidate_id) assert decision.decision == ApprovalDecisionType.REJECTED.value assert candidate is not None assert candidate.status == CandidateStatus.CHECK_PASSED.value # [/DEF:test_reject_persists_decision_without_promoting_candidate_state:Function] # [DEF:test_reject_then_publish_is_blocked:Function] # @PURPOSE: Ensure latest REJECTED decision blocks publication gate. # @PRE: Candidate is rejected for passed report. # @POST: publish_candidate raises PublicationGateError. def test_reject_then_publish_is_blocked(): from src.services.clean_release.approval_service import reject_candidate from src.services.clean_release.publication_service import publish_candidate from src.services.clean_release.exceptions import PublicationGateError repository, candidate_id, report_id = _seed_candidate_with_report() reject_candidate( repository=repository, candidate_id=candidate_id, report_id=report_id, decided_by="approver", comment="rejected before publish", ) with pytest.raises(PublicationGateError, match="APPROVED"): publish_candidate( repository=repository, candidate_id=candidate_id, report_id=report_id, published_by="publisher", target_channel="stable", publication_ref="rel-blocked", ) # [/DEF:test_reject_then_publish_is_blocked:Function] # [/DEF:backend.tests.services.clean_release.test_approval_service:Module]