Spaces:
Sleeping
Sleeping
| """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/' | |
| 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 | |
| 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 | |
| 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' | |