"""Stress / adversarial regression tests for suggest_roles (6ac0e63). Pins the ranking contract (no_requirements last, readiness desc, role_name asc), the top_missing_skills cap + severity ordering, matched/total counts at the threshold boundary, and behaviour at catalog scale and under hostile input. Complements the behavioural cases in test_role_suggestions.py. """ 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/' @pytest.fixture def user(): return User.objects.create_user( username='sgstress@example.com', email='sgstress@example.com', password='password123', name='SuggStress', ) @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(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 _sort_key(row): return (row['no_requirements'], -row['readiness'], row['role_name']) class TestScale: def test_fifty_roles_ranked_without_error(self, user): """50 active roles: a mix of fully-met and unmet fit, returned in the documented order, count exact, no error.""" core = _skill('Core') _set_prof(user, core, 100) for i in range(25): # met → readiness 100 _role(f'met-{i:02d}', [(core, 'INTERMEDIATE', 1.0, True)]) for i in range(25): # unmet unique skill → readiness 0 _role(f'unmet-{i:02d}', [(_skill(f'Niche{i}'), 'INTERMEDIATE', 1.0, True)]) result = suggest_roles(user) rows = result['roles'] assert len(rows) == 50 # globally sorted by the documented key assert [_sort_key(r) for r in rows] == sorted(_sort_key(r) for r in rows) # the 25 fully-met roles sit at the top, the 25 unmet at the bottom assert all(r['readiness'] == 100.0 for r in rows[:25]) assert all(r['readiness'] == 0.0 for r in rows[25:]) class TestTieBreaking: def test_equal_readiness_breaks_alphabetically(self, user): """All readiness tied at 0 → strict role_name ascending, regardless of insertion order.""" for name in ('Zebra', 'Alpha', 'Mango'): _role(name, [(_skill(f'S-{name}'), 'INTERMEDIATE', 1.0, True)]) names = [r['role_name'] for r in suggest_roles(user)['roles']] assert names == ['Alpha', 'Mango', 'Zebra'] class TestNoRequirementsMix: def test_empty_role_sinks_below_fully_met_real_role(self, user): py = _skill('Python') _set_prof(user, py, 100) _role('Empty', []) # no_requirements _role('Real', [(py, 'INTERMEDIATE', 1.0, True)]) # fully met, real rows = suggest_roles(user)['roles'] top, bottom = rows[0], rows[-1] assert top['role_name'] == 'Real' assert top['no_requirements'] is False assert top['readiness'] == 100.0 assert bottom['role_name'] == 'Empty' assert bottom['no_requirements'] is True class TestTopMissingSkills: def test_capped_at_three_and_severity_ordered(self, user): crit_a = _skill('CritA') crit_b = _skill('CritB') high_c = _skill('HighC') med_d = _skill('MedD') low_e = _skill('LowE') role = _role('Mixed', [ (crit_a, 'INTERMEDIATE', 1.0, True), # prof 0 → MISSING CRITICAL (crit_b, 'INTERMEDIATE', 1.0, True), # prof 0 → MISSING CRITICAL (high_c, 'INTERMEDIATE', 1.0, True), # prof 10 → INSUFFICIENT HIGH (med_d, 'INTERMEDIATE', 1.0, False), # prof 0 → MISSING MEDIUM (low_e, 'INTERMEDIATE', 1.0, False), # prof 55 → INSUFFICIENT LOW ]) _set_prof(user, high_c, 10) _set_prof(user, low_e, 55) row = next(r for r in suggest_roles(user)['roles'] if r['role_name'] == 'Mixed') assert row['matched_skills'] == 0 assert row['total_skills'] == 5 # capped at 3, most-critical first; the MEDIUM + LOW skills drop off. assert row['top_missing_skills'] == ['CritA', 'CritB', 'HighC'] def test_empty_for_fully_met_role(self, user): py = _skill('Python') _set_prof(user, py, 100) _role('Met', [(py, 'INTERMEDIATE', 1.0, True)]) row = next(r for r in suggest_roles(user)['roles'] if r['role_name'] == 'Met') assert row['top_missing_skills'] == [] assert row['matched_skills'] == 1 assert row['total_skills'] == 1 class TestThresholdBoundary: @pytest.mark.parametrize('prof,matched,missing', [ (60, 1, False), # == threshold → MET (59, 0, True), # one below → still a gap ]) def test_matched_count_at_threshold(self, user, prof, matched, missing): py = _skill('Python') _role('Boundary', [(py, 'INTERMEDIATE', 1.0, True)]) # threshold 60 _set_prof(user, py, prof) row = next(r for r in suggest_roles(user)['roles'] if r['role_name'] == 'Boundary') assert row['matched_skills'] == matched assert row['total_skills'] == 1 assert ('Python' in row['top_missing_skills']) is missing class TestEndpointAdversarial: def test_response_shape_stable(self, user, auth_client): py = _skill('Python') _role('Backend', [(py, 'INTERMEDIATE', 1.0, True)]) _set_prof(user, py, 70) r = auth_client.get(URL) assert r.status_code == 200, r.data assert set(r.data.keys()) == {'has_skills', 'roles'} row = r.data['roles'][0] assert set(row.keys()) == { 'role_id', 'role_name', 'industry', 'readiness', 'band', 'matched_skills', 'total_skills', 'top_missing_skills', 'no_requirements', } def test_xss_skill_name_round_trips_unescaped(self, user, auth_client): xss = '' skill = _skill(xss) _role('XSS Role', [(skill, 'INTERMEDIATE', 1.0, True)]) # prof 0 → missing r = auth_client.get(URL) assert r.status_code == 200 row = next(x for x in r.data['roles'] if x['role_name'] == 'XSS Role') assert row['top_missing_skills'] == [xss] assert b'' in r.content assert b'<script>' not in r.content