From 58d1da93cea4782ca2410e022140d665000ed12b Mon Sep 17 00:00:00 2001 From: kitos Date: Mon, 6 Jul 2026 10:38:15 +0200 Subject: [PATCH] feat(tests): add red_review/blue_review states to test state machine --- backend/app/domain/enums.py | 4 + backend/app/domain/test_entity.py | 82 ++++++++++++++++--- backend/tests/test_test_entity.py | 129 ++++++++++++++++++++++++++++-- 3 files changed, 197 insertions(+), 18 deletions(-) diff --git a/backend/app/domain/enums.py b/backend/app/domain/enums.py index b1b3782..39f3771 100644 --- a/backend/app/domain/enums.py +++ b/backend/app/domain/enums.py @@ -35,8 +35,12 @@ class TestState(str, enum.Enum): draft = "draft" # Assign red_executing = "red_executing" red_executing = "red_executing" + # Red Lead reviews the operator's work before it queues for Blue Team + red_review = "red_review" # Assign blue_evaluating = "blue_evaluating" blue_evaluating = "blue_evaluating" + # Blue Lead reviews the operator's work before cross-validation + blue_review = "blue_review" # Assign in_review = "in_review" in_review = "in_review" # Assign validated = "validated" diff --git a/backend/app/domain/test_entity.py b/backend/app/domain/test_entity.py index d98eca3..6748d9f 100644 --- a/backend/app/domain/test_entity.py +++ b/backend/app/domain/test_entity.py @@ -60,8 +60,12 @@ class TestState(str, enum.Enum): draft = "draft" # Assign red_executing = "red_executing" red_executing = "red_executing" + # Red Lead reviews the operator's work before it queues for Blue Team + red_review = "red_review" # Assign blue_evaluating = "blue_evaluating" blue_evaluating = "blue_evaluating" + # Blue Lead reviews the operator's work before cross-validation + blue_review = "blue_review" # Assign in_review = "in_review" in_review = "in_review" # Assign validated = "validated" @@ -74,8 +78,10 @@ class TestState(str, enum.Enum): # Assign VALID_TRANSITIONS = { VALID_TRANSITIONS: dict[TestState, list[TestState]] = { TestState.draft: [TestState.red_executing], - TestState.red_executing: [TestState.blue_evaluating], - TestState.blue_evaluating: [TestState.in_review], + TestState.red_executing: [TestState.red_review], + TestState.red_review: [TestState.blue_evaluating, TestState.red_executing], + TestState.blue_evaluating: [TestState.blue_review], + TestState.blue_review: [TestState.in_review, TestState.blue_evaluating], TestState.in_review: [TestState.validated, TestState.rejected, TestState.disputed], TestState.disputed: [TestState.validated, TestState.rejected], TestState.rejected: [TestState.draft], @@ -363,10 +369,12 @@ class TestEntity: # Define function submit_red_evidence def submit_red_evidence(self) -> int: - """Transition the test from ``red_executing`` to ``blue_evaluating``. + """Transition the test from ``red_executing`` to ``red_review``. Auto-resumes if paused. Returns paused seconds accumulated - during this phase (for worklog calculation). + during this phase (for worklog calculation). The Blue Team queue + timer does not start yet — that happens in ``approve_red_review``, + once the Red Lead actually releases the test to Blue Team. Returns: int: Total seconds the red phase was paused. @@ -374,13 +382,9 @@ class TestEntity: # Assign paused_extra = self._auto_resume() paused_extra = self._auto_resume() # Call self._transition() - self._transition(TestState.blue_evaluating) + self._transition(TestState.red_review) # Assign total_paused = self.red_paused_seconds + paused_extra total_paused = self.red_paused_seconds + paused_extra - # Assign self.blue_started_at = datetime.utcnow() - self.blue_started_at = datetime.utcnow() - # Assign self.blue_paused_seconds = 0 - self.blue_paused_seconds = 0 # Call self._events.append() self._events.append(DomainEvent( # Literal argument value @@ -390,9 +394,32 @@ class TestEntity: # Return total_paused return total_paused + def approve_red_review(self) -> None: + """Transition the test from ``red_review`` to ``blue_evaluating``. + + Called when the assigned Red Lead approves the operator's work. + Starts the Blue Team queue timer. + """ + self._transition(TestState.blue_evaluating) + self.blue_started_at = datetime.utcnow() + self.blue_paused_seconds = 0 + self._events.append(DomainEvent("red_review_approved")) + + def reopen_red_review(self) -> None: + """Transition the test from ``red_review`` back to ``red_executing``. + + Called when the assigned Red Lead sends the work back for rework. + Resets the red-phase timer for a fresh attempt (the first attempt's + worklog was already recorded at submit time, so this loses nothing). + """ + self._transition(TestState.red_executing) + self.red_started_at = datetime.utcnow() + self.red_paused_seconds = 0 + self._events.append(DomainEvent("red_review_reopened")) + # Define function submit_blue_evidence def submit_blue_evidence(self) -> int: - """Transition the test from ``blue_evaluating`` to ``in_review``. + """Transition the test from ``blue_evaluating`` to ``blue_review``. Auto-resumes if paused. Returns paused seconds accumulated during this phase (for worklog calculation). @@ -403,7 +430,7 @@ class TestEntity: # Assign paused_extra = self._auto_resume() paused_extra = self._auto_resume() # Call self._transition() - self._transition(TestState.in_review) + self._transition(TestState.blue_review) # Assign total_paused = self.blue_paused_seconds + paused_extra total_paused = self.blue_paused_seconds + paused_extra # Call self._events.append() @@ -415,6 +442,39 @@ class TestEntity: # Return total_paused return total_paused + def approve_blue_review(self) -> None: + """Transition the test from ``blue_review`` to ``in_review``. + + Called when the assigned Blue Lead approves the operator's work. + """ + self._transition(TestState.in_review) + self._events.append(DomainEvent("blue_review_approved")) + + def reopen_blue_review(self) -> None: + """Transition the test from ``blue_review`` back to ``blue_evaluating``. + + Called when the assigned Blue Lead sends the work back for rework. + Resets the blue-phase timer for a fresh attempt. Note: + ``blue_work_started_at`` (the pickup timestamp) lives only on the + ORM model, not on this entity — the service layer resets it there. + """ + self._transition(TestState.blue_evaluating) + self.blue_started_at = datetime.utcnow() + self.blue_paused_seconds = 0 + self._events.append(DomainEvent("blue_review_reopened")) + + def flag_blue_review_gap(self) -> None: + """Transition the test from ``blue_review`` to ``in_review``. + + Called when the assigned Blue Lead determines the shortfall is a + missing capability, not operator error — the test still proceeds + to cross-validation since a retry can't fix a capability gap. The + gap description itself is stored on the ORM model + (``Test.system_gaps``), not on this entity. + """ + self._transition(TestState.in_review) + self._events.append(DomainEvent("blue_review_gap_flagged")) + # Define function pause_timer def pause_timer(self) -> None: """Pause the active phase timer. diff --git a/backend/tests/test_test_entity.py b/backend/tests/test_test_entity.py index 2630d99..8afe59c 100644 --- a/backend/tests/test_test_entity.py +++ b/backend/tests/test_test_entity.py @@ -23,7 +23,11 @@ from app.domain.test_entity import ( VALID_TRANSITIONS, DomainEvent, ) -from app.domain.errors import BusinessRuleViolation, InvalidStateTransition +from app.domain.errors import ( + BusinessRuleViolation, + InvalidOperationError, + InvalidStateTransition, +) # ── Helpers ────────────────────────────────────────────────────────── @@ -97,9 +101,15 @@ def test_validated_is_terminal(): ("draft", "red_executing", True), ("draft", "validated", False), ("draft", "blue_evaluating", False), - ("red_executing", "blue_evaluating", True), + ("red_executing", "red_review", True), + ("red_executing", "blue_evaluating", False), ("red_executing", "draft", False), - ("blue_evaluating", "in_review", True), + ("red_review", "blue_evaluating", True), + ("red_review", "red_executing", True), + ("blue_evaluating", "blue_review", True), + ("blue_evaluating", "in_review", False), + ("blue_review", "in_review", True), + ("blue_review", "blue_evaluating", True), ("in_review", "validated", True), ("in_review", "rejected", True), ("in_review", "draft", False), @@ -187,10 +197,9 @@ def test_start_execution_from_wrong_state(): 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 e.state == TestState.red_review assert total_paused == 0 - assert e.blue_started_at is not None - assert e.blue_paused_seconds == 0 + assert e.blue_started_at is None def test_submit_red_evidence_auto_resumes(): @@ -201,13 +210,55 @@ def test_submit_red_evidence_auto_resumes(): assert total_paused >= 40 +# ── 5b. Lifecycle: red_review gate ─────────────────────────────────── + + +def test_submit_red_evidence_now_goes_to_red_review(): + e = _entity("red_executing", red_started_at=datetime.utcnow()) + total_paused = e.submit_red_evidence() + assert e.state == TestState.red_review + assert total_paused == 0 + # blue timer must NOT start yet — that's approve_red_review's job + assert e.blue_started_at is None + + +def test_approve_red_review_moves_to_blue_evaluating(): + e = _entity("red_review") + e.approve_red_review() + assert e.state == TestState.blue_evaluating + assert e.blue_started_at is not None + assert e.blue_paused_seconds == 0 + assert any(ev.name == "red_review_approved" for ev in e.events) + + +def test_approve_red_review_wrong_state(): + e = _entity("red_executing") + with pytest.raises(InvalidStateTransition): + e.approve_red_review() + + +def test_reopen_red_review_moves_to_red_executing(): + e = _entity("red_review", red_paused_seconds=50) + e.reopen_red_review() + assert e.state == TestState.red_executing + assert e.red_started_at is not None + assert e.red_paused_seconds == 0 + assert any(ev.name == "red_review_reopened" for ev in e.events) + + +def test_reopen_red_review_wrong_state(): + e = _entity("blue_evaluating") + with pytest.raises(InvalidStateTransition): + e.reopen_red_review() + + # ── 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 e.state == TestState.blue_review assert total_paused == 0 @@ -219,6 +270,62 @@ def test_submit_blue_evidence_auto_resumes(): assert total_paused >= 25 +# ── 6b. Lifecycle: blue_review gate ────────────────────────────────── + + +def test_approve_blue_review_moves_to_in_review(): + e = _entity("blue_review") + e.approve_blue_review() + assert e.state == TestState.in_review + assert any(ev.name == "blue_review_approved" for ev in e.events) + + +def test_approve_blue_review_wrong_state(): + e = _entity("blue_evaluating") + with pytest.raises(InvalidStateTransition): + e.approve_blue_review() + + +def test_reopen_blue_review_moves_to_blue_evaluating(): + e = _entity("blue_review", blue_paused_seconds=30) + e.reopen_blue_review() + assert e.state == TestState.blue_evaluating + assert e.blue_started_at is not None + assert e.blue_paused_seconds == 0 + assert any(ev.name == "blue_review_reopened" for ev in e.events) + + +def test_reopen_blue_review_wrong_state(): + e = _entity("in_review") + with pytest.raises(InvalidStateTransition): + e.reopen_blue_review() + + +def test_flag_blue_review_gap_moves_to_in_review(): + e = _entity("blue_review") + e.flag_blue_review_gap() + assert e.state == TestState.in_review + assert any(ev.name == "blue_review_gap_flagged" for ev in e.events) + + +def test_flag_blue_review_gap_wrong_state(): + e = _entity("blue_evaluating") + with pytest.raises(InvalidStateTransition): + e.flag_blue_review_gap() + + +def test_validate_red_not_reachable_from_red_review(): + e = _entity("red_review") + with pytest.raises(InvalidOperationError, match="must be in_review"): + e.validate_red("approved", by=uuid.uuid4()) + + +def test_validate_blue_not_reachable_from_blue_review(): + e = _entity("blue_review") + with pytest.raises(InvalidOperationError, match="must be in_review"): + e.validate_blue("approved", by=uuid.uuid4()) + + # ── 7. pause_timer / resume_timer ──────────────────────────────────── @@ -408,9 +515,15 @@ def test_full_lifecycle_happy_path(): assert e.state == TestState.red_executing e.submit_red_evidence() + assert e.state == TestState.red_review + + e.approve_red_review() assert e.state == TestState.blue_evaluating e.submit_blue_evidence() + assert e.state == TestState.blue_review + + e.approve_blue_review() assert e.state == TestState.in_review e.validate_red("approved", by=uid_red) @@ -428,7 +541,9 @@ def test_full_lifecycle_rejection_reopen(): e = _entity("draft") e.start_execution() e.submit_red_evidence() + e.approve_red_review() e.submit_blue_evidence() + e.approve_blue_review() e.validate_red("rejected", by=uuid.uuid4()) assert e.state == TestState.rejected