gapguide-api / apps /roles /tests /test_admin_roles.py
arifRB's picture
Deploy GapGuide backend (Docker)
ffd36e0 verified
Raw
History Blame Contribute Delete
10.3 kB
"""Tests for admin-only Role + RoleSkill CRUD at /api/admin/."""
import pytest
from django.contrib.auth import get_user_model
from django.db import IntegrityError, transaction
from rest_framework import status
from rest_framework.test import APIClient
from apps.roles.models import Role, RoleSkill, normalize_roleskill_fields
from apps.skills.models import Skill
User = get_user_model()
pytestmark = pytest.mark.django_db
ADMIN_ROLES_URL = '/api/admin/roles/'
ADMIN_ROLE_SKILLS_URL = '/api/admin/role-skills/'
@pytest.fixture
def admin_client():
user = User.objects.create_user(
username='admin@test.example', email='admin@test.example',
password='x', name='Admin', role_type='ADMIN', is_staff=True,
)
c = APIClient()
c.force_authenticate(user=user)
return c
@pytest.fixture
def student_client():
user = User.objects.create_user(
username='student@test.example', email='student@test.example',
password='x', name='Student', role_type='STUDENT',
)
c = APIClient()
c.force_authenticate(user=user)
return c
@pytest.fixture
def skill():
return Skill.objects.create(skill_name='Python', category='Programming', difficulty_level='BEGINNER')
class TestRoleAdminPermissions:
def test_student_cannot_create_role(self, student_client):
resp = student_client.post(ADMIN_ROLES_URL, {
'role_name': 'Backend Dev', 'industry': 'Tech',
}, format='json')
assert resp.status_code == status.HTTP_403_FORBIDDEN
class TestRoleAdminCrud:
def test_admin_full_cycle_and_soft_delete(self, admin_client):
# Create
resp = admin_client.post(ADMIN_ROLES_URL, {
'role_name': 'Backend Dev',
'industry': 'Tech',
'description': 'Backend engineering role.',
}, format='json')
assert resp.status_code == status.HTTP_201_CREATED, resp.data
role_id = resp.data['id']
assert resp.data['is_active'] is True
# Update
resp = admin_client.patch(
f'{ADMIN_ROLES_URL}{role_id}/',
{'description': 'Updated description.'}, format='json',
)
assert resp.status_code == status.HTTP_200_OK
assert resp.data['description'] == 'Updated description.'
# Soft delete
resp = admin_client.delete(f'{ADMIN_ROLES_URL}{role_id}/')
assert resp.status_code == status.HTTP_204_NO_CONTENT
# Row still exists, just inactive.
role = Role.objects.get(pk=role_id)
assert role.is_active is False
def test_admin_list_includes_inactive(self, admin_client):
active = Role.objects.create(role_name='Active', industry='Tech', is_active=True)
inactive = Role.objects.create(role_name='Legacy', industry='Tech', is_active=False)
resp = admin_client.get(ADMIN_ROLES_URL)
assert resp.status_code == status.HTTP_200_OK
rows = resp.data['results'] if isinstance(resp.data, dict) else resp.data
ids = {r['id'] for r in rows}
assert active.id in ids
assert inactive.id in ids, 'Admin list must include inactive roles.'
class TestRoleSkillAdminCrud:
def test_create_update_delete_role_skill(self, admin_client, skill):
role = Role.objects.create(role_name='Dev', industry='Tech')
# Create a junction row
resp = admin_client.post(ADMIN_ROLE_SKILLS_URL, {
'role_id': role.id,
'skill_id': skill.id,
'required_level': 'INTERMEDIATE',
'weight': 1.5,
'is_mandatory': True,
}, format='json')
assert resp.status_code == status.HTTP_201_CREATED, resp.data
rs_id = resp.data['id']
assert resp.data['skill']['id'] == skill.id
# Filter by role
resp = admin_client.get(f'{ADMIN_ROLE_SKILLS_URL}?role={role.id}')
assert resp.status_code == status.HTTP_200_OK
rows = resp.data['results'] if isinstance(resp.data, dict) else resp.data
assert len(rows) == 1
# Update weight
resp = admin_client.patch(
f'{ADMIN_ROLE_SKILLS_URL}{rs_id}/',
{'weight': 2.0}, format='json',
)
assert resp.status_code == status.HTTP_200_OK
assert resp.data['weight'] == 2.0
# Delete
resp = admin_client.delete(f'{ADMIN_ROLE_SKILLS_URL}{rs_id}/')
assert resp.status_code == status.HTTP_204_NO_CONTENT
assert not RoleSkill.objects.filter(pk=rs_id).exists()
def test_non_numeric_role_param_returns_empty(self, admin_client, skill):
"""F26: a non-numeric ?role= must not 500 (ValueError at query prep);
return an empty set instead."""
role = Role.objects.create(role_name='Dev', industry='Tech')
RoleSkill.objects.create(
role=role, skill=skill, required_level='INTERMEDIATE',
weight=1.0, is_mandatory=True,
)
resp = admin_client.get(f'{ADMIN_ROLE_SKILLS_URL}?role=abc')
assert resp.status_code == status.HTTP_200_OK
rows = resp.data['results'] if isinstance(resp.data, dict) else resp.data
assert rows == []
class TestRoleSkillWeightIntegrity:
"""F16/F17 — weight must be non-negative, and a mandatory skill must carry
positive weight. Enforced both at the API (serializer 400) and the DB
(CheckConstraint IntegrityError), since the seeder/import/bulk paths skip
serializer validation."""
def test_serializer_rejects_negative_weight(self, admin_client, skill):
role = Role.objects.create(role_name='Dev', industry='Tech')
resp = admin_client.post(ADMIN_ROLE_SKILLS_URL, {
'role_id': role.id, 'skill_id': skill.id,
'required_level': 'INTERMEDIATE', 'weight': -1.0, 'is_mandatory': False,
}, format='json')
assert resp.status_code == status.HTTP_400_BAD_REQUEST
assert 'weight' in resp.data
def test_serializer_rejects_mandatory_zero_weight(self, admin_client, skill):
role = Role.objects.create(role_name='Dev', industry='Tech')
resp = admin_client.post(ADMIN_ROLE_SKILLS_URL, {
'role_id': role.id, 'skill_id': skill.id,
'required_level': 'INTERMEDIATE', 'weight': 0, 'is_mandatory': True,
}, format='json')
assert resp.status_code == status.HTTP_400_BAD_REQUEST
assert 'weight' in resp.data
def test_mandatory_create_omitting_weight_defaults_to_1(self, admin_client, skill):
# Omitting weight on a mandatory create must NOT be falsely rejected —
# it falls back to the model default (1.0), which is > 0.
role = Role.objects.create(role_name='Dev', industry='Tech')
resp = admin_client.post(ADMIN_ROLE_SKILLS_URL, {
'role_id': role.id, 'skill_id': skill.id,
'required_level': 'INTERMEDIATE', 'is_mandatory': True,
}, format='json')
assert resp.status_code == status.HTTP_201_CREATED, resp.data
assert resp.data['weight'] == 1.0
def test_db_constraint_rejects_negative_weight(self, skill):
role = Role.objects.create(role_name='Dev', industry='Tech')
with pytest.raises(IntegrityError):
with transaction.atomic():
RoleSkill.objects.create(
role=role, skill=skill, required_level='INTERMEDIATE',
weight=-1.0, is_mandatory=False,
)
def test_db_constraint_rejects_mandatory_zero_weight(self, skill):
role = Role.objects.create(role_name='Dev', industry='Tech')
with pytest.raises(IntegrityError):
with transaction.atomic():
RoleSkill.objects.create(
role=role, skill=skill, required_level='INTERMEDIATE',
weight=0.0, is_mandatory=True,
)
def test_db_allows_optional_zero_weight(self, skill):
# The zero-weight contract: a NON-mandatory skill may carry weight 0.
role = Role.objects.create(role_name='Dev', industry='Tech')
rs = RoleSkill.objects.create(
role=role, skill=skill, required_level='INTERMEDIATE',
weight=0.0, is_mandatory=False,
)
assert rs.pk is not None
class TestNormalizeRoleSkillFields:
"""F12 + the bulk-write weight guard: normalize_roleskill_fields lets the
O*NET import action and the role seeder skip+report bad rows instead of
hitting the DB constraint and aborting the whole atomic batch."""
def test_accepts_and_normalizes_valid(self):
level, weight, error = normalize_roleskill_fields('intermediate', '1.5', True)
assert error is None
assert level == 'INTERMEDIATE' # normalized (upper-cased)
assert weight == 1.5
def test_defaults_omitted_weight_to_1(self):
level, weight, error = normalize_roleskill_fields('BEGINNER', None, False)
assert error is None and weight == 1.0
def test_rejects_bad_required_level(self):
_, _, error = normalize_roleskill_fields('EXPERT', 1.0, False)
assert error is not None
def test_rejects_negative_weight(self):
_, _, error = normalize_roleskill_fields('BEGINNER', -1.0, False)
assert error is not None
def test_rejects_mandatory_zero_weight(self):
_, _, error = normalize_roleskill_fields('INTERMEDIATE', 0, True)
assert error is not None
def test_rejects_non_finite_weight(self):
# NaN/Infinity pass float() and slip every < comparison — must be caught.
_, _, e_nan = normalize_roleskill_fields('BEGINNER', float('nan'), False)
_, _, e_inf = normalize_roleskill_fields('BEGINNER', float('inf'), False)
assert e_nan is not None
assert e_inf is not None
def test_empty_or_whitespace_level_defaults_to_beginner(self):
# "" and " " both fall back to BEGINNER consistently.
l1, _, e1 = normalize_roleskill_fields('', 1.0, False)
l2, _, e2 = normalize_roleskill_fields(' ', 1.0, False)
assert e1 is None and l1 == 'BEGINNER'
assert e2 is None and l2 == 'BEGINNER'