import pytest from rest_framework import status from rest_framework.test import APIClient from django.contrib.auth import get_user_model from apps.skills.models import Skill from apps.roles.models import Role, RoleSkill, UserTargetRole User = get_user_model() pytestmark = pytest.mark.django_db ROLES_URL = '/api/roles/' TARGET_ROLE_URL = '/api/target-role/' @pytest.fixture def auth_client(): user = User.objects.create_user( username="testuser@example.com", email="testuser@example.com", password="password123", name="Test User" ) client = APIClient() # Force authenticate user bypasses the JWT requirement for fast testing client.force_authenticate(user=user) return client @pytest.fixture def user(auth_client): return User.objects.first() @pytest.fixture def skill(): return Skill.objects.create(skill_name="Python", category="Programming", difficulty_level="BEGINNER") @pytest.fixture def role(skill): r = Role.objects.create(role_name="Backend Developer", industry="Tech", is_active=True) RoleSkill.objects.create(role=r, skill=skill, required_level="INTERMEDIATE", weight=1.0, is_mandatory=True) return r @pytest.fixture def inactive_role(skill): r = Role.objects.create(role_name="Deprecated Dev", industry="Tech", is_active=False) RoleSkill.objects.create(role=r, skill=skill, required_level="BEGINNER") return r class TestRoleCatalog: def test_list_roles_returns_active_only(self, auth_client, role, inactive_role): response = auth_client.get(ROLES_URL) assert response.status_code == status.HTTP_200_OK assert len(response.data) == 1 assert response.data[0]['id'] == role.id def test_list_roles_filter_industry(self, auth_client, role): # Role is created with industry "Tech" response = auth_client.get(ROLES_URL + '?industry=Tech') assert response.status_code == status.HTTP_200_OK assert len(response.data) == 1 response2 = auth_client.get(ROLES_URL + '?industry=Healthcare') assert response2.status_code == status.HTTP_200_OK assert len(response2.data) == 0 def test_role_detail_includes_skills(self, auth_client, role, skill): response = auth_client.get(f"{ROLES_URL}{role.id}/") assert response.status_code == status.HTTP_200_OK assert response.data['id'] == role.id assert len(response.data['role_skills']) == 1 assert response.data['role_skills'][0]['skill']['id'] == skill.id assert response.data['role_skills'][0]['is_mandatory'] is True class TestUserTargetRole: def test_set_target_role_success(self, auth_client, user, role): response = auth_client.post(TARGET_ROLE_URL, {'role_id': role.id}, format='json') assert response.status_code == status.HTTP_201_CREATED assert UserTargetRole.objects.filter(user=user, is_active=True).exists() def test_set_inactive_role_fails(self, auth_client, inactive_role): response = auth_client.post(TARGET_ROLE_URL, {'role_id': inactive_role.id}, format='json') # Serializer rejects inactive or missing roles as bad input (400). assert response.status_code == status.HTTP_400_BAD_REQUEST assert 'role_id' in response.data def test_switching_deactivates_previous(self, auth_client, user, role, skill): role_b = Role.objects.create(role_name="Frontend Dev", industry="Tech", is_active=True) RoleSkill.objects.create(role=role_b, skill=skill, required_level="BEGINNER") # Select role A auth_client.post(TARGET_ROLE_URL, {'role_id': role.id}, format='json') tr1 = UserTargetRole.objects.get(user=user) assert tr1.is_active is True # Select role B auth_client.post(TARGET_ROLE_URL, {'role_id': role_b.id}, format='json') # Check via API response = auth_client.get(TARGET_ROLE_URL) assert response.status_code == status.HTTP_200_OK assert response.data['role']['id'] == role_b.id # Check DB tr1.refresh_from_db() assert tr1.is_active is False assert UserTargetRole.objects.filter(user=user, is_active=True).count() == 1 assert UserTargetRole.objects.get(user=user, is_active=True).role == role_b def test_delete_target_role(self, auth_client, user, role): auth_client.post(TARGET_ROLE_URL, {'role_id': role.id}, format='json') response = auth_client.delete(TARGET_ROLE_URL) assert response.status_code == status.HTTP_204_NO_CONTENT # Should be inactive in DB assert UserTargetRole.objects.filter(user=user, is_active=True).count() == 0 # GET should return 404 get_response = auth_client.get(TARGET_ROLE_URL) assert get_response.status_code == status.HTTP_404_NOT_FOUND def test_one_active_target_per_user_constraint(self, user, role): """DB-level guard: the partial unique constraint must reject a second active row. App-level upsert in the view avoids this path, but direct ORM writes (admin, scripts) must be safe too.""" from django.db import IntegrityError, transaction UserTargetRole.objects.create(user=user, role=role, is_active=True) with pytest.raises(IntegrityError): with transaction.atomic(): UserTargetRole.objects.create(user=user, role=role, is_active=True) def test_concurrent_deactivation_returns_400(self, auth_client, role): """F14: if the role is deactivated AFTER validate_role_id passes but before the view's Role.objects.get(), the DoesNotExist must surface as a clean 400 keyed on role_id, not an unhandled 500.""" from unittest.mock import patch with patch.object(Role.objects, 'get', side_effect=Role.DoesNotExist): response = auth_client.post( TARGET_ROLE_URL, {'role_id': role.id}, format='json', ) assert response.status_code == status.HTTP_400_BAD_REQUEST assert 'role_id' in response.data class TestRoleAdminActions: _counter = 0 def _admin_request(self): from django.test import RequestFactory from django.contrib.messages.storage.fallback import FallbackStorage TestRoleAdminActions._counter += 1 tag = f'admin-role-{TestRoleAdminActions._counter}@x.com' req = RequestFactory().post('/admin/') setattr(req, 'session', {}) setattr(req, '_messages', FallbackStorage(req)) req.user = User.objects.create_superuser( username=tag, email=tag, password='pw', ) return req def test_duplicate_role_with_skills(self, role, skill): from django.contrib.admin.sites import AdminSite from apps.roles.admin import RoleAdmin admin = RoleAdmin(Role, AdminSite()) request = self._admin_request() admin.duplicate_role_with_skills(request, Role.objects.filter(pk=role.pk)) copy = Role.objects.get(role_name='Backend Developer (copy)') assert copy.pk != role.pk assert copy.industry == role.industry assert copy.onet_soc_code == role.onet_soc_code src_pairs = {(rs.skill_id, rs.required_level, rs.weight, rs.is_mandatory) for rs in role.role_skills.all()} copy_pairs = {(rs.skill_id, rs.required_level, rs.weight, rs.is_mandatory) for rs in copy.role_skills.all()} assert src_pairs == copy_pairs def test_duplicate_role_handles_name_collisions(self, role): from django.contrib.admin.sites import AdminSite from apps.roles.admin import RoleAdmin admin = RoleAdmin(Role, AdminSite()) request = self._admin_request() admin.duplicate_role_with_skills(request, Role.objects.filter(pk=role.pk)) admin.duplicate_role_with_skills(request, Role.objects.filter(pk=role.pk)) names = set(Role.objects.values_list('role_name', flat=True)) assert 'Backend Developer (copy)' in names assert 'Backend Developer (copy) 2' in names def test_import_skills_from_onet_missing_yaml(self, tmp_path, monkeypatch, role): """When the curated YAML is absent, the action emits an error message and makes no changes.""" from django.contrib.admin.sites import AdminSite from apps.roles import admin as roles_admin admin = roles_admin.RoleAdmin(Role, AdminSite()) monkeypatch.setattr(roles_admin, 'CURATED_YAML', tmp_path / 'missing.yaml') before = role.role_skills.count() request = self._admin_request() admin.import_skills_from_onet(request, Role.objects.filter(pk=role.pk)) assert role.role_skills.count() == before def test_import_skills_from_onet_populates_from_yaml( self, tmp_path, monkeypatch, skill, ): from django.contrib.admin.sites import AdminSite from apps.roles import admin as roles_admin # Set up a Role and a second skill referenced by the curated YAML. Skill.objects.create(skill_name='Django', category='Framework', difficulty_level='INTERMEDIATE') target_role = Role.objects.create( role_name='Backend Engineer', industry='Tech', is_active=True, onet_soc_code='15-1252.00', ) yaml_path = tmp_path / 'curated.yaml' yaml_path.write_text( "roles:\n" "- name: Backend Engineer\n" " industry: Tech\n" " primary_soc: 15-1252.00\n" " all_socs: [15-1252.00]\n" " skills:\n" " - skill_name: Python\n" " required_level: INTERMEDIATE\n" " weight: 1.0\n" " is_mandatory: true\n" " - skill_name: Django\n" " required_level: ADVANCED\n" " weight: 2.0\n" " is_mandatory: false\n", encoding='utf-8', ) monkeypatch.setattr(roles_admin, 'CURATED_YAML', yaml_path) admin = roles_admin.RoleAdmin(Role, AdminSite()) request = self._admin_request() admin.import_skills_from_onet(request, Role.objects.filter(pk=target_role.pk)) rs_rows = {rs.skill.skill_name: rs for rs in target_role.role_skills.all()} assert set(rs_rows) == {'Python', 'Django'} assert rs_rows['Python'].is_mandatory is True assert rs_rows['Django'].required_level == 'ADVANCED' assert rs_rows['Django'].weight == 2.0 def test_duplicate_role_writes_audit_log(self, role, skill): from django.contrib.admin.models import ADDITION, LogEntry from django.contrib.admin.sites import AdminSite from apps.roles.admin import RoleAdmin admin = RoleAdmin(Role, AdminSite()) request = self._admin_request() admin.duplicate_role_with_skills(request, Role.objects.filter(pk=role.pk)) copy = Role.objects.get(role_name='Backend Developer (copy)') entries = LogEntry.objects.filter( object_id=str(copy.pk), action_flag=ADDITION, ) assert entries.exists() assert 'Duplicated from' in entries.first().change_message def test_import_skills_writes_audit_log(self, tmp_path, monkeypatch, skill): from django.contrib.admin.models import CHANGE, LogEntry from django.contrib.admin.sites import AdminSite from apps.roles import admin as roles_admin target_role = Role.objects.create( role_name='Audited Role', industry='Tech', is_active=True, onet_soc_code='15-1252.00', ) yaml_path = tmp_path / 'curated.yaml' yaml_path.write_text( "roles:\n" "- name: Audited Role\n" " primary_soc: 15-1252.00\n" " all_socs: [15-1252.00]\n" " skills:\n" " - skill_name: Python\n" " required_level: INTERMEDIATE\n" " weight: 1.0\n" " is_mandatory: true\n", encoding='utf-8', ) monkeypatch.setattr(roles_admin, 'CURATED_YAML', yaml_path) admin_cls = roles_admin.RoleAdmin(Role, AdminSite()) admin_cls.import_skills_from_onet( self._admin_request(), Role.objects.filter(pk=target_role.pk), ) entries = LogEntry.objects.filter( object_id=str(target_role.pk), action_flag=CHANGE, ) assert entries.exists() assert 'O*NET import' in entries.first().change_message def test_import_skills_is_idempotent(self, tmp_path, monkeypatch, skill): from django.contrib.admin.sites import AdminSite from apps.roles import admin as roles_admin target_role = Role.objects.create( role_name='Solo Role', industry='Tech', is_active=True, onet_soc_code='99-9999.99', ) yaml_path = tmp_path / 'curated.yaml' yaml_path.write_text( "roles:\n" "- name: Solo Role\n" " primary_soc: 99-9999.99\n" " all_socs: [99-9999.99]\n" " skills:\n" " - skill_name: Python\n" " required_level: INTERMEDIATE\n" " weight: 1.0\n" " is_mandatory: true\n", encoding='utf-8', ) monkeypatch.setattr(roles_admin, 'CURATED_YAML', yaml_path) admin = roles_admin.RoleAdmin(Role, AdminSite()) admin.import_skills_from_onet(self._admin_request(), Role.objects.filter(pk=target_role.pk)) admin.import_skills_from_onet(self._admin_request(), Role.objects.filter(pk=target_role.pk)) assert target_role.role_skills.count() == 1