musora-content-services 2.167.1 → 2.167.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agent/decisions/2026-07-17-onboarding-statuses.md +21 -0
- 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 +15 -0
- package/package.json +2 -1
- package/src/contentTypeConfig.js +2 -0
- package/src/index.d.ts +2 -0
- package/src/index.js +2 -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/contentProgress.js +1 -1
- package/src/services/eventsAPI.js +49 -7
- package/src/services/user/onboarding.ts +22 -0
- package/src/services/userActivity.js +2 -0
- package/src/version-info.js +1 -0
- package/.claude/settings.local.json +0 -52
- 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 -381
- 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/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
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
import { buildImageSRC, applySanityTransformations } from '../../src/services/imageSRCBuilder.js'
|
|
2
|
-
|
|
3
|
-
describe('imageSRCBuilder', function () {
|
|
4
|
-
beforeEach(() => {})
|
|
5
|
-
|
|
6
|
-
test('applySanityTransformations', async () => {
|
|
7
|
-
const url =
|
|
8
|
-
'https://cdn.sanity.io/images/4032r8py/production/83c3b6e7354a46c605804c093f707daa4e3f8f25-8000x4500.png'
|
|
9
|
-
const width = 500
|
|
10
|
-
const height = 100
|
|
11
|
-
const quality = 95
|
|
12
|
-
const resultingURL = applySanityTransformations(url, {
|
|
13
|
-
width: width,
|
|
14
|
-
quality: quality,
|
|
15
|
-
height: height,
|
|
16
|
-
})
|
|
17
|
-
const expected = `${url}?q=100&w=${width}&h=${height}`
|
|
18
|
-
|
|
19
|
-
expect(resultingURL).toStrictEqual(expected)
|
|
20
|
-
})
|
|
21
|
-
|
|
22
|
-
test('buildImageSRC', async () => {
|
|
23
|
-
const url = 'https://d2vyvo0tyx8ig5.cloudfront.net/books/foundations/level-2.jpg'
|
|
24
|
-
const width = 500
|
|
25
|
-
const height = 100
|
|
26
|
-
const quality = 95
|
|
27
|
-
const resultingURL = buildImageSRC(url, {
|
|
28
|
-
width: width,
|
|
29
|
-
quality: quality,
|
|
30
|
-
height: height,
|
|
31
|
-
})
|
|
32
|
-
|
|
33
|
-
const expected = `https://www.musora.com/cdn-cgi/image/width=${width},height=${height},quality=${quality}/${url}`
|
|
34
|
-
|
|
35
|
-
expect(resultingURL).toStrictEqual(expected)
|
|
36
|
-
})
|
|
37
|
-
})
|
|
@@ -1,155 +0,0 @@
|
|
|
1
|
-
import { extractSanityUrl, isBucketUrl, verifyImageSRC } from '../../src/services/imageSRCVerify.js'
|
|
2
|
-
|
|
3
|
-
const originalConsoleWarn = console.warn
|
|
4
|
-
const originalConsoleError = console.error
|
|
5
|
-
const originalNodeEnv = process.env.NODE_ENV
|
|
6
|
-
|
|
7
|
-
describe('Image URL Verification', () => {
|
|
8
|
-
let consoleWarnMock: any
|
|
9
|
-
|
|
10
|
-
beforeEach(() => {
|
|
11
|
-
consoleWarnMock = jest.fn()
|
|
12
|
-
console.warn = consoleWarnMock
|
|
13
|
-
console.error = jest.fn()
|
|
14
|
-
process.env.NODE_ENV = 'development'
|
|
15
|
-
})
|
|
16
|
-
|
|
17
|
-
afterEach(() => {
|
|
18
|
-
console.warn = originalConsoleWarn
|
|
19
|
-
console.error = originalConsoleError
|
|
20
|
-
process.env.NODE_ENV = originalNodeEnv
|
|
21
|
-
})
|
|
22
|
-
|
|
23
|
-
describe('verifyImageSRC', () => {
|
|
24
|
-
test('should not warn for Sanity URL with parameters', () => {
|
|
25
|
-
// Arrange
|
|
26
|
-
const url =
|
|
27
|
-
'https://cdn.sanity.io/images/4032r8py/staging/504c4e3393170f937a579de6f3c75c457b0c9e65-640x360.jpg?w=500&q=95'
|
|
28
|
-
|
|
29
|
-
// Act
|
|
30
|
-
verifyImageSRC(url)
|
|
31
|
-
|
|
32
|
-
// Assert
|
|
33
|
-
expect(consoleWarnMock).not.toHaveBeenCalled()
|
|
34
|
-
})
|
|
35
|
-
|
|
36
|
-
test('should warn for Sanity URL without parameters', () => {
|
|
37
|
-
// Arrange
|
|
38
|
-
const url =
|
|
39
|
-
'https://cdn.sanity.io/images/4032r8py/staging/504c4e3393170f937a579de6f3c75c457b0c9e65-640x360.jpg'
|
|
40
|
-
|
|
41
|
-
// Act
|
|
42
|
-
verifyImageSRC(url)
|
|
43
|
-
|
|
44
|
-
// Assert
|
|
45
|
-
expect(consoleWarnMock).toHaveBeenCalled()
|
|
46
|
-
expect(consoleWarnMock.mock.calls[0][0]).toContain(
|
|
47
|
-
'WARNING: Sanity CDN URL without parameters detected'
|
|
48
|
-
)
|
|
49
|
-
})
|
|
50
|
-
|
|
51
|
-
test('should warn for direct S3 bucket URL', () => {
|
|
52
|
-
// Arrange
|
|
53
|
-
const url = 'https://musora-images.s3.amazonaws.com/drumeo/images/some-image.jpg'
|
|
54
|
-
|
|
55
|
-
// Act
|
|
56
|
-
verifyImageSRC(url)
|
|
57
|
-
|
|
58
|
-
// Assert
|
|
59
|
-
expect(consoleWarnMock).toHaveBeenCalled()
|
|
60
|
-
expect(consoleWarnMock.mock.calls[0][0]).toContain('Direct S3 bucket URL detected')
|
|
61
|
-
})
|
|
62
|
-
|
|
63
|
-
test('should not warn when URL is empty', () => {
|
|
64
|
-
// Arrange
|
|
65
|
-
const url = ''
|
|
66
|
-
|
|
67
|
-
// Act
|
|
68
|
-
verifyImageSRC(url)
|
|
69
|
-
|
|
70
|
-
// Assert
|
|
71
|
-
expect(consoleWarnMock).not.toHaveBeenCalled()
|
|
72
|
-
})
|
|
73
|
-
})
|
|
74
|
-
|
|
75
|
-
describe('Cloudflare wrapped Sanity URLs', () => {
|
|
76
|
-
test('should extract and validate Sanity URL from Cloudflare wrapper', () => {
|
|
77
|
-
// Arrange
|
|
78
|
-
const url =
|
|
79
|
-
'https://www.musora.com/cdn-cgi/image/width=500,quality=95/https://cdn.sanity.io/images/4032r8py/staging/504c4e3393170f937a579de6f3c75c457b0c9e65-640x360.jpg'
|
|
80
|
-
|
|
81
|
-
// Act
|
|
82
|
-
verifyImageSRC(url)
|
|
83
|
-
|
|
84
|
-
// Assert
|
|
85
|
-
expect(consoleWarnMock).toHaveBeenCalled()
|
|
86
|
-
expect(consoleWarnMock.mock.calls[0][0]).toContain(
|
|
87
|
-
'WARNING: Sanity CDN URL without parameters detected'
|
|
88
|
-
)
|
|
89
|
-
})
|
|
90
|
-
|
|
91
|
-
test('should not warn for Cloudflare wrapped Sanity URL with parameters', () => {
|
|
92
|
-
// Arrange
|
|
93
|
-
const url =
|
|
94
|
-
'https://www.musora.com/cdn-cgi/image/width=500,quality=95/https://cdn.sanity.io/images/4032r8py/staging/504c4e3393170f937a579de6f3c75c457b0c9e65-640x360.jpg?w=500&q=95'
|
|
95
|
-
|
|
96
|
-
// Act
|
|
97
|
-
verifyImageSRC(url)
|
|
98
|
-
|
|
99
|
-
// Assert
|
|
100
|
-
expect(consoleWarnMock).not.toHaveBeenCalled()
|
|
101
|
-
})
|
|
102
|
-
})
|
|
103
|
-
|
|
104
|
-
describe('isBucketUrl', () => {
|
|
105
|
-
test('should detect standard S3 URLs', () => {
|
|
106
|
-
// Arrange & Act & Assert
|
|
107
|
-
expect(isBucketUrl('https://my-bucket.s3.amazonaws.com/image.jpg')).toBe(true)
|
|
108
|
-
expect(isBucketUrl('https://s3.us-east-1.amazonaws.com/my-bucket/image.jpg')).toBe(true)
|
|
109
|
-
expect(isBucketUrl('https://musora-images.s3.amazonaws.com/path/to/image.jpg')).toBe(true)
|
|
110
|
-
})
|
|
111
|
-
|
|
112
|
-
test('should not flag non-S3 URLs', () => {
|
|
113
|
-
// Arrange & Act & Assert
|
|
114
|
-
expect(isBucketUrl('https://cdn.sanity.io/images/123/production/image.jpg')).toBe(false)
|
|
115
|
-
expect(isBucketUrl('https://www.musora.com/image.jpg')).toBe(false)
|
|
116
|
-
})
|
|
117
|
-
})
|
|
118
|
-
|
|
119
|
-
describe('extractSanityUrl', () => {
|
|
120
|
-
test('should extract Sanity URL from Cloudflare wrapper', () => {
|
|
121
|
-
// Arrange
|
|
122
|
-
const wrappedUrl =
|
|
123
|
-
'https://www.musora.com/cdn-cgi/image/width=500,quality=95/https://cdn.sanity.io/images/123/production/image.jpg'
|
|
124
|
-
const expectedExtracted = 'https://cdn.sanity.io/images/123/production/image.jpg'
|
|
125
|
-
|
|
126
|
-
// Act
|
|
127
|
-
const result = extractSanityUrl(wrappedUrl)
|
|
128
|
-
|
|
129
|
-
// Assert
|
|
130
|
-
expect(result).toBe(expectedExtracted)
|
|
131
|
-
})
|
|
132
|
-
|
|
133
|
-
test('should return original URL if not Cloudflare wrapped', () => {
|
|
134
|
-
// Arrange
|
|
135
|
-
const url = 'https://cdn.sanity.io/images/123/production/image.jpg'
|
|
136
|
-
|
|
137
|
-
// Act
|
|
138
|
-
const result = extractSanityUrl(url)
|
|
139
|
-
|
|
140
|
-
// Assert
|
|
141
|
-
expect(result).toBe(url)
|
|
142
|
-
})
|
|
143
|
-
|
|
144
|
-
test('should handle malformed Cloudflare URLs gracefully', () => {
|
|
145
|
-
// Arrange
|
|
146
|
-
const malformedUrl = 'https://www.musora.com/cdn-cgi/image/width=500'
|
|
147
|
-
|
|
148
|
-
// Act
|
|
149
|
-
const result = extractSanityUrl(malformedUrl)
|
|
150
|
-
|
|
151
|
-
// Assert
|
|
152
|
-
expect(result).toBe(malformedUrl)
|
|
153
|
-
})
|
|
154
|
-
})
|
|
155
|
-
})
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import { DefaultHeaderProvider } from '../../../src/infrastructure/http/providers/DefaultHeaderProvider'
|
|
2
|
-
import { globalConfig } from '../../../src/services/config.js'
|
|
3
|
-
jest.mock('../../../src/services/config.js', () => ({
|
|
4
|
-
globalConfig: {
|
|
5
|
-
localTimezoneString: null,
|
|
6
|
-
isMA: false,
|
|
7
|
-
}
|
|
8
|
-
}))
|
|
9
|
-
describe('DefaultHeaderProvider', () => {
|
|
10
|
-
let provider: DefaultHeaderProvider
|
|
11
|
-
beforeEach(() => {
|
|
12
|
-
provider = new DefaultHeaderProvider()
|
|
13
|
-
globalConfig.localTimezoneString = null
|
|
14
|
-
globalConfig.isMA = false
|
|
15
|
-
})
|
|
16
|
-
test('always includes Content-Type and Accept headers', () => {
|
|
17
|
-
const headers = provider.getHeaders()
|
|
18
|
-
expect(headers['Content-Type']).toBe('application/json')
|
|
19
|
-
expect(headers['Accept']).toBe('application/json')
|
|
20
|
-
})
|
|
21
|
-
test('adds M-Client-Timezone header when localTimezoneString is set', () => {
|
|
22
|
-
globalConfig.localTimezoneString = 'America/Vancouver'
|
|
23
|
-
const headers = provider.getHeaders()
|
|
24
|
-
expect(headers['M-Client-Timezone']).toBe('America/Vancouver')
|
|
25
|
-
})
|
|
26
|
-
test('omits M-Client-Timezone header when localTimezoneString is not set', () => {
|
|
27
|
-
const headers = provider.getHeaders()
|
|
28
|
-
expect(headers['M-Client-Timezone']).toBeUndefined()
|
|
29
|
-
})
|
|
30
|
-
test('adds X-Client-Platform header when isMA is true', () => {
|
|
31
|
-
globalConfig.isMA = true
|
|
32
|
-
const headers = provider.getHeaders()
|
|
33
|
-
expect(headers['X-Client-Platform']).toBe('mobile')
|
|
34
|
-
})
|
|
35
|
-
test('omits X-Client-Platform header when isMA is false', () => {
|
|
36
|
-
const headers = provider.getHeaders()
|
|
37
|
-
expect(headers['X-Client-Platform']).toBeUndefined()
|
|
38
|
-
})
|
|
39
|
-
})
|
|
@@ -1,168 +0,0 @@
|
|
|
1
|
-
import { ContentClient } from '../../../../src/infrastructure/sanity/clients/ContentClient'
|
|
2
|
-
import { ConfigProvider } from '../../../../src/infrastructure/sanity/interfaces/ConfigProvider'
|
|
3
|
-
import { QueryExecutor } from '../../../../src/infrastructure/sanity/interfaces/QueryExecutor'
|
|
4
|
-
import { SanityConfig } from '../../../../src/infrastructure/sanity/interfaces/SanityConfig'
|
|
5
|
-
import { SanityQuery } from '../../../../src/infrastructure/sanity/interfaces/SanityQuery'
|
|
6
|
-
import { SanityResponse } from '../../../../src/infrastructure/sanity/interfaces/SanityResponse'
|
|
7
|
-
|
|
8
|
-
describe('ContentClient', () => {
|
|
9
|
-
const config: SanityConfig = {
|
|
10
|
-
projectId: 'p',
|
|
11
|
-
dataset: 'd',
|
|
12
|
-
version: '2021-06-07',
|
|
13
|
-
token: 't',
|
|
14
|
-
}
|
|
15
|
-
let mockConfigProvider: jest.Mocked<ConfigProvider>
|
|
16
|
-
let mockExecutor: jest.Mocked<QueryExecutor>
|
|
17
|
-
let client: ContentClient
|
|
18
|
-
let capturedQueries: string[]
|
|
19
|
-
|
|
20
|
-
beforeEach(() => {
|
|
21
|
-
capturedQueries = []
|
|
22
|
-
mockConfigProvider = { getConfig: jest.fn().mockReturnValue(config) }
|
|
23
|
-
mockExecutor = {
|
|
24
|
-
execute: jest.fn().mockImplementation((q: SanityQuery) => {
|
|
25
|
-
capturedQueries.push(q.query)
|
|
26
|
-
return Promise.resolve({ result: [], ms: 1, query: q.query } as SanityResponse<any>)
|
|
27
|
-
}),
|
|
28
|
-
}
|
|
29
|
-
client = new ContentClient(mockConfigProvider, mockExecutor)
|
|
30
|
-
})
|
|
31
|
-
|
|
32
|
-
describe('fetchById', () => {
|
|
33
|
-
test('builds query with railcontent_id, _type and [0] suffix', async () => {
|
|
34
|
-
mockExecutor.execute.mockResolvedValueOnce({
|
|
35
|
-
result: [{ id: 42, title: 'Song' }],
|
|
36
|
-
ms: 1,
|
|
37
|
-
query: '',
|
|
38
|
-
} as any)
|
|
39
|
-
const result = await client.fetchById<{ id: number; title: string }>({
|
|
40
|
-
type: 'song',
|
|
41
|
-
id: 42,
|
|
42
|
-
})
|
|
43
|
-
|
|
44
|
-
expect(result).toEqual({ id: 42, title: 'Song' })
|
|
45
|
-
const q = mockExecutor.execute.mock.calls[0][0].query
|
|
46
|
-
expect(q).toContain('railcontent_id == 42')
|
|
47
|
-
expect(q).toContain("_type == 'song'")
|
|
48
|
-
expect(q).toContain("'id': railcontent_id")
|
|
49
|
-
expect(q.trim().endsWith('[0]')).toBe(true)
|
|
50
|
-
})
|
|
51
|
-
|
|
52
|
-
test('uses custom fields when provided', async () => {
|
|
53
|
-
mockExecutor.execute.mockResolvedValueOnce({ result: [{}], ms: 1, query: '' } as any)
|
|
54
|
-
await client.fetchById({
|
|
55
|
-
type: 'song',
|
|
56
|
-
id: 1,
|
|
57
|
-
fields: ['title', 'artist'],
|
|
58
|
-
})
|
|
59
|
-
const q = mockExecutor.execute.mock.calls[0][0].query
|
|
60
|
-
expect(q).toContain('{title,\n artist}')
|
|
61
|
-
expect(q).not.toContain("'thumbnail'")
|
|
62
|
-
})
|
|
63
|
-
|
|
64
|
-
test('adds children fields when includeChildren is true', async () => {
|
|
65
|
-
mockExecutor.execute.mockResolvedValueOnce({ result: [{}], ms: 1, query: '' } as any)
|
|
66
|
-
await client.fetchById({
|
|
67
|
-
type: 'course',
|
|
68
|
-
id: 1,
|
|
69
|
-
includeChildren: true,
|
|
70
|
-
})
|
|
71
|
-
const q = mockExecutor.execute.mock.calls[0][0].query
|
|
72
|
-
expect(q).toContain('child_count')
|
|
73
|
-
expect(q).toContain('"lessons": child[]->{')
|
|
74
|
-
})
|
|
75
|
-
|
|
76
|
-
test('returns null when nothing matches', async () => {
|
|
77
|
-
mockExecutor.execute.mockResolvedValueOnce({ result: [], ms: 1, query: '' } as any)
|
|
78
|
-
const result = await client.fetchById({ type: 'song', id: 999 })
|
|
79
|
-
expect(result).toBeNull()
|
|
80
|
-
})
|
|
81
|
-
})
|
|
82
|
-
|
|
83
|
-
describe('fetchByIds', () => {
|
|
84
|
-
test('returns empty array immediately when no ids supplied', async () => {
|
|
85
|
-
const result = await client.fetchByIds([])
|
|
86
|
-
expect(result).toEqual([])
|
|
87
|
-
expect(mockExecutor.execute).not.toHaveBeenCalled()
|
|
88
|
-
})
|
|
89
|
-
|
|
90
|
-
test('builds query with id list and optional type/brand filters', async () => {
|
|
91
|
-
mockExecutor.execute.mockResolvedValueOnce({ result: [], ms: 1, query: '' } as any)
|
|
92
|
-
await client.fetchByIds([1, 2, 3], 'song', 'drumeo')
|
|
93
|
-
const q = mockExecutor.execute.mock.calls[0][0].query
|
|
94
|
-
expect(q).toContain('railcontent_id in [1,2,3]')
|
|
95
|
-
expect(q).toContain("_type == 'song'")
|
|
96
|
-
expect(q).toContain('brand == "drumeo"')
|
|
97
|
-
})
|
|
98
|
-
|
|
99
|
-
test('omits type and brand filters when not provided', async () => {
|
|
100
|
-
mockExecutor.execute.mockResolvedValueOnce({ result: [], ms: 1, query: '' } as any)
|
|
101
|
-
await client.fetchByIds([10, 20])
|
|
102
|
-
const q = mockExecutor.execute.mock.calls[0][0].query
|
|
103
|
-
expect(q).toContain('railcontent_id in [10,20]')
|
|
104
|
-
expect(q).not.toContain('_type ==')
|
|
105
|
-
expect(q).not.toContain('brand ==')
|
|
106
|
-
})
|
|
107
|
-
|
|
108
|
-
test('sorts results to match input id order', async () => {
|
|
109
|
-
mockExecutor.execute.mockResolvedValueOnce({
|
|
110
|
-
result: [{ id: 3 }, { id: 1 }, { id: 2 }],
|
|
111
|
-
ms: 1,
|
|
112
|
-
query: '',
|
|
113
|
-
} as any)
|
|
114
|
-
const result = await client.fetchByIds<{ id: number }>([1, 2, 3])
|
|
115
|
-
expect(result.map((r) => r.id)).toEqual([1, 2, 3])
|
|
116
|
-
})
|
|
117
|
-
|
|
118
|
-
test('sorts by railcontent_id when id field is absent', async () => {
|
|
119
|
-
mockExecutor.execute.mockResolvedValueOnce({
|
|
120
|
-
result: [{ railcontent_id: 2 }, { railcontent_id: 1 }],
|
|
121
|
-
ms: 1,
|
|
122
|
-
query: '',
|
|
123
|
-
} as any)
|
|
124
|
-
const result = await client.fetchByIds<{ railcontent_id: number }>([1, 2])
|
|
125
|
-
expect(result.map((r) => r.railcontent_id)).toEqual([1, 2])
|
|
126
|
-
})
|
|
127
|
-
})
|
|
128
|
-
|
|
129
|
-
describe('fetchByBrandAndType', () => {
|
|
130
|
-
test('applies default limit, offset, sort', async () => {
|
|
131
|
-
mockExecutor.execute.mockResolvedValueOnce({ result: [], ms: 1, query: '' } as any)
|
|
132
|
-
await client.fetchByBrandAndType('drumeo', 'song')
|
|
133
|
-
const q = mockExecutor.execute.mock.calls[0][0].query
|
|
134
|
-
expect(q).toContain('brand == "drumeo"')
|
|
135
|
-
expect(q).toContain('_type == "song"')
|
|
136
|
-
expect(q).toContain('order(published_on desc)')
|
|
137
|
-
expect(q).toContain('[0...10]')
|
|
138
|
-
})
|
|
139
|
-
|
|
140
|
-
test('honours custom limit, offset, sort, fields', async () => {
|
|
141
|
-
mockExecutor.execute.mockResolvedValueOnce({ result: [], ms: 1, query: '' } as any)
|
|
142
|
-
await client.fetchByBrandAndType('pianote', 'course', {
|
|
143
|
-
limit: 5,
|
|
144
|
-
offset: 20,
|
|
145
|
-
sortBy: 'title asc',
|
|
146
|
-
fields: ['title'],
|
|
147
|
-
})
|
|
148
|
-
const q = mockExecutor.execute.mock.calls[0][0].query
|
|
149
|
-
expect(q).toContain('order(title asc)')
|
|
150
|
-
expect(q).toContain('[20...25]')
|
|
151
|
-
expect(q).toContain('{title}')
|
|
152
|
-
})
|
|
153
|
-
|
|
154
|
-
test('returns list from executor', async () => {
|
|
155
|
-
const items = [{ id: 1 }, { id: 2 }]
|
|
156
|
-
mockExecutor.execute.mockResolvedValueOnce({ result: items, ms: 1, query: '' } as any)
|
|
157
|
-
const result = await client.fetchByBrandAndType<{ id: number }>('drumeo', 'song')
|
|
158
|
-
expect(result).toEqual(items)
|
|
159
|
-
})
|
|
160
|
-
|
|
161
|
-
test('rethrows wrapped SanityError on executor failure', async () => {
|
|
162
|
-
mockExecutor.execute.mockRejectedValueOnce(new Error('boom'))
|
|
163
|
-
await expect(client.fetchByBrandAndType('drumeo', 'song')).rejects.toMatchObject({
|
|
164
|
-
message: 'boom',
|
|
165
|
-
})
|
|
166
|
-
})
|
|
167
|
-
})
|
|
168
|
-
})
|
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
import { DefaultConfigProvider } from '../../../../src/infrastructure/sanity/providers/DefaultConfigProvider'
|
|
2
|
-
import { globalConfig } from '../../../../src/services/config.js'
|
|
3
|
-
|
|
4
|
-
jest.mock('../../../../src/services/config.js', () => ({
|
|
5
|
-
globalConfig: {
|
|
6
|
-
sanityConfig: null,
|
|
7
|
-
},
|
|
8
|
-
}))
|
|
9
|
-
|
|
10
|
-
describe('DefaultConfigProvider', () => {
|
|
11
|
-
let provider: DefaultConfigProvider
|
|
12
|
-
|
|
13
|
-
beforeEach(() => {
|
|
14
|
-
provider = new DefaultConfigProvider()
|
|
15
|
-
;(globalConfig as any).sanityConfig = null
|
|
16
|
-
})
|
|
17
|
-
|
|
18
|
-
test('throws when sanityConfig is missing from globalConfig', () => {
|
|
19
|
-
;(globalConfig as any).sanityConfig = null
|
|
20
|
-
expect(() => provider.getConfig()).toThrow('Sanity configuration is not available in globalConfig')
|
|
21
|
-
})
|
|
22
|
-
|
|
23
|
-
test('throws when token is missing', () => {
|
|
24
|
-
;(globalConfig as any).sanityConfig = {
|
|
25
|
-
projectId: 'p',
|
|
26
|
-
dataset: 'd',
|
|
27
|
-
version: 'v',
|
|
28
|
-
}
|
|
29
|
-
expect(() => provider.getConfig()).toThrow('Sanity token is missing in configuration')
|
|
30
|
-
})
|
|
31
|
-
|
|
32
|
-
test('throws when projectId is missing', () => {
|
|
33
|
-
;(globalConfig as any).sanityConfig = {
|
|
34
|
-
token: 't',
|
|
35
|
-
dataset: 'd',
|
|
36
|
-
version: 'v',
|
|
37
|
-
}
|
|
38
|
-
expect(() => provider.getConfig()).toThrow('Sanity projectId is missing in configuration')
|
|
39
|
-
})
|
|
40
|
-
|
|
41
|
-
test('throws when dataset is missing', () => {
|
|
42
|
-
;(globalConfig as any).sanityConfig = {
|
|
43
|
-
token: 't',
|
|
44
|
-
projectId: 'p',
|
|
45
|
-
version: 'v',
|
|
46
|
-
}
|
|
47
|
-
expect(() => provider.getConfig()).toThrow('Sanity dataset is missing in configuration')
|
|
48
|
-
})
|
|
49
|
-
|
|
50
|
-
test('throws when version is missing', () => {
|
|
51
|
-
;(globalConfig as any).sanityConfig = {
|
|
52
|
-
token: 't',
|
|
53
|
-
projectId: 'p',
|
|
54
|
-
dataset: 'd',
|
|
55
|
-
}
|
|
56
|
-
expect(() => provider.getConfig()).toThrow('Sanity version is missing in configuration')
|
|
57
|
-
})
|
|
58
|
-
|
|
59
|
-
test('returns config with defaults when optional fields are absent', () => {
|
|
60
|
-
;(globalConfig as any).sanityConfig = {
|
|
61
|
-
token: 't',
|
|
62
|
-
projectId: 'p',
|
|
63
|
-
dataset: 'd',
|
|
64
|
-
version: '2021-06-07',
|
|
65
|
-
}
|
|
66
|
-
const config = provider.getConfig()
|
|
67
|
-
expect(config).toEqual({
|
|
68
|
-
projectId: 'p',
|
|
69
|
-
dataset: 'd',
|
|
70
|
-
version: '2021-06-07',
|
|
71
|
-
token: 't',
|
|
72
|
-
perspective: 'published',
|
|
73
|
-
useCachedAPI: false,
|
|
74
|
-
debug: false,
|
|
75
|
-
})
|
|
76
|
-
})
|
|
77
|
-
|
|
78
|
-
test('passes through optional perspective, useCachedAPI, debug', () => {
|
|
79
|
-
;(globalConfig as any).sanityConfig = {
|
|
80
|
-
token: 't',
|
|
81
|
-
projectId: 'p',
|
|
82
|
-
dataset: 'd',
|
|
83
|
-
version: '2021-06-07',
|
|
84
|
-
perspective: 'previewDrafts',
|
|
85
|
-
useCachedAPI: true,
|
|
86
|
-
debug: true,
|
|
87
|
-
}
|
|
88
|
-
const config = provider.getConfig()
|
|
89
|
-
expect(config.perspective).toBe('previewDrafts')
|
|
90
|
-
expect(config.useCachedAPI).toBe(true)
|
|
91
|
-
expect(config.debug).toBe(true)
|
|
92
|
-
})
|
|
93
|
-
})
|
|
@@ -1,174 +0,0 @@
|
|
|
1
|
-
import { FetchQueryExecutor } from '../../../../src/infrastructure/sanity/executors/FetchQueryExecutor'
|
|
2
|
-
import { SanityConfig } from '../../../../src/infrastructure/sanity/interfaces/SanityConfig'
|
|
3
|
-
|
|
4
|
-
describe('FetchQueryExecutor', () => {
|
|
5
|
-
const baseConfig: SanityConfig = {
|
|
6
|
-
projectId: 'proj',
|
|
7
|
-
dataset: 'prod',
|
|
8
|
-
version: '2021-06-07',
|
|
9
|
-
token: 'tok',
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
let fetchMock: jest.Mock
|
|
13
|
-
const originalFetch = global.fetch
|
|
14
|
-
|
|
15
|
-
beforeEach(() => {
|
|
16
|
-
fetchMock = jest.fn()
|
|
17
|
-
global.fetch = fetchMock as any
|
|
18
|
-
})
|
|
19
|
-
|
|
20
|
-
afterEach(() => {
|
|
21
|
-
global.fetch = originalFetch
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
function makeResponse(body: any, ok = true, status = 200, statusText = 'OK'): Response {
|
|
25
|
-
return {
|
|
26
|
-
ok,
|
|
27
|
-
status,
|
|
28
|
-
statusText,
|
|
29
|
-
json: async () => body,
|
|
30
|
-
} as any
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
test('posts to non-cached endpoint with default published perspective', async () => {
|
|
34
|
-
fetchMock.mockResolvedValue(makeResponse({ result: [], ms: 1, query: 'q' }))
|
|
35
|
-
const executor = new FetchQueryExecutor()
|
|
36
|
-
await executor.execute({ query: 'q', params: { a: 1 } }, baseConfig)
|
|
37
|
-
|
|
38
|
-
expect(fetchMock).toHaveBeenCalledTimes(1)
|
|
39
|
-
const [url, options] = fetchMock.mock.calls[0]
|
|
40
|
-
expect(url).toBe(
|
|
41
|
-
'https://proj.api.sanity.io/v2021-06-07/data/query/prod?perspective=published'
|
|
42
|
-
)
|
|
43
|
-
expect(options.method).toBe('POST')
|
|
44
|
-
expect(options.headers['Authorization']).toBe('Bearer tok')
|
|
45
|
-
expect(options.headers['Content-Type']).toBe('application/json')
|
|
46
|
-
expect(options.body).toBe(JSON.stringify({ query: 'q', params: { a: 1 } }))
|
|
47
|
-
})
|
|
48
|
-
|
|
49
|
-
test('uses apicdn endpoint when useCachedAPI is true', async () => {
|
|
50
|
-
fetchMock.mockResolvedValue(makeResponse({ result: [] }))
|
|
51
|
-
const executor = new FetchQueryExecutor()
|
|
52
|
-
await executor.execute({ query: 'q' }, { ...baseConfig, useCachedAPI: true })
|
|
53
|
-
|
|
54
|
-
expect(fetchMock.mock.calls[0][0]).toBe(
|
|
55
|
-
'https://proj.apicdn.sanity.io/v2021-06-07/data/query/prod?perspective=published&query=q'
|
|
56
|
-
)
|
|
57
|
-
})
|
|
58
|
-
|
|
59
|
-
test('uses GET with encoded query when no params and URL under length limit', async () => {
|
|
60
|
-
fetchMock.mockResolvedValue(makeResponse({ result: [] }))
|
|
61
|
-
const executor = new FetchQueryExecutor()
|
|
62
|
-
await executor.execute({ query: '*[_type == "foo"]' }, baseConfig)
|
|
63
|
-
|
|
64
|
-
const [url, options] = fetchMock.mock.calls[0]
|
|
65
|
-
expect(url).toBe(
|
|
66
|
-
'https://proj.api.sanity.io/v2021-06-07/data/query/prod?perspective=published&query=' +
|
|
67
|
-
encodeURIComponent('*[_type == "foo"]')
|
|
68
|
-
)
|
|
69
|
-
expect(options.method).toBe('GET')
|
|
70
|
-
expect(options.headers['Authorization']).toBe('Bearer tok')
|
|
71
|
-
expect(options.body).toBeUndefined()
|
|
72
|
-
})
|
|
73
|
-
|
|
74
|
-
test('falls back to POST when params are provided', async () => {
|
|
75
|
-
fetchMock.mockResolvedValue(makeResponse({ result: [] }))
|
|
76
|
-
const executor = new FetchQueryExecutor()
|
|
77
|
-
await executor.execute({ query: 'q', params: { id: 1 } }, baseConfig)
|
|
78
|
-
|
|
79
|
-
const [url, options] = fetchMock.mock.calls[0]
|
|
80
|
-
expect(url).toBe(
|
|
81
|
-
'https://proj.api.sanity.io/v2021-06-07/data/query/prod?perspective=published'
|
|
82
|
-
)
|
|
83
|
-
expect(options.method).toBe('POST')
|
|
84
|
-
expect(options.body).toBe(JSON.stringify({ query: 'q', params: { id: 1 } }))
|
|
85
|
-
})
|
|
86
|
-
|
|
87
|
-
test('falls back to POST when GET URL would exceed length limit', async () => {
|
|
88
|
-
fetchMock.mockResolvedValue(makeResponse({ result: [] }))
|
|
89
|
-
const executor = new FetchQueryExecutor()
|
|
90
|
-
const longQuery = 'a'.repeat(8001)
|
|
91
|
-
await executor.execute({ query: longQuery }, baseConfig)
|
|
92
|
-
|
|
93
|
-
const [url, options] = fetchMock.mock.calls[0]
|
|
94
|
-
expect(url).toBe(
|
|
95
|
-
'https://proj.api.sanity.io/v2021-06-07/data/query/prod?perspective=published'
|
|
96
|
-
)
|
|
97
|
-
expect(options.method).toBe('POST')
|
|
98
|
-
expect(options.body).toBe(JSON.stringify({ query: longQuery }))
|
|
99
|
-
})
|
|
100
|
-
|
|
101
|
-
test('honours custom perspective', async () => {
|
|
102
|
-
fetchMock.mockResolvedValue(makeResponse({ result: [] }))
|
|
103
|
-
const executor = new FetchQueryExecutor()
|
|
104
|
-
await executor.execute({ query: 'q' }, { ...baseConfig, perspective: 'previewDrafts' })
|
|
105
|
-
|
|
106
|
-
expect(fetchMock.mock.calls[0][0]).toContain('perspective=previewDrafts')
|
|
107
|
-
})
|
|
108
|
-
|
|
109
|
-
test('returns parsed response body on success', async () => {
|
|
110
|
-
const body = { result: [{ id: 1 }], ms: 4, query: 'q' }
|
|
111
|
-
fetchMock.mockResolvedValue(makeResponse(body))
|
|
112
|
-
const executor = new FetchQueryExecutor()
|
|
113
|
-
const out = await executor.execute({ query: 'q' }, baseConfig)
|
|
114
|
-
expect(out).toEqual(body)
|
|
115
|
-
})
|
|
116
|
-
|
|
117
|
-
test('throws SanityError using response body message when fetch is not ok', async () => {
|
|
118
|
-
fetchMock.mockResolvedValue(
|
|
119
|
-
makeResponse({ message: 'GROQ parse error' }, false, 400, 'Bad Request')
|
|
120
|
-
)
|
|
121
|
-
const executor = new FetchQueryExecutor()
|
|
122
|
-
await expect(executor.execute({ query: 'q', params: { a: 1 } }, baseConfig)).rejects.toMatchObject({
|
|
123
|
-
message: 'GROQ parse error',
|
|
124
|
-
query: 'q',
|
|
125
|
-
params: { a: 1 },
|
|
126
|
-
})
|
|
127
|
-
})
|
|
128
|
-
|
|
129
|
-
test('falls back to status/statusText when error body cannot be parsed', async () => {
|
|
130
|
-
const response = {
|
|
131
|
-
ok: false,
|
|
132
|
-
status: 500,
|
|
133
|
-
statusText: 'Server Error',
|
|
134
|
-
json: async () => {
|
|
135
|
-
throw new Error('not json')
|
|
136
|
-
},
|
|
137
|
-
} as any
|
|
138
|
-
fetchMock.mockResolvedValue(response)
|
|
139
|
-
const executor = new FetchQueryExecutor()
|
|
140
|
-
await expect(executor.execute({ query: 'q' }, baseConfig)).rejects.toMatchObject({
|
|
141
|
-
message: 'Sanity API error: 500 - Server Error',
|
|
142
|
-
query: 'q',
|
|
143
|
-
})
|
|
144
|
-
})
|
|
145
|
-
|
|
146
|
-
test('wraps thrown network errors as SanityError', async () => {
|
|
147
|
-
const networkErr = new Error('connection refused')
|
|
148
|
-
fetchMock.mockRejectedValue(networkErr)
|
|
149
|
-
const executor = new FetchQueryExecutor()
|
|
150
|
-
await expect(executor.execute({ query: 'q' }, baseConfig)).rejects.toMatchObject({
|
|
151
|
-
message: 'connection refused',
|
|
152
|
-
query: 'q',
|
|
153
|
-
originalError: networkErr,
|
|
154
|
-
})
|
|
155
|
-
})
|
|
156
|
-
|
|
157
|
-
test('does not re-wrap an existing SanityError', async () => {
|
|
158
|
-
const sanityErr = { message: 'already wrapped', query: 'q' }
|
|
159
|
-
fetchMock.mockRejectedValue(sanityErr)
|
|
160
|
-
const executor = new FetchQueryExecutor()
|
|
161
|
-
await expect(executor.execute({ query: 'q' }, baseConfig)).rejects.toEqual(sanityErr)
|
|
162
|
-
})
|
|
163
|
-
|
|
164
|
-
test('debug mode logs query and result', async () => {
|
|
165
|
-
const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {})
|
|
166
|
-
const body = { result: [], ms: 1, query: 'q' }
|
|
167
|
-
fetchMock.mockResolvedValue(makeResponse(body))
|
|
168
|
-
const executor = new FetchQueryExecutor()
|
|
169
|
-
await executor.execute({ query: 'dbg' }, { ...baseConfig, debug: true })
|
|
170
|
-
expect(logSpy).toHaveBeenCalledWith('Sanity Query:', 'dbg')
|
|
171
|
-
expect(logSpy).toHaveBeenCalledWith('Sanity Results:', body)
|
|
172
|
-
logSpy.mockRestore()
|
|
173
|
-
})
|
|
174
|
-
})
|