"""Cross-module test: complete resource → upgrade suggestion → apply → gap report shows improved readiness. Exercises progress + analysis seam. """ import pytest from django.contrib.auth import get_user_model from rest_framework.test import APIClient from apps.progress.models import UserProgress from apps.resources.models import Resource, ResourceCheckpoint, SkillResource from apps.roles.models import Role, RoleSkill, UserTargetRole from apps.skills.models import Skill, UserSkill User = get_user_model() pytestmark = pytest.mark.django_db REGISTER = '/api/auth/register/' USER_SKILLS = '/api/user-skills/' TARGET_ROLE = '/api/target-role/' ANALYSIS = '/api/analysis/' UPGRADES = '/api/progress/upgrade-suggestions/' def _auth_client(email='closed@x.com'): c = APIClient() r = c.post(REGISTER, data={ 'name': 'Closed Loop', 'email': email, 'password': 'StrongPass123!', 'password_confirm': 'StrongPass123!', }, format='json') assert r.status_code == 201, r.data c.credentials(HTTP_AUTHORIZATION=f'Bearer {r.data["access"]}') return c @pytest.fixture def loop_fixture(db): """Data Scientist gap scenario: Python mandatory ADVANCED (short), SQL MET (pushes raw readiness above the 60 cap threshold), ML optional MISSING (the thing we'll upgrade).""" py = Skill.objects.create( skill_name='Python', category='Programming', difficulty_level='BEGINNER', ) sql = Skill.objects.create( skill_name='SQL', category='Database', difficulty_level='BEGINNER', ) ml = Skill.objects.create( skill_name='ML', category='AI', difficulty_level='INTERMEDIATE', ) role = Role.objects.create( role_name='Data Scientist', industry='DS', is_active=True, ) RoleSkill.objects.create( role=role, skill=py, required_level='ADVANCED', weight=1.0, is_mandatory=True, ) RoleSkill.objects.create( role=role, skill=sql, required_level='INTERMEDIATE', weight=2.0, is_mandatory=False, ) RoleSkill.objects.create( role=role, skill=ml, required_level='INTERMEDIATE', weight=0.5, is_mandatory=False, ) ml_course = Resource.objects.create( title='ML Course', provider='Coursera', url='https://coursera.org/ml', difficulty_level='INTERMEDIATE', duration=600, type='COURSE', rating=4.8, ) for i, t in enumerate(['W1', 'W2', 'W3', 'W4'], start=1): ResourceCheckpoint.objects.create( resource=ml_course, order_index=i, title=t, source='manual', ) SkillResource.objects.create(skill=ml, resource=ml_course, relevance_score=1.0) py_course = Resource.objects.create( title='Python Advanced', provider='Docs', url='https://docs.python.org/3/', difficulty_level='ADVANCED', duration=0, type='DOCS', rating=4.6, ) SkillResource.objects.create(skill=py, resource=py_course, relevance_score=1.0) return {'role': role, 'py': py, 'sql': sql, 'ml': ml, 'ml_course': ml_course, 'py_course': py_course} def test_complete_resource_then_gap_improves(loop_fixture): c = _auth_client() c.post(USER_SKILLS, data={'skill_id': loop_fixture['py'].id, 'proficiency': 70}, format='json') c.post(USER_SKILLS, data={'skill_id': loop_fixture['sql'].id, 'proficiency': 100}, format='json') c.post(TARGET_ROLE, data={'role_id': loop_fixture['role'].id}, format='json') before = c.get(ANALYSIS).data # Raw readiness = (0.7*1.0 + 1.0*2.0 + 0*0.5)/3.5 = 77.14 → capped to 60. assert before['mandatory_cap_applied'] is True assert before['readiness'] == 60.0 # Complete every ML checkpoint. for cp in loop_fixture['ml_course'].checkpoints.all(): c.post(f'/api/progress/checkpoint/{cp.id}/toggle/') # Upgrade surfaces. r = c.get(UPGRADES) suggestions = r.data['suggestions'] ml_sug = next(s for s in suggestions if s['skill_id'] == loop_fixture['ml'].id) assert ml_sug['suggested_proficiency'] == 60 # Apply the bump. r = c.post(f"{UPGRADES}{loop_fixture['ml'].id}/apply/") assert r.status_code == 200 assert r.data['applied'] is True after = c.get(ANALYSIS).data # Still capped by Python shortfall, but ML gap is now satisfied — # the raw (uncapped) readiness must go up, and the ML gap is MET. ml_gap = next(g for g in after['gaps'] if g['skill_id'] == loop_fixture['ml'].id) assert ml_gap['gap_type'] == 'MET' assert after['mandatory_cap_applied'] is True assert after['readiness'] == 60.0 def test_mandatory_cap_lifts_after_python_upgrade(loop_fixture): """Drive Python to ADVANCED threshold — mandatory cap drops, readiness uncaps. Each completion unlocks one rung; we start at 85 so a single apply takes us to 100. """ c = _auth_client(email='cap@x.com') c.post(USER_SKILLS, data={'skill_id': loop_fixture['py'].id, 'proficiency': 85}, format='json') c.post(USER_SKILLS, data={'skill_id': loop_fixture['sql'].id, 'proficiency': 100}, format='json') c.post(TARGET_ROLE, data={'role_id': loop_fixture['role'].id}, format='json') # Mark the DOCS resource (no checkpoints) COMPLETED via manual slider. r = c.post( f"/api/progress/resource/{loop_fixture['py_course'].id}/", data={'progress': 100}, format='json', ) assert r.status_code == 200 assert r.data['status'] == 'COMPLETED' res = c.post(f"{UPGRADES}{loop_fixture['py'].id}/apply/") assert res.status_code == 200 assert res.data['applied'] is True # A repeat call without a new completion must be a no-op. res2 = c.post(f"{UPGRADES}{loop_fixture['py'].id}/apply/") assert res2.data['already_applied'] is True us = UserSkill.objects.get(user__email='cap@x.com', skill=loop_fixture['py']) assert us.proficiency == 100 after = c.get(ANALYSIS).data assert after['mandatory_cap_applied'] is False def test_completed_via_manual_slider_triggers_suggestion(loop_fixture): """Slider path must reach the same state as checkpoint path.""" c = _auth_client(email='slider@x.com') c.post(TARGET_ROLE, data={'role_id': loop_fixture['role'].id}, format='json') r = c.post( f"/api/progress/resource/{loop_fixture['py_course'].id}/", data={'progress': 100}, format='json', ) assert r.status_code == 200 suggestions = c.get(UPGRADES).data['suggestions'] py_ids = {s['skill_id'] for s in suggestions} assert loop_fixture['py'].id in py_ids def test_completed_via_checkpoints_triggers_suggestion(loop_fixture): """Checkpoint path must reach the same state as slider path.""" c = _auth_client(email='cp@x.com') c.post(TARGET_ROLE, data={'role_id': loop_fixture['role'].id}, format='json') for cp in loop_fixture['ml_course'].checkpoints.all(): c.post(f'/api/progress/checkpoint/{cp.id}/toggle/') # Status should be COMPLETED after the last checkpoint toggle. up = UserProgress.objects.get( resource=loop_fixture['ml_course'], user__email='cp@x.com', ) assert up.status == 'COMPLETED' suggestions = c.get(UPGRADES).data['suggestions'] assert any(s['skill_id'] == loop_fixture['ml'].id for s in suggestions)