"""Tests for admin-only Resource / Checkpoint / SkillResource CRUD at /api/admin/. """ import pytest from django.contrib.auth import get_user_model from rest_framework import status from rest_framework.test import APIClient from apps.progress.models import UserProgress from apps.resources.models import Resource, ResourceCheckpoint, SkillResource from apps.skills.models import Skill User = get_user_model() pytestmark = pytest.mark.django_db ADMIN_RESOURCES_URL = '/api/admin/resources/' ADMIN_CHECKPOINTS_URL = '/api/admin/checkpoints/' ADMIN_CHECKPOINTS_BULK_URL = '/api/admin/checkpoints/bulk/' ADMIN_CHECKPOINTS_IMPORT_URL = '/api/admin/checkpoints/import/' ADMIN_SKILL_RESOURCES_URL = '/api/admin/skill-resources/' @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 resource(): return Resource.objects.create( title='Python for Everybody', provider='Coursera', url='https://example.com/py4e', difficulty_level='BEGINNER', duration=1200, type='COURSE', ) @pytest.fixture def skill(): return Skill.objects.create(skill_name='Python', category='Programming', difficulty_level='BEGINNER') class TestResourceAdminPermissions: def test_student_cannot_create_resource(self, student_client): resp = student_client.post(ADMIN_RESOURCES_URL, { 'title': 'X', 'provider': 'Y', 'url': 'https://x.test', 'difficulty_level': 'BEGINNER', 'duration': 30, 'type': 'VIDEO', }, format='json') assert resp.status_code == status.HTTP_403_FORBIDDEN class TestResourceAdminCrud: def test_admin_full_cycle(self, admin_client): resp = admin_client.post(ADMIN_RESOURCES_URL, { 'title': 'Django Tut', 'provider': 'MDN', 'url': 'https://example.com/django', 'difficulty_level': 'INTERMEDIATE', 'duration': 180, 'type': 'ARTICLE', }, format='json') assert resp.status_code == status.HTTP_201_CREATED, resp.data rid = resp.data['id'] resp = admin_client.patch( f'{ADMIN_RESOURCES_URL}{rid}/', {'rating': 4.5}, format='json', ) assert resp.status_code == status.HTTP_200_OK assert resp.data['rating'] == 4.5 resp = admin_client.delete(f'{ADMIN_RESOURCES_URL}{rid}/') assert resp.status_code == status.HTTP_204_NO_CONTENT class TestCheckpointAdminCrud: def test_per_row_create_filter_delete(self, admin_client, resource): # Create checkpoint #1 resp = admin_client.post(ADMIN_CHECKPOINTS_URL, { 'resource': resource.id, 'order_index': 1, 'title': 'Week 1: Intro', }, format='json') assert resp.status_code == status.HTTP_201_CREATED, resp.data cp_id = resp.data['id'] # Filter by resource resp = admin_client.get(f'{ADMIN_CHECKPOINTS_URL}?resource={resource.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 # Delete resp = admin_client.delete(f'{ADMIN_CHECKPOINTS_URL}{cp_id}/') assert resp.status_code == status.HTTP_204_NO_CONTENT def test_bulk_paste_creates_ordered_rows(self, admin_client, resource): bulk_text = 'Module 1: Setup\nModule 2: Basics\n\nModule 3: Practice\n' resp = admin_client.post(ADMIN_CHECKPOINTS_BULK_URL, { 'resource': resource.id, 'bulk': bulk_text, }, format='json') assert resp.status_code == status.HTTP_201_CREATED, resp.data assert len(resp.data) == 3 titles = [row['title'] for row in resp.data] assert titles == ['Module 1: Setup', 'Module 2: Basics', 'Module 3: Practice'] # order_index is 1-based and sequential. order_indexes = [row['order_index'] for row in resp.data] assert order_indexes == [1, 2, 3] # DB verification db_rows = list( ResourceCheckpoint.objects .filter(resource=resource) .order_by('order_index') .values_list('title', flat=True) ) assert db_rows == titles def test_bulk_paste_refuses_when_existing_checkpoints(self, admin_client, resource): ResourceCheckpoint.objects.create( resource=resource, order_index=1, title='Existing', source='manual', ) resp = admin_client.post(ADMIN_CHECKPOINTS_BULK_URL, { 'resource': resource.id, 'bulk': 'New Row', }, format='json') assert resp.status_code == status.HTTP_409_CONFLICT def test_bulk_paste_rejects_empty_input(self, admin_client, resource): resp = admin_client.post(ADMIN_CHECKPOINTS_BULK_URL, { 'resource': resource.id, 'bulk': ' \n\n ', }, format='json') assert resp.status_code == status.HTTP_400_BAD_REQUEST def test_bulk_paste_accepts_valid_source_and_stamps_extracted_at(self, admin_client, resource): """Imported lists carry their provenance; non-manual rows get a timestamp. bulk_create bypasses model validation, so the view must accept only valid choices.""" resp = admin_client.post(ADMIN_CHECKPOINTS_BULK_URL, { 'resource': resource.id, 'bulk': 'Week 1\nWeek 2', 'source': 'jsonld', }, format='json') assert resp.status_code == status.HTTP_201_CREATED, resp.data assert all(row['source'] == 'jsonld' for row in resp.data) assert all(row['extracted_at'] is not None for row in resp.data) def test_bulk_paste_defaults_source_manual_no_timestamp(self, admin_client, resource): resp = admin_client.post(ADMIN_CHECKPOINTS_BULK_URL, { 'resource': resource.id, 'bulk': 'Week 1\nWeek 2', }, format='json') assert resp.status_code == status.HTTP_201_CREATED, resp.data assert all(row['source'] == 'manual' for row in resp.data) assert all(row['extracted_at'] is None for row in resp.data) def test_bulk_paste_rejects_invalid_source(self, admin_client, resource): resp = admin_client.post(ADMIN_CHECKPOINTS_BULK_URL, { 'resource': resource.id, 'bulk': 'Week 1', 'source': 'evil', }, format='json') assert resp.status_code == status.HTTP_400_BAD_REQUEST assert not ResourceCheckpoint.objects.filter(resource=resource).exists() class TestCheckpointImportAction: """POST /admin/checkpoints/import/ returns a draft preview and writes nothing (the admin saves via the validated bulk endpoint).""" def test_import_returns_preview_without_writing(self, admin_client, resource, monkeypatch): from apps.resources import views from apps.resources.importers import ExtractedCheckpoint, ExtractResult def fake_extract(url, **kwargs): assert url == resource.url # action reads Resource.url server-side return ExtractResult( checkpoints=[ ExtractedCheckpoint(order_index=1, title='Wk 1'), ExtractedCheckpoint(order_index=2, title='Wk 2'), ], source='jsonld', provider='coursera.org', ) monkeypatch.setattr(views, 'extract_checkpoints', fake_extract) resp = admin_client.post(ADMIN_CHECKPOINTS_IMPORT_URL, { 'resource': resource.id, }, format='json') assert resp.status_code == status.HTTP_200_OK, resp.data assert resp.data['source'] == 'jsonld' assert [c['title'] for c in resp.data['checkpoints']] == ['Wk 1', 'Wk 2'] # crucial: preview only — nothing persisted. assert not ResourceCheckpoint.objects.filter(resource=resource).exists() def test_import_unsupported_returns_note_not_error(self, admin_client, resource, monkeypatch): from apps.resources import views from apps.resources.importers import ExtractResult monkeypatch.setattr( views, 'extract_checkpoints', lambda url, **kw: ExtractResult(note='enter manually', provider='x'), ) resp = admin_client.post(ADMIN_CHECKPOINTS_IMPORT_URL, { 'resource': resource.id, }, format='json') assert resp.status_code == status.HTTP_200_OK assert resp.data['checkpoints'] == [] assert 'manually' in resp.data['note'] def test_import_missing_resource_400(self, admin_client): resp = admin_client.post(ADMIN_CHECKPOINTS_IMPORT_URL, {}, format='json') assert resp.status_code == status.HTTP_400_BAD_REQUEST def test_import_student_forbidden(self, student_client, resource): resp = student_client.post(ADMIN_CHECKPOINTS_IMPORT_URL, { 'resource': resource.id, }, format='json') assert resp.status_code == status.HTTP_403_FORBIDDEN class TestResourceAdminDelete: def test_delete_blocked_when_student_progress_exists(self, admin_client, resource): """F25: deleting a resource a student has tracked must 409, not silently discard the learner's history.""" student = User.objects.create_user( username='s2@test.example', email='s2@test.example', password='x', name='S2', role_type='STUDENT', ) UserProgress.objects.create(user=student, resource=resource) resp = admin_client.delete(f'{ADMIN_RESOURCES_URL}{resource.id}/') assert resp.status_code == status.HTTP_409_CONFLICT assert Resource.objects.filter(id=resource.id).exists() def test_delete_succeeds_without_progress(self, admin_client, resource): resp = admin_client.delete(f'{ADMIN_RESOURCES_URL}{resource.id}/') assert resp.status_code == status.HTTP_204_NO_CONTENT assert not Resource.objects.filter(id=resource.id).exists() class TestResourceAdminIntegrity: def test_duplicate_url_returns_400_not_500(self, admin_client, resource): """F24: a non-concurrent duplicate URL is caught by DRF's UniqueValidator → 400 (never reaches the DB). The IntegrityConflictMixin's 409 only fires on a true concurrent race that slips past validation.""" resp = admin_client.post(ADMIN_RESOURCES_URL, { 'title': 'Dup', 'provider': 'X', 'url': resource.url, 'difficulty_level': 'BEGINNER', 'duration': 30, 'type': 'VIDEO', }, format='json') assert resp.status_code == status.HTTP_400_BAD_REQUEST def test_duplicate_skill_resource_returns_400_not_500(self, admin_client, resource, skill): """F10: a non-concurrent duplicate (skill, resource) link is caught by the UniqueTogetherValidator → 400; the mixin's 409 is race-only.""" first = admin_client.post(ADMIN_SKILL_RESOURCES_URL, { 'skill': skill.id, 'resource': resource.id, 'relevance_score': 0.8, }, format='json') assert first.status_code == status.HTTP_201_CREATED, first.data resp = admin_client.post(ADMIN_SKILL_RESOURCES_URL, { 'skill': skill.id, 'resource': resource.id, 'relevance_score': 0.9, }, format='json') assert resp.status_code == status.HTTP_400_BAD_REQUEST class TestResourceQueryParamGuard: def test_checkpoint_non_numeric_resource_param_returns_empty(self, admin_client, resource): """F26: a non-numeric ?resource= must not 500 (ValueError at query prep); return an empty set.""" ResourceCheckpoint.objects.create( resource=resource, order_index=1, title='X', source='manual', ) # 'abc' (non-numeric) and '²' (a Unicode digit isdigit() accepts but # int() rejects) must both yield an empty 200, never a 500. for bad in ('abc', '²'): resp = admin_client.get(f'{ADMIN_CHECKPOINTS_URL}?resource={bad}') assert resp.status_code == status.HTTP_200_OK rows = resp.data['results'] if isinstance(resp.data, dict) else resp.data assert rows == [] def test_skill_resource_non_numeric_resource_param_returns_empty(self, admin_client, resource, skill): SkillResource.objects.create(skill=skill, resource=resource, relevance_score=0.8) resp = admin_client.get(f'{ADMIN_SKILL_RESOURCES_URL}?resource=abc') assert resp.status_code == status.HTTP_200_OK rows = resp.data['results'] if isinstance(resp.data, dict) else resp.data assert rows == [] class TestSkillResourceAdminCrud: def test_create_update_filter_delete(self, admin_client, resource, skill): resp = admin_client.post(ADMIN_SKILL_RESOURCES_URL, { 'skill': skill.id, 'resource': resource.id, 'relevance_score': 0.8, }, format='json') assert resp.status_code == status.HTTP_201_CREATED, resp.data sr_id = resp.data['id'] resp = admin_client.get(f'{ADMIN_SKILL_RESOURCES_URL}?resource={resource.id}') rows = resp.data['results'] if isinstance(resp.data, dict) else resp.data assert len(rows) == 1 resp = admin_client.patch( f'{ADMIN_SKILL_RESOURCES_URL}{sr_id}/', {'relevance_score': 0.95}, format='json', ) assert resp.status_code == status.HTTP_200_OK assert resp.data['relevance_score'] == 0.95 resp = admin_client.delete(f'{ADMIN_SKILL_RESOURCES_URL}{sr_id}/') assert resp.status_code == status.HTTP_204_NO_CONTENT