Spaces:
Sleeping
Sleeping
| """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/' | |
| 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 resource(): | |
| return Resource.objects.create( | |
| title='Python for Everybody', | |
| provider='Coursera', | |
| url='https://example.com/py4e', | |
| difficulty_level='BEGINNER', | |
| duration=1200, | |
| type='COURSE', | |
| ) | |
| 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 | |