musora-content-services 2.167.2 → 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 (131) hide show
  1. package/.agent/decisions/2026-07-20-mu2-1724-songs-lessons-counts.md +33 -0
  2. package/.agent/decisions/2026-07-20-query-builder-tostring.md +37 -0
  3. package/CHANGELOG.md +8 -0
  4. package/package.json +2 -1
  5. package/src/contentTypeConfig.js +1 -0
  6. package/src/lib/sanity/query.ts +5 -0
  7. package/src/services/config.js +15 -0
  8. package/src/services/content/artist.ts +1 -3
  9. package/src/services/content/counts.ts +47 -0
  10. package/src/services/content/genre.ts +1 -3
  11. package/src/services/content/instructor.ts +1 -3
  12. package/src/services/userActivity.js +2 -0
  13. package/src/version-info.js +1 -0
  14. package/.claude/settings.local.json +0 -53
  15. package/test/initializeTests.js +0 -60
  16. package/test/integration/awards/award-exclusion-handling.test.ts +0 -275
  17. package/test/integration/awards/internal/image-utils.test.ts +0 -86
  18. package/test/integration/content-progress/__mocks__/mocks.ts +0 -104
  19. package/test/integration/content-progress/contentProgress.test.ts +0 -335
  20. package/test/integration/content-progress/e2eOfflineProgress.test.ts +0 -352
  21. package/test/integration/content-progress/e2eProgress.test.ts +0 -612
  22. package/test/integration/content-progress/getters.test.ts +0 -334
  23. package/test/integration/content-progress/helpers.test.ts +0 -263
  24. package/test/integration/content-progress/offlineContentProgress.test.ts +0 -226
  25. package/test/integration/forums.test.ts +0 -209
  26. package/test/integration/infrastructure/FetchRequestExecutor.test.ts +0 -88
  27. package/test/integration/initializeTestDB.ts +0 -80
  28. package/test/integration/learning-paths.test.ts +0 -854
  29. package/test/integration/notifications.test.ts +0 -178
  30. package/test/integration/progress-row/content-card.test.ts +0 -283
  31. package/test/integration/sync/adapters/idb-errors.test.ts +0 -144
  32. package/test/integration/sync/adapters/sqlite-errors.test.ts +0 -173
  33. package/test/integration/sync/fetch.test.ts +0 -224
  34. package/test/integration/sync/repositories/content-likes.test.ts +0 -99
  35. package/test/integration/sync/repositories/practices.test.ts +0 -179
  36. package/test/integration/sync/repositories/progress.test.ts +0 -245
  37. package/test/integration/sync/repositories/user-award-progress.static.test.ts +0 -68
  38. package/test/integration/sync/repositories/user-award-progress.test.ts +0 -387
  39. package/test/integration/sync/store/cross-user-protection.test.ts +0 -217
  40. package/test/integration/sync/store/store-idb.test.ts +0 -180
  41. package/test/integration/sync/store/store.test.ts +0 -361
  42. package/test/integration/user/permissions.test.js +0 -20
  43. package/test/integration/userActivity.test.ts +0 -102
  44. package/test/live/README.md +0 -29
  45. package/test/live/content.test.js +0 -116
  46. package/test/localStorageMock.js +0 -21
  47. package/test/log.js +0 -5
  48. package/test/mockData/award-definitions.js +0 -171
  49. package/test/mockData/mockData_fetchByRailContentIds_one_content.json +0 -35
  50. package/test/mockData/mockData_progress_content.json +0 -182
  51. package/test/mockData/mockData_sanity_progress_content.json +0 -1451
  52. package/test/mockData/mockData_user_practices.json +0 -9
  53. package/test/setupConsole.js +0 -6
  54. package/test/setupNetworkGuard.js +0 -3
  55. package/test/setupTimers.js +0 -13
  56. package/test/unit/HttpClient.test.ts +0 -267
  57. package/test/unit/awards/award-alacarte-observer.test.ts +0 -199
  58. package/test/unit/awards/award-auto-refresh.test.ts +0 -84
  59. package/test/unit/awards/award-calculations.test.ts +0 -34
  60. package/test/unit/awards/award-callbacks.test.ts +0 -144
  61. package/test/unit/awards/award-certificate-display.test.ts +0 -330
  62. package/test/unit/awards/award-collection-edge-cases.test.ts +0 -212
  63. package/test/unit/awards/award-collection-filtering.test.ts +0 -287
  64. package/test/unit/awards/award-completion-flow.test.ts +0 -306
  65. package/test/unit/awards/award-multi-lesson.test.ts +0 -243
  66. package/test/unit/awards/award-observer-integration.test.ts +0 -327
  67. package/test/unit/awards/award-query-messages.test.ts +0 -448
  68. package/test/unit/awards/award-user-collection.test.ts +0 -416
  69. package/test/unit/awards/duplicate-prevention.test.ts +0 -120
  70. package/test/unit/awards/helpers/completion-mock.ts +0 -56
  71. package/test/unit/awards/helpers/index.ts +0 -3
  72. package/test/unit/awards/helpers/mock-setup.ts +0 -79
  73. package/test/unit/awards/helpers/progress-emitter.ts +0 -45
  74. package/test/unit/awards/message-generator.test.ts +0 -162
  75. package/test/unit/content-progress/bubbleTrickle.test.ts +0 -322
  76. package/test/unit/content-progress/helpers.test.ts +0 -329
  77. package/test/unit/content-progress/navigateTo.test.ts +0 -392
  78. package/test/unit/contentLikes.test.ts +0 -62
  79. package/test/unit/contentMetaData.test.ts +0 -58
  80. package/test/unit/dataContext.test.ts +0 -38
  81. package/test/unit/dateUtils.test.ts +0 -188
  82. package/test/unit/endScreen.test.js +0 -712
  83. package/test/unit/eventsAPI.test.ts +0 -159
  84. package/test/unit/imageSRCBuilder.test.ts +0 -37
  85. package/test/unit/imageSRCVerify.test.ts +0 -155
  86. package/test/unit/infrastructure/DefaultHeaderProvider.test.ts +0 -39
  87. package/test/unit/infrastructure/sanity/ContentClient.test.ts +0 -168
  88. package/test/unit/infrastructure/sanity/DefaultConfigProvider.test.ts +0 -93
  89. package/test/unit/infrastructure/sanity/FetchQueryExecutor.test.ts +0 -174
  90. package/test/unit/infrastructure/sanity/SanityClient.test.ts +0 -140
  91. package/test/unit/lib/__snapshots__/filter.test.ts.snap +0 -5
  92. package/test/unit/lib/filter.test.ts +0 -1154
  93. package/test/unit/lib/lastUpdated.test.ts +0 -21
  94. package/test/unit/lib/query.test.ts +0 -1469
  95. package/test/unit/lib/sanity/decorators/base.test.ts +0 -368
  96. package/test/unit/lib/sanity/decorators/navigate-to.test.ts +0 -361
  97. package/test/unit/lib/sanity/decorators/need-access.test.ts +0 -89
  98. package/test/unit/lib/sanity/decorators/need-lifetime-upgrade.test.ts +0 -85
  99. package/test/unit/lib/sanity/decorators/page-type.test.ts +0 -81
  100. package/test/unit/playerSettings.test.ts +0 -73
  101. package/test/unit/progress-row/content-card.test.ts +0 -314
  102. package/test/unit/progress-row/playlist-card.test.ts +0 -104
  103. package/test/unit/progressRows.test.ts +0 -243
  104. package/test/unit/recommendations.test.js +0 -47
  105. package/test/unit/sanityQueryService.test.ts +0 -200
  106. package/test/unit/search.test.ts +0 -44
  107. package/test/unit/sentry.test.ts +0 -62
  108. package/test/unit/services/progress-internal/bubble.test.ts +0 -279
  109. package/test/unit/services/progress.test.ts +0 -396
  110. package/test/unit/streakMessage.test.ts +0 -251
  111. package/test/unit/sync/context.test.ts +0 -51
  112. package/test/unit/sync/effects/logout-warning.test.ts +0 -158
  113. package/test/unit/sync/effects/push-failure-notification.test.ts +0 -196
  114. package/test/unit/sync/errors/sync-errors.test.ts +0 -106
  115. package/test/unit/sync/errors/validators.test.ts +0 -61
  116. package/test/unit/sync/helpers/TestModel.ts +0 -44
  117. package/test/unit/sync/helpers/index.ts +0 -172
  118. package/test/unit/sync/manager.test.ts +0 -303
  119. package/test/unit/sync/models/user-award-progress.test.ts +0 -82
  120. package/test/unit/sync/resolver.test.ts +0 -229
  121. package/test/unit/sync/retry.test.ts +0 -314
  122. package/test/unit/sync/run-scope.test.ts +0 -23
  123. package/test/unit/sync/stale-record-cleanup.test.ts +0 -107
  124. package/test/unit/sync/store/push-coalescer.test.ts +0 -156
  125. package/test/unit/sync/store-configs.test.ts +0 -37
  126. package/test/unit/sync/telemetry/sync-telemetry.test.ts +0 -118
  127. package/test/unit/sync/utils/event-emitter.test.ts +0 -64
  128. package/test/unit/sync/utils/throttle.test.ts +0 -245
  129. package/test/unit/url-builder.test.ts +0 -72
  130. package/test/unit/user/account.test.ts +0 -66
  131. 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
- })