Files
training-software/training/tests/test_training_modes.py
Paperclip CTO 01661bf5a8
Some checks failed
CI / lint (push) Successful in 6s
CI / test (push) Failing after 7s
CI / build-container (push) Has been skipped
fix: restore CI by fixing lint, test collection, and notification syntax
2026-05-18 14:53:29 +02:00

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