jobhunt-platform/apps/api/tests/test_statemachine.py
hermes 17759ef564 T1: FastAPI backend core (models, state machine, approval gate) + compose test runner
- apps/api: psycopg v3 repositories, schema.sql + migration runner, state machine
  implementing the data-model transition table, approval gate with sha256 hash
  match + 24h expiry enforced at send time, EchoTransport pluggable sender,
  LLM calls routed through packages.llm-gateway with mock mode, 47 pytest tests green
- apps/api/Dockerfile.test: python 3.13-slim test image (DinD-safe: migrations
  copied as directory)
- docker-compose.yml: add api-test service on compose network (host port
  publishing is broken in this sandbox; container-to-container networking used)
- Fix: replace masked placeholder password in config.py/conftest.py defaults
2026-07-30 18:10:59 +00:00

161 lines
No EOL
5 KiB
Python

"""State machine transition tests (happy path + 409 invalid transitions)."""
from __future__ import annotations
import pytest
from app.statemachine import (
InvalidTransition,
STATES,
TransitionContext,
check_transition,
)
class TestStateMachineTransitions:
"""Test all valid transitions (happy path)."""
def test_discovered_to_scored(self):
ctx = TransitionContext(
application_id="test",
from_state="discovered",
to_state="scored",
has_score=True,
)
check_transition(ctx) # should not raise
def test_discovered_to_rejected(self):
ctx = TransitionContext("test", "discovered", "rejected")
check_transition(ctx)
def test_scored_to_approved(self):
ctx = TransitionContext("test", "scored", "approved")
check_transition(ctx)
def test_scored_to_rejected(self):
ctx = TransitionContext("test", "scored", "rejected")
check_transition(ctx)
def test_approved_to_drafting(self):
ctx = TransitionContext("test", "approved", "drafting")
check_transition(ctx)
def test_drafting_to_sent(self):
ctx = TransitionContext(
"test",
"drafting",
"sent",
has_confirmed_approval=True,
artifact_hash_match=True,
)
check_transition(ctx)
def test_sent_to_interviewing(self):
ctx = TransitionContext("test", "sent", "interviewing")
check_transition(ctx)
def test_interviewing_to_offer(self):
ctx = TransitionContext("test", "interviewing", "offer")
check_transition(ctx)
def test_interviewing_to_closed(self):
ctx = TransitionContext("test", "interviewing", "closed")
check_transition(ctx)
def test_offer_to_closed(self):
ctx = TransitionContext("test", "offer", "closed")
check_transition(ctx)
def test_scored_to_expired(self):
ctx = TransitionContext("test", "scored", "expired")
check_transition(ctx)
def test_approved_to_expired(self):
ctx = TransitionContext("test", "approved", "expired")
check_transition(ctx)
class TestStateMachineInvalidTransitions:
"""Test invalid transitions raise InvalidTransition."""
def test_discovered_to_approved(self):
ctx = TransitionContext("test", "discovered", "approved")
with pytest.raises(InvalidTransition):
check_transition(ctx)
def test_discovered_to_sent(self):
ctx = TransitionContext("test", "discovered", "sent")
with pytest.raises(InvalidTransition):
check_transition(ctx)
def test_rejected_to_scored(self):
ctx = TransitionContext("test", "rejected", "scored")
with pytest.raises(InvalidTransition):
check_transition(ctx)
def test_closed_to_sent(self):
ctx = TransitionContext("test", "closed", "sent")
with pytest.raises(InvalidTransition):
check_transition(ctx)
def test_unknown_from_state(self):
ctx = TransitionContext("test", "nonexistent", "scored")
with pytest.raises(InvalidTransition):
check_transition(ctx)
def test_unknown_to_state(self):
ctx = TransitionContext("test", "discovered", "nonexistent")
with pytest.raises(InvalidTransition):
check_transition(ctx)
def test_drafting_to_sent_no_approval(self):
"""Guard: drafting -> sent requires confirmed approval."""
ctx = TransitionContext(
"test",
"drafting",
"sent",
has_confirmed_approval=False,
artifact_hash_match=False,
)
with pytest.raises(InvalidTransition) as exc_info:
check_transition(ctx)
assert "guard failed" in str(exc_info.value)
def test_drafting_to_sent_hash_mismatch(self):
"""Guard: drafting -> sent requires hash match."""
ctx = TransitionContext(
"test",
"drafting",
"sent",
has_confirmed_approval=True,
artifact_hash_match=False,
)
with pytest.raises(InvalidTransition):
check_transition(ctx)
def test_discovered_to_scored_no_score(self):
"""Guard: discovered -> scored requires scoring completed (has_score)."""
ctx = TransitionContext(
"test",
"discovered",
"scored",
has_score=False,
)
with pytest.raises(InvalidTransition) as exc_info:
check_transition(ctx)
assert "guard failed" in str(exc_info.value)
class TestStateMachineHelpers:
def test_all_states_present(self):
assert len(STATES) == 10
def test_can_transition_valid(self):
from app.statemachine import can_transition
assert can_transition("discovered", "scored")
assert can_transition("discovered", "rejected")
def test_can_transition_invalid(self):
from app.statemachine import can_transition
assert not can_transition("discovered", "sent")
assert not can_transition("closed", "discovered")