287 lines
11 KiB
Python
287 lines
11 KiB
Python
"""
|
|
TRA-241: Training Modes and Trainer Signoff tests.
|
|
|
|
State machine transitions:
|
|
- Online: not_started → in_progress → (online pass) → completed
|
|
- Offline: not_started → in_progress → (trainer approve) → completed
|
|
- Blended: not_started → in_progress → (online pass) → pending_signoff → (trainer approve) → completed
|
|
- Rejection returns to in_progress
|
|
- Invalid transitions raise InvalidTransitionError
|
|
|
|
Trainer audit:
|
|
- TrainerSignoff records are created with decision, notes, trainer, timestamp
|
|
- Evidence records are attached when provided
|
|
|
|
API:
|
|
- Signoff endpoint requires trainer role
|
|
- State transitions return correct status
|
|
"""
|
|
import pytest
|
|
from rest_framework.test import APIClient
|
|
|
|
from accounts.tests.factories import AccountUserFactory, RoleFactory, UserRoleBindingFactory
|
|
from training.models import (
|
|
CompletionMode,
|
|
CompletionStatus,
|
|
SignoffDecision,
|
|
TrainerSignoff,
|
|
)
|
|
from training.services import (
|
|
InvalidTransitionError,
|
|
mark_in_progress,
|
|
mark_online_passed,
|
|
submit_trainer_signoff,
|
|
)
|
|
|
|
from .factories import TrainingRecordFactory, make_enrollment
|
|
|
|
pytestmark = pytest.mark.django_db
|
|
|
|
|
|
# ── Helpers ────────────────────────────────────────────────────────────────────
|
|
|
|
def make_trainer():
|
|
user = AccountUserFactory()
|
|
role = RoleFactory(slug="trainer", name="Trainer")
|
|
UserRoleBindingFactory(user=user, role=role)
|
|
return user
|
|
|
|
|
|
# ── Unit: Online mode state machine ───────────────────────────────────────────
|
|
|
|
class TestOnlineMode:
|
|
def test_not_started_to_in_progress(self):
|
|
record = TrainingRecordFactory(completion_mode=CompletionMode.ONLINE)
|
|
result = mark_in_progress(record)
|
|
assert result.status == CompletionStatus.IN_PROGRESS
|
|
|
|
def test_online_pass_completes(self):
|
|
record = TrainingRecordFactory(
|
|
completion_mode=CompletionMode.ONLINE,
|
|
status=CompletionStatus.IN_PROGRESS,
|
|
)
|
|
result = mark_online_passed(record)
|
|
assert result.status == CompletionStatus.COMPLETED
|
|
assert result.completed_at is not None
|
|
assert result.online_passed_at is not None
|
|
|
|
def test_online_pass_invalid_from_not_started(self):
|
|
record = TrainingRecordFactory(completion_mode=CompletionMode.ONLINE)
|
|
with pytest.raises(InvalidTransitionError):
|
|
mark_online_passed(record)
|
|
|
|
def test_online_no_trainer_signoff_needed(self):
|
|
record = TrainingRecordFactory(
|
|
completion_mode=CompletionMode.ONLINE,
|
|
status=CompletionStatus.IN_PROGRESS,
|
|
)
|
|
trainer = make_trainer()
|
|
with pytest.raises(InvalidTransitionError):
|
|
submit_trainer_signoff(record, trainer, SignoffDecision.APPROVED)
|
|
|
|
|
|
# ── Unit: Offline mode state machine ──────────────────────────────────────────
|
|
|
|
class TestOfflineMode:
|
|
def test_trainer_approve_completes(self):
|
|
record = TrainingRecordFactory(
|
|
completion_mode=CompletionMode.OFFLINE,
|
|
status=CompletionStatus.IN_PROGRESS,
|
|
)
|
|
trainer = make_trainer()
|
|
signoff = submit_trainer_signoff(record, trainer, SignoffDecision.APPROVED, notes="Well done")
|
|
record.refresh_from_db()
|
|
|
|
assert signoff.decision == SignoffDecision.APPROVED
|
|
assert record.status == CompletionStatus.COMPLETED
|
|
assert record.completed_at is not None
|
|
|
|
def test_trainer_reject_returns_to_in_progress(self):
|
|
record = TrainingRecordFactory(
|
|
completion_mode=CompletionMode.OFFLINE,
|
|
status=CompletionStatus.IN_PROGRESS,
|
|
)
|
|
trainer = make_trainer()
|
|
signoff = submit_trainer_signoff(record, trainer, SignoffDecision.REJECTED, notes="Missing evidence")
|
|
record.refresh_from_db()
|
|
|
|
assert signoff.decision == SignoffDecision.REJECTED
|
|
assert record.status == CompletionStatus.IN_PROGRESS
|
|
|
|
def test_online_pass_invalid_for_offline(self):
|
|
record = TrainingRecordFactory(
|
|
completion_mode=CompletionMode.OFFLINE,
|
|
status=CompletionStatus.IN_PROGRESS,
|
|
)
|
|
with pytest.raises(InvalidTransitionError):
|
|
mark_online_passed(record)
|
|
|
|
def test_evidence_recorded_with_signoff(self):
|
|
from training.models import SignoffEvidence
|
|
record = TrainingRecordFactory(
|
|
completion_mode=CompletionMode.OFFLINE,
|
|
status=CompletionStatus.IN_PROGRESS,
|
|
)
|
|
trainer = make_trainer()
|
|
evidence = [("cert.pdf", "/uploads/cert.pdf")]
|
|
submit_trainer_signoff(record, trainer, SignoffDecision.APPROVED, evidence_paths=evidence)
|
|
|
|
assert SignoffEvidence.objects.filter(training_record=record).count() == 1
|
|
|
|
|
|
# ── Unit: Blended mode state machine ──────────────────────────────────────────
|
|
|
|
class TestBlendedMode:
|
|
def test_online_pass_moves_to_pending_signoff(self):
|
|
record = TrainingRecordFactory(
|
|
completion_mode=CompletionMode.BLENDED,
|
|
status=CompletionStatus.IN_PROGRESS,
|
|
)
|
|
result = mark_online_passed(record)
|
|
assert result.status == CompletionStatus.PENDING_SIGNOFF
|
|
assert result.online_passed_at is not None
|
|
|
|
def test_trainer_approve_after_pending_completes(self):
|
|
record = TrainingRecordFactory(
|
|
completion_mode=CompletionMode.BLENDED,
|
|
status=CompletionStatus.PENDING_SIGNOFF,
|
|
)
|
|
trainer = make_trainer()
|
|
submit_trainer_signoff(record, trainer, SignoffDecision.APPROVED)
|
|
record.refresh_from_db()
|
|
|
|
assert record.status == CompletionStatus.COMPLETED
|
|
|
|
def test_trainer_signoff_without_pending_rejected(self):
|
|
record = TrainingRecordFactory(
|
|
completion_mode=CompletionMode.BLENDED,
|
|
status=CompletionStatus.IN_PROGRESS,
|
|
)
|
|
trainer = make_trainer()
|
|
with pytest.raises(InvalidTransitionError):
|
|
submit_trainer_signoff(record, trainer, SignoffDecision.APPROVED)
|
|
|
|
def test_full_blended_path(self):
|
|
"""Full path: not_started → in_progress → pending_signoff → completed"""
|
|
record = TrainingRecordFactory(
|
|
completion_mode=CompletionMode.BLENDED,
|
|
status=CompletionStatus.NOT_STARTED,
|
|
)
|
|
trainer = make_trainer()
|
|
|
|
mark_in_progress(record)
|
|
mark_online_passed(record)
|
|
record.refresh_from_db()
|
|
assert record.status == CompletionStatus.PENDING_SIGNOFF
|
|
|
|
submit_trainer_signoff(record, trainer, SignoffDecision.APPROVED, notes="Excellent")
|
|
record.refresh_from_db()
|
|
assert record.status == CompletionStatus.COMPLETED
|
|
|
|
# Signoff audit trail exists
|
|
assert TrainerSignoff.objects.filter(
|
|
training_record=record, decision=SignoffDecision.APPROVED
|
|
).count() == 1
|
|
|
|
|
|
# ── Unit: Invalid transitions ──────────────────────────────────────────────────
|
|
|
|
class TestInvalidTransitions:
|
|
def test_double_in_progress_raises(self):
|
|
record = TrainingRecordFactory(
|
|
completion_mode=CompletionMode.ONLINE,
|
|
status=CompletionStatus.IN_PROGRESS,
|
|
)
|
|
with pytest.raises(InvalidTransitionError):
|
|
mark_in_progress(record)
|
|
|
|
def test_mark_online_passed_when_completed_raises(self):
|
|
record = TrainingRecordFactory(
|
|
completion_mode=CompletionMode.ONLINE,
|
|
status=CompletionStatus.COMPLETED,
|
|
)
|
|
with pytest.raises(InvalidTransitionError):
|
|
mark_online_passed(record)
|
|
|
|
|
|
# ── Integration: API ──────────────────────────────────────────────────────────
|
|
|
|
class TestTrainingAPI:
|
|
def test_signoff_requires_trainer_role(self):
|
|
learner_user = AccountUserFactory()
|
|
learner_role = RoleFactory(slug="learner", name="Learner")
|
|
UserRoleBindingFactory(user=learner_user, role=learner_role)
|
|
|
|
enrollment = make_enrollment(user=AccountUserFactory())
|
|
record = TrainingRecordFactory(
|
|
enrollment=enrollment,
|
|
completion_mode=CompletionMode.OFFLINE,
|
|
status=CompletionStatus.IN_PROGRESS,
|
|
)
|
|
|
|
client = APIClient()
|
|
client.force_authenticate(user=learner_user)
|
|
|
|
response = client.post(
|
|
f"/api/v1/training/records/{record.id}/signoff/",
|
|
{"decision": "approved", "notes": ""},
|
|
format="json",
|
|
)
|
|
assert response.status_code == 403
|
|
|
|
def test_trainer_can_signoff(self):
|
|
trainer = make_trainer()
|
|
enrollment = make_enrollment(user=AccountUserFactory())
|
|
record = TrainingRecordFactory(
|
|
enrollment=enrollment,
|
|
completion_mode=CompletionMode.OFFLINE,
|
|
status=CompletionStatus.IN_PROGRESS,
|
|
)
|
|
|
|
client = APIClient()
|
|
client.force_authenticate(user=trainer)
|
|
|
|
response = client.post(
|
|
f"/api/v1/training/records/{record.id}/signoff/",
|
|
{"decision": "approved", "notes": "Good work"},
|
|
format="json",
|
|
)
|
|
assert response.status_code == 201
|
|
record.refresh_from_db()
|
|
assert record.status == CompletionStatus.COMPLETED
|
|
|
|
def test_mark_in_progress_api(self):
|
|
user = AccountUserFactory()
|
|
enrollment = make_enrollment(user=user)
|
|
record = TrainingRecordFactory(
|
|
enrollment=enrollment,
|
|
completion_mode=CompletionMode.ONLINE,
|
|
status=CompletionStatus.NOT_STARTED,
|
|
)
|
|
|
|
client = APIClient()
|
|
client.force_authenticate(user=user)
|
|
|
|
response = client.post(f"/api/v1/training/records/{record.id}/start/")
|
|
assert response.status_code == 200
|
|
assert response.data["status"] == "in_progress"
|
|
|
|
def test_signoff_invalid_transition_returns_409(self):
|
|
trainer = make_trainer()
|
|
enrollment = make_enrollment(user=AccountUserFactory())
|
|
record = TrainingRecordFactory(
|
|
enrollment=enrollment,
|
|
completion_mode=CompletionMode.BLENDED,
|
|
status=CompletionStatus.IN_PROGRESS, # not pending_signoff
|
|
)
|
|
|
|
client = APIClient()
|
|
client.force_authenticate(user=trainer)
|
|
|
|
response = client.post(
|
|
f"/api/v1/training/records/{record.id}/signoff/",
|
|
{"decision": "approved", "notes": ""},
|
|
format="json",
|
|
)
|
|
assert response.status_code == 409
|