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.
- 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/.idea/codeStyles/Project.xml +61 -0
- package/.idea/codeStyles/codeStyleConfig.xml +5 -0
- package/.idea/modules.xml +8 -0
- package/.idea/musora-content-services.iml +8 -0
- package/.idea/php.xml +19 -0
- package/.idea/prettier.xml +6 -0
- package/.idea/vcs.xml +7 -0
- package/.yarn/install-state.gz +0 -0
- package/CHANGELOG.md +10 -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/multi-user-accounts/multi-user-accounts.ts +1 -0
- 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
|
@@ -1,159 +0,0 @@
|
|
|
1
|
-
import eventsAPISingleton from '../../src/services/eventsAPI.js'
|
|
2
|
-
import { initializeTestService } from '../initializeTests.js'
|
|
3
|
-
|
|
4
|
-
const notificationsModule = require('../../src/services/user/notifications.js')
|
|
5
|
-
const EventsAPI = eventsAPISingleton.constructor as new () => typeof eventsAPISingleton
|
|
6
|
-
|
|
7
|
-
function deferred<T>() {
|
|
8
|
-
let resolve!: (value: T) => void
|
|
9
|
-
const promise = new Promise<T>((res) => {
|
|
10
|
-
resolve = res
|
|
11
|
-
})
|
|
12
|
-
|
|
13
|
-
return { promise, resolve }
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
describe('EventsAPI notification polling lifecycle', () => {
|
|
17
|
-
let api: typeof eventsAPISingleton
|
|
18
|
-
let fetchUnreadCountMock: jest.SpyInstance
|
|
19
|
-
|
|
20
|
-
beforeEach(() => {
|
|
21
|
-
initializeTestService()
|
|
22
|
-
jest.useFakeTimers()
|
|
23
|
-
|
|
24
|
-
api = new EventsAPI()
|
|
25
|
-
jest.spyOn(api, 'initPollingControl').mockResolvedValue({})
|
|
26
|
-
fetchUnreadCountMock = jest
|
|
27
|
-
.spyOn(notificationsModule, 'fetchUnreadCount')
|
|
28
|
-
.mockResolvedValue({ data: 0 })
|
|
29
|
-
})
|
|
30
|
-
|
|
31
|
-
afterEach(() => {
|
|
32
|
-
api.destroy()
|
|
33
|
-
jest.useRealTimers()
|
|
34
|
-
jest.restoreAllMocks()
|
|
35
|
-
})
|
|
36
|
-
|
|
37
|
-
test('initialize keeps the existing polling behavior', async () => {
|
|
38
|
-
await api.initialize({ brand: 'pianote' })
|
|
39
|
-
|
|
40
|
-
expect(fetchUnreadCountMock).toHaveBeenCalledWith({ brand: 'pianote' })
|
|
41
|
-
expect(api.pollingInterval).not.toBeNull()
|
|
42
|
-
|
|
43
|
-
fetchUnreadCountMock.mockClear()
|
|
44
|
-
await jest.advanceTimersByTimeAsync(60000)
|
|
45
|
-
|
|
46
|
-
expect(fetchUnreadCountMock).toHaveBeenCalledTimes(1)
|
|
47
|
-
})
|
|
48
|
-
|
|
49
|
-
test('pause stops polling without removing subscribers', async () => {
|
|
50
|
-
const handler = jest.fn()
|
|
51
|
-
api.addNotificationStateUpdatedHandler(handler)
|
|
52
|
-
await api.initialize()
|
|
53
|
-
handler.mockClear()
|
|
54
|
-
fetchUnreadCountMock.mockClear()
|
|
55
|
-
|
|
56
|
-
api.pauseNotificationPolling()
|
|
57
|
-
await jest.advanceTimersByTimeAsync(120000)
|
|
58
|
-
|
|
59
|
-
expect(api.pollingInterval).toBeNull()
|
|
60
|
-
expect(api.onNotificationStateUpdated).toEqual([handler])
|
|
61
|
-
expect(fetchUnreadCountMock).not.toHaveBeenCalled()
|
|
62
|
-
})
|
|
63
|
-
|
|
64
|
-
test('resume starts one interval and immediately refreshes existing subscribers', async () => {
|
|
65
|
-
const handler = jest.fn()
|
|
66
|
-
api.addNotificationStateUpdatedHandler(handler)
|
|
67
|
-
await api.initialize()
|
|
68
|
-
api.pauseNotificationPolling()
|
|
69
|
-
fetchUnreadCountMock.mockResolvedValue({ data: 1, liveEvent: null })
|
|
70
|
-
fetchUnreadCountMock.mockClear()
|
|
71
|
-
handler.mockClear()
|
|
72
|
-
|
|
73
|
-
await api.resumeNotificationPolling()
|
|
74
|
-
const resumedInterval = api.pollingInterval
|
|
75
|
-
await api.resumeNotificationPolling()
|
|
76
|
-
|
|
77
|
-
expect(resumedInterval).not.toBeNull()
|
|
78
|
-
expect(api.pollingInterval).toBe(resumedInterval)
|
|
79
|
-
expect(fetchUnreadCountMock).toHaveBeenCalledTimes(2)
|
|
80
|
-
expect(handler).toHaveBeenLastCalledWith({ unreadCount: 1, liveEvent: null })
|
|
81
|
-
})
|
|
82
|
-
|
|
83
|
-
test('repeated pause calls are safe', async () => {
|
|
84
|
-
await api.initialize()
|
|
85
|
-
|
|
86
|
-
api.pauseNotificationPolling()
|
|
87
|
-
api.pauseNotificationPolling()
|
|
88
|
-
|
|
89
|
-
expect(api.pollingInterval).toBeNull()
|
|
90
|
-
})
|
|
91
|
-
|
|
92
|
-
test('pause during asynchronous setup prevents the interval from being created', async () => {
|
|
93
|
-
const pollingControl = deferred<Record<string, never>>()
|
|
94
|
-
jest.spyOn(api, 'initPollingControl').mockReturnValue(pollingControl.promise)
|
|
95
|
-
|
|
96
|
-
const initializePromise = api.initialize()
|
|
97
|
-
api.pauseNotificationPolling()
|
|
98
|
-
pollingControl.resolve({})
|
|
99
|
-
await initializePromise
|
|
100
|
-
|
|
101
|
-
expect(api.pollingInterval).toBeNull()
|
|
102
|
-
expect(fetchUnreadCountMock).toHaveBeenCalledTimes(1)
|
|
103
|
-
})
|
|
104
|
-
|
|
105
|
-
test('resume during asynchronous setup creates no duplicate interval', async () => {
|
|
106
|
-
const pollingControl = deferred<Record<string, never>>()
|
|
107
|
-
jest.spyOn(api, 'initPollingControl').mockReturnValue(pollingControl.promise)
|
|
108
|
-
|
|
109
|
-
const initializePromise = api.initialize()
|
|
110
|
-
api.pauseNotificationPolling()
|
|
111
|
-
const resumePromise = api.resumeNotificationPolling()
|
|
112
|
-
const resumedInterval = api.pollingInterval
|
|
113
|
-
pollingControl.resolve({})
|
|
114
|
-
await Promise.all([initializePromise, resumePromise])
|
|
115
|
-
|
|
116
|
-
expect(resumedInterval).not.toBeNull()
|
|
117
|
-
expect(api.pollingInterval).toBe(resumedInterval)
|
|
118
|
-
})
|
|
119
|
-
|
|
120
|
-
test('pause and resume while the initial unread request is pending restores polling', async () => {
|
|
121
|
-
const initialUnreadCount = deferred<{ data: number }>()
|
|
122
|
-
fetchUnreadCountMock
|
|
123
|
-
.mockReturnValueOnce(initialUnreadCount.promise)
|
|
124
|
-
.mockResolvedValue({ data: 1 })
|
|
125
|
-
|
|
126
|
-
const initializePromise = api.initialize()
|
|
127
|
-
await Promise.resolve()
|
|
128
|
-
await Promise.resolve()
|
|
129
|
-
expect(api.pollingInterval).not.toBeNull()
|
|
130
|
-
|
|
131
|
-
api.pauseNotificationPolling()
|
|
132
|
-
const resumePromise = api.resumeNotificationPolling()
|
|
133
|
-
|
|
134
|
-
expect(api.pollingInterval).not.toBeNull()
|
|
135
|
-
initialUnreadCount.resolve({ data: 0 })
|
|
136
|
-
await Promise.all([initializePromise, resumePromise])
|
|
137
|
-
|
|
138
|
-
fetchUnreadCountMock.mockClear()
|
|
139
|
-
await jest.advanceTimersByTimeAsync(60000)
|
|
140
|
-
expect(fetchUnreadCountMock).toHaveBeenCalledTimes(1)
|
|
141
|
-
})
|
|
142
|
-
|
|
143
|
-
test('destroy remains a full teardown and initialize can start again', async () => {
|
|
144
|
-
api.addNotificationStateUpdatedHandler(jest.fn())
|
|
145
|
-
await api.initialize()
|
|
146
|
-
|
|
147
|
-
api.destroy()
|
|
148
|
-
|
|
149
|
-
expect(api.pollingInterval).toBeNull()
|
|
150
|
-
expect(api.onNotificationStateUpdated).toEqual([])
|
|
151
|
-
expect(api.isLiveEventPollingActive).toBe(false)
|
|
152
|
-
|
|
153
|
-
fetchUnreadCountMock.mockClear()
|
|
154
|
-
await api.initialize()
|
|
155
|
-
|
|
156
|
-
expect(api.pollingInterval).not.toBeNull()
|
|
157
|
-
expect(fetchUnreadCountMock).toHaveBeenCalledTimes(1)
|
|
158
|
-
})
|
|
159
|
-
})
|
|
@@ -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
|
-
})
|