File size: 13,854 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
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 UserCheckpointProgress, UserProgress
from apps.resources.models import Resource, ResourceCheckpoint, SkillResource
from apps.roles.models import Role, RoleSkill, UserTargetRole
from apps.skills.models import Skill

User = get_user_model()
pytestmark = pytest.mark.django_db


@pytest.fixture
def user():
    return User.objects.create_user(
        username='p@x.com', email='p@x.com', password='pw', name='P',
    )


@pytest.fixture
def auth_client(user):
    c = APIClient()
    c.force_authenticate(user=user)
    return c


@pytest.fixture
def skill():
    return Skill.objects.create(
        skill_name='Python', category='Programming', difficulty_level='BEGINNER',
    )


@pytest.fixture
def target_role(user, skill):
    """Active target role + RoleSkill covering `skill`. Required by the

    progress endpoints, which gate toggles on the user's current plan."""
    role = Role.objects.create(
        role_name='Backend Dev', industry='Tech', is_active=True,
    )
    RoleSkill.objects.create(
        role=role, skill=skill,
        required_level='INTERMEDIATE', weight=1.0, is_mandatory=True,
    )
    return UserTargetRole.objects.create(user=user, role=role, is_active=True)


@pytest.fixture
def resource_no_cp(skill):
    r = Resource.objects.create(
        title='Docs', provider='Python.org',
        url='https://docs.python.org/3/',
        difficulty_level='INTERMEDIATE', duration=0, type='DOCS',
    )
    SkillResource.objects.create(skill=skill, resource=r, relevance_score=1.0)
    return r


@pytest.fixture
def resource_with_cp(skill):
    r = Resource.objects.create(
        title='ML Course', provider='Coursera',
        url='https://coursera.org/ml',
        difficulty_level='INTERMEDIATE', duration=3600, type='COURSE',
    )
    SkillResource.objects.create(skill=skill, resource=r, relevance_score=1.0)
    for i, title in enumerate(['Week 1', 'Week 2', 'Week 3', 'Week 4'], start=1):
        ResourceCheckpoint.objects.create(
            resource=r, order_index=i, title=title, source='manual',
        )
    return r


class TestCheckpointRollup:
    def test_no_checkpoints_returns_false(self, user, resource_no_cp):
        up = UserProgress.objects.create(user=user, resource=resource_no_cp)
        assert up.recalculate_from_checkpoints() is False

    def test_zero_completed(self, user, resource_with_cp):
        up = UserProgress.objects.create(user=user, resource=resource_with_cp)
        up.recalculate_from_checkpoints()
        assert up.progress == 0
        assert up.status == 'NOT_STARTED'
        assert up.started_at is None
        assert up.completed_at is None

    def test_half_completed(self, user, resource_with_cp):
        cps = list(resource_with_cp.checkpoints.all()[:2])
        from django.utils import timezone
        for cp in cps:
            UserCheckpointProgress.objects.create(
                user=user, checkpoint=cp, completed_at=timezone.now(),
            )
        up = UserProgress.objects.create(user=user, resource=resource_with_cp)
        up.recalculate_from_checkpoints()
        assert up.progress == 50
        assert up.status == 'IN_PROGRESS'
        assert up.started_at is not None
        assert up.completed_at is None

    def test_all_completed_sets_completed(self, user, resource_with_cp):
        from django.utils import timezone
        for cp in resource_with_cp.checkpoints.all():
            UserCheckpointProgress.objects.create(
                user=user, checkpoint=cp, completed_at=timezone.now(),
            )
        up = UserProgress.objects.create(user=user, resource=resource_with_cp)
        up.recalculate_from_checkpoints()
        assert up.progress == 100
        assert up.status == 'COMPLETED'
        assert up.started_at is not None
        assert up.completed_at is not None


