musora-content-services 2.94.7 → 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 (210) hide show
  1. package/.coderabbit.yaml +0 -0
  2. package/.editorconfig +0 -0
  3. package/.github/pull_request_template.md +0 -0
  4. package/.github/workflows/conventional-commits.yaml +0 -0
  5. package/.github/workflows/docs.js.yml +0 -0
  6. package/.github/workflows/node.js.yml +0 -0
  7. package/.prettierignore +0 -0
  8. package/.prettierrc +0 -0
  9. package/CHANGELOG.md +23 -0
  10. package/CLAUDE.md +408 -0
  11. package/README.md +0 -0
  12. package/babel.config.cjs +10 -0
  13. package/jest.config.js +0 -0
  14. package/jsdoc.json +2 -1
  15. package/package.json +2 -2
  16. package/src/constants/award-assets.js +35 -0
  17. package/src/contentMetaData.js +0 -0
  18. package/src/filterBuilder.js +7 -2
  19. package/src/index.d.ts +26 -5
  20. package/src/index.js +26 -5
  21. package/src/infrastructure/http/HttpClient.ts +0 -0
  22. package/src/infrastructure/http/executors/FetchRequestExecutor.ts +0 -0
  23. package/src/infrastructure/http/index.ts +0 -0
  24. package/src/infrastructure/http/interfaces/HeaderProvider.ts +0 -0
  25. package/src/infrastructure/http/interfaces/HttpError.ts +0 -0
  26. package/src/infrastructure/http/interfaces/NetworkError.ts +0 -0
  27. package/src/infrastructure/http/interfaces/RequestExecutor.ts +0 -0
  28. package/src/infrastructure/http/interfaces/RequestOptions.ts +0 -0
  29. package/src/infrastructure/http/providers/DefaultHeaderProvider.ts +0 -0
  30. package/src/lib/brands.ts +0 -0
  31. package/src/lib/httpHelper.js +0 -0
  32. package/src/lib/lastUpdated.js +0 -0
  33. package/src/lib/sanity/query.ts +0 -0
  34. package/src/services/api/types.js +0 -0
  35. package/src/services/api/types.ts +0 -0
  36. package/src/services/awards/award-callbacks.js +126 -0
  37. package/src/services/awards/award-query.js +327 -0
  38. package/src/services/awards/internal/.indexignore +1 -0
  39. package/src/services/awards/internal/award-definitions.js +239 -0
  40. package/src/services/awards/internal/award-events.js +102 -0
  41. package/src/services/awards/internal/award-manager.js +162 -0
  42. package/src/services/awards/internal/certificate-builder.js +66 -0
  43. package/src/services/awards/internal/completion-data-generator.js +84 -0
  44. package/src/services/awards/internal/content-progress-observer.js +137 -0
  45. package/src/services/awards/internal/image-utils.js +62 -0
  46. package/src/services/awards/internal/message-generator.js +17 -0
  47. package/src/services/awards/internal/types.js +5 -0
  48. package/src/services/awards/types.d.ts +79 -0
  49. package/src/services/awards/types.js +101 -0
  50. package/src/services/config.js +24 -4
  51. package/src/services/content/artist.ts +0 -0
  52. package/src/services/content/content.ts +0 -0
  53. package/src/services/content/genre.ts +0 -0
  54. package/src/services/content/instructor.ts +0 -0
  55. package/src/services/content-org/content-org.js +0 -0
  56. package/src/services/content-org/guided-courses.ts +0 -0
  57. package/src/services/content-org/learning-paths.ts +19 -15
  58. package/src/services/content-org/playlists-types.js +0 -0
  59. package/src/services/content-org/playlists.js +0 -0
  60. package/src/services/content.js +0 -0
  61. package/src/services/contentAggregator.js +0 -0
  62. package/src/services/contentLikes.js +0 -0
  63. package/src/services/contentProgress.js +2 -1
  64. package/src/services/dataContext.js +0 -0
  65. package/src/services/dateUtils.js +0 -0
  66. package/src/services/eventsAPI.js +0 -0
  67. package/src/services/forums/forums.ts +0 -0
  68. package/src/services/forums/posts.ts +0 -0
  69. package/src/services/forums/threads.ts +0 -0
  70. package/src/services/forums/types.ts +0 -0
  71. package/src/services/gamification/awards.ts +114 -83
  72. package/src/services/gamification/gamification.js +0 -0
  73. package/src/services/imageSRCBuilder.js +0 -0
  74. package/src/services/imageSRCVerify.js +0 -0
  75. package/src/services/liveTesting.ts +0 -0
  76. package/src/services/permissions/PermissionsAdapter.ts +0 -0
  77. package/src/services/permissions/PermissionsAdapterFactory.ts +0 -0
  78. package/src/services/permissions/PermissionsV1Adapter.ts +0 -0
  79. package/src/services/permissions/PermissionsV2Adapter.ts +0 -0
  80. package/src/services/permissions/README.md +0 -0
  81. package/src/services/permissions/index.ts +0 -0
  82. package/src/services/progress-events.js +58 -0
  83. package/src/services/progress-row/method-card.js +20 -5
  84. package/src/services/railcontent.js +0 -0
  85. package/src/services/recommendations.js +0 -0
  86. package/src/services/reporting/README.md +0 -0
  87. package/src/services/reporting/reporting.ts +0 -0
  88. package/src/services/reporting/types.ts +0 -0
  89. package/src/services/sanity.js +1 -1
  90. package/src/services/sentry/.indexignore +0 -0
  91. package/src/services/sentry/index.ts +0 -0
  92. package/src/services/sync/.indexignore +0 -0
  93. package/src/services/sync/adapters/factory.ts +0 -0
  94. package/src/services/sync/adapters/lokijs.ts +0 -0
  95. package/src/services/sync/adapters/sqlite.ts +0 -0
  96. package/src/services/sync/concurrency-safety.ts +0 -0
  97. package/src/services/sync/context/index.ts +0 -0
  98. package/src/services/sync/context/providers/base.ts +0 -0
  99. package/src/services/sync/context/providers/connectivity.ts +0 -0
  100. package/src/services/sync/context/providers/durability.ts +0 -0
  101. package/src/services/sync/context/providers/index.ts +0 -0
  102. package/src/services/sync/context/providers/session.ts +0 -0
  103. package/src/services/sync/context/providers/tabs.ts +0 -0
  104. package/src/services/sync/context/providers/visibility.ts +0 -0
  105. package/src/services/sync/database/factory.ts +0 -0
  106. package/src/services/sync/errors/boundary.ts +0 -0
  107. package/src/services/sync/errors/index.ts +0 -0
  108. package/src/services/sync/fetch.ts +10 -2
  109. package/src/services/sync/index.ts +0 -0
  110. package/src/services/sync/manager.ts +6 -0
  111. package/src/services/sync/models/Base.ts +0 -0
  112. package/src/services/sync/models/ContentLike.ts +0 -0
  113. package/src/services/sync/models/ContentProgress.ts +5 -6
  114. package/src/services/sync/models/Practice.ts +0 -0
  115. package/src/services/sync/models/PracticeDayNote.ts +0 -0
  116. package/src/services/sync/models/UserAwardProgress.ts +55 -0
  117. package/src/services/sync/models/index.ts +1 -0
  118. package/src/services/sync/repositories/base.ts +0 -0
  119. package/src/services/sync/repositories/content-likes.ts +0 -0
  120. package/src/services/sync/repositories/content-progress.ts +47 -25
  121. package/src/services/sync/repositories/index.ts +1 -0
  122. package/src/services/sync/repositories/practice-day-notes.ts +0 -0
  123. package/src/services/sync/repositories/practices.ts +16 -1
  124. package/src/services/sync/repositories/user-award-progress.ts +133 -0
  125. package/src/services/sync/repository-proxy.ts +6 -0
  126. package/src/services/sync/resolver.ts +0 -0
  127. package/src/services/sync/retry.ts +12 -11
  128. package/src/services/sync/run-scope.ts +0 -0
  129. package/src/services/sync/schema/index.ts +18 -3
  130. package/src/services/sync/serializers/index.ts +0 -0
  131. package/src/services/sync/serializers/model.ts +0 -0
  132. package/src/services/sync/serializers/raw.ts +0 -0
  133. package/src/services/sync/store/index.ts +53 -8
  134. package/src/services/sync/store/push-coalescer.ts +3 -3
  135. package/src/services/sync/store-configs.ts +7 -1
  136. package/src/services/sync/strategies/base.ts +0 -0
  137. package/src/services/sync/strategies/index.ts +0 -0
  138. package/src/services/sync/strategies/initial.ts +0 -0
  139. package/src/services/sync/strategies/polling.ts +0 -0
  140. package/src/services/sync/telemetry/index.ts +0 -0
  141. package/src/services/sync/telemetry/sampling.ts +0 -0
  142. package/src/services/sync/utils/event-emitter.ts +0 -0
  143. package/src/services/sync/utils/index.ts +0 -0
  144. package/src/services/sync/utils/throttle.ts +0 -0
  145. package/src/services/sync/utils/timers.ts +0 -0
  146. package/src/services/types.js +0 -0
  147. package/src/services/user/account.ts +0 -0
  148. package/src/services/user/chat.js +0 -0
  149. package/src/services/user/interests.js +0 -0
  150. package/src/services/user/management.js +0 -0
  151. package/src/services/user/memberships.ts +0 -0
  152. package/src/services/user/notifications.js +0 -0
  153. package/src/services/user/onboarding.ts +0 -0
  154. package/src/services/user/payments.ts +0 -0
  155. package/src/services/user/permissions.js +0 -0
  156. package/src/services/user/profile.js +0 -0
  157. package/src/services/user/sessions.js +0 -0
  158. package/src/services/user/types.d.ts +0 -0
  159. package/src/services/user/types.js +0 -0
  160. package/src/services/user/user-management-system.js +0 -0
  161. package/src/services/userActivity.js +0 -1
  162. package/test/HttpClient.test.js +6 -6
  163. package/test/awards/award-alacarte-observer.test.js +196 -0
  164. package/test/awards/award-auto-refresh.test.js +83 -0
  165. package/test/awards/award-calculations.test.js +33 -0
  166. package/test/awards/award-certificate-display.test.js +328 -0
  167. package/test/awards/award-collection-edge-cases.test.js +210 -0
  168. package/test/awards/award-collection-filtering.test.js +285 -0
  169. package/test/awards/award-completion-flow.test.js +213 -0
  170. package/test/awards/award-exclusion-handling.test.js +273 -0
  171. package/test/awards/award-multi-lesson.test.js +241 -0
  172. package/test/awards/award-observer-integration.test.js +325 -0
  173. package/test/awards/award-query-messages.test.js +438 -0
  174. package/test/awards/award-user-collection.test.js +412 -0
  175. package/test/awards/duplicate-prevention.test.js +118 -0
  176. package/test/awards/helpers/completion-mock.js +54 -0
  177. package/test/awards/helpers/index.js +3 -0
  178. package/test/awards/helpers/mock-setup.js +69 -0
  179. package/test/awards/helpers/progress-emitter.js +39 -0
  180. package/test/awards/message-generator.test.js +162 -0
  181. package/test/content.test.js +0 -0
  182. package/test/contentLikes.test.js +0 -0
  183. package/test/contentProgress.test.js +0 -0
  184. package/test/dataContext.test.js +0 -0
  185. package/test/forum.test.js +0 -0
  186. package/test/imageSRCBuilder.test.js +0 -0
  187. package/test/imageSRCVerify.test.js +0 -0
  188. package/test/initializeTests.js +6 -0
  189. package/test/learningPaths.test.js +0 -0
  190. package/test/lib/lastUpdated.test.js +0 -0
  191. package/test/live/contentProgressLive.test.js +0 -0
  192. package/test/live/railcontentLive.test.js +0 -0
  193. package/test/localStorageMock.js +0 -0
  194. package/test/log.js +0 -0
  195. package/test/mockData/award-definitions.js +171 -0
  196. package/test/mockData/mockData_fetchByRailContentIds_one_content.json +0 -0
  197. package/test/mockData/mockData_progress_content.json +0 -0
  198. package/test/mockData/mockData_sanity_progress_content.json +0 -0
  199. package/test/mockData/mockData_user_practices.json +0 -0
  200. package/test/notifications.test.js +0 -0
  201. package/test/progressRows.test.js +0 -0
  202. package/test/sanityQueryService.test.js +0 -0
  203. package/test/streakMessage.test.js +0 -0
  204. package/test/sync/models/award-database-integration.test.js +519 -0
  205. package/test/user/permissions.test.js +0 -0
  206. package/test/userActivity.test.js +0 -0
  207. package/tools/generate-index.cjs +9 -0
  208. package/.claude/settings.local.json +0 -14
  209. package/.yarnrc.yml +0 -1
  210. package/test/reporting.test.js +0 -132
@@ -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
+ })