musora-content-services 2.167.1 → 2.167.3

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 (136) hide show
  1. package/.agent/decisions/2026-07-17-onboarding-statuses.md +21 -0
  2. package/.agent/decisions/2026-07-20-mu2-1724-songs-lessons-counts.md +33 -0
  3. package/.agent/decisions/2026-07-20-query-builder-tostring.md +37 -0
  4. package/CHANGELOG.md +15 -0
  5. package/package.json +2 -1
  6. package/src/contentTypeConfig.js +2 -0
  7. package/src/index.d.ts +2 -0
  8. package/src/index.js +2 -0
  9. package/src/lib/sanity/query.ts +5 -0
  10. package/src/services/config.js +15 -0
  11. package/src/services/content/artist.ts +1 -3
  12. package/src/services/content/counts.ts +47 -0
  13. package/src/services/content/genre.ts +1 -3
  14. package/src/services/content/instructor.ts +1 -3
  15. package/src/services/contentProgress.js +1 -1
  16. package/src/services/eventsAPI.js +49 -7
  17. package/src/services/user/onboarding.ts +22 -0
  18. package/src/services/userActivity.js +2 -0
  19. package/src/version-info.js +1 -0
  20. package/.claude/settings.local.json +0 -52
  21. package/test/initializeTests.js +0 -60
  22. package/test/integration/awards/award-exclusion-handling.test.ts +0 -275
  23. package/test/integration/awards/internal/image-utils.test.ts +0 -86
  24. package/test/integration/content-progress/__mocks__/mocks.ts +0 -104
  25. package/test/integration/content-progress/contentProgress.test.ts +0 -335
  26. package/test/integration/content-progress/e2eOfflineProgress.test.ts +0 -352
  27. package/test/integration/content-progress/e2eProgress.test.ts +0 -612
  28. package/test/integration/content-progress/getters.test.ts +0 -334
  29. package/test/integration/content-progress/helpers.test.ts +0 -263
  30. package/test/integration/content-progress/offlineContentProgress.test.ts +0 -226
  31. package/test/integration/forums.test.ts +0 -209
  32. package/test/integration/infrastructure/FetchRequestExecutor.test.ts +0 -88
  33. package/test/integration/initializeTestDB.ts +0 -80
  34. package/test/integration/learning-paths.test.ts +0 -854
  35. package/test/integration/notifications.test.ts +0 -178
  36. package/test/integration/progress-row/content-card.test.ts +0 -283
  37. package/test/integration/sync/adapters/idb-errors.test.ts +0 -144
  38. package/test/integration/sync/adapters/sqlite-errors.test.ts +0 -173
  39. package/test/integration/sync/fetch.test.ts +0 -224
  40. package/test/integration/sync/repositories/content-likes.test.ts +0 -99
  41. package/test/integration/sync/repositories/practices.test.ts +0 -179
  42. package/test/integration/sync/repositories/progress.test.ts +0 -245
  43. package/test/integration/sync/repositories/user-award-progress.static.test.ts +0 -68
  44. package/test/integration/sync/repositories/user-award-progress.test.ts +0 -387
  45. package/test/integration/sync/store/cross-user-protection.test.ts +0 -217
  46. package/test/integration/sync/store/store-idb.test.ts +0 -180
  47. package/test/integration/sync/store/store.test.ts +0 -361
  48. package/test/integration/user/permissions.test.js +0 -20
  49. package/test/integration/userActivity.test.ts +0 -102
  50. package/test/live/README.md +0 -29
  51. package/test/live/content.test.js +0 -116
  52. package/test/localStorageMock.js +0 -21
  53. package/test/log.js +0 -5
  54. package/test/mockData/award-definitions.js +0 -171
  55. package/test/mockData/mockData_fetchByRailContentIds_one_content.json +0 -35
  56. package/test/mockData/mockData_progress_content.json +0 -182
  57. package/test/mockData/mockData_sanity_progress_content.json +0 -1451
  58. package/test/mockData/mockData_user_practices.json +0 -9
  59. package/test/setupConsole.js +0 -6
  60. package/test/setupNetworkGuard.js +0 -3
  61. package/test/setupTimers.js +0 -13
  62. package/test/unit/HttpClient.test.ts +0 -267
  63. package/test/unit/awards/award-alacarte-observer.test.ts +0 -199
  64. package/test/unit/awards/award-auto-refresh.test.ts +0 -84
  65. package/test/unit/awards/award-calculations.test.ts +0 -34
  66. package/test/unit/awards/award-callbacks.test.ts +0 -144
  67. package/test/unit/awards/award-certificate-display.test.ts +0 -330
  68. package/test/unit/awards/award-collection-edge-cases.test.ts +0 -212
  69. package/test/unit/awards/award-collection-filtering.test.ts +0 -287
  70. package/test/unit/awards/award-completion-flow.test.ts +0 -306
  71. package/test/unit/awards/award-multi-lesson.test.ts +0 -243
  72. package/test/unit/awards/award-observer-integration.test.ts +0 -327
  73. package/test/unit/awards/award-query-messages.test.ts +0 -448
  74. package/test/unit/awards/award-user-collection.test.ts +0 -416
  75. package/test/unit/awards/duplicate-prevention.test.ts +0 -120
  76. package/test/unit/awards/helpers/completion-mock.ts +0 -56
  77. package/test/unit/awards/helpers/index.ts +0 -3
  78. package/test/unit/awards/helpers/mock-setup.ts +0 -79
  79. package/test/unit/awards/helpers/progress-emitter.ts +0 -45
  80. package/test/unit/awards/message-generator.test.ts +0 -162
  81. package/test/unit/content-progress/bubbleTrickle.test.ts +0 -322
  82. package/test/unit/content-progress/helpers.test.ts +0 -329
  83. package/test/unit/content-progress/navigateTo.test.ts +0 -381
  84. package/test/unit/contentLikes.test.ts +0 -62
  85. package/test/unit/contentMetaData.test.ts +0 -58
  86. package/test/unit/dataContext.test.ts +0 -38
  87. package/test/unit/dateUtils.test.ts +0 -188
  88. package/test/unit/endScreen.test.js +0 -712
  89. package/test/unit/imageSRCBuilder.test.ts +0 -37
  90. package/test/unit/imageSRCVerify.test.ts +0 -155
  91. package/test/unit/infrastructure/DefaultHeaderProvider.test.ts +0 -39
  92. package/test/unit/infrastructure/sanity/ContentClient.test.ts +0 -168
  93. package/test/unit/infrastructure/sanity/DefaultConfigProvider.test.ts +0 -93
  94. package/test/unit/infrastructure/sanity/FetchQueryExecutor.test.ts +0 -174
  95. package/test/unit/infrastructure/sanity/SanityClient.test.ts +0 -140
  96. package/test/unit/lib/__snapshots__/filter.test.ts.snap +0 -5
  97. package/test/unit/lib/filter.test.ts +0 -1154
  98. package/test/unit/lib/lastUpdated.test.ts +0 -21
  99. package/test/unit/lib/query.test.ts +0 -1469
  100. package/test/unit/lib/sanity/decorators/base.test.ts +0 -368
  101. package/test/unit/lib/sanity/decorators/navigate-to.test.ts +0 -361
  102. package/test/unit/lib/sanity/decorators/need-access.test.ts +0 -89
  103. package/test/unit/lib/sanity/decorators/need-lifetime-upgrade.test.ts +0 -85
  104. package/test/unit/lib/sanity/decorators/page-type.test.ts +0 -81
  105. package/test/unit/playerSettings.test.ts +0 -73
  106. package/test/unit/progress-row/content-card.test.ts +0 -314
  107. package/test/unit/progress-row/playlist-card.test.ts +0 -104
  108. package/test/unit/progressRows.test.ts +0 -243
  109. package/test/unit/recommendations.test.js +0 -47
  110. package/test/unit/sanityQueryService.test.ts +0 -200
  111. package/test/unit/search.test.ts +0 -44
  112. package/test/unit/sentry.test.ts +0 -62
  113. package/test/unit/services/progress-internal/bubble.test.ts +0 -279
  114. package/test/unit/services/progress.test.ts +0 -396
  115. package/test/unit/streakMessage.test.ts +0 -251
  116. package/test/unit/sync/context.test.ts +0 -51
  117. package/test/unit/sync/effects/logout-warning.test.ts +0 -158
  118. package/test/unit/sync/effects/push-failure-notification.test.ts +0 -196
  119. package/test/unit/sync/errors/sync-errors.test.ts +0 -106
  120. package/test/unit/sync/errors/validators.test.ts +0 -61
  121. package/test/unit/sync/helpers/TestModel.ts +0 -44
  122. package/test/unit/sync/helpers/index.ts +0 -172
  123. package/test/unit/sync/manager.test.ts +0 -303
  124. package/test/unit/sync/models/user-award-progress.test.ts +0 -82
  125. package/test/unit/sync/resolver.test.ts +0 -229
  126. package/test/unit/sync/retry.test.ts +0 -314
  127. package/test/unit/sync/run-scope.test.ts +0 -23
  128. package/test/unit/sync/stale-record-cleanup.test.ts +0 -107
  129. package/test/unit/sync/store/push-coalescer.test.ts +0 -156
  130. package/test/unit/sync/store-configs.test.ts +0 -37
  131. package/test/unit/sync/telemetry/sync-telemetry.test.ts +0 -118
  132. package/test/unit/sync/utils/event-emitter.test.ts +0 -64
  133. package/test/unit/sync/utils/throttle.test.ts +0 -245
  134. package/test/unit/url-builder.test.ts +0 -72
  135. package/test/unit/user/account.test.ts +0 -66
  136. package/test/unit/user/permissions.test.js +0 -191
