musora-content-services 2.167.2 → 2.167.4

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