feat(clean-release): complete compliance redesign phases and polish tasks T047-T052
This commit is contained in:
199
backend/tests/services/clean_release/test_approval_service.py
Normal file
199
backend/tests/services/clean_release/test_approval_service.py
Normal file
@@ -0,0 +1,199 @@
|
||||
# [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]
|
||||
Reference in New Issue
Block a user