class TestCheckpointToggleAPI:
    def test_toggle_creates_and_flips(self, auth_client, target_role, resource_with_cp):
        cp = resource_with_cp.checkpoints.first()
        r1 = auth_client.post(f'/api/progress/checkpoint/{cp.id}/toggle/')
        assert r1.status_code == status.HTTP_200_OK
        assert r1.data['status'] == 'IN_PROGRESS'
        assert r1.data['progress'] == 25  # 1/4

        r2 = auth_client.post(f'/api/progress/checkpoint/{cp.id}/toggle/')
        assert r2.data['status'] == 'NOT_STARTED'
        assert r2.data['progress'] == 0

    def test_toggle_explicit_completed_flag(self, auth_client, target_role, resource_with_cp):
        cp = resource_with_cp.checkpoints.first()
        r = auth_client.post(
            f'/api/progress/checkpoint/{cp.id}/toggle/',
            {'completed': True}, format='json',
        )
        assert r.data['progress'] == 25

        r2 = auth_client.post(
            f'/api/progress/checkpoint/{cp.id}/toggle/',
            {'completed': True}, format='json',
        )
        # Idempotent when already completed.
        assert r2.data['progress'] == 25

    def test_all_toggles_complete_resource(self, auth_client, target_role, resource_with_cp):
        for cp in resource_with_cp.checkpoints.all():
            r = auth_client.post(f'/api/progress/checkpoint/{cp.id}/toggle/')
        assert r.data['status'] == 'COMPLETED'
        assert r.data['progress'] == 100
        assert r.data['completed_at'] is not None

    def test_toggle_reuses_preexisting_progress_row(

        self, auth_client, user, target_role, resource_with_cp,

    ):
        """F47-real: a pre-existing UserProgress row is reused (get_or_create

        graceful path), not collided with. Mirrors the post-race state where a

        concurrent first-toggle already won the unique(user, resource) insert."""
        UserProgress.objects.create(user=user, resource=resource_with_cp)
        cp = resource_with_cp.checkpoints.first()
        r = auth_client.post(f'/api/progress/checkpoint/{cp.id}/toggle/')
        assert r.status_code == status.HTTP_200_OK
        assert r.data['progress'] == 25
        assert UserProgress.objects.filter(
            user=user, resource=resource_with_cp,
        ).count() == 1

    def test_toggle_blocked_without_active_target_role(self, auth_client, resource_with_cp):
        """No active target role → 403. Users must commit to a plan before

        tracking progress (prevents arbitrary progress on any resource)."""
        cp = resource_with_cp.checkpoints.first()
        r = auth_client.post(f'/api/progress/checkpoint/{cp.id}/toggle/')
        assert r.status_code == status.HTTP_403_FORBIDDEN

    def test_toggle_blocked_when_resource_outside_target_plan(

        self, auth_client, user, resource_with_cp,

    ):
        """If the user has selected a target role, checkpoints on resources

        unrelated to that role's skills must not be toggleable — prevents

        fake progress on out-of-plan resources."""
        unrelated_skill = Skill.objects.create(
            skill_name='Rust', category='Programming', difficulty_level='BEGINNER',
        )
        role = Role.objects.create(role_name='Rust Dev', industry='Tech', is_active=True)
        RoleSkill.objects.create(
            role=role, skill=unrelated_skill,
            required_level='INTERMEDIATE', weight=1.0, is_mandatory=True,
        )
        UserTargetRole.objects.create(user=user, role=role, is_active=True)

        cp = resource_with_cp.checkpoints.first()
        r = auth_client.post(f'/api/progress/checkpoint/{cp.id}/toggle/')
        assert r.status_code == status.HTTP_403_FORBIDDEN


