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