musora-content-services 2.94.8 → 2.95.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/.claude/settings.local.json +18 -0
  2. package/CHANGELOG.md +18 -0
  3. package/CLAUDE.md +408 -0
  4. package/babel.config.cjs +10 -0
  5. package/jsdoc.json +2 -1
  6. package/package.json +2 -2
  7. package/src/constants/award-assets.js +35 -0
  8. package/src/filterBuilder.js +7 -2
  9. package/src/index.d.ts +26 -5
  10. package/src/index.js +26 -5
  11. package/src/services/awards/award-callbacks.js +165 -0
  12. package/src/services/awards/award-query.js +495 -0
  13. package/src/services/awards/internal/.indexignore +1 -0
  14. package/src/services/awards/internal/award-definitions.js +239 -0
  15. package/src/services/awards/internal/award-events.js +102 -0
  16. package/src/services/awards/internal/award-manager.js +162 -0
  17. package/src/services/awards/internal/certificate-builder.js +66 -0
  18. package/src/services/awards/internal/completion-data-generator.js +84 -0
  19. package/src/services/awards/internal/content-progress-observer.js +137 -0
  20. package/src/services/awards/internal/image-utils.js +62 -0
  21. package/src/services/awards/internal/message-generator.js +17 -0
  22. package/src/services/awards/internal/types.js +5 -0
  23. package/src/services/awards/types.d.ts +79 -0
  24. package/src/services/awards/types.js +101 -0
  25. package/src/services/config.js +24 -4
  26. package/src/services/content-org/learning-paths.ts +19 -15
  27. package/src/services/gamification/awards.ts +114 -83
  28. package/src/services/progress-events.js +58 -0
  29. package/src/services/progress-row/method-card.js +20 -5
  30. package/src/services/sanity.js +1 -1
  31. package/src/services/sync/fetch.ts +10 -2
  32. package/src/services/sync/manager.ts +6 -0
  33. package/src/services/sync/models/ContentProgress.ts +5 -6
  34. package/src/services/sync/models/UserAwardProgress.ts +55 -0
  35. package/src/services/sync/models/index.ts +1 -0
  36. package/src/services/sync/repositories/content-progress.ts +47 -25
  37. package/src/services/sync/repositories/index.ts +1 -0
  38. package/src/services/sync/repositories/practices.ts +16 -1
  39. package/src/services/sync/repositories/user-award-progress.ts +133 -0
  40. package/src/services/sync/repository-proxy.ts +6 -0
  41. package/src/services/sync/retry.ts +12 -11
  42. package/src/services/sync/schema/index.ts +18 -3
  43. package/src/services/sync/store/index.ts +53 -8
  44. package/src/services/sync/store/push-coalescer.ts +3 -3
  45. package/src/services/sync/store-configs.ts +7 -1
  46. package/src/services/userActivity.js +0 -1
  47. package/test/HttpClient.test.js +6 -6
  48. package/test/awards/award-alacarte-observer.test.js +196 -0
  49. package/test/awards/award-auto-refresh.test.js +83 -0
  50. package/test/awards/award-calculations.test.js +33 -0
  51. package/test/awards/award-certificate-display.test.js +328 -0
  52. package/test/awards/award-collection-edge-cases.test.js +210 -0
  53. package/test/awards/award-collection-filtering.test.js +285 -0
  54. package/test/awards/award-completion-flow.test.js +213 -0
  55. package/test/awards/award-exclusion-handling.test.js +273 -0
  56. package/test/awards/award-multi-lesson.test.js +241 -0
  57. package/test/awards/award-observer-integration.test.js +325 -0
  58. package/test/awards/award-query-messages.test.js +438 -0
  59. package/test/awards/award-user-collection.test.js +412 -0
  60. package/test/awards/duplicate-prevention.test.js +118 -0
  61. package/test/awards/helpers/completion-mock.js +54 -0
  62. package/test/awards/helpers/index.js +3 -0
  63. package/test/awards/helpers/mock-setup.js +69 -0
  64. package/test/awards/helpers/progress-emitter.js +39 -0
  65. package/test/awards/message-generator.test.js +162 -0
  66. package/test/initializeTests.js +6 -0
  67. package/test/mockData/award-definitions.js +171 -0
  68. package/test/sync/models/award-database-integration.test.js +519 -0
  69. package/tools/generate-index.cjs +9 -0
