Spaces:
Sleeping
Sleeping
| """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/' | |
| def user(): | |
| return User.objects.create_user( | |
| username='recs@example.com', | |
| email='recs@example.com', | |
| password='password123', | |
| name='Recs Tester', | |
| ) | |
| 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 | |