Spaces:
Sleeping
Sleeping
| """Unit tests for the role-suggestion service + endpoint. | |
| suggest_roles ranks every active role by the user's skill fit (reusing | |
| compute_gap_report) so the UI can show "Recommended for you" above the rest | |
| of the catalog, while every role stays reachable (search / switch-track case). | |
| """ | |
| import pytest | |
| from django.contrib.auth import get_user_model | |
| from rest_framework.test import APIClient | |
| from apps.analysis.services import suggest_roles | |
| from apps.roles.models import Role, RoleSkill | |
| from apps.skills.models import Skill, UserSkill | |
| User = get_user_model() | |
| pytestmark = pytest.mark.django_db | |
| URL = '/api/role-suggestions/' | |
| def user(): | |
| return User.objects.create_user( | |
| username='sugg@example.com', email='sugg@example.com', | |
| password='password123', name='Sugg 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(name, skill_specs, industry='Tech', is_active=True): | |
| """skill_specs: list of (Skill, level, weight, mandatory).""" | |
| role = Role.objects.create( | |
| role_name=name, industry=industry, is_active=is_active, | |
| ) | |
| for skill, level, weight, mandatory in skill_specs: | |
| RoleSkill.objects.create( | |
| role=role, skill=skill, | |
| required_level=level, weight=weight, is_mandatory=mandatory, | |
| ) | |
| return role | |
| def _set_prof(user, skill, proficiency, level='INTERMEDIATE'): | |
| UserSkill.objects.update_or_create( | |
| user=user, skill=skill, | |
| defaults={'proficiency': proficiency, 'user_level': level}, | |
| ) | |
| def test_better_fit_role_ranks_higher(user): | |
| python = _skill('Python') | |
| rust = _skill('Rust') | |
| _role('Backend (Python)', [(python, 'INTERMEDIATE', 1.0, True)]) | |
| _role('Systems (Rust)', [(rust, 'INTERMEDIATE', 1.0, True)]) | |
| _set_prof(user, python, 100) # fully meets the Python role; Rust unset → 0 | |
| result = suggest_roles(user) | |
| names = [r['role_name'] for r in result['roles']] | |
| assert names.index('Backend (Python)') < names.index('Systems (Rust)') | |
| good = next(r for r in result['roles'] if r['role_name'] == 'Backend (Python)') | |
| assert good['readiness'] == 100.0 | |
| assert good['matched_skills'] == 1 | |
| assert good['total_skills'] == 1 | |
| assert good['top_missing_skills'] == [] | |
| def test_top_missing_skills_lists_unmet(user): | |
| python = _skill('Python') | |
| sql = _skill('SQL') | |
| _role('Data Role', [ | |
| (python, 'INTERMEDIATE', 1.0, True), | |
| (sql, 'INTERMEDIATE', 1.0, True), | |
| ]) | |
| _set_prof(user, python, 100) # MET; SQL missing | |
| result = suggest_roles(user) | |
| row = next(r for r in result['roles'] if r['role_name'] == 'Data Role') | |
| assert row['matched_skills'] == 1 | |
| assert row['total_skills'] == 2 | |
| assert 'SQL' in row['top_missing_skills'] | |
| assert 'Python' not in row['top_missing_skills'] | |
| def test_no_requirements_role_sinks_to_bottom(user): | |
| python = _skill('Python') | |
| _role('Empty Role', []) # no RoleSkill → no_requirements, readiness=100 (spurious) | |
| _role('Backend (Python)', [(python, 'INTERMEDIATE', 1.0, True)]) | |
| _set_prof(user, python, 50) # partial fit on the real role | |
| result = suggest_roles(user) | |
| names = [r['role_name'] for r in result['roles']] | |
| assert names[-1] == 'Empty Role' | |
| empty = next(r for r in result['roles'] if r['role_name'] == 'Empty Role') | |
| assert empty['no_requirements'] is True | |
| def test_has_skills_false_when_user_has_no_skills(user): | |
| python = _skill('Python') | |
| _role('Backend (Python)', [(python, 'INTERMEDIATE', 1.0, True)]) | |
| result = suggest_roles(user) | |
| assert result['has_skills'] is False | |
| # Roles are still returned (all low fit) so the catalog stays browsable. | |
| assert len(result['roles']) == 1 | |
| def test_has_skills_true_after_setting_a_skill(user): | |
| python = _skill('Python') | |
| _role('Backend (Python)', [(python, 'INTERMEDIATE', 1.0, True)]) | |
| _set_prof(user, python, 70) | |
| assert suggest_roles(user)['has_skills'] is True | |
| def test_low_fit_role_still_listed_and_reachable(user): | |
| """The switch-track case: a strong analyst can still see (and pick) a | |
| poorly-fitting role — it's just ranked below the good matches.""" | |
| python = _skill('Python') | |
| k8s = _skill('Kubernetes') | |
| _role('Data Analyst', [(python, 'INTERMEDIATE', 1.0, True)]) | |
| _role('DevOps Engineer', [(k8s, 'ADVANCED', 1.0, True)]) | |
| _set_prof(user, python, 80) # strong analyst, zero DevOps | |
| result = suggest_roles(user) | |
| names = [r['role_name'] for r in result['roles']] | |
| assert 'DevOps Engineer' in names # reachable despite poor fit | |
| assert names.index('Data Analyst') < names.index('DevOps Engineer') | |
| def test_inactive_roles_excluded(user): | |
| python = _skill('Python') | |
| _role('Active Role', [(python, 'INTERMEDIATE', 1.0, True)], is_active=True) | |
| _role('Inactive Role', [(python, 'INTERMEDIATE', 1.0, True)], is_active=False) | |
| names = [r['role_name'] for r in suggest_roles(user)['roles']] | |
| assert 'Active Role' in names | |
| assert 'Inactive Role' not in names | |
| def test_endpoint_returns_ranked_roles(user, auth_client): | |
| python = _skill('Python') | |
| _role('Backend (Python)', [(python, 'INTERMEDIATE', 1.0, True)]) | |
| _set_prof(user, python, 100) | |
| r = auth_client.get(URL) | |
| assert r.status_code == 200, r.data | |
| assert r.data['has_skills'] is True | |
| assert r.data['roles'][0]['role_name'] == 'Backend (Python)' | |
| def test_endpoint_requires_authentication(): | |
| r = APIClient().get(URL) | |
| assert r.status_code == 401 | |