@@ -0,0 +1,438 @@
1
+ import { mockAwardDefinitions } from '../mockData/award-definitions'
2
+
3
+ jest.mock('../../src/services/sanity', () => ({
4
+ default: {
5
+ fetch: jest.fn()
6
+ },
7
+ fetchSanity: jest.fn()
8
+ }))
9
+
10
+ jest.mock('../../src/services/sync/repository-proxy', () => {
11
+ const mockFns = {
12
+ userAwardProgress: {
13
+ getAll: jest.fn(),
14
+ getByAwardId: jest.fn(),
15
+ getAwardsForContent: jest.fn()
16
+ }
17
+ }
18
+ return { default: mockFns, ...mockFns }
19
+ })
20
+
21
+ import sanityClient, { fetchSanity } from '../../src/services/sanity'
22
+ import db from '../../src/services/sync/repository-proxy'
23
+ import { awardDefinitions } from '../../src/services/awards/internal/award-definitions'
24
+ import { getCompletedAwards, getContentAwards, getInProgressAwards } from '../../src/services/awards/award-query'
25
+
26
+ describe('Award Query Message Generation', () => {
27
+ beforeEach(async () => {
28
+ jest.clearAllMocks()
29
+
30
+ sanityClient.fetch = jest.fn().mockResolvedValue(mockAwardDefinitions)
31
+ fetchSanity.mockResolvedValue(mockAwardDefinitions)
32
+
33
+ await awardDefinitions.refresh()
34
+ })
35
+
36
+ afterEach(() => {
37
+ awardDefinitions.clear()
38
+ })
39
+
40
+ describe('getCompletedAwards', () => {
41
+ test('includes message in completionData for guided course awards', async () => {
42
+ const mockProgress = [{
43
+ award_id: 'award-1',
44
+ progress_percentage: 100,
45
+ completed_at: 1672531200,
46
+ completion_data: {
47
+ content_title: 'Blues Foundations',
48
+ completed_at: '2023-01-01T00:00:00.000Z',
49
+ days_user_practiced: 14,
50
+ practice_minutes: 180
51
+ }
52
+ }]
53
+
54
+ const mockDefinition = {
55
+ _id: 'award-1',
56
+ name: 'Blues Master',
57
+ badge: 'https://example.com/badge.png',
58
+ award: 'certificate',
59
+ brand: 'drumeo',
60
+ instructor_name: 'Mike Johnston',
61
+ content_type: 'guided-course'
62
+ }
63
+
64
+ jest.spyOn(db.userAwardProgress, 'getAll').mockResolvedValue({
65
+ data: mockProgress
66
+ })
67
+
68
+ jest.spyOn(awardDefinitions, 'getById').mockResolvedValue(mockDefinition)
69
+
70
+ const awards = await getCompletedAwards()
71
+
72
+ expect(awards).toHaveLength(1)
73
+ expect(awards[0].completionData).toHaveProperty('message')
74
+ expect(awards[0].completionData.message).toContain('Blues Foundations')
75
+ expect(awards[0].completionData.message).toContain('14 days')
76
+ expect(awards[0].completionData.message).toContain('180 minutes')
77
+ })
78
+
79
+ test('includes message in completionData for learning path awards', async () => {
80
+ const mockProgress = [{
81
+ award_id: 'award-2',
82
+ progress_percentage: 100,
83
+ completed_at: 1672531200,
84
+ completion_data: {
85
+ content_title: 'Jazz Essentials',
86
+ completed_at: '2023-01-01T00:00:00.000Z',
87
+ days_user_practiced: 30,
88
+ practice_minutes: 450
89
+ }
90
+ }]
91
+
92
+ const mockDefinition = {
93
+ _id: 'award-2',
94
+ name: 'Jazz Journey',
95
+ badge: 'https://example.com/badge2.png',
96
+ award: 'certificate',
97
+ brand: 'pianote',
98
+ instructor_name: 'Lisa Witt',
99
+ content_type: 'learning-path-v2'
100
+ }
101
+
102
+ jest.spyOn(db.userAwardProgress, 'getAll').mockResolvedValue({
103
+ data: mockProgress
104
+ })
105
+
106
+ jest.spyOn(awardDefinitions, 'getById').mockResolvedValue(mockDefinition)
107
+
108
+ const awards = await getCompletedAwards()
109
+
110
+ expect(awards).toHaveLength(1)
111
+ expect(awards[0].completionData).toHaveProperty('message')
112
+ expect(awards[0].completionData.message).toContain('Jazz Essentials')
113
+ expect(awards[0].completionData.message).toContain('30 days')
114
+ expect(awards[0].completionData.message).toContain('450 minutes')
115
+ })
116
+
117
+ test('uses correct practice statistics in message', async () => {
118
+ const mockProgress = [{
119
+ award_id: 'award-3',
120
+ progress_percentage: 100,
121
+ completed_at: 1672531200,
122
+ completion_data: {
123
+ content_title: 'Rock Foundations',
124
+ completed_at: '2023-01-01T00:00:00.000Z',
125
+ days_user_practiced: 7,
126
+ practice_minutes: 90
127
+ }
128
+ }]
129
+
130
+ const mockDefinition = {
131
+ _id: 'award-3',
132
+ name: 'Rock Star',
133
+ badge: 'https://example.com/badge3.png',
134
+ award: 'badge',
135
+ brand: 'guitareo',
136
+ instructor_name: 'Steve Stine',
137
+ content_type: 'guided-course'
138
+ }
139
+
140
+ jest.spyOn(db.userAwardProgress, 'getAll').mockResolvedValue({
141
+ data: mockProgress
142
+ })
143
+
144
+ jest.spyOn(awardDefinitions, 'getById').mockResolvedValue(mockDefinition)
145
+
146
+ const awards = await getCompletedAwards()
147
+
148
+ expect(awards[0].completionData.message).toContain('7 days')
149
+ expect(awards[0].completionData.message).toContain('90 minutes')
150
+ expect(awards[0].completionData.message).not.toContain('180 minutes')
151
+ })
152
+
153
+ test('handles awards with zero practice minutes', async () => {
154
+ const mockProgress = [{
155
+ award_id: 'award-4',
156
+ progress_percentage: 100,
157
+ completed_at: 1672531200,
158
+ completion_data: {
159
+ content_title: 'Quick Course',
160
+ completed_at: '2023-01-01T00:00:00.000Z',
161
+ days_user_practiced: 1,
162
+ practice_minutes: 0
163
+ }
164
+ }]
165
+
166
+ const mockDefinition = {
167
+ _id: 'award-4',
168
+ name: 'Quick Learner',
169
+ badge: 'https://example.com/badge4.png',
170
+ award: 'badge',
171
+ brand: 'singeo',
172
+ instructor_name: 'Camille van Niekerk',
173
+ content_type: 'guided-course'
174
+ }
175
+
176
+ jest.spyOn(db.userAwardProgress, 'getAll').mockResolvedValue({
177
+ data: mockProgress
178
+ })
179
+
180
+ jest.spyOn(awardDefinitions, 'getById').mockResolvedValue(mockDefinition)
181
+
182
+ const awards = await getCompletedAwards()
183
+
184
+ expect(awards[0].completionData.message).toContain('0 minutes')
185
+ expect(awards[0].completionData.message).toContain('1 day')
186
+ expect(awards[0].completionData.message).not.toContain('1 days')
187
+ })
188
+ })
189
+
190
+ describe('getContentAwards', () => {
191
+ test('includes message in completionData when award is completed', async () => {
192
+ const mockDefinitions = [{
193
+ _id: 'award-5',
194
+ name: 'Content Award',
195
+ badge: 'https://example.com/badge5.png',
196
+ award: 'certificate',
197
+ brand: 'drumeo',
198
+ instructor_name: 'Jared Falk',
199
+ content_type: 'guided-course'
200
+ }]
201
+
202
+ const mockProgress = new Map()
203
+ mockProgress.set('award-5', {
204
+ progress_percentage: 100,
205
+ isCompleted: true,
206
+ completed_at: 1672531200,
207
+ completion_data: {
208
+ content_title: 'Advanced Techniques',
209
+ completed_at: '2023-01-01T00:00:00.000Z',
210
+ days_user_practiced: 21,
211
+ practice_minutes: 300
212
+ }
213
+ })
214
+
215
+ jest.spyOn(db.userAwardProgress, 'getAwardsForContent').mockResolvedValue({
216
+ definitions: mockDefinitions,
217
+ progress: mockProgress
218
+ })
219
+
220
+ jest.spyOn(awardDefinitions, 'hasAwards').mockResolvedValue(true)
221
+
222
+ const result = await getContentAwards(12345)
223
+
224
+ expect(result.hasAwards).toBe(true)
225
+ expect(result.awards).toHaveLength(1)
226
+ expect(result.awards[0].completionData).toHaveProperty('message')
227
+ expect(result.awards[0].completionData.message).toContain('21 days')
228
+ expect(result.awards[0].completionData.message).toContain('300 minutes')
229
+ })
230
+
231
+ test('handles awards without completion data gracefully', async () => {
232
+ const mockDefinitions = [{
233
+ _id: 'award-6',
234
+ name: 'In Progress Award',
235
+ badge: 'https://example.com/badge6.png',
236
+ award: 'badge',
237
+ brand: 'pianote',
238
+ instructor_name: 'Lisa Witt',
239
+ content_type: 'learning-path-v2'
240
+ }]
241
+
242
+ const mockProgress = new Map()
243
+ mockProgress.set('award-6', {
244
+ progress_percentage: 50,
245
+ isCompleted: false,
246
+ completed_at: null,
247
+ completion_data: null
248
+ })
249
+
250
+ jest.spyOn(db.userAwardProgress, 'getAwardsForContent').mockResolvedValue({
251
+ definitions: mockDefinitions,
252
+ progress: mockProgress
253
+ })
254
+
255
+ jest.spyOn(awardDefinitions, 'hasAwards').mockResolvedValue(true)
256
+
257
+ const result = await getContentAwards(12345)
258
+
259
+ expect(result.hasAwards).toBe(true)
260
+ expect(result.awards).toHaveLength(1)
261
+ expect(result.awards[0].completionData).toBeNull()
262
+ })
263
+ })
264
+
265
+ describe('getInProgressAwards', () => {
266
+ test('handles in-progress awards with partial completion data', async () => {
267
+ const mockProgress = [{
268
+ award_id: 'award-7',
269
+ progress_percentage: 75,
270
+ completed_at: null,
271
+ completion_data: {
272
+ content_title: 'Intermediate Skills',
273
+ completed_at: null,
274
+ days_user_practiced: 10,
275
+ practice_minutes: 150
276
+ }
277
+ }]
278
+
279
+ const mockDefinition = {
280
+ _id: 'award-7',
281
+ name: 'Progress Champion',
282
+ badge: 'https://example.com/badge7.png',
283
+ award: 'badge',
284
+ brand: 'guitareo',
285
+ instructor_name: 'Anders Mouridsen',
286
+ content_type: 'guided-course'
287
+ }
288
+
289
+ jest.spyOn(db.userAwardProgress, 'getAll').mockResolvedValue({
290
+ data: mockProgress
291
+ })
292
+
293
+ jest.spyOn(awardDefinitions, 'getById').mockResolvedValue(mockDefinition)
294
+
295
+ const awards = await getInProgressAwards()
296
+
297
+ expect(awards).toHaveLength(1)
298
+ expect(awards[0].isCompleted).toBe(false)
299
+ expect(awards[0].completionData).toHaveProperty('message')
300
+ expect(awards[0].completionData.message).toContain('10 days')
301
+ expect(awards[0].completionData.message).toContain('150 minutes')
302
+ })
303
+
304
+ test('handles in-progress awards without completion data', async () => {
305
+ const mockProgress = [{
306
+ award_id: 'award-8',
307
+ progress_percentage: 25,
308
+ completed_at: null,
309
+ completion_data: null
310
+ }]
311
+
312
+ const mockDefinition = {
313
+ _id: 'award-8',
314
+ name: 'Just Started',
315
+ badge: 'https://example.com/badge8.png',
316
+ award: 'badge',
317
+ brand: 'singeo',
318
+ instructor_name: 'Ramsey Voice Studio',
319
+ content_type: 'learning-path-v2'
320
+ }
321
+
322
+ jest.spyOn(db.userAwardProgress, 'getAll').mockResolvedValue({
323
+ data: mockProgress
324
+ })
325
+
326
+ jest.spyOn(awardDefinitions, 'getById').mockResolvedValue(mockDefinition)
327
+
328
+ const awards = await getInProgressAwards()
329
+
330
+ expect(awards).toHaveLength(1)
331
+ expect(awards[0].completionData).toBeNull()
332
+ })
333
+ })
334
+
335
+ describe('Award type determination', () => {
336
+ test('generates message with content title for guided-course type', async () => {
337
+ const mockProgress = [{
338
+ award_id: 'gc-award',
339
+ progress_percentage: 100,
340
+ completed_at: 1672531200,
341
+ completion_data: {
342
+ content_title: 'Test Course',
343
+ completed_at: '2023-01-01T00:00:00.000Z',
344
+ days_user_practiced: 5,
345
+ practice_minutes: 60
346
+ }
347
+ }]
348
+
349
+ const mockDefinition = {
350
+ _id: 'gc-award',
351
+ name: 'Guided Course Award',
352
+ badge: 'https://example.com/gc-badge.png',
353
+ award: 'certificate',
354
+ brand: 'drumeo',
355
+ instructor_name: 'Test Instructor',
356
+ content_type: 'guided-course'
357
+ }
358
+
359
+ jest.spyOn(db.userAwardProgress, 'getAll').mockResolvedValue({
360
+ data: mockProgress
361
+ })
362
+
363
+ jest.spyOn(awardDefinitions, 'getById').mockResolvedValue(mockDefinition)
364
+
365
+ const awards = await getCompletedAwards()
366
+
367
+ expect(awards[0].completionData.message).toContain('Test Course')
368
+ })
369
+
370
+ test('generates message with content title for learning-path-v2 type', async () => {
371
+ const mockProgress = [{
372
+ award_id: 'lp-award',
373
+ progress_percentage: 100,
374
+ completed_at: 1672531200,
375
+ completion_data: {
376
+ content_title: 'Test Path',
377
+ completed_at: '2023-01-01T00:00:00.000Z',
378
+ days_user_practiced: 5,
379
+ practice_minutes: 60
380
+ }
381
+ }]
382
+
383
+ const mockDefinition = {
384
+ _id: 'lp-award',
385
+ name: 'Learning Path Award',
386
+ badge: 'https://example.com/lp-badge.png',
387
+ award: 'certificate',
388
+ brand: 'pianote',
389
+ instructor_name: 'Test Instructor',
390
+ content_type: 'learning-path-v2'
391
+ }
392
+
393
+ jest.spyOn(db.userAwardProgress, 'getAll').mockResolvedValue({
394
+ data: mockProgress
395
+ })
396
+
397
+ jest.spyOn(awardDefinitions, 'getById').mockResolvedValue(mockDefinition)
398
+
399
+ const awards = await getCompletedAwards()
400
+
401
+ expect(awards[0].completionData.message).toContain('Test Path')
402
+ })
403
+
404
+ test('generates message for unknown content types', async () => {
405
+ const mockProgress = [{
406
+ award_id: 'unknown-award',
407
+ progress_percentage: 100,
408
+ completed_at: 1672531200,
409
+ completion_data: {
410
+ content_title: 'Test Content',
411
+ completed_at: '2023-01-01T00:00:00.000Z',
412
+ days_user_practiced: 5,
413
+ practice_minutes: 60
414
+ }
415
+ }]
416
+
417
+ const mockDefinition = {
418
+ _id: 'unknown-award',
419
+ name: 'Unknown Type Award',
420
+ badge: 'https://example.com/unknown-badge.png',
421
+ award: 'certificate',
422
+ brand: 'guitareo',
423
+ instructor_name: 'Test Instructor',
424
+ content_type: 'some-unknown-type'
425
+ }
426
+
427
+ jest.spyOn(db.userAwardProgress, 'getAll').mockResolvedValue({
428
+ data: mockProgress
429
+ })
430
+
431
+ jest.spyOn(awardDefinitions, 'getById').mockResolvedValue(mockDefinition)
432
+
433
+ const awards = await getCompletedAwards()
434
+
435
+ expect(awards[0].completionData.message).toContain('Test Content')
436
+ })
437
+ })
438
+ })