"""Unit tests for the recommendation ranking service + endpoint. See research/08-next-modules-build-plan.md §Module A. Logic under test lives in apps.analysis.services.compute_recommendations. """ import pytest from django.contrib.auth import get_user_model from rest_framework.test import APIClient from apps.analysis.services import compute_recommendations from apps.resources.models import Resource, 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 RECS_URL = '/api/recommendations/' @pytest.fixture def user(): return User.objects.create_user( username='recs@example.com', email='recs@example.com', password='password123', name='Recs Tester', ) @pytest.fixture def auth_client(user): client = APIClient() client.force_authenticate(user=user) return client def _skill(name, category='Programming'): return Skill.objects.create( skill_name=name, category=category, difficulty_level='BEGINNER', ) def _role_with(skill_specs, name_hint='Role'): role = Role.objects.create( role_name=f'{name_hint}-{id(skill_specs)}', industry='Tech', is_active=True, ) for name, level, weight, mandatory in skill_specs: RoleSkill.objects.create( role=role, skill=_skill(name), required_level=level, weight=weight, is_mandatory=mandatory, ) return role def _set_prof(user, skill_name, proficiency, level='BEGINNER'): skill = Skill.objects.get(skill_name=skill_name) UserSkill.objects.update_or_create( user=user, skill=skill, defaults={'proficiency': proficiency, 'user_level': level}, ) def _resource(title, r_type, difficulty='INTERMEDIATE', rating=3.5, url=None, duration=60): return Resource.objects.create( title=title, provider='TestProvider', url=url or f'https://example.com/{title.replace(" ", "-").lower()}', difficulty_level=difficulty, duration=duration, type=r_type, rating=rating, ) def _link(skill_name, resource, relevance=1.0): SkillResource.objects.create( skill=Skill.objects.get(skill_name=skill_name), resource=resource, relevance_score=relevance, ) class TestRanking: def test_type_preference_weight_order(self, user): """Equal rating/relevance/difficulty → VIDEO > COURSE > ARTICLE > DOCS.""" role = _role_with([('Python', 'INTERMEDIATE', 1.0, True)]) for ttype in ('VIDEO', 'COURSE', 'ARTICLE', 'DOCS'): _link('Python', _resource( f'{ttype} res', ttype, difficulty='INTERMEDIATE', rating=3.5, ), relevance=0.8) skill_id = Skill.objects.get(skill_name='Python').id rec = compute_recommendations(user, role, limit_per_skill=10) order = [r['type'] for r in rec['recommendations'][skill_id]] assert order == ['VIDEO', 'COURSE', 'ARTICLE', 'DOCS'] def test_difficulty_matches_user_level_beginner(self, user): """Difficulty is anchored to the USER's current level, not the role's required level. A junior (proficiency 30 → BEGINNER) on an INTERMEDIATE-required skill gets BEGINNER material first, ADVANCED last.""" role = _role_with([('Python', 'INTERMEDIATE', 1.0, True)]) _set_prof(user, 'Python', 30) # BEGINNER level, still < 60 → INSUFFICIENT for diff in ('BEGINNER', 'INTERMEDIATE', 'ADVANCED'): _link('Python', _resource( f'{diff} course', 'COURSE', difficulty=diff, rating=3.5, ), relevance=0.8) skill_id = Skill.objects.get(skill_name='Python').id rec = compute_recommendations(user, role, limit_per_skill=10) order = [r['difficulty_level'] for r in rec['recommendations'][skill_id]] assert order[0] == 'BEGINNER' assert order[-1] == 'ADVANCED' def test_difficulty_anchor_follows_higher_user_level(self, user): """The anchor moves with the user: an INTERMEDIATE learner (proficiency 55) on an ADVANCED-required skill gets INTERMEDIATE material first — proving the match follows the user, not the role's required level.""" role = _role_with([('Python', 'ADVANCED', 1.0, True)]) _set_prof(user, 'Python', 55) # INTERMEDIATE level, still < 100 → INSUFFICIENT for diff in ('BEGINNER', 'INTERMEDIATE', 'ADVANCED'): _link('Python', _resource( f'{diff} course', 'COURSE', difficulty=diff, rating=3.5, ), relevance=0.8) skill_id = Skill.objects.get(skill_name='Python').id rec = compute_recommendations(user, role, limit_per_skill=10) first = rec['recommendations'][skill_id][0] assert first['difficulty_level'] == 'INTERMEDIATE' def test_relevance_score_tiebreaker(self, user): """Two COURSE rows same rating/difficulty → higher relevance wins.""" role = _role_with([('Python', 'INTERMEDIATE', 1.0, True)]) low = _resource('low-rel', 'COURSE', rating=3.5) high = _resource('high-rel', 'COURSE', rating=3.5) _link('Python', low, relevance=0.3) _link('Python', high, relevance=0.9) skill_id = Skill.objects.get(skill_name='Python').id rec = compute_recommendations(user, role, limit_per_skill=10) titles = [r['title'] for r in rec['recommendations'][skill_id]] assert titles[0] == 'high-rel' def test_rating_tiebreaker(self, user): """Two COURSE rows same relevance → higher rating wins.""" role = _role_with([('Python', 'INTERMEDIATE', 1.0, True)]) low = _resource('low-rating', 'COURSE', rating=2.0) high = _resource('high-rating', 'COURSE', rating=5.0) _link('Python', low, relevance=0.8) _link('Python', high, relevance=0.8) skill_id = Skill.objects.get(skill_name='Python').id rec = compute_recommendations(user, role, limit_per_skill=10) titles = [r['title'] for r in rec['recommendations'][skill_id]] assert titles[0] == 'high-rating' def test_deterministic_tie_break_by_id(self, user): """Two identical rows → lower resource_id comes first so snapshots are stable.""" role = _role_with([('Python', 'INTERMEDIATE', 1.0, True)]) first = _resource('first', 'COURSE', rating=3.5, url='https://e.com/a') second = _resource('second', 'COURSE', rating=3.5, url='https://e.com/b') _link('Python', first, relevance=0.8) _link('Python', second, relevance=0.8) assert first.id < second.id skill_id = Skill.objects.get(skill_name='Python').id rec = compute_recommendations(user, role, limit_per_skill=10) ids = [r['resource_id'] for r in rec['recommendations'][skill_id]] assert ids == [first.id, second.id] def test_relevance_can_override_type_preference(self, user): """Very-relevant DOCS (1.0) should beat low-relevance VIDEO (0.3) — this is the documented behavior from the ranking design.""" role = _role_with([('Python', 'INTERMEDIATE', 1.0, True)]) video = _resource('sparse video', 'VIDEO', rating=3.5) docs = _resource('perfect docs', 'DOCS', rating=3.5) _link('Python', video, relevance=0.3) _link('Python', docs, relevance=1.0) skill_id = Skill.objects.get(skill_name='Python').id rec = compute_recommendations(user, role, limit_per_skill=10) titles = [r['title'] for r in rec['recommendations'][skill_id]] assert titles[0] == 'perfect docs' def test_far_level_resource_never_outranks_nearer_one(self, user): """Level-tier guarantee: a resource 2 levels off the user's current level ("far") can never outrank one within 1 level ("near"), even when the far resource wins on type, relevance AND rating. Regression for the seeded-SQL case where an ADVANCED course (higher type/relevance) edged an INTERMEDIATE doc for a BEGINNER learner.""" role = _role_with([('Python', 'INTERMEDIATE', 1.0, True)]) _set_prof(user, 'Python', 30) # BEGINNER anchor # near (1 level off): deliberately the WEAKER resource on every other axis near = _resource('intermediate docs', 'DOCS', difficulty='INTERMEDIATE', rating=4.0) # far (2 levels off): stronger type + relevance + rating far = _resource('advanced course', 'COURSE', difficulty='ADVANCED', rating=5.0) _link('Python', near, relevance=0.7) _link('Python', far, relevance=1.0) skill_id = Skill.objects.get(skill_name='Python').id rec = compute_recommendations(user, role, limit_per_skill=10) order = [r['difficulty_level'] for r in rec['recommendations'][skill_id]] assert order == ['INTERMEDIATE', 'ADVANCED'] class TestFilteringAndShape: def test_met_skills_excluded(self, user): role = _role_with([ ('Python', 'INTERMEDIATE', 1.0, True), # we'll MET this ('SQL', 'INTERMEDIATE', 1.0, True), # gap ]) _set_prof(user, 'Python', 60) # == threshold → MET _link('Python', _resource('py docs', 'DOCS'), relevance=1.0) _link('SQL', _resource('sql docs', 'DOCS'), relevance=1.0) python_id = Skill.objects.get(skill_name='Python').id sql_id = Skill.objects.get(skill_name='SQL').id rec = compute_recommendations(user, role) assert python_id not in rec['recommendations'] assert sql_id in rec['recommendations'] def test_top_n_per_skill(self, user): role = _role_with([('Python', 'INTERMEDIATE', 1.0, True)]) for i in range(10): _link('Python', _resource( f'r{i}', 'COURSE', url=f'https://e.com/{i}', ), relevance=0.8) skill_id = Skill.objects.get(skill_name='Python').id rec = compute_recommendations(user, role, limit_per_skill=3) assert len(rec['recommendations'][skill_id]) == 3 def test_empty_catalog_returns_empty_per_gap(self, user): role = _role_with([('Python', 'INTERMEDIATE', 1.0, True)]) skill_id = Skill.objects.get(skill_name='Python').id rec = compute_recommendations(user, role) assert rec['recommendations'][skill_id] == [] def test_no_gaps_returns_empty_map(self, user): """User with zero gaps (or role with no requirements) gets an empty recommendations map, not an error.""" role = _role_with([('Python', 'INTERMEDIATE', 1.0, True)]) _set_prof(user, 'Python', 100) rec = compute_recommendations(user, role) assert rec['recommendations'] == {} class TestEndpoint: def test_endpoint_uses_active_target_role(self, user, auth_client): role = _role_with([('Python', 'INTERMEDIATE', 1.0, True)]) UserTargetRole.objects.create(user=user, role=role, is_active=True) _link('Python', _resource('py course', 'COURSE'), relevance=0.8) r = auth_client.get(RECS_URL) assert r.status_code == 200, r.data assert r.data['role_id'] == role.id skill_id = Skill.objects.get(skill_name='Python').id assert skill_id in [int(k) for k in r.data['recommendations']] def test_endpoint_no_target_no_param_returns_400(self, auth_client): r = auth_client.get(RECS_URL) assert r.status_code == 400 assert 'target role' in r.data['detail'].lower() def test_endpoint_role_param_overrides_target(self, user, auth_client): a = _role_with([('Python', 'INTERMEDIATE', 1.0, True)], name_hint='A') b = _role_with([('SQL', 'INTERMEDIATE', 1.0, True)], name_hint='B') UserTargetRole.objects.create(user=user, role=a, is_active=True) _link('Python', _resource('py c', 'COURSE'), relevance=0.8) _link('SQL', _resource('sql c', 'COURSE'), relevance=0.8) r = auth_client.get(f'{RECS_URL}?role={b.id}') assert r.status_code == 200 assert r.data['role_id'] == b.id def test_endpoint_limit_query_param(self, user, auth_client): role = _role_with([('Python', 'INTERMEDIATE', 1.0, True)]) UserTargetRole.objects.create(user=user, role=role, is_active=True) for i in range(5): _link('Python', _resource( f'r{i}', 'COURSE', url=f'https://e.com/{i}', ), relevance=0.8) r = auth_client.get(f'{RECS_URL}?limit=2') assert r.status_code == 200 skill_id = Skill.objects.get(skill_name='Python').id assert len(r.data['recommendations'][skill_id]) == 2 def test_endpoint_rejects_invalid_limit(self, user, auth_client): role = _role_with([('Python', 'INTERMEDIATE', 1.0, True)]) UserTargetRole.objects.create(user=user, role=role, is_active=True) r = auth_client.get(f'{RECS_URL}?limit=0') assert r.status_code == 400 r = auth_client.get(f'{RECS_URL}?limit=abc') assert r.status_code == 400 def test_endpoint_empty_for_fully_met_user(self, user, auth_client): role = _role_with([('Python', 'BEGINNER', 1.0, True)]) UserTargetRole.objects.create(user=user, role=role, is_active=True) _set_prof(user, 'Python', 100) r = auth_client.get(RECS_URL) assert r.status_code == 200 assert r.data['recommendations'] == {} def test_endpoint_clamps_huge_limit(self, user, auth_client): """An adversarial ?limit=100000 is clamped to MAX_LIMIT_PER_SKILL, not passed through verbatim. With more links than the cap, the per-skill list never exceeds MAX_LIMIT_PER_SKILL.""" from apps.analysis.services import MAX_LIMIT_PER_SKILL role = _role_with([('Python', 'INTERMEDIATE', 1.0, True)]) UserTargetRole.objects.create(user=user, role=role, is_active=True) for i in range(MAX_LIMIT_PER_SKILL + 10): _link('Python', _resource( f'r{i}', 'COURSE', url=f'https://e.com/{i}', ), relevance=0.8) r = auth_client.get(f'{RECS_URL}?limit=100000') assert r.status_code == 200, r.data skill_id = Skill.objects.get(skill_name='Python').id assert len(r.data['recommendations'][skill_id]) == MAX_LIMIT_PER_SKILL def test_endpoint_requires_authentication(self): client = APIClient() r = client.get(RECS_URL) assert r.status_code == 401