- 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)
449 lines
14 KiB
Python
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
|