File size: 14,276 Bytes
ffd36e0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
"""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