Files
Aegis/backend/tests/test_test_entity.py
Kitos 9e204b78ec test: add TestEntity tests and fix test infrastructure (222 green)
- Add test_test_entity.py with 46 pure unit tests covering the full domain entity

- Fix _FakeSettings in 11 test files (REPORT_TEMPLATES_DIR, JIRA, TEMPO)

- Fix stale db.commit assertions to db.flush after UoW refactor

- Add missing mock fields for TestEntity.from_orm compatibility

- Make database.py skip pool args for SQLite in test environment

- Disable slowapi rate limiter in test client fixture

- Inject test engine into app.database to fix threading errors

- Update role assertions to match current require_any_role policy

- Mark 6 legacy V1 endpoint tests as xfail (replaced by V2 workflow)
2026-02-18 15:29:24 +01:00

449 lines
14 KiB
Python

"""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