@@ -1,144 +0,0 @@
1
- import type { AwardCallbackPayload, ProgressCallbackPayload, UnregisterFunction } from '../../../src/services/awards/types.d.ts'
2
- import { registerAwardCallback, registerProgressCallback } from '../../../src/services/awards/award-callbacks.js'
3
- import { awardEvents } from '../../../src/services/awards/internal/award-events.js'
4
- jest.mock('../../../src/services/awards/award-query.js', () => ({
5
- ...jest.requireActual('../../../src/services/awards/award-query.js'),
6
- getBadgeFields: jest.fn().mockReturnValue({
7
- badge: 'https://cdn.example.com/badge.png',
8
- badge_rear: 'https://cdn.example.com/badge_rear.png',
9
- badge_logo: 'https://cdn.example.com/logo.png',
10
- badge_template: 'template_front',
11
- badge_template_rear: 'template_rear',
12
- badge_template_unearned: 'template_unearned',
13
- })
14
- }))
15
- interface AwardGrantedEmitPayload {
16
- awardId: string
17
- definition: {
18
- name: string
19
- brand: string
20
- content_type: string
21
- type: string
22
- is_active: boolean
23
- }
24
- completionData: {
25
- completed_at: string
26
- days_user_practiced: number
27
- practice_minutes: number
28
- content_title: string
29
- }
30
- popupMessage: string
31
- }
32
- const mockPayload: AwardGrantedEmitPayload = {
33
- awardId: 'award-123',
34
- definition: {
35
- name: 'Test Award',
36
- brand: 'drumeo',
37
- content_type: 'guided-course',
38
- type: 'content-award',
39
- is_active: true,
40
- },
41
- completionData: {
42
- completed_at: '2024-01-01T00:00:00Z',
43
- days_user_practiced: 14,
44
- practice_minutes: 180,
45
- content_title: 'Blues Foundations',
46
- },
47
- popupMessage: 'Congratulations!'
48
- }
49
- describe('registerAwardCallback', () => {
50
- let unregister: UnregisterFunction
51
-
52
- afterEach(() => {
53
- unregister?.()
54
- awardEvents.removeAllListeners()
55
- })
56
-
57
- test('throws if callback is not a function', () => {
58
- expect(() => registerAwardCallback('not a function' as any)).toThrow(
59
- 'registerAwardCallback requires a function'
60
- )
61
- })
62
-
63
- test('returns an unregister function', () => {
64
- unregister = registerAwardCallback(jest.fn())
65
- expect(typeof unregister).toBe('function')
66
- })
67
-
68
- test('callback is invoked with correctly shaped award object when awardGranted fires', async () => {
69
- const callback = jest.fn() as jest.MockedFunction<(award: AwardCallbackPayload) => void>
70
- unregister = registerAwardCallback(callback)
71
- awardEvents.emitAwardGranted(mockPayload)
72
- await new Promise(resolve => setTimeout(resolve, 0))
73
- expect(callback).toHaveBeenCalledWith(expect.objectContaining({
74
- awardId: 'award-123',
75
- name: 'Test Award',
76
- brand: 'drumeo',
77
- contentType: 'guided-course',
78
- hasCertificate: true,
79
- isCompleted: true,
80
- completedAt: '2024-01-01T00:00:00Z',
81
- completionData: expect.objectContaining({
82
- content_title: 'Blues Foundations',
83
- days_user_practiced: 14,
84
- practice_minutes: 180,
85
- message: 'Congratulations!',
86
- })
87
- }))
88
- })
89
-
90
- test('callback is not invoked after unregister is called', async () => {
91
- const callback = jest.fn()
92
- unregister = registerAwardCallback(callback)
93
- unregister()
94
- awardEvents.emitAwardGranted(mockPayload)
95
- await new Promise(resolve => setTimeout(resolve, 0))
96
- expect(callback).not.toHaveBeenCalled()
97
- })
98
-
99
- test('registering a second callback replaces the first', async () => {
100
- const firstCallback = jest.fn()
101
- const secondCallback = jest.fn()
102
- registerAwardCallback(firstCallback)
103
- unregister = registerAwardCallback(secondCallback)
104
- awardEvents.emitAwardGranted(mockPayload)
105
- await new Promise(resolve => setTimeout(resolve, 0))
106
- expect(firstCallback).not.toHaveBeenCalled()
107
- expect(secondCallback).toHaveBeenCalled()
108
- })
109
-
110
- test('throws if callback is not a function', () => {
111
- expect(() => registerProgressCallback('not a function' as any)).toThrow(
112
- 'registerProgressCallback requires a function'
113
- )
114
- })
115
-
116
- test('returns an unregister function', () => {
117
- unregister = registerProgressCallback(jest.fn())
118
- expect(typeof unregister).toBe('function')
119
- })
120
-
121
- test('callback is invoked with awardId and progressPercentage when awardProgress fires', () => {
122
- const callback = jest.fn() as jest.MockedFunction<(progress: ProgressCallbackPayload) => void>
123
- unregister = registerProgressCallback(callback)
124
- awardEvents.emitAwardProgress({ awardId: 'award-123', progressPercentage: 50 })
125
- expect(callback).toHaveBeenCalledWith({
126
- awardId: 'award-123',
127
- progressPercentage: 50,
128
- })
129
- })
130
- })
131
-
132
- describe('registerProgressCallback', () => {
133
- let unregister: UnregisterFunction
134
-
135
- afterEach(() => {
136
- unregister?.()
137
- awardEvents.removeAllListeners()
138
- })
139
-
140
- test.todo('throws if callback is not a function')
141
- test.todo('returns an unregister function')
142
- test.todo('callback is invoked with awardId and progressPercentage when awardProgress fires')
143
- test.todo('callback is not invoked after unregister is called')
144
- })
@@ -1,330 +0,0 @@
1
- import { buildCertificateData } from '../../../src/services/awards/internal/certificate-builder.js'
2
- import { mockAwardDefinitions, getAwardById } from '../../mockData/award-definitions.js'
3
- import { globalConfig } from '../../../src/services/config.js'
4
-
5
- jest.mock('../../../src/services/sanity.js', () => ({
6
- ...jest.requireActual('../../../src/services/sanity'),
7
- default: {
8
- fetch: jest.fn()
9
- },
10
- fetchSanity: jest.fn()
11
- }))
12
-
13
- jest.mock('../../../src/services/user/management.js', () => ({
14
- getUserData: jest.fn()
15
- }))
16
-
17
- jest.mock('../../../src/services/sync/repository-proxy.ts', () => {
18
- const mockFns = {
19
- userAwardProgress: {
20
- getByAwardId: jest.fn()
21
- }
22
- }
23
- return { default: mockFns, ...mockFns }
24
- })
25
-
26
- import sanityClient, { fetchSanity } from '../../../src/services/sanity.js'
27
- import { getUserData } from '../../../src/services/user/management.js'
28
- import db_ from '../../../src/services/sync/repository-proxy'
29
- const db = db_ as any
30
- import { awardDefinitions } from '../../../src/services/awards/internal/award-definitions.js'
31
-
32
- describe('Award Certificate Display - E2E Scenarios', () => {
33
- const testAward = getAwardById('0238b1e5-ebee-42b3-9390-91467d113575')
34
- const awardId = testAward._id
35
-
36
- beforeEach(async () => {
37
- jest.clearAllMocks()
38
-
39
- globalConfig.sessionConfig = {
40
- userId: 12345,
41
- token: 'test-token'
42
- }
43
-
44
- sanityClient.fetch = jest.fn().mockResolvedValue(mockAwardDefinitions)
45
- fetchSanity.mockResolvedValue(mockAwardDefinitions)
46
-
47
- db.userAwardProgress = {
48
- getByAwardId: jest.fn()
49
- }
50
-
51
- getUserData.mockResolvedValue({
52
- id: 12345,
53
- display_name: 'John Doe',
54
- name: 'John Doe',
55
- email: 'john@example.com'
56
- })
57
-
58
- await awardDefinitions.refresh()
59
- })
60
-
61
- afterEach(() => {
62
- awardDefinitions.clear()
63
- })
64
-
65
- describe('Scenario: Display complete certificate for earned award', () => {
66
- beforeEach(() => {
67
- db.userAwardProgress.getByAwardId.mockResolvedValue({
68
- data: {
69
- award_id: awardId,
70
- progress_percentage: 100,
71
- completed_at: Math.floor(Date.now() / 1000) - 86400 * 3,
72
- completion_data: {
73
- content_title: 'Adrian Guided Course Test',
74
- completed_at: new Date().toISOString(),
75
- days_user_practiced: 14,
76
- practice_minutes: 180
77
- },
78
- },
79
- })
80
- })
81
-
82
- test('returns complete certificate data with all fields', async () => {
83
- const certificate = await buildCertificateData(awardId)
84
-
85
- expect(certificate).toMatchObject({
86
- userId: 12345,
87
- userName: 'John Doe',
88
- completedAt: expect.any(Number),
89
- awardId: awardId,
90
- awardType: 'content-award',
91
- awardTitle: testAward.name,
92
- popupMessage: expect.any(String),
93
- certificateMessage: expect.any(String),
94
- ribbonImage: expect.any(String),
95
- awardImage: testAward.award,
96
- badgeImage: testAward.badge,
97
- brandLogo: expect.any(String),
98
- musoraLogo: expect.any(String),
99
- musoraBgLogo: expect.any(String),
100
- instructorName: testAward.instructor_name
101
- })
102
- })
103
-
104
- test('includes correct user information from globalConfig and getUserData', async () => {
105
- const certificate = await buildCertificateData(awardId)
106
-
107
- expect(certificate.userId).toBe(12345)
108
- expect(certificate.userName).toBe('John Doe')
109
- })
110
-
111
- test('includes correct award information from Sanity', async () => {
112
- const certificate = await buildCertificateData(awardId)
113
-
114
- expect(certificate.awardId).toBe(awardId)
115
- expect(certificate.awardTitle).toBe('Adrian Guided Course Test Award')
116
- expect(certificate.badgeImage).toBe(testAward.badge)
117
- expect(certificate.awardImage).toBe(testAward.award)
118
- expect(certificate.instructorName).toBe('Aaron Graham')
119
- })
120
-
121
- test('includes client-generated popup message', async () => {
122
- const certificate = await buildCertificateData(awardId)
123
-
124
- expect(certificate.popupMessage).toContain('Adrian Guided Course Test')
125
- expect(certificate.popupMessage).toContain('180 minutes')
126
- expect(certificate.popupMessage).toContain('14 days')
127
- })
128
-
129
- test('includes client-generated certificate message', async () => {
130
- const certificate = await buildCertificateData(awardId)
131
-
132
- expect(certificate.certificateMessage).toContain('180 minutes')
133
- expect(certificate.certificateMessage).toContain('Adrian Guided Course Test')
134
- expect(certificate.certificateMessage).toContain('Well Done!')
135
- })
136
-
137
- test('includes brand logo based on award brand', async () => {
138
- const certificate = await buildCertificateData(awardId)
139
-
140
- expect(certificate.brandLogo).toBeDefined()
141
- expect(typeof certificate.brandLogo).toBe('string')
142
- })
143
-
144
- test('includes completion date from WatermelonDB', async () => {
145
- const certificate = await buildCertificateData(awardId)
146
-
147
- expect(certificate.completedAt).toBeDefined()
148
- const completedDate = new Date(certificate.completedAt)
149
- expect(completedDate).toBeInstanceOf(Date)
150
- expect(completedDate.getTime()).toBeLessThanOrEqual(Date.now())
151
- })
152
- })
153
-
154
- describe('Scenario: Certificate with custom award text', () => {
155
- const awardWithCustomText = getAwardById('0f49cb6a-1b23-4628-968e-15df02ffad7f')
156
-
157
- beforeEach(() => {
158
- db.userAwardProgress.getByAwardId.mockResolvedValue({
159
- data: {
160
- award_id: awardWithCustomText._id,
161
- progress_percentage: 100,
162
- completed_at: Math.floor(Date.now() / 1000),
163
- completion_data: {
164
- content_title: 'Enrolling w/ Kickoff, has product GC (EC)',
165
- completed_at: new Date().toISOString(),
166
- days_user_practiced: 10,
167
- practice_minutes: 200
168
- },
169
- },
170
- })
171
- })
172
-
173
- test('includes custom text in certificate message', async () => {
174
- const certificate = await buildCertificateData(awardWithCustomText._id)
175
-
176
- expect(certificate.certificateMessage).toContain('Huzzah congratz')
177
- expect(certificate.certificateMessage).toContain('200 minutes')
178
- expect(certificate.certificateMessage).toContain('Enrolling w/ Kickoff, has product GC (EC)')
179
- })
180
- })
181
-
182
- describe('Scenario: Certificate with null instructor signature', () => {
183
- const awardWithoutSignature = getAwardById('0f49cb6a-1b23-4628-968e-15df02ffad7f')
184
-
185
- beforeEach(() => {
186
- db.userAwardProgress.getByAwardId.mockResolvedValue({
187
- data: {
188
- award_id: awardWithoutSignature._id,
189
- progress_percentage: 100,
190
- completed_at: Math.floor(Date.now() / 1000),
191
- completion_data: {
192
- content_title: 'Test Course',
193
- completed_at: new Date().toISOString(),
194
- days_user_practiced: 5,
195
- practice_minutes: 100
196
- },
197
- },
198
- })
199
- })
200
-
201
- test('handles null instructor signature gracefully', async () => {
202
- const certificate = await buildCertificateData(awardWithoutSignature._id)
203
-
204
- expect(certificate.instructorName).toBe('Lisa Witt')
205
- })
206
- })
207
-
208
- describe('Scenario: User with no display_name falls back to name', () => {
209
- beforeEach(() => {
210
- getUserData.mockResolvedValue({
211
- id: 12345,
212
- display_name: null,
213
- name: 'Jane Smith',
214
- email: 'jane@example.com'
215
- })
216
-
217
- db.userAwardProgress.getByAwardId.mockResolvedValue({
218
- data: {
219
- award_id: awardId,
220
- progress_percentage: 100,
221
- completed_at: Math.floor(Date.now() / 1000),
222
- completion_data: {
223
- content_title: 'Test Course',
224
- completed_at: new Date().toISOString(),
225
- days_user_practiced: 7,
226
- practice_minutes: 150
227
- },
228
- },
229
- })
230
- })
231
-
232
- test('uses name field when display_name is null', async () => {
233
- const certificate = await buildCertificateData(awardId)
234
-
235
- expect(certificate.userName).toBe('Jane Smith')
236
- })
237
- })
238
-
239
- describe('Scenario: User with neither display_name nor name', () => {
240
- beforeEach(() => {
241
- getUserData.mockResolvedValue({
242
- id: 12345,
243
- display_name: null,
244
- name: null,
245
- email: 'anonymous@example.com'
246
- })
247
-
248
- db.userAwardProgress.getByAwardId.mockResolvedValue({
249
- data: {
250
- award_id: awardId,
251
- progress_percentage: 100,
252
- completed_at: Math.floor(Date.now() / 1000),
253
- completion_data: {
254
- content_title: 'Test Course',
255
- completed_at: new Date().toISOString(),
256
- days_user_practiced: 3,
257
- practice_minutes: 90
258
- },
259
- },
260
- })
261
- })
262
-
263
- test('falls back to "User" when no name available', async () => {
264
- const certificate = await buildCertificateData(awardId)
265
-
266
- expect(certificate.userName).toBe('User')
267
- })
268
- })
269
-
270
- describe('Scenario: Error handling', () => {
271
- test('throws error when award definition not found', async () => {
272
- await expect(
273
- buildCertificateData('non-existent-award-id')
274
- ).rejects.toThrow('Award definition not found')
275
- })
276
-
277
- test('throws error when completion data not found in local DB', async () => {
278
- db.userAwardProgress.getByAwardId.mockResolvedValue({
279
- data: null
280
- })
281
-
282
- await expect(
283
- buildCertificateData(awardId)
284
- ).rejects.toThrow('Completion data not found')
285
- })
286
-
287
- test('throws error when completion_data is missing', async () => {
288
- db.userAwardProgress.getByAwardId.mockResolvedValue({
289
- data: {
290
- award_id: awardId,
291
- progress_percentage: 100,
292
- completed_at: Math.floor(Date.now() / 1000),
293
- completion_data: null
294
- },
295
- })
296
-
297
- await expect(
298
- buildCertificateData(awardId)
299
- ).rejects.toThrow('Completion data not found')
300
- })
301
- })
302
-
303
- describe('Scenario: Learning path vs guided course message differences', () => {
304
- const learningPathAward = getAwardById('361f3034-c6c9-45f7-bbfb-0d58dbe14411')
305
-
306
- beforeEach(() => {
307
- db.userAwardProgress.getByAwardId.mockResolvedValue({
308
- data: {
309
- award_id: learningPathAward._id,
310
- progress_percentage: 100,
311
- completed_at: Math.floor(Date.now() / 1000),
312
- completion_data: {
313
- content_title: 'Learn To Play The Drums',
314
- completed_at: new Date().toISOString(),
315
- days_user_practiced: 30,
316
- practice_minutes: 600
317
- },
318
- },
319
- })
320
- })
321
-
322
- test('generates learning path message for learning path award', async () => {
323
- const certificate = await buildCertificateData(learningPathAward._id)
324
-
325
- expect(certificate.popupMessage).toContain('Learn To Play The Drums')
326
- expect(certificate.popupMessage).toContain('600 minutes')
327
- expect(certificate.popupMessage).toContain('30 days')
328
- })
329
- })
330
- })
@@ -1,212 +0,0 @@
1
- import { contentProgressObserver } from '../../../src/services/awards/internal/content-progress-observer.js'
2
- import { awardEvents } from '../../../src/services/awards/internal/award-events.js'
3
- import { mockAwardDefinitions } from '../../mockData/award-definitions.js'
4
- import { setupDefaultMocks, setupAwardEventListeners } from './helpers/index'
5
- import { COLLECTION_TYPE, emitProgress, waitForDebounce } from './helpers/progress-emitter'
6
-
7
- jest.mock('../../../src/services/sanity.js', () => ({
8
- ...jest.requireActual('../../../src/services/sanity'),
9
- default: { fetch: jest.fn() },
10
- fetchSanity: jest.fn()
11
- }))
12
-
13
- jest.mock('../../../src/services/sync/repository-proxy.ts', () => {
14
- const mockFns = {
15
- contentProgress: {
16
- getOneProgressByContentId: jest.fn(),
17
- getSomeProgressByContentIds: jest.fn(),
18
- queryOne: jest.fn(),
19
- queryAll: jest.fn()
20
- },
21
- practices: {
22
- sumPracticeMinutesForContent: jest.fn()
23
- },
24
- userAwardProgress: {
25
- hasCompletedAward: jest.fn(),
26
- recordAwardProgress: jest.fn(),
27
- getByAwardId: jest.fn()
28
- }
29
- }
30
- return { default: mockFns, ...mockFns }
31
- })
32
-
33
- import sanityClient, { fetchSanity } from '../../../src/services/sanity.js'
34
- import db_ from '../../../src/services/sync/repository-proxy'
35
- const db = db_ as any
36
- import { awardDefinitions } from '../../../src/services/awards/internal/award-definitions.js'
37
-
38
- describe('Award Collection Filtering - Edge Cases', () => {
39
- let listeners
40
-
41
- beforeEach(async () => {
42
- jest.clearAllMocks()
43
- awardEvents.removeAllListeners()
44
-
45
- sanityClient.fetch = jest.fn().mockResolvedValue(mockAwardDefinitions)
46
- setupDefaultMocks(db, fetchSanity)
47
-
48
- await awardDefinitions.refresh()
49
-
50
- listeners = setupAwardEventListeners(awardEvents)
51
-
52
- await contentProgressObserver.start()
53
- })
54
-
55
- afterEach(() => {
56
- awardEvents.removeAllListeners()
57
- awardDefinitions.clear()
58
- contentProgressObserver.stop()
59
- })
60
-
61
- describe('Child in collection with no awards', () => {
62
- test('gracefully handles child with no matching awards', async () => {
63
- emitProgress({
64
- contentId: 999999,
65
- collectionType: 'skill-pack',
66
- collectionId: 999000
67
- })
68
- await waitForDebounce()
69
-
70
- expect(listeners.granted).not.toHaveBeenCalled()
71
- expect(listeners.progress).not.toHaveBeenCalled()
72
- })
73
- })
74
-
75
- describe('Collection type case sensitivity for learning paths', () => {
76
- test('wrong case LP collection type falls back to a la carte', async () => {
77
- emitProgress({
78
- contentId: 418004,
79
- collectionType: 'Learning-Path-V2',
80
- collectionId: 418010
81
- })
82
- await waitForDebounce()
83
-
84
- expect(listeners.granted).toHaveBeenCalled()
85
- })
86
-
87
- test('case mismatch in non-LP still triggers (a la carte)', async () => {
88
- emitProgress({
89
- contentId: 418001,
90
- collectionType: 'SKILL-PACK',
91
- collectionId: 418000
92
- })
93
- await waitForDebounce()
94
-
95
- expect(listeners.granted).toHaveBeenCalled()
96
- })
97
- })
98
-
99
- describe('Debouncing per collection', () => {
100
- test('rapid completion of children debounces per award content_id', async () => {
101
- emitProgress({
102
- contentId: 418001,
103
- collectionType: 'skill-pack',
104
- collectionId: 418000
105
- })
106
- emitProgress({
107
- contentId: 418002,
108
- collectionType: 'skill-pack',
109
- collectionId: 418000
110
- })
111
-
112
- await waitForDebounce()
113
-
114
- expect(db.userAwardProgress.recordAwardProgress).toHaveBeenCalledTimes(1)
115
- })
116
-
117
- test('rapid completion of children in different contexts processes separately', async () => {
118
- emitProgress({
119
- contentId: 418004,
120
- collectionType: COLLECTION_TYPE.LEARNING_PATH,
121
- collectionId: 418010
122
- })
123
- emitProgress({
124
- contentId: 416448
125
- })
126
-
127
- await waitForDebounce()
128
-
129
- expect(listeners.granted.mock.calls.length).toBeGreaterThanOrEqual(2)
130
- const awardIds = listeners.granted.mock.calls.map(call => call[0].awardId)
131
- expect(awardIds).toContain('b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e')
132
- expect(awardIds).toContain('0238b1e5-ebee-42b3-9390-91467d113575')
133
- })
134
- })
135
-
136
- describe('Observer state management', () => {
137
- test('stop clears debounce timers', async () => {
138
- emitProgress({
139
- contentId: 418001,
140
- collectionType: 'skill-pack',
141
- collectionId: 418000
142
- })
143
-
144
- contentProgressObserver.stop()
145
-
146
- expect(contentProgressObserver.debounceTimers.size).toBe(0)
147
- })
148
-
149
- test('stop clears processing content IDs', async () => {
150
- emitProgress({
151
- contentId: 418001,
152
- collectionType: 'skill-pack',
153
- collectionId: 418000
154
- })
155
-
156
- contentProgressObserver.stop()
157
-
158
- expect(contentProgressObserver.processingContentIds.size).toBe(0)
159
- })
160
-
161
- test('restart after stop works correctly', async () => {
162
- contentProgressObserver.stop()
163
- await contentProgressObserver.start()
164
-
165
- emitProgress({
166
- contentId: 418001,
167
- collectionType: 'skill-pack',
168
- collectionId: 418000
169
- })
170
- await waitForDebounce()
171
-
172
- expect(listeners.granted).toHaveBeenCalled()
173
- })
174
- })
175
-
176
- describe('Collection ID type handling', () => {
177
- test('collection ID as number matches correctly for LP', async () => {
178
- emitProgress({
179
- contentId: 418004,
180
- collectionType: COLLECTION_TYPE.LEARNING_PATH,
181
- collectionId: 418010
182
- })
183
- await waitForDebounce()
184
-
185
- expect(listeners.granted).toHaveBeenCalledWith(
186
- expect.objectContaining({ awardId: 'b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e' })
187
- )
188
- })
189
-
190
- test('LP collection ID mismatch with correct type fails', async () => {
191
- emitProgress({
192
- contentId: 418004,
193
- collectionType: COLLECTION_TYPE.LEARNING_PATH,
194
- collectionId: 999999
195
- })
196
- await waitForDebounce()
197
-
198
- expect(listeners.granted).not.toHaveBeenCalled()
199
- })
200
-
201
- test('non-LP collection ID mismatch still triggers (a la carte)', async () => {
202
- emitProgress({
203
- contentId: 418001,
204
- collectionType: 'skill-pack',
205
- collectionId: 418002
206
- })
207
- await waitForDebounce()
208
-
209
- expect(listeners.granted).toHaveBeenCalled()
210
- })
211
- })
212
- })