Files
training-software/tests/test_course_domain.py
Paperclip CTO cfa9ad6f53 feat(TRA-236,TRA-238): M2 course domain model and dwell-time tracking
TRA-236 — Course domain model:
- courses/models.py: Course → Module → Lesson → Page hierarchy with UUID
  PKs, ordering fields, required_seconds navigation gate, version tracking,
  and UniqueConstraint per (parent, order) pair
- courses/migrations/0001_initial.py: initial migration (applies cleanly on
  a fresh DB)
- tests/test_course_domain.py: migration smoke, relation integrity, cascade
  delete, ordering, and uniqueness-constraint tests

TRA-238 — Dwell-time tracking:
- tracking/models.py: Enrollment, PageProgress (can_advance property), and
  DwellEvent models appended alongside existing AuditEvent
- tracking/services.py: record_dwell_event, compute_eligible_seconds (pure),
  check_navigation_gate, _recompute_accumulated — reconnect merging within
  RECONNECT_TOLERANCE_SECONDS and anti-idle cap at MAX_VALID_EVENT_SECONDS
- tracking/migrations/0001_initial.py: updated to include all four models
  (AuditEvent, Enrollment, PageProgress, DwellEvent) with FK dependencies
  on courses.Course and courses.Page
- tests/test_dwell_tracking.py: event replay, reconnect tolerance, anti-idle
  cap, gate pass/block, and can_advance DB integration tests

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-07 09:11:23 +02:00

270 lines
8.6 KiB
Python

