gapguide-api / apps /progress /tests /test_progress.py
arifRB's picture
Deploy GapGuide backend (Docker)
ffd36e0 verified
Raw
History Blame Contribute Delete
13.9 kB
import pytest
from django.contrib.auth import get_user_model
from rest_framework import status
from rest_framework.test import APIClient
from apps.progress.models import UserCheckpointProgress, UserProgress
from apps.resources.models import Resource, ResourceCheckpoint, SkillResource
from apps.roles.models import Role, RoleSkill, UserTargetRole
from apps.skills.models import Skill
User = get_user_model()
pytestmark = pytest.mark.django_db
@pytest.fixture
def user():
return User.objects.create_user(
username='p@x.com', email='p@x.com', password='pw', name='P',
)
@pytest.fixture
def auth_client(user):
c = APIClient()
c.force_authenticate(user=user)
return c
@pytest.fixture
def skill():
return Skill.objects.create(
skill_name='Python', category='Programming', difficulty_level='BEGINNER',
)
@pytest.fixture
def target_role(user, skill):
"""Active target role + RoleSkill covering `skill`. Required by the
progress endpoints, which gate toggles on the user's current plan."""
role = Role.objects.create(
role_name='Backend Dev', industry='Tech', is_active=True,
)
RoleSkill.objects.create(
role=role, skill=skill,
required_level='INTERMEDIATE', weight=1.0, is_mandatory=True,
)
return UserTargetRole.objects.create(user=user, role=role, is_active=True)
@pytest.fixture
def resource_no_cp(skill):
r = Resource.objects.create(
title='Docs', provider='Python.org',
url='https://docs.python.org/3/',
difficulty_level='INTERMEDIATE', duration=0, type='DOCS',
)
SkillResource.objects.create(skill=skill, resource=r, relevance_score=1.0)
return r
@pytest.fixture
def resource_with_cp(skill):
r = Resource.objects.create(
title='ML Course', provider='Coursera',
url='https://coursera.org/ml',
difficulty_level='INTERMEDIATE', duration=3600, type='COURSE',
)
SkillResource.objects.create(skill=skill, resource=r, relevance_score=1.0)
for i, title in enumerate(['Week 1', 'Week 2', 'Week 3', 'Week 4'], start=1):
ResourceCheckpoint.objects.create(
resource=r, order_index=i, title=title, source='manual',
)
return r
class TestCheckpointRollup:
def test_no_checkpoints_returns_false(self, user, resource_no_cp):
up = UserProgress.objects.create(user=user, resource=resource_no_cp)
assert up.recalculate_from_checkpoints() is False
def test_zero_completed(self, user, resource_with_cp):
up = UserProgress.objects.create(user=user, resource=resource_with_cp)
up.recalculate_from_checkpoints()
assert up.progress == 0
assert up.status == 'NOT_STARTED'
assert up.started_at is None
assert up.completed_at is None
def test_half_completed(self, user, resource_with_cp):
cps = list(resource_with_cp.checkpoints.all()[:2])
from django.utils import timezone
for cp in cps:
UserCheckpointProgress.objects.create(
user=user, checkpoint=cp, completed_at=timezone.now(),
)
up = UserProgress.objects.create(user=user, resource=resource_with_cp)
up.recalculate_from_checkpoints()
assert up.progress == 50
assert up.status == 'IN_PROGRESS'
assert up.started_at is not None
assert up.completed_at is None
def test_all_completed_sets_completed(self, user, resource_with_cp):
from django.utils import timezone
for cp in resource_with_cp.checkpoints.all():
UserCheckpointProgress.objects.create(
user=user, checkpoint=cp, completed_at=timezone.now(),
)
up = UserProgress.objects.create(user=user, resource=resource_with_cp)
up.recalculate_from_checkpoints()
assert up.progress == 100
assert up.status == 'COMPLETED'
assert up.started_at is not None
assert up.completed_at is not None
class TestCheckpointToggleAPI:
def test_toggle_creates_and_flips(self, auth_client, target_role, resource_with_cp):
cp = resource_with_cp.checkpoints.first()
r1 = auth_client.post(f'/api/progress/checkpoint/{cp.id}/toggle/')
assert r1.status_code == status.HTTP_200_OK
assert r1.data['status'] == 'IN_PROGRESS'
assert r1.data['progress'] == 25 # 1/4
r2 = auth_client.post(f'/api/progress/checkpoint/{cp.id}/toggle/')
assert r2.data['status'] == 'NOT_STARTED'
assert r2.data['progress'] == 0
def test_toggle_explicit_completed_flag(self, auth_client, target_role, resource_with_cp):
cp = resource_with_cp.checkpoints.first()
r = auth_client.post(
f'/api/progress/checkpoint/{cp.id}/toggle/',
{'completed': True}, format='json',
)
assert r.data['progress'] == 25
r2 = auth_client.post(
f'/api/progress/checkpoint/{cp.id}/toggle/',
{'completed': True}, format='json',
)
# Idempotent when already completed.
assert r2.data['progress'] == 25
def test_all_toggles_complete_resource(self, auth_client, target_role, resource_with_cp):
for cp in resource_with_cp.checkpoints.all():
r = auth_client.post(f'/api/progress/checkpoint/{cp.id}/toggle/')
assert r.data['status'] == 'COMPLETED'
assert r.data['progress'] == 100
assert r.data['completed_at'] is not None
def test_toggle_reuses_preexisting_progress_row(
self, auth_client, user, target_role, resource_with_cp,
):
"""F47-real: a pre-existing UserProgress row is reused (get_or_create
graceful path), not collided with. Mirrors the post-race state where a
concurrent first-toggle already won the unique(user, resource) insert."""
UserProgress.objects.create(user=user, resource=resource_with_cp)
cp = resource_with_cp.checkpoints.first()
r = auth_client.post(f'/api/progress/checkpoint/{cp.id}/toggle/')
assert r.status_code == status.HTTP_200_OK
assert r.data['progress'] == 25
assert UserProgress.objects.filter(
user=user, resource=resource_with_cp,
).count() == 1
def test_toggle_blocked_without_active_target_role(self, auth_client, resource_with_cp):
"""No active target role → 403. Users must commit to a plan before
tracking progress (prevents arbitrary progress on any resource)."""
cp = resource_with_cp.checkpoints.first()
r = auth_client.post(f'/api/progress/checkpoint/{cp.id}/toggle/')
assert r.status_code == status.HTTP_403_FORBIDDEN
def test_toggle_blocked_when_resource_outside_target_plan(
self, auth_client, user, resource_with_cp,
):
"""If the user has selected a target role, checkpoints on resources
unrelated to that role's skills must not be toggleable — prevents
fake progress on out-of-plan resources."""
unrelated_skill = Skill.objects.create(
skill_name='Rust', category='Programming', difficulty_level='BEGINNER',
)
role = Role.objects.create(role_name='Rust Dev', industry='Tech', is_active=True)
RoleSkill.objects.create(
role=role, skill=unrelated_skill,
required_level='INTERMEDIATE', weight=1.0, is_mandatory=True,
)
UserTargetRole.objects.create(user=user, role=role, is_active=True)
cp = resource_with_cp.checkpoints.first()
r = auth_client.post(f'/api/progress/checkpoint/{cp.id}/toggle/')
assert r.status_code == status.HTTP_403_FORBIDDEN
class TestManualSliderAPI:
def test_set_manual_progress(self, auth_client, target_role, resource_no_cp):
r = auth_client.post(
f'/api/progress/resource/{resource_no_cp.id}/',
{'progress': 50}, format='json',
)
assert r.status_code == status.HTTP_200_OK
assert r.data['progress'] == 50
assert r.data['status'] == 'IN_PROGRESS'
def test_manual_100_marks_completed(self, auth_client, target_role, resource_no_cp):
r = auth_client.post(
f'/api/progress/resource/{resource_no_cp.id}/',
{'progress': 100}, format='json',
)
assert r.data['status'] == 'COMPLETED'
assert r.data['completed_at'] is not None
def test_manual_zero_resets(self, auth_client, target_role, resource_no_cp):
auth_client.post(f'/api/progress/resource/{resource_no_cp.id}/',
{'progress': 50}, format='json')
r = auth_client.post(f'/api/progress/resource/{resource_no_cp.id}/',
{'progress': 0}, format='json')
assert r.data['status'] == 'NOT_STARTED'
assert r.data['completed_at'] is None
def test_manual_rejected_when_checkpoints_exist(self, auth_client, target_role, resource_with_cp):
r = auth_client.post(
f'/api/progress/resource/{resource_with_cp.id}/',
{'progress': 50}, format='json',
)
assert r.status_code == status.HTTP_400_BAD_REQUEST
def test_manual_out_of_range(self, auth_client, target_role, resource_no_cp):
r = auth_client.post(
f'/api/progress/resource/{resource_no_cp.id}/',
{'progress': 200}, format='json',
)
assert r.status_code == status.HTTP_400_BAD_REQUEST
def test_manual_missing_field(self, auth_client, target_role, resource_no_cp):
r = auth_client.post(
f'/api/progress/resource/{resource_no_cp.id}/',
{}, format='json',
)
assert r.status_code == status.HTTP_400_BAD_REQUEST
def test_manual_blocked_without_active_target_role(self, auth_client, resource_no_cp):
r = auth_client.post(
f'/api/progress/resource/{resource_no_cp.id}/',
{'progress': 50}, format='json',
)
assert r.status_code == status.HTTP_403_FORBIDDEN
def test_get_returns_zero_without_creating_row(self, auth_client, resource_no_cp):
# F20: GET is read-only — it must synthesize a zero-progress response
# WITHOUT writing a UserProgress row.
r = auth_client.get(f'/api/progress/resource/{resource_no_cp.id}/')
assert r.status_code == status.HTTP_200_OK
assert r.data['progress'] == 0
assert r.data['status'] == 'NOT_STARTED'
assert r.data['id'] is None
assert UserProgress.objects.count() == 0
def test_get_returns_existing_progress(self, auth_client, user, resource_no_cp):
# F20: a saved value must survive a GET (regression guard for the
# read-then-synthesize change).
UserProgress.objects.create(
user=user, resource=resource_no_cp, progress=42, status='IN_PROGRESS',
)
r = auth_client.get(f'/api/progress/resource/{resource_no_cp.id}/')
assert r.status_code == status.HTTP_200_OK
assert r.data['progress'] == 42
assert r.data['status'] == 'IN_PROGRESS'
assert UserProgress.objects.count() == 1
class TestProgressListAPI:
def test_list_filters_to_current_user(self, auth_client, user, resource_no_cp, resource_with_cp):
other = User.objects.create_user(
username='o@x.com', email='o@x.com', password='pw', name='O',
)
UserProgress.objects.create(user=user, resource=resource_no_cp, progress=20, status='IN_PROGRESS')
UserProgress.objects.create(user=other, resource=resource_with_cp, progress=0)
r = auth_client.get('/api/progress/')
assert r.status_code == status.HTTP_200_OK
assert len(r.data) == 1
assert r.data[0]['resource'] == resource_no_cp.id
assert r.data[0]['has_checkpoints'] is False
def test_in_current_plan_true_for_in_plan_resource(
self, auth_client, user, target_role, resource_no_cp,
):
# F48: resource_no_cp is linked to `skill`, which target_role requires.
UserProgress.objects.create(user=user, resource=resource_no_cp, progress=10)
r = auth_client.get('/api/progress/')
assert len(r.data) == 1
assert r.data[0]['in_current_plan'] is True
def test_in_current_plan_false_without_active_target(
self, auth_client, user, resource_no_cp,
):
# F48: no active target role → flagged out-of-plan, but still listed.
UserProgress.objects.create(user=user, resource=resource_no_cp, progress=10)
r = auth_client.get('/api/progress/')
assert len(r.data) == 1
assert r.data[0]['in_current_plan'] is False
def test_in_current_plan_annotates_not_filters(
self, auth_client, user, target_role, resource_no_cp,
):
# F48: an out-of-plan tracked resource is flagged False but NOT removed
# from the list (annotate-don't-filter — the e2e flow relies on this).
other = Resource.objects.create(
title='Unrelated', provider='X', url='https://x.test/unrelated',
difficulty_level='BEGINNER', duration=10, type='ARTICLE',
)
UserProgress.objects.create(user=user, resource=resource_no_cp, progress=10)
UserProgress.objects.create(user=user, resource=other, progress=5)
r = auth_client.get('/api/progress/')
assert len(r.data) == 2
by_res = {row['resource']: row for row in r.data}
assert by_res[resource_no_cp.id]['in_current_plan'] is True
assert by_res[other.id]['in_current_plan'] is False