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,325 @@
1
+ import { contentProgressObserver } from '../../src/services/awards/internal/content-progress-observer'
2
+ import { awardEvents } from '../../src/services/awards/internal/award-events'
3
+ import { emitProgressSaved } from '../../src/services/progress-events'
4
+ import { mockAwardDefinitions, getAwardByContentId } from '../mockData/award-definitions'
5
+ import { setupDefaultMocks, setupAwardEventListeners } from './helpers'
6
+ import { mockCompletionStates } from './helpers/completion-mock'
7
+ import { COLLECTION_TYPE, waitForDebounce } from './helpers/progress-emitter'
8
+
9
+ jest.mock('../../src/services/sanity', () => ({
10
+ default: { fetch: jest.fn() },
11
+ fetchSanity: jest.fn()
12
+ }))
13
+
14
+ jest.mock('../../src/services/sync/repository-proxy', () => {
15
+ const mockFns = {
16
+ contentProgress: {
17
+ getOneProgressByContentId: jest.fn(),
18
+ getSomeProgressByContentIds: jest.fn(),
19
+ queryOne: jest.fn(),
20
+ queryAll: jest.fn()
21
+ },
22
+ practices: {
23
+ sumPracticeMinutesForContent: jest.fn()
24
+ },
25
+ userAwardProgress: {
26
+ hasCompletedAward: jest.fn(),
27
+ recordAwardProgress: jest.fn(),
28
+ getByAwardId: jest.fn()
29
+ }
30
+ }
31
+ return { default: mockFns, ...mockFns }
32
+ })
33
+
34
+ import sanityClient, { fetchSanity } from '../../src/services/sanity'
35
+ import db from '../../src/services/sync/repository-proxy'
36
+ import { awardDefinitions } from '../../src/services/awards/internal/award-definitions'
37
+
38
+ describe('Award Observer Integration - E2E Scenarios', () => {
39
+ let listeners
40
+
41
+ const emitProgressWithCollection = (contentId, collectionType, collectionId, progressPercent = 100) => {
42
+ emitProgressSaved({
43
+ userId: 123,
44
+ contentId,
45
+ progressPercent,
46
+ progressStatus: progressPercent === 100 ? 'completed' : 'started',
47
+ bubble: true,
48
+ collectionType,
49
+ collectionId,
50
+ resumeTimeSeconds: null,
51
+ timestamp: Date.now()
52
+ })
53
+ }
54
+
55
+ const emitAlaCarteProgress = (contentId, progressPercent = 100) => {
56
+ emitProgressWithCollection(contentId, null, null, progressPercent)
57
+ }
58
+
59
+ beforeEach(async () => {
60
+ jest.clearAllMocks()
61
+ awardEvents.removeAllListeners()
62
+
63
+ sanityClient.fetch = jest.fn().mockResolvedValue(mockAwardDefinitions)
64
+ setupDefaultMocks(db, fetchSanity)
65
+
66
+ await awardDefinitions.refresh()
67
+
68
+ listeners = setupAwardEventListeners(awardEvents)
69
+
70
+ await contentProgressObserver.start()
71
+ })
72
+
73
+ afterEach(() => {
74
+ awardEvents.removeAllListeners()
75
+ awardDefinitions.clear()
76
+ contentProgressObserver.stop()
77
+ })
78
+
79
+ describe('Scenario: Observer setup and initialization', () => {
80
+ test('starts observing progress events', async () => {
81
+ expect(contentProgressObserver.isObserving).toBe(true)
82
+ })
83
+
84
+ test('loads award definitions on start', async () => {
85
+ expect(fetchSanity).toHaveBeenCalled()
86
+ expect(contentProgressObserver.allChildIds.size).toBeGreaterThan(0)
87
+ })
88
+
89
+ test('includes content_id in allChildIds Set', async () => {
90
+ const lpAward = getAwardByContentId(417140)
91
+ const gcAward = getAwardByContentId(416446)
92
+
93
+ expect(contentProgressObserver.allChildIds.has(lpAward.content_id)).toBe(true)
94
+ expect(contentProgressObserver.allChildIds.has(gcAward.content_id)).toBe(true)
95
+ })
96
+
97
+ test('includes all child_ids in allChildIds Set', async () => {
98
+ const award = getAwardByContentId(416446)
99
+
100
+ award.child_ids.forEach(childId => {
101
+ expect(contentProgressObserver.allChildIds.has(childId)).toBe(true)
102
+ })
103
+ })
104
+
105
+ test('returns cleanup function', async () => {
106
+ const cleanup = await contentProgressObserver.start()
107
+ expect(typeof cleanup).toBe('function')
108
+ })
109
+
110
+ test('does not start twice', async () => {
111
+ const firstCallCount = fetchSanity.mock.calls.length
112
+ await contentProgressObserver.start()
113
+ expect(fetchSanity.mock.calls.length).toBe(firstCallCount)
114
+ })
115
+ })
116
+
117
+ describe('Scenario: Progress update triggers award progress event', () => {
118
+ const testAward = getAwardByContentId(417049)
119
+
120
+ beforeEach(() => {
121
+ mockCompletionStates(db, [417045])
122
+ })
123
+
124
+ test('emits awardProgress event when lesson completed', async () => {
125
+ emitAlaCarteProgress(417045)
126
+ await waitForDebounce()
127
+
128
+ expect(listeners.progress).toHaveBeenCalled()
129
+ const payload = listeners.progress.mock.calls[0][0]
130
+ expect(payload).toHaveProperty('awardId', testAward._id)
131
+ expect(payload).toHaveProperty('progressPercentage')
132
+ })
133
+
134
+ test('calculates correct progress percentage', async () => {
135
+ mockCompletionStates(db, [417045, 417046])
136
+
137
+ emitAlaCarteProgress(417045)
138
+ await waitForDebounce()
139
+
140
+ expect(db.userAwardProgress.recordAwardProgress).toHaveBeenCalledWith(
141
+ testAward._id,
142
+ 50,
143
+ expect.objectContaining({
144
+ progressData: expect.any(Object)
145
+ })
146
+ )
147
+ })
148
+ })
149
+
150
+ describe('Scenario: Progress update triggers award granted event', () => {
151
+ const testAward = getAwardByContentId(417049)
152
+
153
+ test('emits awardGranted event when all lessons completed', async () => {
154
+ emitAlaCarteProgress(417045)
155
+ await waitForDebounce()
156
+
157
+ expect(listeners.granted).toHaveBeenCalledTimes(1)
158
+ const payload = listeners.granted.mock.calls[0][0]
159
+ expect(payload).toHaveProperty('awardId', testAward._id)
160
+ expect(payload).toHaveProperty('completionData')
161
+ expect(payload).toHaveProperty('popupMessage')
162
+ })
163
+
164
+ test('includes completion data in granted event', async () => {
165
+ emitAlaCarteProgress(417045)
166
+ await waitForDebounce()
167
+
168
+ const payload = listeners.granted.mock.calls[0][0]
169
+ expect(payload.completionData).toMatchObject({
170
+ content_title: expect.any(String),
171
+ completed_at: expect.any(String),
172
+ days_user_practiced: expect.any(Number),
173
+ practice_minutes: 200
174
+ })
175
+ })
176
+ })
177
+
178
+ describe('Scenario: Multiple lessons update rapidly (debouncing)', () => {
179
+ const testAward = getAwardByContentId(416464)
180
+ const nonKickoffLessons = [
181
+ 416467, 416468, 416469, 416470, 416471, 416472, 416473,
182
+ 416474, 416475, 416476, 416477, 416478, 416479, 416480, 416481,
183
+ 416482, 416483, 416484, 416485, 416486, 416487, 416488, 416489
184
+ ]
185
+
186
+ beforeEach(() => {
187
+ mockCompletionStates(db, nonKickoffLessons.slice(0, 12))
188
+ })
189
+
190
+ test('debounces multiple rapid updates to same course', async () => {
191
+ emitAlaCarteProgress(416467)
192
+ emitAlaCarteProgress(416468)
193
+ emitAlaCarteProgress(416469)
194
+
195
+ await waitForDebounce()
196
+
197
+ expect(db.userAwardProgress.recordAwardProgress).toHaveBeenCalledTimes(1)
198
+ })
199
+ })
200
+
201
+ describe('Scenario: Progress for lessons without awards', () => {
202
+ test('ignores lessons not associated with awards', async () => {
203
+ emitAlaCarteProgress(999999)
204
+ await waitForDebounce()
205
+
206
+ expect(listeners.progress).not.toHaveBeenCalled()
207
+ expect(listeners.granted).not.toHaveBeenCalled()
208
+ })
209
+ })
210
+
211
+ describe('Scenario: Non-completed progress status', () => {
212
+ beforeEach(() => {
213
+ mockCompletionStates(db, [])
214
+ })
215
+
216
+ test('does not trigger award granted when lessons not completed in DB', async () => {
217
+ emitAlaCarteProgress(417045, 50)
218
+ await waitForDebounce()
219
+
220
+ expect(listeners.granted).not.toHaveBeenCalled()
221
+ })
222
+
223
+ test('started progress state in DB does not grant award', async () => {
224
+ emitProgressSaved({
225
+ userId: 123,
226
+ contentId: 417045,
227
+ progressPercent: 75,
228
+ progressStatus: 'started',
229
+ bubble: true,
230
+ collectionType: null,
231
+ collectionId: null,
232
+ resumeTimeSeconds: null,
233
+ timestamp: Date.now()
234
+ })
235
+ await waitForDebounce()
236
+
237
+ expect(listeners.granted).not.toHaveBeenCalled()
238
+ })
239
+ })
240
+
241
+ describe('Scenario: Excluded content progress', () => {
242
+ test('excluded content (not in child_ids) does not trigger award progress', async () => {
243
+ emitAlaCarteProgress(416447)
244
+ await waitForDebounce()
245
+
246
+ expect(db.userAwardProgress.recordAwardProgress).not.toHaveBeenCalled()
247
+ })
248
+ })
249
+
250
+ describe('Scenario: Already completed award', () => {
251
+ beforeEach(() => {
252
+ db.userAwardProgress.hasCompletedAward.mockResolvedValue(true)
253
+ })
254
+
255
+ test('does not re-grant already completed award', async () => {
256
+ emitAlaCarteProgress(417045)
257
+ await waitForDebounce()
258
+
259
+ expect(listeners.granted).not.toHaveBeenCalled()
260
+ expect(db.userAwardProgress.recordAwardProgress).not.toHaveBeenCalledWith(
261
+ expect.anything(),
262
+ 100,
263
+ expect.objectContaining({ immediate: true })
264
+ )
265
+ })
266
+ })
267
+
268
+ describe('Scenario: Observer cleanup', () => {
269
+ test('removes event listeners on stop', () => {
270
+ contentProgressObserver.stop()
271
+ expect(contentProgressObserver.isObserving).toBe(false)
272
+ })
273
+
274
+ test('clears debounce timers on stop', async () => {
275
+ emitAlaCarteProgress(417045)
276
+ contentProgressObserver.stop()
277
+
278
+ expect(contentProgressObserver.debounceTimers.size).toBe(0)
279
+ })
280
+
281
+ test('clears allChildIds on stop', async () => {
282
+ contentProgressObserver.stop()
283
+
284
+ expect(contentProgressObserver.allChildIds.size).toBe(0)
285
+ })
286
+ })
287
+
288
+ describe('Scenario: Learning path award events', () => {
289
+ const learningPathAward = getAwardByContentId(417140)
290
+
291
+ test('learning path award has correct popup message', async () => {
292
+ emitProgressWithCollection(417105, COLLECTION_TYPE.LEARNING_PATH, 417140)
293
+ await waitForDebounce()
294
+
295
+ const payload = listeners.granted.mock.calls[0][0]
296
+ expect(payload.popupMessage).toContain('Learn To Play The Drums')
297
+ expect(payload.popupMessage).toContain('minutes')
298
+ expect(payload.popupMessage).toContain('days')
299
+ })
300
+ })
301
+
302
+ describe('Scenario: Multiple courses progressing simultaneously', () => {
303
+ const testAward1 = getAwardByContentId(416446)
304
+ const testAward2 = getAwardByContentId(417049)
305
+
306
+ beforeEach(() => {
307
+ mockCompletionStates(db, [])
308
+ })
309
+
310
+ test('tracks progress for multiple courses independently', async () => {
311
+ emitAlaCarteProgress(416448)
312
+ await waitForDebounce()
313
+
314
+ emitAlaCarteProgress(417045)
315
+ await waitForDebounce()
316
+
317
+ expect(listeners.progress).toHaveBeenCalledTimes(2)
318
+
319
+ const calls = listeners.progress.mock.calls
320
+ const awardIds = calls.map(call => call[0].awardId)
321
+ expect(awardIds).toContain(testAward1._id)
322
+ expect(awardIds).toContain(testAward2._id)
323
+ })
324
+ })
325
+ })