musora-content-services 2.94.8 → 2.95.0

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 (68) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/CLAUDE.md +408 -0
  3. package/babel.config.cjs +10 -0
  4. package/jsdoc.json +2 -1
  5. package/package.json +2 -2
  6. package/src/constants/award-assets.js +35 -0
  7. package/src/filterBuilder.js +7 -2
  8. package/src/index.d.ts +26 -5
  9. package/src/index.js +26 -5
  10. package/src/services/awards/award-callbacks.js +126 -0
  11. package/src/services/awards/award-query.js +327 -0
  12. package/src/services/awards/internal/.indexignore +1 -0
  13. package/src/services/awards/internal/award-definitions.js +239 -0
  14. package/src/services/awards/internal/award-events.js +102 -0
  15. package/src/services/awards/internal/award-manager.js +162 -0
  16. package/src/services/awards/internal/certificate-builder.js +66 -0
  17. package/src/services/awards/internal/completion-data-generator.js +84 -0
  18. package/src/services/awards/internal/content-progress-observer.js +137 -0
  19. package/src/services/awards/internal/image-utils.js +62 -0
  20. package/src/services/awards/internal/message-generator.js +17 -0
  21. package/src/services/awards/internal/types.js +5 -0
  22. package/src/services/awards/types.d.ts +79 -0
  23. package/src/services/awards/types.js +101 -0
  24. package/src/services/config.js +24 -4
  25. package/src/services/content-org/learning-paths.ts +19 -15
  26. package/src/services/gamification/awards.ts +114 -83
  27. package/src/services/progress-events.js +58 -0
  28. package/src/services/progress-row/method-card.js +20 -5
  29. package/src/services/sanity.js +1 -1
  30. package/src/services/sync/fetch.ts +10 -2
  31. package/src/services/sync/manager.ts +6 -0
  32. package/src/services/sync/models/ContentProgress.ts +5 -6
  33. package/src/services/sync/models/UserAwardProgress.ts +55 -0
  34. package/src/services/sync/models/index.ts +1 -0
  35. package/src/services/sync/repositories/content-progress.ts +47 -25
  36. package/src/services/sync/repositories/index.ts +1 -0
  37. package/src/services/sync/repositories/practices.ts +16 -1
  38. package/src/services/sync/repositories/user-award-progress.ts +133 -0
  39. package/src/services/sync/repository-proxy.ts +6 -0
  40. package/src/services/sync/retry.ts +12 -11
  41. package/src/services/sync/schema/index.ts +18 -3
  42. package/src/services/sync/store/index.ts +53 -8
  43. package/src/services/sync/store/push-coalescer.ts +3 -3
  44. package/src/services/sync/store-configs.ts +7 -1
  45. package/src/services/userActivity.js +0 -1
  46. package/test/HttpClient.test.js +6 -6
  47. package/test/awards/award-alacarte-observer.test.js +196 -0
  48. package/test/awards/award-auto-refresh.test.js +83 -0
  49. package/test/awards/award-calculations.test.js +33 -0
  50. package/test/awards/award-certificate-display.test.js +328 -0
  51. package/test/awards/award-collection-edge-cases.test.js +210 -0
  52. package/test/awards/award-collection-filtering.test.js +285 -0
  53. package/test/awards/award-completion-flow.test.js +213 -0
  54. package/test/awards/award-exclusion-handling.test.js +273 -0
  55. package/test/awards/award-multi-lesson.test.js +241 -0
  56. package/test/awards/award-observer-integration.test.js +325 -0
  57. package/test/awards/award-query-messages.test.js +438 -0
  58. package/test/awards/award-user-collection.test.js +412 -0
  59. package/test/awards/duplicate-prevention.test.js +118 -0
  60. package/test/awards/helpers/completion-mock.js +54 -0
  61. package/test/awards/helpers/index.js +3 -0
  62. package/test/awards/helpers/mock-setup.js +69 -0
  63. package/test/awards/helpers/progress-emitter.js +39 -0
  64. package/test/awards/message-generator.test.js +162 -0
  65. package/test/initializeTests.js +6 -0
  66. package/test/mockData/award-definitions.js +171 -0
  67. package/test/sync/models/award-database-integration.test.js +519 -0
  68. package/tools/generate-index.cjs +9 -0
