"""Tests for the TestEntity pure domain object. These tests exercise the state machine, lifecycle commands, domain events, business rule enforcement, and the from_orm/apply_to round-trip — all without any database or framework dependency. """ import uuid from datetime import datetime, timedelta from unittest.mock import MagicMock import pytest import sys, os backend_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) if backend_dir not in sys.path: sys.path.insert(0, backend_dir) from app.domain.test_entity import ( TestEntity, TestState, VALID_TRANSITIONS, DomainEvent, ) from app.domain.errors import BusinessRuleViolation, InvalidStateTransition # ── Helpers ────────────────────────────────────────────────────────── def _entity(state: str = "draft", **overrides) -> TestEntity: defaults = dict( id=uuid.uuid4(), state=TestState(state), red_validation_status=None, red_validated_by=None, red_validated_at=None, red_validation_notes=None, blue_validation_status=None, blue_validated_by=None, blue_validated_at=None, blue_validation_notes=None, execution_date=None, red_started_at=None, blue_started_at=None, paused_at=None, red_paused_seconds=0, blue_paused_seconds=0, ) defaults.update(overrides) return TestEntity(**defaults) def _fake_orm(state: str = "draft", **overrides) -> MagicMock: """Build a mock that looks like a SQLAlchemy Test model.""" m = MagicMock() m.id = uuid.uuid4() m.state = state m.red_validation_status = None m.red_validated_by = None m.red_validated_at = None m.red_validation_notes = None m.blue_validation_status = None m.blue_validated_by = None m.blue_validated_at = None m.blue_validation_notes = None m.execution_date = None m.red_started_at = None m.blue_started_at = None m.paused_at = None m.red_paused_seconds = 0 m.blue_paused_seconds = 0 for k, v in overrides.items(): setattr(m, k, v) return m # ── 1. VALID_TRANSITIONS completeness ─────────────────────────────── def test_every_state_has_a_transition_entry(): for s in TestState: assert s in VALID_TRANSITIONS, f"Missing entry for {s}" def test_validated_is_terminal(): assert VALID_TRANSITIONS[TestState.validated] == [] # ── 2. can_transition ──────────────────────────────────────────────── @pytest.mark.parametrize( "current, target, expected", [ ("draft", "red_executing", True), ("draft", "validated", False), ("draft", "blue_evaluating", False), ("red_executing", "blue_evaluating", True), ("red_executing", "draft", False), ("blue_evaluating", "in_review", True), ("in_review", "validated", True), ("in_review", "rejected", True), ("in_review", "draft", False), ("rejected", "draft", True), ("validated", "draft", False), ("validated", "rejected", False), ], ) def test_can_transition(current, target, expected): e = _entity(current) assert e.can_transition(TestState(target)) is expected # ── 3. transition_to (public API) ─────────────────────────────────── def test_transition_to_valid(): e = _entity("draft") prev = e.transition_to(TestState.red_executing) assert prev == "draft" assert e.state == TestState.red_executing def test_transition_to_accepts_string(): e = _entity("draft") prev = e.transition_to("red_executing") assert prev == "draft" assert e.state == TestState.red_executing def test_transition_to_accepts_foreign_enum(): """Simulates models.enums.TestState (different class, same .value).""" import enum class ForeignState(str, enum.Enum): red_executing = "red_executing" e = _entity("draft") prev = e.transition_to(ForeignState.red_executing) assert prev == "draft" assert e.state == TestState.red_executing def test_transition_to_invalid_raises(): e = _entity("draft") with pytest.raises(InvalidStateTransition) as exc_info: e.transition_to("validated") assert exc_info.value.current_state == "draft" assert exc_info.value.target_state == "validated" assert "red_executing" in exc_info.value.valid_transitions def test_transition_emits_state_changed_event(): e = _entity("draft") e.transition_to("red_executing") evts = [ev for ev in e.events if ev.name == "state_changed"] assert len(evts) == 1 assert evts[0].payload["previous"] == "draft" assert evts[0].payload["new"] == "red_executing" # ── 4. Lifecycle: start_execution ──────────────────────────────────── def test_start_execution(): e = _entity("draft") before = datetime.utcnow() e.start_execution() assert e.state == TestState.red_executing assert e.execution_date is not None assert e.red_started_at is not None assert e.execution_date >= before assert any(ev.name == "execution_started" for ev in e.events) def test_start_execution_from_wrong_state(): e = _entity("in_review") with pytest.raises(InvalidStateTransition): e.start_execution() # ── 5. Lifecycle: submit_red_evidence ──────────────────────────────── def test_submit_red_evidence(): e = _entity("red_executing", red_started_at=datetime.utcnow()) total_paused = e.submit_red_evidence() assert e.state == TestState.blue_evaluating assert total_paused == 0 assert e.blue_started_at is not None assert e.blue_paused_seconds == 0 def test_submit_red_evidence_auto_resumes(): paused_time = datetime.utcnow() - timedelta(seconds=30) e = _entity("red_executing", paused_at=paused_time, red_paused_seconds=10) total_paused = e.submit_red_evidence() assert e.paused_at is None assert total_paused >= 40 # ── 6. Lifecycle: submit_blue_evidence ─────────────────────────────── def test_submit_blue_evidence(): e = _entity("blue_evaluating", blue_started_at=datetime.utcnow()) total_paused = e.submit_blue_evidence() assert e.state == TestState.in_review assert total_paused == 0 def test_submit_blue_evidence_auto_resumes(): paused_time = datetime.utcnow() - timedelta(seconds=20) e = _entity("blue_evaluating", paused_at=paused_time, blue_paused_seconds=5) total_paused = e.submit_blue_evidence() assert e.paused_at is None assert total_paused >= 25 # ── 7. pause_timer / resume_timer ──────────────────────────────────── def test_pause_timer_in_red_executing(): e = _entity("red_executing") e.pause_timer() assert e.paused_at is not None assert any(ev.name == "timer_paused" for ev in e.events) def test_pause_timer_in_blue_evaluating(): e = _entity("blue_evaluating") e.pause_timer() assert e.paused_at is not None def test_pause_timer_wrong_state(): e = _entity("draft") with pytest.raises(BusinessRuleViolation, match="Cannot pause"): e.pause_timer() def test_pause_timer_already_paused(): e = _entity("red_executing", paused_at=datetime.utcnow()) with pytest.raises(BusinessRuleViolation, match="already paused"): e.pause_timer() def test_resume_timer_red(): paused_time = datetime.utcnow() - timedelta(seconds=10) e = _entity("red_executing", paused_at=paused_time, red_paused_seconds=5) secs = e.resume_timer() assert secs >= 10 assert e.paused_at is None assert e.red_paused_seconds >= 15 def test_resume_timer_blue(): paused_time = datetime.utcnow() - timedelta(seconds=5) e = _entity("blue_evaluating", paused_at=paused_time, blue_paused_seconds=0) secs = e.resume_timer() assert secs >= 5 assert e.blue_paused_seconds >= 5 def test_resume_timer_not_paused(): e = _entity("red_executing") with pytest.raises(BusinessRuleViolation, match="not paused"): e.resume_timer() # ── 8. Dual validation ────────────────────────────────────────────── def test_dual_validation_both_approved(): e = _entity("in_review") user_r = uuid.uuid4() user_b = uuid.uuid4() e.validate_red("approved", by=user_r, notes="LGTM") assert e.state == TestState.in_review e.validate_blue("approved", by=user_b, notes="Detection OK") assert e.state == TestState.validated assert any(ev.name == "dual_validation_approved" for ev in e.events) def test_dual_validation_red_rejects(): e = _entity("in_review") e.validate_red("rejected", by=uuid.uuid4()) assert e.state == TestState.rejected assert any(ev.name == "dual_validation_rejected" for ev in e.events) def test_dual_validation_blue_rejects(): e = _entity("in_review") e.validate_red("approved", by=uuid.uuid4()) e.validate_blue("rejected", by=uuid.uuid4()) assert e.state == TestState.rejected def test_validate_wrong_state(): e = _entity("draft") with pytest.raises(BusinessRuleViolation, match="must be in_review"): e.validate_red("approved", by=uuid.uuid4()) def test_validate_invalid_status(): e = _entity("in_review") with pytest.raises(BusinessRuleViolation, match="approved.*rejected"): e.validate_red("maybe", by=uuid.uuid4()) def test_validate_red_sets_fields(): e = _entity("in_review") uid = uuid.uuid4() e.validate_red("approved", by=uid, notes="ok") assert e.red_validation_status == "approved" assert e.red_validated_by == uid assert e.red_validated_at is not None assert e.red_validation_notes == "ok" # ── 9. reopen ──────────────────────────────────────────────────────── def test_reopen_clears_all_fields(): e = _entity( "rejected", red_validation_status="rejected", red_validated_by=uuid.uuid4(), red_validated_at=datetime.utcnow(), red_validation_notes="bad", blue_validation_status="approved", blue_validated_by=uuid.uuid4(), blue_validated_at=datetime.utcnow(), blue_validation_notes="ok", red_started_at=datetime.utcnow(), blue_started_at=datetime.utcnow(), paused_at=datetime.utcnow(), red_paused_seconds=100, blue_paused_seconds=200, ) e.reopen() assert e.state == TestState.draft assert e.red_validation_status is None assert e.red_validated_by is None assert e.red_validated_at is None assert e.blue_validation_status is None assert e.blue_validated_by is None assert e.blue_validated_at is None assert e.red_started_at is None assert e.blue_started_at is None assert e.paused_at is None assert e.red_paused_seconds == 0 assert e.blue_paused_seconds == 0 assert any(ev.name == "test_reopened" for ev in e.events) def test_reopen_from_non_rejected_fails(): e = _entity("draft") with pytest.raises(InvalidStateTransition): e.reopen() # ── 10. from_orm / apply_to round-trip ─────────────────────────────── def test_from_orm_apply_to_roundtrip(): model = _fake_orm("draft") entity = TestEntity.from_orm(model) assert entity.state == TestState.draft assert entity.id == model.id entity.start_execution() entity.apply_to(model) assert model.state == TestState.red_executing assert model.execution_date is not None assert model.red_started_at is not None def test_from_orm_coerces_string_state(): model = _fake_orm("blue_evaluating") entity = TestEntity.from_orm(model) assert entity.state == TestState.blue_evaluating def test_from_orm_handles_none_paused_seconds(): model = _fake_orm("draft") model.red_paused_seconds = None model.blue_paused_seconds = None entity = TestEntity.from_orm(model) assert entity.red_paused_seconds == 0 assert entity.blue_paused_seconds == 0 # ── 11. Full lifecycle (happy path) ───────────────────────────────── def test_full_lifecycle_happy_path(): e = _entity("draft") uid_red = uuid.uuid4() uid_blue = uuid.uuid4() e.start_execution() assert e.state == TestState.red_executing e.submit_red_evidence() assert e.state == TestState.blue_evaluating e.submit_blue_evidence() assert e.state == TestState.in_review e.validate_red("approved", by=uid_red) e.validate_blue("approved", by=uid_blue) assert e.state == TestState.validated assert e.is_terminal is True event_names = [ev.name for ev in e.events] assert "state_changed" in event_names assert "execution_started" in event_names assert "dual_validation_approved" in event_names def test_full_lifecycle_rejection_reopen(): e = _entity("draft") e.start_execution() e.submit_red_evidence() e.submit_blue_evidence() e.validate_red("rejected", by=uuid.uuid4()) assert e.state == TestState.rejected e.reopen() assert e.state == TestState.draft e.start_execution() assert e.state == TestState.red_executing # ── 12. is_terminal property ──────────────────────────────────────── def test_is_terminal(): assert _entity("validated").is_terminal is True assert _entity("rejected").is_terminal is False assert _entity("draft").is_terminal is False