Spaces:
Sleeping
Sleeping
| 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 | |
| def user(): | |
| return User.objects.create_user( | |
| username='p@x.com', email='p@x.com', password='pw', name='P', | |
| ) | |
| def auth_client(user): | |
| c = APIClient() | |
| c.force_authenticate(user=user) | |
| return c | |
| def skill(): | |
| return Skill.objects.create( | |
| skill_name='Python', category='Programming', difficulty_level='BEGINNER', | |
| ) | |
| 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) | |
| 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 | |
| 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 | |