claimflow-api / tests /test_state_machine.py
Minifigures's picture
feat: ClaimFlow API demo backend
ceea9e1 verified
Raw
History Blame Contribute Delete
7.65 kB
from itertools import count
import pytest
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.claimguard import audit
from app.models import Claim, ClaimAction, ClaimState, Decision, Role, User
from app.workflow.state_machine import (
SYSTEM_ROLE,
TERMINAL_STATES,
TRANSITIONS,
TransitionError,
allowed_actions,
apply_transition,
record_initial_submit,
)
_ref_counter = count(1)
ALL_ACTOR_ROLES = [r.value for r in Role] + [SYSTEM_ROLE]
ACTION_ACTOR_ROLE: dict[ClaimAction, str] = {
ClaimAction.SUBMIT: Role.CLAIMANT.value,
ClaimAction.IMAGING_COMPLETE: SYSTEM_ROLE,
ClaimAction.FORWARD: Role.IMAGING_SPECIALIST.value,
ClaimAction.RETURN_TO_CLAIMANT: Role.IMAGING_SPECIALIST.value,
ClaimAction.RESUBMIT: Role.CLAIMANT.value,
ClaimAction.SEND_TO_INSURER: Role.MEDICAL_SPECIALIST.value,
ClaimAction.REQUEST_FURTHER_TESTING: Role.MEDICAL_SPECIALIST.value,
ClaimAction.APPROVE: Role.INSURANCE_AGENT.value,
ClaimAction.REJECT: Role.INSURANCE_AGENT.value,
}
INVALID_PAIRS = [
(state, action)
for state in ClaimState
for action in ClaimAction
if (state, action) not in TRANSITIONS
]
def make_claim(session: Session, claimant: User, state: ClaimState) -> Claim:
claim = Claim(
claim_ref=f"CLM-TEST-{next(_ref_counter):05d}",
claimant_id=claimant.id,
claim_type="imaging",
state=state,
)
session.add(claim)
session.flush()
return claim
def make_other_claimant(session: Session) -> User:
other = User(
email="other-claimant@demo.ca",
password_hash="not-a-real-hash",
role=Role.CLAIMANT,
full_name="Olive Other",
member_id="MBR-2002",
)
session.add(other)
session.flush()
return other
def actor_for(users: dict[str, User], role_value: str) -> User | None:
return None if role_value == SYSTEM_ROLE else users[role_value]
def test_full_matrix_invalid_pairs_raise(session: Session, users: dict[str, User]) -> None:
claimant = users[Role.CLAIMANT.value]
for state, action in INVALID_PAIRS:
claim = make_claim(session, claimant, state)
actor = actor_for(users, ACTION_ACTOR_ROLE[action])
with pytest.raises(TransitionError):
apply_transition(session, claim, action, actor=actor)
assert claim.state == state, f"{state.value} x {action.value} mutated state"
@pytest.mark.parametrize(
("state", "action"),
list(TRANSITIONS),
ids=[f"{s.value}-{a.value}" for s, a in TRANSITIONS],
)
def test_allowed_transition_succeeds(
session: Session, users: dict[str, User], state: ClaimState, action: ClaimAction
) -> None:
expected_state, allowed_roles = TRANSITIONS[(state, action)]
role_value = next(iter(allowed_roles))
actor = actor_for(users, role_value)
claimant = users[Role.CLAIMANT.value]
claim = make_claim(session, claimant, state)
decision = apply_transition(session, claim, action, actor=actor, note="ok")
assert claim.state == expected_state
assert decision.id is not None
assert decision.claim_id == claim.id
assert decision.action == action
assert decision.from_state == state
assert decision.to_state == expected_state
assert decision.note == "ok"
assert decision.actor_role == role_value
assert decision.actor_id == (actor.id if actor is not None else None)
stored = session.scalars(select(Decision).where(Decision.claim_id == claim.id)).all()
assert len(stored) == 1
ok, n = audit.verify_chain(session)
assert ok is True
assert n == 1
def test_role_enforcement_wrong_roles_raise(session: Session, users: dict[str, User]) -> None:
claimant = users[Role.CLAIMANT.value]
for (state, action), (_, allowed_roles) in TRANSITIONS.items():
for role_value in ALL_ACTOR_ROLES:
if role_value in allowed_roles:
continue
claim = make_claim(session, claimant, state)
actor = actor_for(users, role_value)
with pytest.raises(TransitionError):
apply_transition(session, claim, action, actor=actor)
assert claim.state == state
def test_claimant_cannot_resubmit_someone_elses_claim(
session: Session, users: dict[str, User]
) -> None:
owner = users[Role.CLAIMANT.value]
other = make_other_claimant(session)
for state in (ClaimState.RETURNED_TO_CLAIMANT, ClaimState.PENDING_FURTHER_TESTING):
claim = make_claim(session, owner, state)
with pytest.raises(TransitionError):
apply_transition(session, claim, ClaimAction.RESUBMIT, actor=other)
assert claim.state == state
decision = apply_transition(session, claim, ClaimAction.RESUBMIT, actor=owner)
assert claim.state == ClaimState.SUBMITTED
assert decision.actor_id == owner.id
def test_terminal_states_allow_no_actions(session: Session, users: dict[str, User]) -> None:
claimant = users[Role.CLAIMANT.value]
for state in TERMINAL_STATES:
claim = make_claim(session, claimant, state)
for action in ClaimAction:
actor = actor_for(users, ACTION_ACTOR_ROLE[action])
with pytest.raises(TransitionError):
apply_transition(session, claim, action, actor=actor)
assert claim.state == state
for role_value in ALL_ACTOR_ROLES:
assert allowed_actions(state, role_value) == set()
def test_record_initial_submit_owner(session: Session, users: dict[str, User]) -> None:
claimant = users[Role.CLAIMANT.value]
claim = make_claim(session, claimant, ClaimState.SUBMITTED)
decision = record_initial_submit(session, claim, claimant)
assert decision.id is not None
assert decision.claim_id == claim.id
assert decision.action == ClaimAction.SUBMIT
assert decision.from_state is None
assert decision.to_state == ClaimState.SUBMITTED
assert decision.actor_id == claimant.id
assert decision.actor_role == Role.CLAIMANT.value
assert claim.state == ClaimState.SUBMITTED
ok, n = audit.verify_chain(session)
assert ok is True
assert n == 1
def test_record_initial_submit_rejects_non_owner_and_non_claimant(
session: Session, users: dict[str, User]
) -> None:
owner = users[Role.CLAIMANT.value]
other = make_other_claimant(session)
claim = make_claim(session, owner, ClaimState.SUBMITTED)
with pytest.raises(TransitionError):
record_initial_submit(session, claim, other)
for role in (Role.IMAGING_SPECIALIST, Role.MEDICAL_SPECIALIST, Role.INSURANCE_AGENT):
with pytest.raises(TransitionError):
record_initial_submit(session, claim, users[role.value])
assert session.scalars(select(Decision)).all() == []
def test_allowed_actions_spot_checks() -> None:
assert allowed_actions(ClaimState.IMAGING_REVIEW, Role.IMAGING_SPECIALIST.value) == {
ClaimAction.FORWARD,
ClaimAction.RETURN_TO_CLAIMANT,
}
assert allowed_actions(ClaimState.IMAGING_REVIEW, Role.CLAIMANT.value) == set()
assert allowed_actions(ClaimState.SUBMITTED, SYSTEM_ROLE) == {ClaimAction.IMAGING_COMPLETE}
assert allowed_actions(ClaimState.SUBMITTED, Role.CLAIMANT.value) == set()
assert allowed_actions(ClaimState.ADJUDICATION, Role.INSURANCE_AGENT.value) == {
ClaimAction.APPROVE,
ClaimAction.REJECT,
}
assert allowed_actions(ClaimState.RETURNED_TO_CLAIMANT, Role.CLAIMANT.value) == {
ClaimAction.RESUBMIT,
}
assert allowed_actions(ClaimState.PENDING_FURTHER_TESTING, Role.CLAIMANT.value) == {
ClaimAction.RESUBMIT,
}