- Replaced @TIER: TRIVIAL with @COMPLEXITY: 1 - Replaced @TIER: STANDARD with @COMPLEXITY: 3 - Replaced @TIER: CRITICAL with @COMPLEXITY: 5 - Manually elevated specific critical/complex components to levels 2 and 4 - Ignored legacy, specs, and node_modules directories - Updated generated semantic map
204 lines
6.6 KiB
Python
204 lines
6.6 KiB
Python
# [DEF:test_candidate_manifest_services:Module]
|
|
# @COMPLEXITY: 3
|
|
# @PURPOSE: Test lifecycle and manifest versioning for release candidates.
|
|
# @LAYER: Tests
|
|
|
|
import pytest
|
|
from datetime import datetime, timezone
|
|
from sqlalchemy import create_engine
|
|
from sqlalchemy.orm import sessionmaker
|
|
from src.core.database import Base
|
|
from src.models.clean_release import ReleaseCandidate, DistributionManifest, CandidateArtifact
|
|
from src.services.clean_release.enums import CandidateStatus
|
|
from src.services.clean_release.candidate_service import register_candidate
|
|
from src.services.clean_release.manifest_service import build_manifest_snapshot
|
|
from src.services.clean_release.repository import CleanReleaseRepository
|
|
|
|
@pytest.fixture
|
|
def db_session():
|
|
engine = create_engine("sqlite:///:memory:")
|
|
Base.metadata.create_all(engine)
|
|
Session = sessionmaker(bind=engine)
|
|
session = Session()
|
|
yield session
|
|
session.close()
|
|
|
|
def test_candidate_lifecycle_transitions(db_session):
|
|
"""
|
|
@PURPOSE: Verify legal state transitions for ReleaseCandidate.
|
|
"""
|
|
candidate = ReleaseCandidate(
|
|
id="test-candidate-1",
|
|
name="Test Candidate",
|
|
version="1.0.0",
|
|
source_snapshot_ref="ref-1",
|
|
created_by="operator",
|
|
status=CandidateStatus.DRAFT
|
|
)
|
|
db_session.add(candidate)
|
|
db_session.commit()
|
|
|
|
# Valid transition: DRAFT -> PREPARED
|
|
candidate.transition_to(CandidateStatus.PREPARED)
|
|
assert candidate.status == CandidateStatus.PREPARED
|
|
|
|
# Invalid transition: PREPARED -> DRAFT (should raise IllegalTransitionError)
|
|
from src.services.clean_release.exceptions import IllegalTransitionError
|
|
with pytest.raises(IllegalTransitionError, match="Forbidden transition"):
|
|
candidate.transition_to(CandidateStatus.DRAFT)
|
|
|
|
def test_manifest_versioning_and_immutability(db_session):
|
|
"""
|
|
@PURPOSE: Verify manifest versioning and immutability invariants.
|
|
"""
|
|
candidate_id = "test-candidate-2"
|
|
|
|
# Create version 1
|
|
m1 = DistributionManifest(
|
|
id="manifest-v1",
|
|
candidate_id=candidate_id,
|
|
manifest_version=1,
|
|
manifest_digest="hash1",
|
|
artifacts_digest="hash1",
|
|
source_snapshot_ref="ref1",
|
|
content_json={},
|
|
created_at=datetime.now(timezone.utc),
|
|
created_by="operator"
|
|
)
|
|
db_session.add(m1)
|
|
|
|
# Create version 2
|
|
m2 = DistributionManifest(
|
|
id="manifest-v2",
|
|
candidate_id=candidate_id,
|
|
manifest_version=2,
|
|
manifest_digest="hash2",
|
|
artifacts_digest="hash2",
|
|
source_snapshot_ref="ref1",
|
|
content_json={},
|
|
created_at=datetime.now(timezone.utc),
|
|
created_by="operator"
|
|
)
|
|
db_session.add(m2)
|
|
db_session.commit()
|
|
|
|
latest = db_session.query(DistributionManifest).filter_by(candidate_id=candidate_id).order_by(DistributionManifest.manifest_version.desc()).first()
|
|
assert latest.manifest_version == 2
|
|
assert latest.id == "manifest-v2"
|
|
|
|
all_manifests = db_session.query(DistributionManifest).filter_by(candidate_id=candidate_id).all()
|
|
assert len(all_manifests) == 2
|
|
|
|
|
|
def _valid_artifacts():
|
|
return [
|
|
{
|
|
"id": "art-1",
|
|
"path": "bin/app",
|
|
"sha256": "abc123",
|
|
"size": 42,
|
|
}
|
|
]
|
|
|
|
|
|
def test_register_candidate_rejects_duplicate_candidate_id():
|
|
repository = CleanReleaseRepository()
|
|
register_candidate(
|
|
repository=repository,
|
|
candidate_id="dup-1",
|
|
version="1.0.0",
|
|
source_snapshot_ref="git:sha1",
|
|
created_by="operator",
|
|
artifacts=_valid_artifacts(),
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="already exists"):
|
|
register_candidate(
|
|
repository=repository,
|
|
candidate_id="dup-1",
|
|
version="1.0.0",
|
|
source_snapshot_ref="git:sha1",
|
|
created_by="operator",
|
|
artifacts=_valid_artifacts(),
|
|
)
|
|
|
|
|
|
def test_register_candidate_rejects_malformed_artifact_input():
|
|
repository = CleanReleaseRepository()
|
|
bad_artifacts = [{"id": "art-1", "path": "bin/app", "size": 42}] # missing sha256
|
|
|
|
with pytest.raises(ValueError, match="missing required field 'sha256'"):
|
|
register_candidate(
|
|
repository=repository,
|
|
candidate_id="bad-art-1",
|
|
version="1.0.0",
|
|
source_snapshot_ref="git:sha2",
|
|
created_by="operator",
|
|
artifacts=bad_artifacts,
|
|
)
|
|
|
|
|
|
def test_register_candidate_rejects_empty_artifact_set():
|
|
repository = CleanReleaseRepository()
|
|
|
|
with pytest.raises(ValueError, match="artifacts must not be empty"):
|
|
register_candidate(
|
|
repository=repository,
|
|
candidate_id="empty-art-1",
|
|
version="1.0.0",
|
|
source_snapshot_ref="git:sha3",
|
|
created_by="operator",
|
|
artifacts=[],
|
|
)
|
|
|
|
|
|
def test_manifest_service_rebuild_creates_new_version():
|
|
repository = CleanReleaseRepository()
|
|
register_candidate(
|
|
repository=repository,
|
|
candidate_id="manifest-version-1",
|
|
version="1.0.0",
|
|
source_snapshot_ref="git:sha10",
|
|
created_by="operator",
|
|
artifacts=_valid_artifacts(),
|
|
)
|
|
|
|
first = build_manifest_snapshot(repository=repository, candidate_id="manifest-version-1", created_by="operator")
|
|
second = build_manifest_snapshot(repository=repository, candidate_id="manifest-version-1", created_by="operator")
|
|
|
|
assert first.manifest_version == 1
|
|
assert second.manifest_version == 2
|
|
assert first.id != second.id
|
|
|
|
|
|
def test_manifest_service_existing_manifest_cannot_be_mutated():
|
|
repository = CleanReleaseRepository()
|
|
register_candidate(
|
|
repository=repository,
|
|
candidate_id="manifest-immutable-1",
|
|
version="1.0.0",
|
|
source_snapshot_ref="git:sha11",
|
|
created_by="operator",
|
|
artifacts=_valid_artifacts(),
|
|
)
|
|
|
|
created = build_manifest_snapshot(repository=repository, candidate_id="manifest-immutable-1", created_by="operator")
|
|
original_digest = created.manifest_digest
|
|
|
|
rebuilt = build_manifest_snapshot(repository=repository, candidate_id="manifest-immutable-1", created_by="operator")
|
|
old_manifest = repository.get_manifest(created.id)
|
|
|
|
assert old_manifest is not None
|
|
assert old_manifest.manifest_digest == original_digest
|
|
assert old_manifest.id == created.id
|
|
assert rebuilt.id != created.id
|
|
|
|
|
|
def test_manifest_service_rejects_missing_candidate():
|
|
repository = CleanReleaseRepository()
|
|
|
|
with pytest.raises(ValueError, match="not found"):
|
|
build_manifest_snapshot(repository=repository, candidate_id="missing-candidate", created_by="operator")
|
|
|
|
# [/DEF:test_candidate_manifest_services:Module]
|