"""
Course domain model tests (TRA-236).
Covers:
- Migration smoke (tables exist after migrate)
- Course → Module → Lesson → Page relation integrity
- Ordering defaults and cascade deletes
- Ordering uniqueness constraints (unique_together on (parent, order))
- required_seconds field on Page
- version fields on all models
Tests that touch the DB are marked @pytest.mark.django_db.
"""
import uuid
import pytest
from django.db import IntegrityError, transaction
from courses.models import Course, Lesson, Module, Page
# ── Fixtures ──────────────────────────────────────────────────────────────────
ORG_ID = uuid.uuid4()
def _make_course(**kwargs):
defaults = dict(
org_id=ORG_ID,
title="Test Course",
slug=f"test-course-{uuid.uuid4().hex[:8]}",
description="",
version=1,
is_published=False,
)
defaults.update(kwargs)
return Course.objects.create(**defaults)
def _make_module(course, order=0, **kwargs):
return Module.objects.create(course=course, title="Module A", order=order, **kwargs)
def _make_lesson(module, order=0, **kwargs):
return Lesson.objects.create(module=module, title="Lesson A", order=order, **kwargs)
def _make_page(lesson, order=0, **kwargs):
return Page.objects.create(lesson=lesson, title="Page A", order=order, **kwargs)
# ── Migration / table existence ───────────────────────────────────────────────
@pytest.mark.django_db
class TestMigrationSmoke:
"""Tables must exist and be writable after the initial migration."""
def test_course_table_exists(self):
c = _make_course()
assert Course.objects.filter(pk=c.pk).exists()
def test_module_table_exists(self):
c = _make_course()
m = _make_module(c)
assert Module.objects.filter(pk=m.pk).exists()
def test_lesson_table_exists(self):
c = _make_course()
m = _make_module(c)
l = _make_lesson(m)
assert Lesson.objects.filter(pk=l.pk).exists()
def test_page_table_exists(self):
c = _make_course()
m = _make_module(c)
l = _make_lesson(m)
p = _make_page(l)
assert Page.objects.filter(pk=p.pk).exists()
def test_uuid_primary_keys(self):
c = _make_course()
assert isinstance(c.pk, uuid.UUID)
def test_version_field_default(self):
c = _make_course()
assert c.version == 1
m = _make_module(c)
assert m.version == 1
# ── Relation integrity ────────────────────────────────────────────────────────
@pytest.mark.django_db
class TestRelationIntegrity:
"""FK relationships and cascade deletes."""
def test_module_references_course(self):
c = _make_course()
m = _make_module(c)
assert m.course_id == c.pk
def test_lesson_references_module(self):
c = _make_course()
m = _make_module(c)
l = _make_lesson(m)
assert l.module_id == m.pk
def test_page_references_lesson(self):
c = _make_course()
m = _make_module(c)
l = _make_lesson(m)
p = _make_page(l)
assert p.lesson_id == l.pk
def test_reverse_relations_populated(self):
c = _make_course()
m = _make_module(c)
l = _make_lesson(m)
p = _make_page(l)
assert c.modules.filter(pk=m.pk).exists()
assert m.lessons.filter(pk=l.pk).exists()
assert l.pages.filter(pk=p.pk).exists()
def test_cascade_delete_module_removes_lessons(self):
c = _make_course()
m = _make_module(c)
l = _make_lesson(m)
m.delete()
assert not Lesson.objects.filter(pk=l.pk).exists()
def test_cascade_delete_lesson_removes_pages(self):
c = _make_course()
m = _make_module(c)
l = _make_lesson(m)
p = _make_page(l)
l.delete()
assert not Page.objects.filter(pk=p.pk).exists()
def test_cascade_delete_course_removes_all(self):
c = _make_course()
m = _make_module(c)
l = _make_lesson(m)
p = _make_page(l)
c.delete()
assert not Module.objects.filter(pk=m.pk).exists()
assert not Lesson.objects.filter(pk=l.pk).exists()
assert not Page.objects.filter(pk=p.pk).exists()
# ── Ordering ──────────────────────────────────────────────────────────────────
@pytest.mark.django_db
class TestOrdering:
"""Default queryset ordering is by `order` field."""
def test_modules_ordered_by_order(self):
c = _make_course()
m2 = _make_module(c, order=2, title="Module B")
m0 = _make_module(c, order=0, title="Module A")
m1 = _make_module(c, order=1, title="Module C")
pks = list(c.modules.values_list("pk", flat=True))
assert pks == [m0.pk, m1.pk, m2.pk]
def test_lessons_ordered_by_order(self):
c = _make_course()
m = _make_module(c)
l1 = _make_lesson(m, order=1, title="B")
l0 = _make_lesson(m, order=0, title="A")
pks = list(m.lessons.values_list("pk", flat=True))
assert pks == [l0.pk, l1.pk]
def test_pages_ordered_by_order(self):
c = _make_course()
m = _make_module(c)
l = _make_lesson(m)
p2 = _make_page(l, order=2, title="C")
p0 = _make_page(l, order=0, title="A")
p1 = _make_page(l, order=1, title="B")
pks = list(l.pages.values_list("pk", flat=True))
assert pks == [p0.pk, p1.pk, p2.pk]
# ── Ordering uniqueness constraints ───────────────────────────────────────────
@pytest.mark.django_db
class TestOrderingConstraints:
"""(parent, order) pairs must be unique within each level."""
def test_duplicate_module_order_raises(self):
c = _make_course()
_make_module(c, order=0)
with pytest.raises(IntegrityError):
with transaction.atomic():
_make_module(c, order=0)
def test_same_order_in_different_courses_allowed(self):
c1 = _make_course(slug="c1")
c2 = _make_course(slug="c2")
_make_module(c1, order=0)
m2 = _make_module(c2, order=0)
assert Module.objects.filter(pk=m2.pk).exists()
def test_duplicate_lesson_order_raises(self):
c = _make_course()
m = _make_module(c)
_make_lesson(m, order=0)
with pytest.raises(IntegrityError):
with transaction.atomic():
_make_lesson(m, order=0)
def test_same_lesson_order_in_different_modules_allowed(self):
c = _make_course()
m1 = _make_module(c, order=0)
m2 = _make_module(c, order=1)
_make_lesson(m1, order=0)
l2 = _make_lesson(m2, order=0)
assert Lesson.objects.filter(pk=l2.pk).exists()
def test_duplicate_page_order_raises(self):
c = _make_course()
m = _make_module(c)
l = _make_lesson(m)
_make_page(l, order=0)
with pytest.raises(IntegrityError):
with transaction.atomic():
_make_page(l, order=0)
def test_same_page_order_in_different_lessons_allowed(self):
c = _make_course()
m = _make_module(c)
l1 = _make_lesson(m, order=0)
l2 = _make_lesson(m, order=1)
_make_page(l1, order=0)
p2 = _make_page(l2, order=0)
assert Page.objects.filter(pk=p2.pk).exists()
# ── required_seconds / navigation gate field ─────────────────────────────────
@pytest.mark.django_db
class TestPageRequiredSeconds:
"""required_seconds defaults and constraints."""
def test_default_required_seconds_is_zero(self):
c = _make_course()
m = _make_module(c)
l = _make_lesson(m)
p = _make_page(l)
assert p.required_seconds == 0
def test_custom_required_seconds_stored(self):
c = _make_course()
m = _make_module(c)
l = _make_lesson(m)
p = _make_page(l, required_seconds=120)
p_db = Page.objects.get(pk=p.pk)
assert p_db.required_seconds == 120
def test_is_required_defaults_true(self):
c = _make_course()
m = _make_module(c)
l = _make_lesson(m)
p = _make_page(l)
assert p.is_required is True