gapguide-api / apps /resources /tests /test_admin_resources.py
arifRB's picture
Deploy GapGuide backend (Docker)
ffd36e0 verified
Raw
History Blame Contribute Delete
14.3 kB
"""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