@@ -0,0 +1,273 @@
1
+ import { awardManager } from '../../src/services/awards/internal/award-manager'
2
+ import { awardEvents } from '../../src/services/awards/internal/award-events'
3
+ import { mockAwardDefinitions, getAwardByContentId } from '../mockData/award-definitions'
4
+ import { globalConfig } from '../../src/services/config'
5
+ import { LocalStorageMock } from '../localStorageMock'
6
+ import { setupDefaultMocks, setupAwardEventListeners } from './helpers'
7
+ import { mockCompletionStates, mockAllCompleted, mockNoneCompleted } from './helpers/completion-mock'
8
+
9
+ jest.mock('../../src/services/sanity', () => ({
10
+ default: { fetch: jest.fn() },
11
+ fetchSanity: jest.fn()
12
+ }))
13
+
14
+ jest.mock('../../src/services/railcontent', () => ({
15
+ ...jest.requireActual('../../src/services/railcontent'),
16
+ fetchUserPermissionsData: jest.fn().mockResolvedValue({ permissions: [108, 91, 92], isAdmin: false })
17
+ }))
18
+
19
+ jest.mock('../../src/services/sync/repository-proxy', () => {
20
+ const mockFns = {
21
+ contentProgress: {
22
+ getOneProgressByContentId: jest.fn(),
23
+ getSomeProgressByContentIds: jest.fn(),
24
+ queryOne: jest.fn(),
25
+ queryAll: jest.fn()
26
+ },
27
+ practices: {
28
+ sumPracticeMinutesForContent: jest.fn()
29
+ },
30
+ userAwardProgress: {
31
+ hasCompletedAward: jest.fn(),
32
+ recordAwardProgress: jest.fn(),
33
+ getByAwardId: jest.fn(),
34
+ completeAward: jest.fn()
35
+ }
36
+ }
37
+ return { default: mockFns, ...mockFns }
38
+ })
39
+
40
+ import sanityClient, { fetchSanity } from '../../src/services/sanity'
41
+ import db from '../../src/services/sync/repository-proxy'
42
+ import { awardDefinitions } from '../../src/services/awards/internal/award-definitions'
43
+
44
+ describe('Award Content Exclusion Handling - E2E Scenarios', () => {
45
+ let listeners
46
+
47
+ beforeEach(async () => {
48
+ jest.clearAllMocks()
49
+ globalConfig.localStorage = new LocalStorageMock()
50
+ awardEvents.removeAllListeners()
51
+
52
+ sanityClient.fetch = jest.fn().mockResolvedValue(mockAwardDefinitions)
53
+ setupDefaultMocks(db, fetchSanity, { practiceMinutes: 150 })
54
+
55
+ db.userAwardProgress.completeAward = jest.fn().mockResolvedValue({ data: {}, status: 'synced' })
56
+
57
+ db.contentProgress.getSomeProgressByContentIds.mockResolvedValue({
58
+ data: [{ created_at: Math.floor(Date.now() / 1000) - 86400 * 7 }]
59
+ })
60
+
61
+ await awardDefinitions.refresh()
62
+
63
+ listeners = setupAwardEventListeners(awardEvents)
64
+ })
65
+
66
+ afterEach(() => {
67
+ awardEvents.removeAllListeners()
68
+ awardDefinitions.clear()
69
+ })
70
+
71
+ describe('Scenario: Guided course with excluded intro video (416446 - 1 eligible lesson)', () => {
72
+ const testAward = getAwardByContentId(416446)
73
+ const courseId = 416446
74
+
75
+ test('completing the single eligible lesson grants award at 100%', async () => {
76
+ mockAllCompleted(db)
77
+
78
+ await awardManager.onContentCompleted(courseId)
79
+
80
+ expect(db.userAwardProgress.recordAwardProgress).toHaveBeenCalledWith(
81
+ expect.any(String),
82
+ 100,
83
+ expect.objectContaining({
84
+ completedAt: expect.any(Number),
85
+ immediate: true
86
+ })
87
+ )
88
+ expect(listeners.granted).toHaveBeenCalledTimes(1)
89
+ })
90
+
91
+ test('shows 0% progress when eligible lesson not completed', async () => {
92
+ mockNoneCompleted(db)
93
+
94
+ await awardManager.onContentCompleted(courseId)
95
+
96
+ expect(db.userAwardProgress.recordAwardProgress).toHaveBeenCalledWith(
97
+ testAward._id,
98
+ 0,
99
+ expect.objectContaining({
100
+ progressData: expect.any(Object)
101
+ })
102
+ )
103
+ })
104
+ })
105
+
106
+ describe('Scenario: Course with 4 eligible lessons (417049 - intro excluded)', () => {
107
+ const testAward = getAwardByContentId(417049)
108
+ const courseId = 417049
109
+
110
+ test('completing 1 of 4 lessons shows 25% progress', async () => {
111
+ mockCompletionStates(db, [417045])
112
+
113
+ await awardManager.onContentCompleted(courseId)
114
+
115
+ expect(db.userAwardProgress.recordAwardProgress).toHaveBeenCalledWith(
116
+ testAward._id,
117
+ 25,
118
+ expect.objectContaining({
119
+ progressData: expect.any(Object)
120
+ })
121
+ )
122
+ })
123
+
124
+ test('completing 2 of 4 lessons shows 50% progress', async () => {
125
+ mockCompletionStates(db, [417045, 417046])
126
+
127
+ await awardManager.onContentCompleted(courseId)
128
+
129
+ expect(db.userAwardProgress.recordAwardProgress).toHaveBeenCalledWith(
130
+ testAward._id,
131
+ 50,
132
+ expect.objectContaining({
133
+ progressData: expect.any(Object)
134
+ })
135
+ )
136
+ })
137
+
138
+ test('must complete all 4 eligible lessons to earn award', async () => {
139
+ mockCompletionStates(db, [417045, 417046, 417047, 417048])
140
+
141
+ await awardManager.onContentCompleted(courseId)
142
+
143
+ expect(db.userAwardProgress.recordAwardProgress).toHaveBeenCalledWith(
144
+ expect.any(String),
145
+ 100,
146
+ expect.objectContaining({
147
+ completedAt: expect.any(Number),
148
+ immediate: true
149
+ })
150
+ )
151
+ expect(listeners.granted).toHaveBeenCalledTimes(1)
152
+ })
153
+ })
154
+
155
+ describe('Scenario: Large course with 23 eligible lessons (416464 - intro excluded)', () => {
156
+ const testAward = getAwardByContentId(416464)
157
+ const courseId = 416464
158
+ const eligibleLessons = [
159
+ 416467, 416468, 416469, 416470, 416471, 416472, 416473,
160
+ 416474, 416475, 416476, 416477, 416478, 416479, 416480, 416481,
161
+ 416482, 416483, 416484, 416485, 416486, 416487, 416488, 416489
162
+ ]
163
+
164
+ test('shows 0% progress when no eligible lessons completed', async () => {
165
+ mockNoneCompleted(db)
166
+
167
+ await awardManager.onContentCompleted(courseId)
168
+
169
+ expect(db.userAwardProgress.recordAwardProgress).toHaveBeenCalledWith(
170
+ testAward._id,
171
+ 0,
172
+ expect.objectContaining({
173
+ progressData: expect.any(Object)
174
+ })
175
+ )
176
+ })
177
+
178
+ test('completing 12 of 23 lessons shows ~52% progress', async () => {
179
+ mockCompletionStates(db, eligibleLessons.slice(0, 12))
180
+
181
+ await awardManager.onContentCompleted(courseId)
182
+
183
+ expect(db.userAwardProgress.recordAwardProgress).toHaveBeenCalledWith(
184
+ testAward._id,
185
+ 52,
186
+ expect.objectContaining({
187
+ progressData: expect.any(Object)
188
+ })
189
+ )
190
+ })
191
+
192
+ test('must complete all 23 eligible lessons to earn award', async () => {
193
+ mockCompletionStates(db, eligibleLessons)
194
+
195
+ await awardManager.onContentCompleted(courseId)
196
+
197
+ expect(db.userAwardProgress.recordAwardProgress).toHaveBeenCalledWith(
198
+ expect.any(String),
199
+ 100,
200
+ expect.objectContaining({
201
+ completedAt: expect.any(Number),
202
+ immediate: true
203
+ })
204
+ )
205
+ expect(listeners.granted).toHaveBeenCalledTimes(1)
206
+ })
207
+ })
208
+
209
+ describe('Scenario: Course type without excluded content (417039 - all 3 lessons count)', () => {
210
+ const testAward = getAwardByContentId(417039)
211
+ const courseId = 417039
212
+
213
+ test('first lesson counts toward progress', async () => {
214
+ mockCompletionStates(db, [417035])
215
+
216
+ await awardManager.onContentCompleted(courseId)
217
+
218
+ expect(db.userAwardProgress.recordAwardProgress).toHaveBeenCalledWith(
219
+ testAward._id,
220
+ 33,
221
+ expect.objectContaining({
222
+ progressData: expect.any(Object)
223
+ })
224
+ )
225
+ })
226
+
227
+ test('all 3 lessons must be completed to earn award', async () => {
228
+ mockAllCompleted(db)
229
+
230
+ await awardManager.onContentCompleted(courseId)
231
+
232
+ expect(db.userAwardProgress.recordAwardProgress).toHaveBeenCalledWith(
233
+ expect.any(String),
234
+ 100,
235
+ expect.objectContaining({
236
+ completedAt: expect.any(Number),
237
+ immediate: true
238
+ })
239
+ )
240
+ })
241
+ })
242
+
243
+ describe('Scenario: Learning path content type (417140)', () => {
244
+ const testAward = getAwardByContentId(417140)
245
+ const courseId = 417140
246
+
247
+ test('all child content counts toward progress', async () => {
248
+ mockCompletionStates(db, testAward.child_ids.slice(0, 11))
249
+
250
+ await awardManager.onContentCompleted(courseId)
251
+
252
+ expect(db.userAwardProgress.recordAwardProgress).toHaveBeenCalledWith(
253
+ testAward._id,
254
+ 50,
255
+ expect.objectContaining({
256
+ progressData: expect.any(Object)
257
+ })
258
+ )
259
+ })
260
+ })
261
+
262
+ describe('Scenario: Skill pack content type (418000)', () => {
263
+ const courseId = 418000
264
+
265
+ test('completing all lessons grants award', async () => {
266
+ mockAllCompleted(db)
267
+
268
+ await awardManager.onContentCompleted(courseId)
269
+
270
+ expect(listeners.granted).toHaveBeenCalledTimes(1)
271
+ })
272
+ })
273
+ })
@@ -0,0 +1,241 @@
1
+ import { awardManager } from '../../src/services/awards/internal/award-manager'
2
+ import { awardEvents } from '../../src/services/awards/internal/award-events'
3
+ import { mockAwardDefinitions, getAwardByContentId } from '../mockData/award-definitions'
4
+ import { setupDefaultMocks, setupAwardEventListeners } from './helpers'
5
+ import { mockCompletionStates, mockAllCompleted, mockNoneCompleted } from './helpers/completion-mock'
6
+
7
+ jest.mock('../../src/services/sanity', () => ({
8
+ default: { fetch: jest.fn() },
9
+ fetchSanity: jest.fn()
10
+ }))
11
+
12
+ jest.mock('../../src/services/sync/repository-proxy', () => {
13
+ const mockFns = {
14
+ contentProgress: {
15
+ getOneProgressByContentId: jest.fn(),
16
+ getSomeProgressByContentIds: jest.fn(),
17
+ queryOne: jest.fn(),
18
+ queryAll: jest.fn()
19
+ },
20
+ practices: {
21
+ sumPracticeMinutesForContent: jest.fn()
22
+ },
23
+ userAwardProgress: {
24
+ hasCompletedAward: jest.fn(),
25
+ recordAwardProgress: jest.fn(),
26
+ getByAwardId: jest.fn()
27
+ }
28
+ }
29
+ return { default: mockFns, ...mockFns }
30
+ })
31
+
32
+ import sanityClient, { fetchSanity } from '../../src/services/sanity'
33
+ import db from '../../src/services/sync/repository-proxy'
34
+ import { awardDefinitions } from '../../src/services/awards/internal/award-definitions'
35
+
36
+ describe('Award Progress Calculation', () => {
37
+ let listeners
38
+
39
+ beforeEach(async () => {
40
+ jest.clearAllMocks()
41
+ awardEvents.removeAllListeners()
42
+
43
+ sanityClient.fetch = jest.fn().mockResolvedValue(mockAwardDefinitions)
44
+ setupDefaultMocks(db, fetchSanity, { practiceMinutes: 120 })
45
+
46
+ db.contentProgress.getSomeProgressByContentIds.mockResolvedValue({
47
+ data: [{ created_at: Math.floor(Date.now() / 1000) - 86400 * 5 }]
48
+ })
49
+
50
+ await awardDefinitions.refresh()
51
+
52
+ listeners = setupAwardEventListeners(awardEvents)
53
+ })
54
+
55
+ afterEach(() => {
56
+ awardEvents.removeAllListeners()
57
+ awardDefinitions.clear()
58
+ })
59
+
60
+ describe('Single lesson course (content_id: 416446, child_ids: [416448])', () => {
61
+ const award = getAwardByContentId(416446)
62
+ const childId = 416448
63
+
64
+ test('grants award at 100% when the single lesson is completed', async () => {
65
+ mockAllCompleted(db)
66
+
67
+ await awardManager.onContentCompleted(childId)
68
+
69
+ expect(db.userAwardProgress.recordAwardProgress).toHaveBeenCalledWith(
70
+ award._id,
71
+ 100,
72
+ expect.objectContaining({
73
+ completedAt: expect.any(Number),
74
+ completionData: expect.objectContaining({
75
+ content_title: award.content_title,
76
+ days_user_practiced: expect.any(Number),
77
+ practice_minutes: expect.any(Number)
78
+ }),
79
+ progressData: {
80
+ completedLessonIds: [childId],
81
+ totalLessons: 1,
82
+ completedCount: 1
83
+ },
84
+ immediate: true
85
+ })
86
+ )
87
+ expect(listeners.granted).toHaveBeenCalledTimes(1)
88
+ })
89
+
90
+ test('records 0% progress when the lesson is not completed', async () => {
91
+ mockNoneCompleted(db)
92
+
93
+ await awardManager.onContentCompleted(childId)
94
+
95
+ expect(db.userAwardProgress.recordAwardProgress).toHaveBeenCalledWith(
96
+ award._id,
97
+ 0,
98
+ expect.objectContaining({
99
+ progressData: {
100
+ completedLessonIds: [],
101
+ totalLessons: 1,
102
+ completedCount: 0
103
+ }
104
+ })
105
+ )
106
+ expect(listeners.granted).not.toHaveBeenCalled()
107
+ })
108
+ })
109
+
110
+ describe('10-lesson course (content_id: 416450, child_ids: 10 lessons)', () => {
111
+ const award = getAwardByContentId(416450)
112
+ const childIds = award.child_ids
113
+
114
+ test('calculates 50% progress when 5 of 10 lessons are completed', async () => {
115
+ mockCompletionStates(db, childIds.slice(0, 5))
116
+
117
+ await awardManager.onContentCompleted(childIds[0])
118
+
119
+ expect(db.userAwardProgress.recordAwardProgress).toHaveBeenCalledWith(
120
+ award._id,
121
+ 50,
122
+ expect.objectContaining({
123
+ progressData: expect.objectContaining({
124
+ totalLessons: 10,
125
+ completedCount: 5
126
+ })
127
+ })
128
+ )
129
+ })
130
+
131
+ test('calculates 90% progress when 9 of 10 lessons are completed', async () => {
132
+ mockCompletionStates(db, childIds.slice(0, 9))
133
+
134
+ await awardManager.onContentCompleted(childIds[0])
135
+
136
+ expect(db.userAwardProgress.recordAwardProgress).toHaveBeenCalledWith(
137
+ award._id,
138
+ 90,
139
+ expect.objectContaining({
140
+ progressData: expect.objectContaining({
141
+ totalLessons: 10,
142
+ completedCount: 9
143
+ })
144
+ })
145
+ )
146
+ expect(listeners.granted).not.toHaveBeenCalled()
147
+ })
148
+
149
+ test('grants award when all 10 lessons are completed', async () => {
150
+ mockAllCompleted(db)
151
+
152
+ await awardManager.onContentCompleted(childIds[0])
153
+
154
+ expect(db.userAwardProgress.recordAwardProgress).toHaveBeenCalledWith(
155
+ award._id,
156
+ 100,
157
+ expect.objectContaining({
158
+ completedAt: expect.any(Number),
159
+ progressData: expect.objectContaining({
160
+ totalLessons: 10,
161
+ completedCount: 10
162
+ }),
163
+ immediate: true
164
+ })
165
+ )
166
+ expect(listeners.granted).toHaveBeenCalledTimes(1)
167
+ })
168
+ })
169
+
170
+ describe('22-lesson learning path (content_id: 417140)', () => {
171
+ const award = getAwardByContentId(417140)
172
+ const childIds = award.child_ids
173
+
174
+ test('calculates correct percentage for partial completion (11/22 = 50%)', async () => {
175
+ mockCompletionStates(db, childIds.slice(0, 11))
176
+
177
+ await awardManager.onContentCompleted(childIds[0])
178
+
179
+ expect(db.userAwardProgress.recordAwardProgress).toHaveBeenCalledWith(
180
+ award._id,
181
+ 50,
182
+ expect.objectContaining({
183
+ progressData: expect.objectContaining({
184
+ totalLessons: 22,
185
+ completedCount: 11
186
+ })
187
+ })
188
+ )
189
+ })
190
+
191
+ test('does not grant award when 21 of 22 lessons completed (95%)', async () => {
192
+ mockCompletionStates(db, childIds.slice(0, 21))
193
+
194
+ await awardManager.onContentCompleted(childIds[0])
195
+
196
+ expect(db.userAwardProgress.recordAwardProgress).toHaveBeenCalledWith(
197
+ award._id,
198
+ 95,
199
+ expect.objectContaining({
200
+ progressData: expect.objectContaining({
201
+ totalLessons: 22,
202
+ completedCount: 21
203
+ })
204
+ })
205
+ )
206
+ expect(listeners.granted).not.toHaveBeenCalled()
207
+ })
208
+
209
+ test('grants award with correct popup message when all 22 lessons completed', async () => {
210
+ mockAllCompleted(db)
211
+
212
+ await awardManager.onContentCompleted(childIds[0])
213
+
214
+ expect(listeners.granted).toHaveBeenCalledTimes(1)
215
+
216
+ const payload = listeners.granted.mock.calls[0][0]
217
+ expect(payload.awardId).toBe(award._id)
218
+ expect(payload.popupMessage).toContain(award.content_title)
219
+ expect(payload.popupMessage).toContain('120 minutes')
220
+ expect(payload.completionData).toEqual(expect.objectContaining({
221
+ content_title: award.content_title,
222
+ practice_minutes: 120
223
+ }))
224
+ })
225
+ })
226
+
227
+ describe('Award already completed', () => {
228
+ const award = getAwardByContentId(416446)
229
+ const childId = 416448
230
+
231
+ test('skips evaluation when award is already completed', async () => {
232
+ db.userAwardProgress.hasCompletedAward.mockResolvedValue(true)
233
+ mockAllCompleted(db)
234
+
235
+ await awardManager.onContentCompleted(childId)
236
+
237
+ expect(db.userAwardProgress.recordAwardProgress).not.toHaveBeenCalled()
238
+ expect(listeners.granted).not.toHaveBeenCalled()
239
+ })
240
+ })
241
+ })