class TestManualSliderAPI:
    def test_set_manual_progress(self, auth_client, target_role, resource_no_cp):
        r = auth_client.post(
            f'/api/progress/resource/{resource_no_cp.id}/',
            {'progress': 50}, format='json',
        )
        assert r.status_code == status.HTTP_200_OK
        assert r.data['progress'] == 50
        assert r.data['status'] == 'IN_PROGRESS'

    def test_manual_100_marks_completed(self, auth_client, target_role, resource_no_cp):
        r = auth_client.post(
            f'/api/progress/resource/{resource_no_cp.id}/',
            {'progress': 100}, format='json',
        )
        assert r.data['status'] == 'COMPLETED'
        assert r.data['completed_at'] is not None

    def test_manual_zero_resets(self, auth_client, target_role, resource_no_cp):
        auth_client.post(f'/api/progress/resource/{resource_no_cp.id}/',
                         {'progress': 50}, format='json')
        r = auth_client.post(f'/api/progress/resource/{resource_no_cp.id}/',
                             {'progress': 0}, format='json')
        assert r.data['status'] == 'NOT_STARTED'
        assert r.data['completed_at'] is None

    def test_manual_rejected_when_checkpoints_exist(self, auth_client, target_role, resource_with_cp):
        r = auth_client.post(
            f'/api/progress/resource/{resource_with_cp.id}/',
            {'progress': 50}, format='json',
        )
        assert r.status_code == status.HTTP_400_BAD_REQUEST

    def test_manual_out_of_range(self, auth_client, target_role, resource_no_cp):
        r = auth_client.post(
            f'/api/progress/resource/{resource_no_cp.id}/',
            {'progress': 200}, format='json',
        )
        assert r.status_code == status.HTTP_400_BAD_REQUEST

    def test_manual_missing_field(self, auth_client, target_role, resource_no_cp):
        r = auth_client.post(
            f'/api/progress/resource/{resource_no_cp.id}/',
            {}, format='json',
        )
        assert r.status_code == status.HTTP_400_BAD_REQUEST

    def test_manual_blocked_without_active_target_role(self, auth_client, resource_no_cp):
        r = auth_client.post(
            f'/api/progress/resource/{resource_no_cp.id}/',
            {'progress': 50}, format='json',
        )
        assert r.status_code == status.HTTP_403_FORBIDDEN

    def test_get_returns_zero_without_creating_row(self, auth_client, resource_no_cp):
        # F20: GET is read-only — it must synthesize a zero-progress response
        # WITHOUT writing a UserProgress row.
        r = auth_client.get(f'/api/progress/resource/{resource_no_cp.id}/')
        assert r.status_code == status.HTTP_200_OK
        assert r.data['progress'] == 0
        assert r.data['status'] == 'NOT_STARTED'
        assert r.data['id'] is None
        assert UserProgress.objects.count() == 0

    def test_get_returns_existing_progress(self, auth_client, user, resource_no_cp):
        # F20: a saved value must survive a GET (regression guard for the
        # read-then-synthesize change).
        UserProgress.objects.create(
            user=user, resource=resource_no_cp, progress=42, status='IN_PROGRESS',
        )
        r = auth_client.get(f'/api/progress/resource/{resource_no_cp.id}/')
        assert r.status_code == status.HTTP_200_OK
        assert r.data['progress'] == 42
        assert r.data['status'] == 'IN_PROGRESS'
        assert UserProgress.objects.count() == 1


class TestProgressListAPI:
    def test_list_filters_to_current_user(self, auth_client, user, resource_no_cp, resource_with_cp):
        other = User.objects.create_user(
            username='o@x.com', email='o@x.com', password='pw', name='O',
        )
        UserProgress.objects.create(user=user, resource=resource_no_cp, progress=20, status='IN_PROGRESS')
        UserProgress.objects.create(user=other, resource=resource_with_cp, progress=0)

        r = auth_client.get('/api/progress/')
        assert r.status_code == status.HTTP_200_OK
        assert len(r.data) == 1
        assert r.data[0]['resource'] == resource_no_cp.id
        assert r.data[0]['has_checkpoints'] is False

    def test_in_current_plan_true_for_in_plan_resource(

        self, auth_client, user, target_role, resource_no_cp,

    ):
        # F48: resource_no_cp is linked to `skill`, which target_role requires.
        UserProgress.objects.create(user=user, resource=resource_no_cp, progress=10)
        r = auth_client.get('/api/progress/')
        assert len(r.data) == 1
        assert r.data[0]['in_current_plan'] is True

    def test_in_current_plan_false_without_active_target(

        self, auth_client, user, resource_no_cp,

    ):
        # F48: no active target role → flagged out-of-plan, but still listed.
        UserProgress.objects.create(user=user, resource=resource_no_cp, progress=10)
        r = auth_client.get('/api/progress/')
        assert len(r.data) == 1
        assert r.data[0]['in_current_plan'] is False

    def test_in_current_plan_annotates_not_filters(

        self, auth_client, user, target_role, resource_no_cp,

    ):
        # F48: an out-of-plan tracked resource is flagged False but NOT removed
        # from the list (annotate-don't-filter — the e2e flow relies on this).
        other = Resource.objects.create(
            title='Unrelated', provider='X', url='https://x.test/unrelated',
            difficulty_level='BEGINNER', duration=10, type='ARTICLE',
        )
        UserProgress.objects.create(user=user, resource=resource_no_cp, progress=10)
        UserProgress.objects.create(user=user, resource=other, progress=5)
        r = auth_client.get('/api/progress/')
        assert len(r.data) == 2
        by_res = {row['resource']: row for row in r.data}
        assert by_res[resource_no_cp.id]['in_current_plan'] is True
        assert by_res[other.id]['in_current_plan'] is False