import pytest from django.contrib.auth import get_user_model from django.contrib.admin.sites import AdminSite from django.contrib.messages.storage.fallback import FallbackStorage from django.test import RequestFactory from rest_framework import status from rest_framework.test import APIClient from apps.resources.admin import ResourceAdmin, ResourceAdminForm from apps.resources.models import Resource, ResourceCheckpoint, SkillResource from apps.skills.models import Skill def _admin_request(method='post', path='/admin/'): """Build a RequestFactory request with messages storage wired up so admin views that call self.message_user don't explode.""" request = getattr(RequestFactory(), method)(path) setattr(request, 'session', {}) setattr(request, '_messages', FallbackStorage(request)) return request User = get_user_model() pytestmark = pytest.mark.django_db @pytest.fixture def user(): return User.objects.create_user( username='r@x.com', email='r@x.com', password='pw', name='R', ) @pytest.fixture def auth_client(user): c = APIClient() c.force_authenticate(user=user) return c @pytest.fixture def python_skill(): return Skill.objects.create(skill_name='Python', category='Programming', difficulty_level='BEGINNER') @pytest.fixture def sql_skill(): return Skill.objects.create(skill_name='SQL', category='Database', difficulty_level='BEGINNER') @pytest.fixture def resource(python_skill): r = Resource.objects.create( title='Python for Everybody', provider='Coursera', url='https://coursera.org/python-for-everybody', difficulty_level='BEGINNER', duration=1200, type='COURSE', rating=4.7, ) SkillResource.objects.create(skill=python_skill, resource=r, relevance_score=0.95) return r class TestResourceAPI: def test_list_requires_auth(self): assert APIClient().get('/api/resources/').status_code == status.HTTP_401_UNAUTHORIZED def test_list_resources(self, auth_client, resource): response = auth_client.get('/api/resources/') assert response.status_code == status.HTTP_200_OK assert response.data['count'] == 1 results = response.data['results'] assert results[0]['title'] == 'Python for Everybody' assert results[0]['has_checkpoints'] is False def test_filter_by_skill(self, auth_client, resource, python_skill, sql_skill): # Another resource mapped to SQL only. other = Resource.objects.create( title='SQL Tutorial', provider='W3Schools', url='https://w3schools.com/sql', difficulty_level='BEGINNER', duration=60, type='DOCS', ) SkillResource.objects.create(skill=sql_skill, resource=other, relevance_score=0.8) response = auth_client.get(f'/api/resources/?skill={python_skill.id}') assert response.status_code == status.HTTP_200_OK assert response.data['count'] == 1 assert response.data['results'][0]['id'] == resource.id def test_filter_by_type_and_level(self, auth_client, resource): Resource.objects.create( title='Python Basics Video', provider='YouTube', url='https://youtube.com/p1', difficulty_level='ADVANCED', duration=30, type='VIDEO', ) r1 = auth_client.get('/api/resources/?type=COURSE') assert r1.data['count'] == 1 and r1.data['results'][0]['id'] == resource.id r2 = auth_client.get('/api/resources/?difficulty_level=ADVANCED') assert r2.data['count'] == 1 and r2.data['results'][0]['title'] == 'Python Basics Video' def test_detail_includes_checkpoints_and_skills(self, auth_client, resource, python_skill): ResourceCheckpoint.objects.create(resource=resource, order_index=1, title='Intro') ResourceCheckpoint.objects.create(resource=resource, order_index=2, title='Variables') response = auth_client.get(f'/api/resources/{resource.id}/') assert response.status_code == status.HTTP_200_OK assert len(response.data['checkpoints']) == 2 assert response.data['checkpoints'][0]['order_index'] == 1 assert len(response.data['skill_mappings']) == 1 assert response.data['skill_mappings'][0]['skill']['skill_name'] == 'Python' assert response.data['skill_mappings'][0]['relevance_score'] == 0.95 class TestModelConstraints: def test_url_unique(self, resource): with pytest.raises(Exception): Resource.objects.create( title='Dup', provider='X', url=resource.url, difficulty_level='BEGINNER', duration=10, type='DOCS', ) def test_checkpoint_order_unique_per_resource(self, resource): ResourceCheckpoint.objects.create(resource=resource, order_index=1, title='A') with pytest.raises(Exception): ResourceCheckpoint.objects.create(resource=resource, order_index=1, title='B') def test_same_order_ok_across_resources(self, python_skill): r1 = Resource.objects.create(title='R1', provider='P', url='https://a/x', difficulty_level='BEGINNER', duration=10, type='DOCS') r2 = Resource.objects.create(title='R2', provider='P', url='https://a/y', difficulty_level='BEGINNER', duration=10, type='DOCS') ResourceCheckpoint.objects.create(resource=r1, order_index=1, title='X') ResourceCheckpoint.objects.create(resource=r2, order_index=1, title='Y') assert ResourceCheckpoint.objects.count() == 2 class TestResourceAdminBulkPaste: def test_bulk_paste_appends_checkpoints(self, resource): site = AdminSite() admin = ResourceAdmin(Resource, site) request = _admin_request() request.user = User.objects.create_superuser( username='admin@x.com', email='admin@x.com', password='pw', ) form = ResourceAdminForm( instance=resource, data={ 'title': resource.title, 'provider': resource.provider, 'url': resource.url, 'difficulty_level': resource.difficulty_level, 'duration': resource.duration, 'type': resource.type, 'rating': resource.rating, 'bulk_checkpoints': 'Module 1: Intro\n\nModule 2: Variables\n \nModule 3: Loops', }, ) assert form.is_valid(), form.errors admin.save_model(request, resource, form, change=True) cps = list(resource.checkpoints.order_by('order_index')) assert [c.title for c in cps] == [ 'Module 1: Intro', 'Module 2: Variables', 'Module 3: Loops', ] assert [c.order_index for c in cps] == [1, 2, 3] assert all(c.source == 'manual' for c in cps) def test_bulk_paste_refused_when_checkpoints_exist(self, resource): """Re-pasting onto a resource that already has checkpoints is rejected — admins must edit the inline rows instead. Prevents silent duplication and orphaning of UserCheckpointProgress.""" ResourceCheckpoint.objects.create(resource=resource, order_index=1, title='Existing') site = AdminSite() admin = ResourceAdmin(Resource, site) request = _admin_request() request.user = User.objects.create_superuser( username='admin2@x.com', email='admin2@x.com', password='pw', ) form = ResourceAdminForm( instance=resource, data={ 'title': resource.title, 'provider': resource.provider, 'url': resource.url, 'difficulty_level': resource.difficulty_level, 'duration': resource.duration, 'type': resource.type, 'rating': resource.rating, 'bulk_checkpoints': 'New A\nNew B', }, ) assert form.is_valid(), form.errors admin.save_model(request, resource, form, change=True) # Only the pre-existing checkpoint remains — the paste was rejected. orders = list(resource.checkpoints.order_by('order_index').values_list('order_index', 'title')) assert orders == [(1, 'Existing')] def test_bulk_paste_empty_noop(self, resource): site = AdminSite() admin = ResourceAdmin(Resource, site) request = _admin_request() request.user = User.objects.create_superuser( username='admin3@x.com', email='admin3@x.com', password='pw', ) form = ResourceAdminForm( instance=resource, data={ 'title': resource.title, 'provider': resource.provider, 'url': resource.url, 'difficulty_level': resource.difficulty_level, 'duration': resource.duration, 'type': resource.type, 'rating': resource.rating, 'bulk_checkpoints': ' \n\n ', }, ) assert form.is_valid(), form.errors admin.save_model(request, resource, form, change=True) assert resource.checkpoints.count() == 0