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.
- package/.agent/decisions/2026-07-20-mu2-1724-songs-lessons-counts.md +33 -0
- package/.agent/decisions/2026-07-20-query-builder-tostring.md +37 -0
- package/CHANGELOG.md +8 -0
- package/package.json +2 -1
- package/src/contentTypeConfig.js +1 -0
- package/src/lib/sanity/query.ts +5 -0
- package/src/services/config.js +15 -0
- package/src/services/content/artist.ts +1 -3
- package/src/services/content/counts.ts +47 -0
- package/src/services/content/genre.ts +1 -3
- package/src/services/content/instructor.ts +1 -3
- package/src/services/userActivity.js +2 -0
- package/src/version-info.js +1 -0
- package/.claude/settings.local.json +0 -53
- package/test/initializeTests.js +0 -60
- package/test/integration/awards/award-exclusion-handling.test.ts +0 -275
- package/test/integration/awards/internal/image-utils.test.ts +0 -86
- package/test/integration/content-progress/__mocks__/mocks.ts +0 -104
- package/test/integration/content-progress/contentProgress.test.ts +0 -335
- package/test/integration/content-progress/e2eOfflineProgress.test.ts +0 -352
- package/test/integration/content-progress/e2eProgress.test.ts +0 -612
- package/test/integration/content-progress/getters.test.ts +0 -334
- package/test/integration/content-progress/helpers.test.ts +0 -263
- package/test/integration/content-progress/offlineContentProgress.test.ts +0 -226
- package/test/integration/forums.test.ts +0 -209
- package/test/integration/infrastructure/FetchRequestExecutor.test.ts +0 -88
- package/test/integration/initializeTestDB.ts +0 -80
- package/test/integration/learning-paths.test.ts +0 -854
- package/test/integration/notifications.test.ts +0 -178
- package/test/integration/progress-row/content-card.test.ts +0 -283
- package/test/integration/sync/adapters/idb-errors.test.ts +0 -144
- package/test/integration/sync/adapters/sqlite-errors.test.ts +0 -173
- package/test/integration/sync/fetch.test.ts +0 -224
- package/test/integration/sync/repositories/content-likes.test.ts +0 -99
- package/test/integration/sync/repositories/practices.test.ts +0 -179
- package/test/integration/sync/repositories/progress.test.ts +0 -245
- package/test/integration/sync/repositories/user-award-progress.static.test.ts +0 -68
- package/test/integration/sync/repositories/user-award-progress.test.ts +0 -387
- package/test/integration/sync/store/cross-user-protection.test.ts +0 -217
- package/test/integration/sync/store/store-idb.test.ts +0 -180
- package/test/integration/sync/store/store.test.ts +0 -361
- package/test/integration/user/permissions.test.js +0 -20
- package/test/integration/userActivity.test.ts +0 -102
- package/test/live/README.md +0 -29
- package/test/live/content.test.js +0 -116
- package/test/localStorageMock.js +0 -21
- package/test/log.js +0 -5
- package/test/mockData/award-definitions.js +0 -171
- package/test/mockData/mockData_fetchByRailContentIds_one_content.json +0 -35
- package/test/mockData/mockData_progress_content.json +0 -182
- package/test/mockData/mockData_sanity_progress_content.json +0 -1451
- package/test/mockData/mockData_user_practices.json +0 -9
- package/test/setupConsole.js +0 -6
- package/test/setupNetworkGuard.js +0 -3
- package/test/setupTimers.js +0 -13
- package/test/unit/HttpClient.test.ts +0 -267
- package/test/unit/awards/award-alacarte-observer.test.ts +0 -199
- package/test/unit/awards/award-auto-refresh.test.ts +0 -84
- package/test/unit/awards/award-calculations.test.ts +0 -34
- package/test/unit/awards/award-callbacks.test.ts +0 -144
- package/test/unit/awards/award-certificate-display.test.ts +0 -330
- package/test/unit/awards/award-collection-edge-cases.test.ts +0 -212
- package/test/unit/awards/award-collection-filtering.test.ts +0 -287
- package/test/unit/awards/award-completion-flow.test.ts +0 -306
- package/test/unit/awards/award-multi-lesson.test.ts +0 -243
- package/test/unit/awards/award-observer-integration.test.ts +0 -327
- package/test/unit/awards/award-query-messages.test.ts +0 -448
- package/test/unit/awards/award-user-collection.test.ts +0 -416
- package/test/unit/awards/duplicate-prevention.test.ts +0 -120
- package/test/unit/awards/helpers/completion-mock.ts +0 -56
- package/test/unit/awards/helpers/index.ts +0 -3
- package/test/unit/awards/helpers/mock-setup.ts +0 -79
- package/test/unit/awards/helpers/progress-emitter.ts +0 -45
- package/test/unit/awards/message-generator.test.ts +0 -162
- package/test/unit/content-progress/bubbleTrickle.test.ts +0 -322
- package/test/unit/content-progress/helpers.test.ts +0 -329
- package/test/unit/content-progress/navigateTo.test.ts +0 -392
- package/test/unit/contentLikes.test.ts +0 -62
- package/test/unit/contentMetaData.test.ts +0 -58
- package/test/unit/dataContext.test.ts +0 -38
- package/test/unit/dateUtils.test.ts +0 -188
- package/test/unit/endScreen.test.js +0 -712
- package/test/unit/eventsAPI.test.ts +0 -159
- package/test/unit/imageSRCBuilder.test.ts +0 -37
- package/test/unit/imageSRCVerify.test.ts +0 -155
- package/test/unit/infrastructure/DefaultHeaderProvider.test.ts +0 -39
- package/test/unit/infrastructure/sanity/ContentClient.test.ts +0 -168
- package/test/unit/infrastructure/sanity/DefaultConfigProvider.test.ts +0 -93
- package/test/unit/infrastructure/sanity/FetchQueryExecutor.test.ts +0 -174
- package/test/unit/infrastructure/sanity/SanityClient.test.ts +0 -140
- package/test/unit/lib/__snapshots__/filter.test.ts.snap +0 -5
- package/test/unit/lib/filter.test.ts +0 -1154
- package/test/unit/lib/lastUpdated.test.ts +0 -21
- package/test/unit/lib/query.test.ts +0 -1469
- package/test/unit/lib/sanity/decorators/base.test.ts +0 -368
- package/test/unit/lib/sanity/decorators/navigate-to.test.ts +0 -361
- package/test/unit/lib/sanity/decorators/need-access.test.ts +0 -89
- package/test/unit/lib/sanity/decorators/need-lifetime-upgrade.test.ts +0 -85
- package/test/unit/lib/sanity/decorators/page-type.test.ts +0 -81
- package/test/unit/playerSettings.test.ts +0 -73
- package/test/unit/progress-row/content-card.test.ts +0 -314
- package/test/unit/progress-row/playlist-card.test.ts +0 -104
- package/test/unit/progressRows.test.ts +0 -243
- package/test/unit/recommendations.test.js +0 -47
- package/test/unit/sanityQueryService.test.ts +0 -200
- package/test/unit/search.test.ts +0 -44
- package/test/unit/sentry.test.ts +0 -62
- package/test/unit/services/progress-internal/bubble.test.ts +0 -279
- package/test/unit/services/progress.test.ts +0 -396
- package/test/unit/streakMessage.test.ts +0 -251
- package/test/unit/sync/context.test.ts +0 -51
- package/test/unit/sync/effects/logout-warning.test.ts +0 -158
- package/test/unit/sync/effects/push-failure-notification.test.ts +0 -196
- package/test/unit/sync/errors/sync-errors.test.ts +0 -106
- package/test/unit/sync/errors/validators.test.ts +0 -61
- package/test/unit/sync/helpers/TestModel.ts +0 -44
- package/test/unit/sync/helpers/index.ts +0 -172
- package/test/unit/sync/manager.test.ts +0 -303
- package/test/unit/sync/models/user-award-progress.test.ts +0 -82
- package/test/unit/sync/resolver.test.ts +0 -229
- package/test/unit/sync/retry.test.ts +0 -314
- package/test/unit/sync/run-scope.test.ts +0 -23
- package/test/unit/sync/stale-record-cleanup.test.ts +0 -107
- package/test/unit/sync/store/push-coalescer.test.ts +0 -156
- package/test/unit/sync/store-configs.test.ts +0 -37
- package/test/unit/sync/telemetry/sync-telemetry.test.ts +0 -118
- package/test/unit/sync/utils/event-emitter.test.ts +0 -64
- package/test/unit/sync/utils/throttle.test.ts +0 -245
- package/test/unit/url-builder.test.ts +0 -72
- package/test/unit/user/account.test.ts +0 -66
- package/test/unit/user/permissions.test.js +0 -191
package/test/setupConsole.js
DELETED
package/test/setupTimers.js
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
const _originalSetTimeout = global.setTimeout
|
|
2
|
-
global.setTimeout = function(fn, delay, ...args) {
|
|
3
|
-
const timer = _originalSetTimeout(fn, delay, ...args)
|
|
4
|
-
timer?.unref?.()
|
|
5
|
-
return timer
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
const _originalSetInterval = global.setInterval
|
|
9
|
-
global.setInterval = function(fn, delay, ...args) {
|
|
10
|
-
const timer = _originalSetInterval(fn, delay, ...args)
|
|
11
|
-
timer?.unref?.()
|
|
12
|
-
return timer
|
|
13
|
-
}
|
|
@@ -1,267 +0,0 @@
|
|
|
1
|
-
import { HeaderProvider, HttpClient, RequestExecutor } from '../../src/infrastructure/http'
|
|
2
|
-
|
|
3
|
-
describe('HttpClient', () => {
|
|
4
|
-
let httpClient: HttpClient
|
|
5
|
-
let mockHeaderProvider: jest.Mocked<HeaderProvider>
|
|
6
|
-
let mockRequestExecutor: jest.Mocked<RequestExecutor>
|
|
7
|
-
const baseUrl = 'https://api.example.com'
|
|
8
|
-
const token = 'test-token'
|
|
9
|
-
const headers = { 'Content-Type': 'application/json', Accept: 'application/json' }
|
|
10
|
-
const responseData = { success: true }
|
|
11
|
-
|
|
12
|
-
beforeEach(() => {
|
|
13
|
-
// Mock HeaderProvider
|
|
14
|
-
mockHeaderProvider = {
|
|
15
|
-
getHeaders: jest.fn().mockReturnValue(headers),
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
// Mock RequestExecutor
|
|
19
|
-
mockRequestExecutor = {
|
|
20
|
-
execute: jest.fn().mockResolvedValue(responseData),
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
// Create HttpClient instance with mocks
|
|
24
|
-
httpClient = new HttpClient(baseUrl, token, mockHeaderProvider, mockRequestExecutor)
|
|
25
|
-
})
|
|
26
|
-
|
|
27
|
-
describe('Constructor', () => {
|
|
28
|
-
test('should initialize with default parameters when not provided', () => {
|
|
29
|
-
const client = new HttpClient(baseUrl)
|
|
30
|
-
expect(client).toBeInstanceOf(HttpClient)
|
|
31
|
-
})
|
|
32
|
-
|
|
33
|
-
test('should initialize with provided parameters', () => {
|
|
34
|
-
expect(httpClient).toBeInstanceOf(HttpClient)
|
|
35
|
-
})
|
|
36
|
-
})
|
|
37
|
-
|
|
38
|
-
describe('credentials', () => {
|
|
39
|
-
test('should default to include', async () => {
|
|
40
|
-
const client = new HttpClient(baseUrl, token, mockHeaderProvider, mockRequestExecutor)
|
|
41
|
-
await client.get('/test')
|
|
42
|
-
const options = mockRequestExecutor.execute.mock.calls[0][1]
|
|
43
|
-
expect(options.credentials).toBe('include')
|
|
44
|
-
})
|
|
45
|
-
|
|
46
|
-
test('should use provided credentials value', async () => {
|
|
47
|
-
const client = new HttpClient(baseUrl, token, mockHeaderProvider, mockRequestExecutor, 'omit')
|
|
48
|
-
await client.get('/test')
|
|
49
|
-
const options = mockRequestExecutor.execute.mock.calls[0][1]
|
|
50
|
-
expect(options.credentials).toBe('omit')
|
|
51
|
-
})
|
|
52
|
-
})
|
|
53
|
-
|
|
54
|
-
describe('setToken', () => {
|
|
55
|
-
test('should update the token', () => {
|
|
56
|
-
const newToken = 'new-test-token'
|
|
57
|
-
httpClient.setToken(newToken)
|
|
58
|
-
|
|
59
|
-
// Verify token was updated by making a request and checking if the new token is used
|
|
60
|
-
httpClient.get('/test')
|
|
61
|
-
|
|
62
|
-
expect(mockRequestExecutor.execute).toHaveBeenCalledWith(
|
|
63
|
-
expect.anything(),
|
|
64
|
-
expect.objectContaining({
|
|
65
|
-
headers: expect.objectContaining({
|
|
66
|
-
Authorization: `Bearer ${newToken}`,
|
|
67
|
-
}),
|
|
68
|
-
})
|
|
69
|
-
)
|
|
70
|
-
})
|
|
71
|
-
})
|
|
72
|
-
|
|
73
|
-
describe('HTTP Methods', () => {
|
|
74
|
-
test('get should make a GET request', async () => {
|
|
75
|
-
const url = '/test'
|
|
76
|
-
await httpClient.get(url)
|
|
77
|
-
|
|
78
|
-
expect(mockHeaderProvider.getHeaders).toHaveBeenCalled()
|
|
79
|
-
expect(mockRequestExecutor.execute).toHaveBeenCalledWith(
|
|
80
|
-
`${baseUrl}${url}`,
|
|
81
|
-
expect.objectContaining({
|
|
82
|
-
method: 'GET',
|
|
83
|
-
headers: expect.objectContaining({
|
|
84
|
-
Authorization: `Bearer ${token}`,
|
|
85
|
-
}),
|
|
86
|
-
})
|
|
87
|
-
)
|
|
88
|
-
})
|
|
89
|
-
|
|
90
|
-
test('post should make a POST request with data', async () => {
|
|
91
|
-
const url = '/test'
|
|
92
|
-
const data = { name: 'test' }
|
|
93
|
-
await httpClient.post(url, data)
|
|
94
|
-
|
|
95
|
-
expect(mockHeaderProvider.getHeaders).toHaveBeenCalled()
|
|
96
|
-
expect(mockRequestExecutor.execute).toHaveBeenCalledWith(
|
|
97
|
-
`${baseUrl}${url}`,
|
|
98
|
-
expect.objectContaining({
|
|
99
|
-
method: 'POST',
|
|
100
|
-
headers: expect.objectContaining({
|
|
101
|
-
Authorization: `Bearer ${token}`,
|
|
102
|
-
}),
|
|
103
|
-
body: JSON.stringify(data),
|
|
104
|
-
})
|
|
105
|
-
)
|
|
106
|
-
})
|
|
107
|
-
|
|
108
|
-
test('put should make a PUT request with data', async () => {
|
|
109
|
-
const url = '/test'
|
|
110
|
-
const data = { name: 'test' }
|
|
111
|
-
await httpClient.put(url, data)
|
|
112
|
-
|
|
113
|
-
expect(mockHeaderProvider.getHeaders).toHaveBeenCalled()
|
|
114
|
-
expect(mockRequestExecutor.execute).toHaveBeenCalledWith(
|
|
115
|
-
`${baseUrl}${url}`,
|
|
116
|
-
expect.objectContaining({
|
|
117
|
-
method: 'PUT',
|
|
118
|
-
headers: expect.objectContaining({
|
|
119
|
-
Authorization: `Bearer ${token}`,
|
|
120
|
-
}),
|
|
121
|
-
body: JSON.stringify(data),
|
|
122
|
-
})
|
|
123
|
-
)
|
|
124
|
-
})
|
|
125
|
-
|
|
126
|
-
test('patch should make a PATCH request with data', async () => {
|
|
127
|
-
const url = '/test'
|
|
128
|
-
const data = { name: 'test' }
|
|
129
|
-
await httpClient.patch(url, data)
|
|
130
|
-
|
|
131
|
-
expect(mockHeaderProvider.getHeaders).toHaveBeenCalled()
|
|
132
|
-
expect(mockRequestExecutor.execute).toHaveBeenCalledWith(
|
|
133
|
-
`${baseUrl}${url}`,
|
|
134
|
-
expect.objectContaining({
|
|
135
|
-
method: 'PATCH',
|
|
136
|
-
headers: expect.objectContaining({
|
|
137
|
-
Authorization: `Bearer ${token}`,
|
|
138
|
-
}),
|
|
139
|
-
body: JSON.stringify(data),
|
|
140
|
-
})
|
|
141
|
-
)
|
|
142
|
-
})
|
|
143
|
-
|
|
144
|
-
test('delete should make a DELETE request', async () => {
|
|
145
|
-
const url = '/test'
|
|
146
|
-
await httpClient.delete(url)
|
|
147
|
-
|
|
148
|
-
expect(mockHeaderProvider.getHeaders).toHaveBeenCalled()
|
|
149
|
-
expect(mockRequestExecutor.execute).toHaveBeenCalledWith(
|
|
150
|
-
`${baseUrl}${url}`,
|
|
151
|
-
expect.objectContaining({
|
|
152
|
-
method: 'DELETE',
|
|
153
|
-
headers: expect.objectContaining({
|
|
154
|
-
Authorization: `Bearer ${token}`,
|
|
155
|
-
}),
|
|
156
|
-
})
|
|
157
|
-
)
|
|
158
|
-
})
|
|
159
|
-
})
|
|
160
|
-
|
|
161
|
-
describe('URL Resolution', () => {
|
|
162
|
-
test('should prepend baseUrl to relative URLs', async () => {
|
|
163
|
-
const relativeUrl = '/api/endpoint'
|
|
164
|
-
await httpClient.get(relativeUrl)
|
|
165
|
-
|
|
166
|
-
expect(mockRequestExecutor.execute).toHaveBeenCalledWith(
|
|
167
|
-
`${baseUrl}${relativeUrl}`,
|
|
168
|
-
expect.anything()
|
|
169
|
-
)
|
|
170
|
-
})
|
|
171
|
-
|
|
172
|
-
test('should not modify absolute URLs', async () => {
|
|
173
|
-
const absoluteUrl = 'https://another-api.example.com/endpoint'
|
|
174
|
-
await httpClient.get(absoluteUrl)
|
|
175
|
-
|
|
176
|
-
expect(mockRequestExecutor.execute).toHaveBeenCalledWith(absoluteUrl, expect.anything())
|
|
177
|
-
})
|
|
178
|
-
})
|
|
179
|
-
|
|
180
|
-
describe('Data Version Header', () => {
|
|
181
|
-
beforeEach(() => {
|
|
182
|
-
// Reset all mocks and create a fresh HttpClient for each test
|
|
183
|
-
jest.resetAllMocks()
|
|
184
|
-
mockHeaderProvider = {
|
|
185
|
-
getHeaders: jest.fn().mockReturnValue({ ...headers }),
|
|
186
|
-
}
|
|
187
|
-
mockRequestExecutor = {
|
|
188
|
-
execute: jest.fn().mockResolvedValue(responseData),
|
|
189
|
-
}
|
|
190
|
-
httpClient = new HttpClient(baseUrl, token, mockHeaderProvider, mockRequestExecutor)
|
|
191
|
-
})
|
|
192
|
-
|
|
193
|
-
test('should add Data-Version header when provided', async () => {
|
|
194
|
-
const url = '/test'
|
|
195
|
-
const dataVersion = '1.2.3'
|
|
196
|
-
|
|
197
|
-
await httpClient.get(url, { dataVersion })
|
|
198
|
-
|
|
199
|
-
// Check the actual calls that were made
|
|
200
|
-
expect(mockRequestExecutor.execute.mock.calls.length).toBe(1)
|
|
201
|
-
|
|
202
|
-
const requestOptions = mockRequestExecutor.execute.mock.calls[0][1]
|
|
203
|
-
expect(requestOptions.headers['Data-Version']).toBe(dataVersion)
|
|
204
|
-
})
|
|
205
|
-
|
|
206
|
-
test('should not add Data-Version header when not provided', async () => {
|
|
207
|
-
|
|
208
|
-
const url = '/test'
|
|
209
|
-
|
|
210
|
-
// Make sure we're using exact null here, not undefined
|
|
211
|
-
const dataVersion: string | null = null
|
|
212
|
-
|
|
213
|
-
// Create separate mocks for this test to avoid state bleeding
|
|
214
|
-
const testHeaderProvider: jest.Mocked<HeaderProvider> = {
|
|
215
|
-
getHeaders: jest.fn().mockReturnValue({
|
|
216
|
-
'Content-Type': 'application/json',
|
|
217
|
-
Accept: 'application/json',
|
|
218
|
-
}),
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
const testRequestExecutor: jest.Mocked<RequestExecutor> = {
|
|
222
|
-
execute: jest.fn().mockResolvedValue(responseData),
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
// Create a completely fresh HttpClient instance
|
|
226
|
-
const testClient = new HttpClient(baseUrl, token, testHeaderProvider, testRequestExecutor)
|
|
227
|
-
|
|
228
|
-
await testClient.get(url, { dataVersion })
|
|
229
|
-
|
|
230
|
-
// Direct check on the mock to validate Data-Version absence
|
|
231
|
-
const actualHeaders = testRequestExecutor.execute.mock.calls[0][1].headers
|
|
232
|
-
const hasDataVersion = Object.prototype.hasOwnProperty.call(actualHeaders, 'Data-Version')
|
|
233
|
-
expect(hasDataVersion).toBe(false)
|
|
234
|
-
})
|
|
235
|
-
})
|
|
236
|
-
|
|
237
|
-
describe('Error Handling', () => {
|
|
238
|
-
test('should properly handle HTTP errors', async () => {
|
|
239
|
-
const httpError = {
|
|
240
|
-
status: 404,
|
|
241
|
-
statusText: 'Not Found',
|
|
242
|
-
body: { message: 'Resource not found' },
|
|
243
|
-
url: `${baseUrl}/test`,
|
|
244
|
-
method: 'get',
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
mockRequestExecutor.execute.mockRejectedValueOnce(httpError)
|
|
248
|
-
|
|
249
|
-
await expect(httpClient.get('/test')).rejects.toEqual(httpError)
|
|
250
|
-
})
|
|
251
|
-
|
|
252
|
-
test('should properly handle network errors', async () => {
|
|
253
|
-
const networkError = new Error('Network error')
|
|
254
|
-
|
|
255
|
-
mockRequestExecutor.execute.mockImplementationOnce(() => {
|
|
256
|
-
throw networkError
|
|
257
|
-
})
|
|
258
|
-
|
|
259
|
-
await expect(httpClient.get('/test')).rejects.toMatchObject({
|
|
260
|
-
message: 'Network error',
|
|
261
|
-
url: '/test',
|
|
262
|
-
method: 'GET',
|
|
263
|
-
originalError: networkError,
|
|
264
|
-
})
|
|
265
|
-
})
|
|
266
|
-
})
|
|
267
|
-
})
|
|
@@ -1,199 +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, getAwardByContentId } from '../../mockData/award-definitions.js'
|
|
4
|
-
import { setupDefaultMocks, setupAwardEventListeners } from './helpers/index'
|
|
5
|
-
import { mockCompletionStates, mockAllCompleted } from './helpers/completion-mock'
|
|
6
|
-
import { COLLECTION_TYPE, emitAlaCarteProgress, emitProgress, waitForDebounce } from './helpers/progress-emitter'
|
|
7
|
-
|
|
8
|
-
jest.mock('../../../src/services/sanity.js', () => ({
|
|
9
|
-
...jest.requireActual('../../../src/services/sanity'),
|
|
10
|
-
default: { fetch: jest.fn() },
|
|
11
|
-
fetchSanity: jest.fn()
|
|
12
|
-
}))
|
|
13
|
-
|
|
14
|
-
jest.mock('../../../src/services/sync/repository-proxy.ts', () => {
|
|
15
|
-
const mockFns = {
|
|
16
|
-
contentProgress: {
|
|
17
|
-
getOneProgressByContentId: jest.fn(),
|
|
18
|
-
getSomeProgressByContentIds: jest.fn(),
|
|
19
|
-
queryOne: jest.fn(),
|
|
20
|
-
queryAll: jest.fn()
|
|
21
|
-
},
|
|
22
|
-
practices: {
|
|
23
|
-
sumPracticeMinutesForContent: jest.fn()
|
|
24
|
-
},
|
|
25
|
-
userAwardProgress: {
|
|
26
|
-
hasCompletedAward: jest.fn(),
|
|
27
|
-
recordAwardProgress: jest.fn(),
|
|
28
|
-
getByAwardId: jest.fn()
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
return { default: mockFns, ...mockFns }
|
|
32
|
-
})
|
|
33
|
-
|
|
34
|
-
import sanityClient, { fetchSanity } from '../../../src/services/sanity.js'
|
|
35
|
-
import db from '../../../src/services/sync/repository-proxy'
|
|
36
|
-
import { awardDefinitions } from '../../../src/services/awards/internal/award-definitions.js'
|
|
37
|
-
|
|
38
|
-
const mockDb = db as jest.MockedObjectDeep<typeof db>
|
|
39
|
-
|
|
40
|
-
describe('Award Observer - A La Carte Progress (null collection)', () => {
|
|
41
|
-
let listeners
|
|
42
|
-
|
|
43
|
-
beforeEach(async () => {
|
|
44
|
-
jest.clearAllMocks()
|
|
45
|
-
awardEvents.removeAllListeners()
|
|
46
|
-
|
|
47
|
-
sanityClient.fetch = jest.fn().mockResolvedValue(mockAwardDefinitions)
|
|
48
|
-
setupDefaultMocks(mockDb, fetchSanity)
|
|
49
|
-
|
|
50
|
-
await awardDefinitions.refresh()
|
|
51
|
-
|
|
52
|
-
listeners = setupAwardEventListeners(awardEvents)
|
|
53
|
-
|
|
54
|
-
await contentProgressObserver.start()
|
|
55
|
-
})
|
|
56
|
-
|
|
57
|
-
afterEach(() => {
|
|
58
|
-
awardEvents.removeAllListeners()
|
|
59
|
-
awardDefinitions.clear()
|
|
60
|
-
contentProgressObserver.stop()
|
|
61
|
-
})
|
|
62
|
-
|
|
63
|
-
describe('A la carte progress triggers award evaluation', () => {
|
|
64
|
-
const testAward = getAwardByContentId(417049)
|
|
65
|
-
|
|
66
|
-
test('finds awards when progress has null collection type', async () => {
|
|
67
|
-
emitAlaCarteProgress(417045)
|
|
68
|
-
await waitForDebounce()
|
|
69
|
-
|
|
70
|
-
expect(listeners.granted).toHaveBeenCalled()
|
|
71
|
-
const payload = listeners.granted.mock.calls[0][0]
|
|
72
|
-
expect(payload).toHaveProperty('awardId', testAward._id)
|
|
73
|
-
})
|
|
74
|
-
|
|
75
|
-
test('emits awardProgress for partial completion with null collection', async () => {
|
|
76
|
-
mockCompletionStates(mockDb, [417045])
|
|
77
|
-
|
|
78
|
-
emitAlaCarteProgress(417045)
|
|
79
|
-
await waitForDebounce()
|
|
80
|
-
|
|
81
|
-
expect(listeners.progress).toHaveBeenCalled()
|
|
82
|
-
const payload = listeners.progress.mock.calls[0][0]
|
|
83
|
-
expect(payload).toHaveProperty('awardId', testAward._id)
|
|
84
|
-
expect(payload).toHaveProperty('progressPercentage')
|
|
85
|
-
})
|
|
86
|
-
})
|
|
87
|
-
|
|
88
|
-
describe('A la carte grants award when all children completed', () => {
|
|
89
|
-
const testAward = getAwardByContentId(416442)
|
|
90
|
-
|
|
91
|
-
test('grants award for guided course with null collection progress', async () => {
|
|
92
|
-
emitAlaCarteProgress(416444)
|
|
93
|
-
await waitForDebounce()
|
|
94
|
-
|
|
95
|
-
expect(listeners.granted).toHaveBeenCalled()
|
|
96
|
-
const payload = listeners.granted.mock.calls[0][0]
|
|
97
|
-
expect(payload).toHaveProperty('awardId', testAward._id)
|
|
98
|
-
expect(payload).toHaveProperty('completionData')
|
|
99
|
-
})
|
|
100
|
-
|
|
101
|
-
test('includes completion data in granted event', async () => {
|
|
102
|
-
emitAlaCarteProgress(416444)
|
|
103
|
-
await waitForDebounce()
|
|
104
|
-
|
|
105
|
-
const payload = listeners.granted.mock.calls[0][0]
|
|
106
|
-
expect(payload.completionData).toMatchObject({
|
|
107
|
-
content_title: expect.any(String),
|
|
108
|
-
completed_at: expect.any(String),
|
|
109
|
-
days_user_practiced: expect.any(Number),
|
|
110
|
-
practice_minutes: 200
|
|
111
|
-
})
|
|
112
|
-
})
|
|
113
|
-
})
|
|
114
|
-
|
|
115
|
-
describe('A la carte progress matches multiple awards', () => {
|
|
116
|
-
test('finds all awards containing the child content id', async () => {
|
|
117
|
-
const sharedChildId = 418003
|
|
118
|
-
|
|
119
|
-
mockAllCompleted(mockDb)
|
|
120
|
-
|
|
121
|
-
emitAlaCarteProgress(sharedChildId)
|
|
122
|
-
await waitForDebounce()
|
|
123
|
-
|
|
124
|
-
expect(mockDb.userAwardProgress.recordAwardProgress).toHaveBeenCalled()
|
|
125
|
-
})
|
|
126
|
-
})
|
|
127
|
-
|
|
128
|
-
describe('A la carte debouncing', () => {
|
|
129
|
-
beforeEach(() => {
|
|
130
|
-
mockCompletionStates(mockDb, [])
|
|
131
|
-
})
|
|
132
|
-
|
|
133
|
-
test('debounces multiple rapid a la carte updates', async () => {
|
|
134
|
-
emitAlaCarteProgress(416451)
|
|
135
|
-
emitAlaCarteProgress(416453)
|
|
136
|
-
emitAlaCarteProgress(416454)
|
|
137
|
-
|
|
138
|
-
await waitForDebounce()
|
|
139
|
-
|
|
140
|
-
expect(mockDb.userAwardProgress.recordAwardProgress).toHaveBeenCalledTimes(1)
|
|
141
|
-
})
|
|
142
|
-
})
|
|
143
|
-
|
|
144
|
-
describe('A la carte does not match unrelated content', () => {
|
|
145
|
-
test('ignores content not in any award child_ids', async () => {
|
|
146
|
-
emitAlaCarteProgress(999999)
|
|
147
|
-
await waitForDebounce()
|
|
148
|
-
|
|
149
|
-
expect(listeners.progress).not.toHaveBeenCalled()
|
|
150
|
-
expect(listeners.granted).not.toHaveBeenCalled()
|
|
151
|
-
})
|
|
152
|
-
})
|
|
153
|
-
|
|
154
|
-
describe('A la carte already completed award', () => {
|
|
155
|
-
beforeEach(() => {
|
|
156
|
-
mockDb.userAwardProgress.hasCompletedAward.mockResolvedValue(true)
|
|
157
|
-
})
|
|
158
|
-
|
|
159
|
-
test('does not re-grant already completed award', async () => {
|
|
160
|
-
emitAlaCarteProgress(417045)
|
|
161
|
-
await waitForDebounce()
|
|
162
|
-
|
|
163
|
-
expect(listeners.granted).not.toHaveBeenCalled()
|
|
164
|
-
})
|
|
165
|
-
})
|
|
166
|
-
|
|
167
|
-
describe('A la carte vs collection-scoped progress handling', () => {
|
|
168
|
-
test('a la carte progress finds awards regardless of award content_type', async () => {
|
|
169
|
-
emitAlaCarteProgress(417045)
|
|
170
|
-
await waitForDebounce()
|
|
171
|
-
|
|
172
|
-
expect(listeners.granted).toHaveBeenCalled()
|
|
173
|
-
})
|
|
174
|
-
|
|
175
|
-
test('non-LP collection context still triggers awards (a la carte)', async () => {
|
|
176
|
-
emitProgress({
|
|
177
|
-
contentId: 417045,
|
|
178
|
-
collectionType: 'skill-pack',
|
|
179
|
-
collectionId: 999999
|
|
180
|
-
})
|
|
181
|
-
await waitForDebounce()
|
|
182
|
-
|
|
183
|
-
expect(listeners.granted).toHaveBeenCalled()
|
|
184
|
-
})
|
|
185
|
-
|
|
186
|
-
test('LP collection context requires matching collection', async () => {
|
|
187
|
-
listeners.granted.mockClear()
|
|
188
|
-
|
|
189
|
-
emitProgress({
|
|
190
|
-
contentId: 418004,
|
|
191
|
-
collectionType: COLLECTION_TYPE.LEARNING_PATH,
|
|
192
|
-
collectionId: 999999
|
|
193
|
-
})
|
|
194
|
-
await waitForDebounce()
|
|
195
|
-
|
|
196
|
-
expect(listeners.granted).not.toHaveBeenCalled()
|
|
197
|
-
})
|
|
198
|
-
})
|
|
199
|
-
})
|
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
import { awardDefinitions } from '../../../src/services/awards/internal/award-definitions.js'
|
|
2
|
-
|
|
3
|
-
jest.mock('../../../src/services/sanity.js', () => ({
|
|
4
|
-
...jest.requireActual('../../../src/services/sanity'),
|
|
5
|
-
default: {
|
|
6
|
-
fetch: jest.fn()
|
|
7
|
-
},
|
|
8
|
-
fetchSanity: jest.fn()
|
|
9
|
-
}))
|
|
10
|
-
|
|
11
|
-
import { fetchSanity } from '../../../src/services/sanity.js'
|
|
12
|
-
|
|
13
|
-
describe('Award Definitions Cache', () => {
|
|
14
|
-
const mockAwards = [
|
|
15
|
-
{
|
|
16
|
-
_id: 'test-award-1',
|
|
17
|
-
name: 'Test Award',
|
|
18
|
-
brand: 'drumeo',
|
|
19
|
-
content_id: 12345,
|
|
20
|
-
is_active: true,
|
|
21
|
-
content_type: 'guided-course'
|
|
22
|
-
}
|
|
23
|
-
]
|
|
24
|
-
|
|
25
|
-
beforeEach(() => {
|
|
26
|
-
jest.clearAllMocks()
|
|
27
|
-
awardDefinitions.clear()
|
|
28
|
-
fetchSanity.mockResolvedValue(mockAwards)
|
|
29
|
-
})
|
|
30
|
-
|
|
31
|
-
afterEach(() => {
|
|
32
|
-
awardDefinitions.clear()
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
test('loads award definitions on first access', async () => {
|
|
36
|
-
const awards = await awardDefinitions.getAll()
|
|
37
|
-
|
|
38
|
-
expect(fetchSanity).toHaveBeenCalled()
|
|
39
|
-
expect(awards).toHaveLength(1)
|
|
40
|
-
expect(awards[0]._id).toBe('test-award-1')
|
|
41
|
-
})
|
|
42
|
-
|
|
43
|
-
test('uses cached definitions within refresh window', async () => {
|
|
44
|
-
await awardDefinitions.getAll()
|
|
45
|
-
|
|
46
|
-
fetchSanity.mockClear()
|
|
47
|
-
|
|
48
|
-
const awards = await awardDefinitions.getAll()
|
|
49
|
-
|
|
50
|
-
expect(fetchSanity).not.toHaveBeenCalled()
|
|
51
|
-
expect(awards).toHaveLength(1)
|
|
52
|
-
})
|
|
53
|
-
|
|
54
|
-
test('refreshes award definitions when cache expires', async () => {
|
|
55
|
-
awardDefinitions.lastFetch = Date.now() - (25 * 60 * 60 * 1000)
|
|
56
|
-
|
|
57
|
-
const awards = await awardDefinitions.getAll()
|
|
58
|
-
|
|
59
|
-
expect(fetchSanity).toHaveBeenCalled()
|
|
60
|
-
expect(awards).toHaveLength(1)
|
|
61
|
-
|
|
62
|
-
fetchSanity.mockClear()
|
|
63
|
-
|
|
64
|
-
const cachedAwards = await awardDefinitions.getAll()
|
|
65
|
-
|
|
66
|
-
expect(fetchSanity).not.toHaveBeenCalled()
|
|
67
|
-
expect(cachedAwards).toHaveLength(1)
|
|
68
|
-
})
|
|
69
|
-
|
|
70
|
-
test('does not refetch when called rapidly within cache window', async () => {
|
|
71
|
-
await awardDefinitions.getAll()
|
|
72
|
-
const callCountAfterFirst = fetchSanity.mock.calls.length
|
|
73
|
-
|
|
74
|
-
await Promise.all([
|
|
75
|
-
awardDefinitions.getAll(),
|
|
76
|
-
awardDefinitions.getAll(),
|
|
77
|
-
awardDefinitions.getAll(),
|
|
78
|
-
awardDefinitions.getAll(),
|
|
79
|
-
awardDefinitions.getAll()
|
|
80
|
-
])
|
|
81
|
-
|
|
82
|
-
expect(fetchSanity.mock.calls.length).toBe(callCountAfterFirst)
|
|
83
|
-
})
|
|
84
|
-
})
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
import { getEligibleChildIds } from '../../../src/services/awards/internal/award-definitions.js'
|
|
2
|
-
|
|
3
|
-
jest.mock('../../../src/services/sanity.js', () => ({
|
|
4
|
-
...jest.requireActual('../../../src/services/sanity'),
|
|
5
|
-
default: {
|
|
6
|
-
fetch: jest.fn()
|
|
7
|
-
},
|
|
8
|
-
fetchSanity: jest.fn()
|
|
9
|
-
}))
|
|
10
|
-
|
|
11
|
-
describe('Award Calculations', () => {
|
|
12
|
-
describe('getEligibleChildIds', () => {
|
|
13
|
-
test('returns all child_ids from award definition', () => {
|
|
14
|
-
const award = { child_ids: [1, 2, 3, 4] }
|
|
15
|
-
expect(getEligibleChildIds(award)).toEqual([1, 2, 3, 4])
|
|
16
|
-
})
|
|
17
|
-
|
|
18
|
-
test('returns empty array for empty child_ids', () => {
|
|
19
|
-
const award = { child_ids: [] }
|
|
20
|
-
expect(getEligibleChildIds(award)).toEqual([])
|
|
21
|
-
})
|
|
22
|
-
|
|
23
|
-
test('returns empty array for undefined child_ids', () => {
|
|
24
|
-
const award = {}
|
|
25
|
-
expect(getEligibleChildIds(award)).toEqual([])
|
|
26
|
-
})
|
|
27
|
-
|
|
28
|
-
test('returns empty array for null child_ids', () => {
|
|
29
|
-
const award = { child_ids: null }
|
|
30
|
-
expect(getEligibleChildIds(award)).toEqual([])
|
|
31
|
-
})
|
|
32
|
-
})
|
|
33
|
-
|
|
34
|
-
})
|