# #region TestApprovalService [C:2] [TYPE Module] # @RELATION BINDS_TO -> SrcRoot # @SEMANTICS: tests, clean-release, approval, lifecycle, gate # @PURPOSE: Define approval gate contracts for approve/reject operations over immutable compliance evidence. # @LAYER Tests # @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 UTC, datetime 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 # #region _seed_candidate_with_report [C:2] [TYPE Function] # @RELATION BINDS_TO -> TestApprovalService # @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(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(UTC), immutable=True, ) ) return repository, candidate_id, report_id # #endregion _seed_candidate_with_report # #region test_approve_rejects_blocked_report [C:2] [TYPE Function] # @RELATION BINDS_TO -> TestApprovalService # @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", ) # #endregion test_approve_rejects_blocked_report # #region test_approve_rejects_foreign_report [C:2] [TYPE Function] # @RELATION BINDS_TO -> TestApprovalService # @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(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", ) # #endregion test_approve_rejects_foreign_report # #region test_approve_rejects_duplicate_approve [C:2] [TYPE Function] # @RELATION BINDS_TO -> TestApprovalService # @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", ) # #endregion test_approve_rejects_duplicate_approve # #region test_reject_persists_decision_without_promoting_candidate_state [C:2] [TYPE Function] # @RELATION BINDS_TO -> TestApprovalService # @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 # #endregion test_reject_persists_decision_without_promoting_candidate_state # #region test_reject_then_publish_is_blocked [C:2] [TYPE Function] # @RELATION BINDS_TO -> TestApprovalService # @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.exceptions import PublicationGateError from src.services.clean_release.publication_service import publish_candidate 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", ) # #endregion test_reject_then_publish_is_blocked # #region test_approve_rejects_empty_decided_by [C:2] [TYPE Function] def test_approve_rejects_empty_decided_by(): from src.services.clean_release.approval_service import approve_candidate repository, candidate_id, report_id = _seed_candidate_with_report() with pytest.raises(ApprovalGateError, match="non-empty"): approve_candidate( repository=repository, candidate_id=candidate_id, report_id=report_id, decided_by="", comment="empty decided_by", ) # #endregion test_approve_rejects_empty_decided_by # #region test_approve_rejects_nonexistent_candidate [C:2] [TYPE Function] def test_approve_rejects_nonexistent_candidate(): from src.services.clean_release.approval_service import approve_candidate repository, _, report_id = _seed_candidate_with_report() with pytest.raises(ApprovalGateError, match="not found"): approve_candidate( repository=repository, candidate_id="nonexistent", report_id=report_id, decided_by="approver", ) # #endregion test_approve_rejects_nonexistent_candidate # #region test_approve_rejects_nonexistent_report [C:2] [TYPE Function] def test_approve_rejects_nonexistent_report(): from src.services.clean_release.approval_service import approve_candidate repository, candidate_id, _ = _seed_candidate_with_report() with pytest.raises(ApprovalGateError, match="not found"): approve_candidate( repository=repository, candidate_id=candidate_id, report_id="nonexistent", decided_by="approver", ) # #endregion test_approve_rejects_nonexistent_report # #region test_approve_rejects_wrong_candidate_status [C:2] [TYPE Function] def test_approve_rejects_wrong_candidate_status(): from src.services.clean_release.approval_service import approve_candidate repository, candidate_id, report_id = _seed_candidate_with_report( candidate_id="cand-wrong-status", report_id="CCR-wrong-status", ) candidate = repository.get_candidate(candidate_id) candidate.status = CandidateStatus.DRAFT.value # Not CHECK_PASSED repository.save_candidate(candidate) with pytest.raises(ApprovalGateError, match="cannot transition"): approve_candidate( repository=repository, candidate_id=candidate_id, report_id=report_id, decided_by="approver", ) # #endregion test_approve_rejects_wrong_candidate_status # #region test_reject_rejects_empty_decided_by [C:2] [TYPE Function] def test_reject_rejects_empty_decided_by(): from src.services.clean_release.approval_service import reject_candidate repository, candidate_id, report_id = _seed_candidate_with_report() with pytest.raises(ApprovalGateError, match="non-empty"): reject_candidate( repository=repository, candidate_id=candidate_id, report_id=report_id, decided_by="", ) # #endregion test_reject_rejects_empty_decided_by # #region test_approve_success_with_comment [C:2] [TYPE Function] def test_approve_success_with_comment(): from src.services.clean_release.approval_service import approve_candidate repository, candidate_id, report_id = _seed_candidate_with_report( candidate_id="cand-comment-1", report_id="CCR-comment-1", ) decision = approve_candidate( repository=repository, candidate_id=candidate_id, report_id=report_id, decided_by="approver", comment="Looks good to me", ) assert decision.comment == "Looks good to me" assert decision.decision == ApprovalDecisionType.APPROVED.value assert repository.get_candidate(candidate_id).status == CandidateStatus.APPROVED.value # #endregion test_approve_success_with_comment # #region test_reject_success_without_comment [C:2] [TYPE Function] def test_reject_success_without_comment(): from src.services.clean_release.approval_service import reject_candidate repository, candidate_id, report_id = _seed_candidate_with_report( candidate_id="cand-reject-nc", report_id="CCR-reject-nc", ) decision = reject_candidate( repository=repository, candidate_id=candidate_id, report_id=report_id, decided_by="approver", ) assert decision.decision == ApprovalDecisionType.REJECTED.value assert decision.comment is None # #endregion test_reject_success_without_comment # #region test_approve_persists_audit_event [C:2] [TYPE Function] def test_approve_persists_audit_event(): from src.services.clean_release.approval_service import approve_candidate repository, candidate_id, report_id = _seed_candidate_with_report( candidate_id="cand-audit-1", report_id="CCR-audit-1", ) approve_candidate( repository=repository, candidate_id=candidate_id, report_id=report_id, decided_by="approver", comment="audit check", ) assert len(repository.audit_events) >= 1 last_event = repository.audit_events[-1] # audit_preparation sets action="PREPARATION", decision type goes in "status" assert "APPROVED" in str(last_event.get("status", "")) # #endregion test_approve_persists_audit_event # #region test_reject_persists_audit_event [C:2] [TYPE Function] def test_reject_persists_audit_event(): from src.services.clean_release.approval_service import reject_candidate repository, candidate_id, report_id = _seed_candidate_with_report( candidate_id="cand-rej-audit", report_id="CCR-rej-audit", ) reject_candidate( repository=repository, candidate_id=candidate_id, report_id=report_id, decided_by="approver", ) assert len(repository.audit_events) >= 1 last_event = repository.audit_events[-1] # audit_preparation sets action="PREPARATION", decision type goes in "status" assert "REJECTED" in str(last_event.get("status", "")) # #endregion test_reject_persists_audit_event # #region test_approve_rejects_already_approved_status [C:2] [TYPE Function] def test_approve_rejects_already_approved_status(): """Candidate already has APPROVED status -> raise ApprovalGateError.""" from src.services.clean_release.approval_service import approve_candidate repository, candidate_id, report_id = _seed_candidate_with_report( candidate_id="cand-already-approved", report_id="CCR-already-approved", ) candidate = repository.get_candidate(candidate_id) candidate.status = CandidateStatus.APPROVED.value repository.save_candidate(candidate) with pytest.raises(ApprovalGateError, match="already approved"): approve_candidate( repository=repository, candidate_id=candidate_id, report_id=report_id, decided_by="approver", ) # #endregion test_approve_rejects_already_approved_status # #region test_approve_handles_transition_exception [C:2] [TYPE Function] def test_approve_handles_transition_exception(): """Non-ApprovalGateError in candidate.transition_to wraps into ApprovalGateError.""" from src.services.clean_release.approval_service import approve_candidate repository, candidate_id, report_id = _seed_candidate_with_report( candidate_id="cand-transition-err", report_id="CCR-transition-err", ) candidate = repository.get_candidate(candidate_id) original_transition = candidate.transition_to def broken_transition(_new_status): raise RuntimeError("unexpected internal error") candidate.transition_to = broken_transition repository.save_candidate(candidate) with pytest.raises(ApprovalGateError, match="unexpected internal error"): approve_candidate( repository=repository, candidate_id=candidate_id, report_id=report_id, decided_by="approver", ) # Restore original transition candidate.transition_to = original_transition # #endregion test_approve_handles_transition_exception # #endregion TestApprovalService