"""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'