gapguide-api / apps /analysis /tests /test_role_suggestions_stress.py
arifRB's picture
Deploy GapGuide backend (Docker)
ffd36e0 verified
Raw
History Blame Contribute Delete
7.3 kB
"""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 = '<script>alert(1)</script>'
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'<script>alert(1)</script>' in r.content
assert b'&lt;script&gt;' not in r.content