gapguide-api / apps /analysis /tests /test_recommendations.py
arifRB's picture
Deploy GapGuide backend (Docker)
ffd36e0 verified
Raw
History Blame Contribute Delete
14.6 kB
"""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