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,156 +0,0 @@
|
|
|
1
|
-
import PushCoalescer from '@/services/sync/store/push-coalescer'
|
|
2
|
-
import type BaseModel from '@/services/sync/models/Base'
|
|
3
|
-
import type { SyncPushResponse } from '@/services/sync/fetch'
|
|
4
|
-
|
|
5
|
-
const BASE_TIME = 1700000000000
|
|
6
|
-
const SUCCESS_RESPONSE: SyncPushResponse = { ok: true, results: [] }
|
|
7
|
-
|
|
8
|
-
class TestSetup {
|
|
9
|
-
coalescer = new PushCoalescer()
|
|
10
|
-
|
|
11
|
-
record(id: string, timeOffset = 0): BaseModel {
|
|
12
|
-
return { id, updated_at: BASE_TIME + timeOffset } as unknown as BaseModel
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
mockPusher() {
|
|
16
|
-
return jest.fn().mockReturnValue(new Promise(() => {}))
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
controllablePusher() {
|
|
20
|
-
let resolve!: (value: SyncPushResponse) => void
|
|
21
|
-
let reject!: (error: Error) => void
|
|
22
|
-
|
|
23
|
-
const promise = new Promise<SyncPushResponse>((res, rej) => {
|
|
24
|
-
resolve = res
|
|
25
|
-
reject = rej
|
|
26
|
-
})
|
|
27
|
-
|
|
28
|
-
return {
|
|
29
|
-
pusher: jest.fn().mockReturnValue(promise),
|
|
30
|
-
resolve: () => resolve(SUCCESS_RESPONSE),
|
|
31
|
-
reject: (error: Error) => reject(error)
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
let setup: TestSetup
|
|
37
|
-
beforeEach(() => {
|
|
38
|
-
setup = new TestSetup()
|
|
39
|
-
})
|
|
40
|
-
|
|
41
|
-
describe('coalescing behavior', () => {
|
|
42
|
-
describe('when to coalesce', () => {
|
|
43
|
-
test('identical records return same promise', () => {
|
|
44
|
-
const records = [setup.record('rec-1')]
|
|
45
|
-
const pusher = setup.mockPusher()
|
|
46
|
-
|
|
47
|
-
const p1 = setup.coalescer.push(records, pusher)
|
|
48
|
-
const p2 = setup.coalescer.push(records, pusher)
|
|
49
|
-
|
|
50
|
-
expect(p1).toBe(p2)
|
|
51
|
-
expect(pusher).toHaveBeenCalledTimes(1)
|
|
52
|
-
})
|
|
53
|
-
|
|
54
|
-
test('same timestamp coalesces', () => {
|
|
55
|
-
const pusher = setup.mockPusher()
|
|
56
|
-
const p1 = setup.coalescer.push([setup.record('rec-1')], pusher)
|
|
57
|
-
const p2 = setup.coalescer.push([setup.record('rec-1')], pusher)
|
|
58
|
-
|
|
59
|
-
expect(p1).toBe(p2)
|
|
60
|
-
expect(pusher).toHaveBeenCalledTimes(1)
|
|
61
|
-
})
|
|
62
|
-
|
|
63
|
-
test('all records must match for multi-record coalescing', () => {
|
|
64
|
-
const pusher = setup.mockPusher()
|
|
65
|
-
const records = [setup.record('rec-1'), setup.record('rec-2')]
|
|
66
|
-
|
|
67
|
-
const p1 = setup.coalescer.push(records, pusher)
|
|
68
|
-
const p2 = setup.coalescer.push([setup.record('rec-1'), setup.record('rec-2')], pusher)
|
|
69
|
-
|
|
70
|
-
expect(p1).toBe(p2)
|
|
71
|
-
expect(pusher).toHaveBeenCalledTimes(1)
|
|
72
|
-
})
|
|
73
|
-
})
|
|
74
|
-
|
|
75
|
-
describe('when NOT to coalesce', () => {
|
|
76
|
-
test('newer timestamp triggers new push', () => {
|
|
77
|
-
const pusher = setup.mockPusher()
|
|
78
|
-
|
|
79
|
-
setup.coalescer.push([setup.record('rec-1')], pusher)
|
|
80
|
-
setup.coalescer.push([setup.record('rec-1', 1)], pusher) // +1ms newer
|
|
81
|
-
|
|
82
|
-
expect(pusher).toHaveBeenCalledTimes(2)
|
|
83
|
-
})
|
|
84
|
-
|
|
85
|
-
test('different record IDs trigger separate pushes', () => {
|
|
86
|
-
const pusher = setup.mockPusher()
|
|
87
|
-
|
|
88
|
-
setup.coalescer.push([setup.record('rec-1')], pusher)
|
|
89
|
-
setup.coalescer.push([setup.record('rec-2')], pusher)
|
|
90
|
-
|
|
91
|
-
expect(pusher).toHaveBeenCalledTimes(2)
|
|
92
|
-
})
|
|
93
|
-
|
|
94
|
-
test('partial record match prevents coalescing', () => {
|
|
95
|
-
const pusher = setup.mockPusher()
|
|
96
|
-
|
|
97
|
-
setup.coalescer.push([setup.record('rec-1'), setup.record('rec-2')], pusher)
|
|
98
|
-
setup.coalescer.push([setup.record('rec-1'), setup.record('rec-2', 1)], pusher) // rec-2 newer
|
|
99
|
-
|
|
100
|
-
expect(pusher).toHaveBeenCalledTimes(2)
|
|
101
|
-
})
|
|
102
|
-
})
|
|
103
|
-
})
|
|
104
|
-
|
|
105
|
-
describe('intent lifecycle', () => {
|
|
106
|
-
test('cleans up after successful push', async () => {
|
|
107
|
-
const { pusher, resolve } = setup.controllablePusher()
|
|
108
|
-
const records = [setup.record('rec-1')]
|
|
109
|
-
|
|
110
|
-
setup.coalescer.push(records, pusher)
|
|
111
|
-
resolve()
|
|
112
|
-
await Promise.resolve() // flush cleanup
|
|
113
|
-
|
|
114
|
-
// Should trigger new push since intent was cleaned up
|
|
115
|
-
const newPusher = setup.mockPusher()
|
|
116
|
-
setup.coalescer.push(records, newPusher)
|
|
117
|
-
expect(newPusher).toHaveBeenCalled()
|
|
118
|
-
})
|
|
119
|
-
|
|
120
|
-
test('cleans up after failed push', async () => {
|
|
121
|
-
const { pusher, reject } = setup.controllablePusher()
|
|
122
|
-
const records = [setup.record('rec-1')]
|
|
123
|
-
|
|
124
|
-
const promise = setup.coalescer.push(records, pusher)
|
|
125
|
-
promise.catch(() => {}) // prevent unhandled rejection
|
|
126
|
-
|
|
127
|
-
reject(new Error('push failed'))
|
|
128
|
-
await Promise.resolve()
|
|
129
|
-
|
|
130
|
-
const newPusher = setup.mockPusher()
|
|
131
|
-
setup.coalescer.push(records, newPusher)
|
|
132
|
-
expect(newPusher).toHaveBeenCalled()
|
|
133
|
-
})
|
|
134
|
-
|
|
135
|
-
test('coalesces while intent is in-flight', () => {
|
|
136
|
-
const { pusher } = setup.controllablePusher()
|
|
137
|
-
const records = [setup.record('rec-1')]
|
|
138
|
-
|
|
139
|
-
const p1 = setup.coalescer.push(records, pusher)
|
|
140
|
-
const p2 = setup.coalescer.push(records, setup.mockPusher())
|
|
141
|
-
|
|
142
|
-
expect(p1).toBe(p2) // Same promise returned
|
|
143
|
-
})
|
|
144
|
-
})
|
|
145
|
-
|
|
146
|
-
describe('edge cases', () => {
|
|
147
|
-
test('empty record arrays coalesce', () => {
|
|
148
|
-
const pusher = setup.mockPusher()
|
|
149
|
-
|
|
150
|
-
const p1 = setup.coalescer.push([], pusher)
|
|
151
|
-
const p2 = setup.coalescer.push([], pusher)
|
|
152
|
-
|
|
153
|
-
expect(p1).toBe(p2)
|
|
154
|
-
expect(pusher).toHaveBeenCalledTimes(1)
|
|
155
|
-
})
|
|
156
|
-
})
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
jest.mock('../../../src/services/sync/manager', () => ({ default: class SyncManager {} }))
|
|
2
|
-
jest.mock('../../../src/services/sync/repository-proxy', () => ({ db: {} }))
|
|
3
|
-
jest.mock('../../../src/services/sync/fetch', () => ({
|
|
4
|
-
handlePull: jest.fn(() => jest.fn()),
|
|
5
|
-
handlePush: jest.fn(() => jest.fn()),
|
|
6
|
-
makeFetchRequest: jest.fn(() => jest.fn()),
|
|
7
|
-
}))
|
|
8
|
-
import createStoresFromConfig from '../../../src/services/sync/store-configs'
|
|
9
|
-
import ContentProgress from '../../../src/services/sync/models/ContentProgress'
|
|
10
|
-
describe('createStoresFromConfig', () => {
|
|
11
|
-
const configs = createStoresFromConfig()
|
|
12
|
-
const progressConfig = configs.find(c => c.model === ContentProgress)!
|
|
13
|
-
const comparator = progressConfig.comparator!
|
|
14
|
-
describe('ContentProgress comparator', () => {
|
|
15
|
-
test('returns SERVER when server progress_percent is higher', () => {
|
|
16
|
-
const result = comparator(
|
|
17
|
-
{ record: { progress_percent: 80 }, meta: { lifecycle: { updated_at: 1000 } } } as any,
|
|
18
|
-
{ progress_percent: 50, updated_at: 2000 } as any
|
|
19
|
-
)
|
|
20
|
-
expect(result).toBe('SERVER')
|
|
21
|
-
})
|
|
22
|
-
test('returns LOCAL when local progress_percent is higher', () => {
|
|
23
|
-
const result = comparator(
|
|
24
|
-
{ record: { progress_percent: 50 }, meta: { lifecycle: { updated_at: 2000 } } } as any,
|
|
25
|
-
{ progress_percent: 80, updated_at: 1000 } as any
|
|
26
|
-
)
|
|
27
|
-
expect(result).toBe('LOCAL')
|
|
28
|
-
})
|
|
29
|
-
test('returns SERVER when server updated_at is newer and either side is 0', () => {
|
|
30
|
-
const result = comparator(
|
|
31
|
-
{ record: { progress_percent: 0 }, meta: { lifecycle: { updated_at: 2000 } } } as any,
|
|
32
|
-
{ progress_percent: 50, updated_at: 1000 } as any
|
|
33
|
-
)
|
|
34
|
-
expect(result).toBe('SERVER')
|
|
35
|
-
})
|
|
36
|
-
})
|
|
37
|
-
})
|
|
@@ -1,118 +0,0 @@
|
|
|
1
|
-
import { SyncTelemetry, SeverityLevel } from '../../../../src/services/sync/telemetry/index'
|
|
2
|
-
import { SyncError, SyncUnexpectedError } from '../../../../src/services/sync/errors/index'
|
|
3
|
-
import { makeUserScope } from '../helpers/index'
|
|
4
|
-
const makeSentry = () => ({
|
|
5
|
-
captureException: jest.fn(),
|
|
6
|
-
captureMessage: jest.fn(),
|
|
7
|
-
addBreadcrumb: jest.fn(),
|
|
8
|
-
startSpan: jest.fn(),
|
|
9
|
-
logger: {
|
|
10
|
-
debug: jest.fn(),
|
|
11
|
-
info: jest.fn(),
|
|
12
|
-
},
|
|
13
|
-
})
|
|
14
|
-
const makeTelemetry = (level = SeverityLevel.DEBUG) => {
|
|
15
|
-
const sentry = makeSentry()
|
|
16
|
-
const telemetry = new SyncTelemetry(makeUserScope(), {
|
|
17
|
-
Sentry: sentry,
|
|
18
|
-
level,
|
|
19
|
-
pretty: false,
|
|
20
|
-
})
|
|
21
|
-
return { telemetry, sentry }
|
|
22
|
-
}
|
|
23
|
-
describe('SyncTelemetry', () => {
|
|
24
|
-
afterEach(() => {
|
|
25
|
-
SyncTelemetry.clearInstance()
|
|
26
|
-
})
|
|
27
|
-
describe('static methods', () => {
|
|
28
|
-
test('setInstance and getInstance round-trip', () => {
|
|
29
|
-
const { telemetry } = makeTelemetry()
|
|
30
|
-
SyncTelemetry.setInstance(telemetry)
|
|
31
|
-
expect(SyncTelemetry.getInstance()).toBe(telemetry)
|
|
32
|
-
})
|
|
33
|
-
test('clearInstance sets instance to null', () => {
|
|
34
|
-
const { telemetry } = makeTelemetry()
|
|
35
|
-
SyncTelemetry.setInstance(telemetry)
|
|
36
|
-
SyncTelemetry.clearInstance()
|
|
37
|
-
expect(SyncTelemetry.getInstance()).toBeNull()
|
|
38
|
-
})
|
|
39
|
-
test('isSyncConsoleMessage returns true for sync-prefixed messages', () => {
|
|
40
|
-
expect(SyncTelemetry.isSyncConsoleMessage(['📡 SYNC: something'])).toBe(true)
|
|
41
|
-
})
|
|
42
|
-
test('isSyncConsoleMessage returns false for non-sync messages', () => {
|
|
43
|
-
expect(SyncTelemetry.isSyncConsoleMessage(['regular message'])).toBe(false)
|
|
44
|
-
})
|
|
45
|
-
})
|
|
46
|
-
describe('capture', () => {
|
|
47
|
-
// BUG: condition checks `err instanceof SyncUnexpectedError` but err is the original Error,
|
|
48
|
-
// not the wrapped one. mechanism: { handled: false } is never triggered for plain Errors.
|
|
49
|
-
// Should be checking `wrapped instanceof SyncUnexpectedError` instead.
|
|
50
|
-
test('calls captureException with undefined when err is a plain Error', () => {
|
|
51
|
-
const { telemetry, sentry } = makeTelemetry()
|
|
52
|
-
const error = new Error('raw error')
|
|
53
|
-
telemetry.capture(error)
|
|
54
|
-
expect(sentry.captureException).toHaveBeenCalledWith(error, undefined)
|
|
55
|
-
})
|
|
56
|
-
test('passes mechanism handled:false when err is SyncUnexpectedError', () => {
|
|
57
|
-
const { telemetry, sentry } = makeTelemetry()
|
|
58
|
-
const error = new SyncUnexpectedError('unexpected')
|
|
59
|
-
telemetry.capture(error)
|
|
60
|
-
expect(sentry.captureException).toHaveBeenCalledWith(error, { mechanism: { handled: false } })
|
|
61
|
-
})
|
|
62
|
-
test('passes SyncError directly without wrapping', () => {
|
|
63
|
-
const { telemetry, sentry } = makeTelemetry()
|
|
64
|
-
const error = new SyncError('sync error')
|
|
65
|
-
telemetry.capture(error)
|
|
66
|
-
expect(sentry.captureException).toHaveBeenCalledWith(error, undefined)
|
|
67
|
-
})
|
|
68
|
-
test('marks error as reported after capture', () => {
|
|
69
|
-
const { telemetry } = makeTelemetry()
|
|
70
|
-
const error = new SyncError('sync error')
|
|
71
|
-
telemetry.capture(error)
|
|
72
|
-
expect(error.isReported()).toBe(true)
|
|
73
|
-
})
|
|
74
|
-
})
|
|
75
|
-
describe('ignoreLike and shouldIgnoreMessage', () => {
|
|
76
|
-
test('ignoreLike with string suppresses matching messages', () => {
|
|
77
|
-
const { telemetry, sentry } = makeTelemetry()
|
|
78
|
-
telemetry.ignoreLike('ignored message')
|
|
79
|
-
telemetry.debug('ignored message')
|
|
80
|
-
expect(sentry.addBreadcrumb).not.toHaveBeenCalled()
|
|
81
|
-
})
|
|
82
|
-
test('ignoreLike with RegExp suppresses matching messages', () => {
|
|
83
|
-
const { telemetry, sentry } = makeTelemetry()
|
|
84
|
-
telemetry.ignoreLike(/ignore.*/)
|
|
85
|
-
telemetry.debug('ignore this')
|
|
86
|
-
expect(sentry.addBreadcrumb).not.toHaveBeenCalled()
|
|
87
|
-
})
|
|
88
|
-
test('shouldIgnoreRejection returns true for matching error', () => {
|
|
89
|
-
const { telemetry } = makeTelemetry()
|
|
90
|
-
telemetry.ignoreLike('abort')
|
|
91
|
-
expect(telemetry.shouldIgnoreRejection(new Error('abort error'))).toBe(true)
|
|
92
|
-
})
|
|
93
|
-
test('shouldIgnoreException returns true for matching Error', () => {
|
|
94
|
-
const { telemetry } = makeTelemetry()
|
|
95
|
-
telemetry.ignoreLike('abort')
|
|
96
|
-
expect(telemetry.shouldIgnoreException(new Error('abort error'))).toBe(true)
|
|
97
|
-
})
|
|
98
|
-
})
|
|
99
|
-
describe('level filtering', () => {
|
|
100
|
-
test('debug does not call Sentry when level is above DEBUG', () => {
|
|
101
|
-
const { telemetry, sentry } = makeTelemetry(SeverityLevel.ERROR)
|
|
102
|
-
telemetry.debug('debug message')
|
|
103
|
-
expect(sentry.addBreadcrumb).not.toHaveBeenCalled()
|
|
104
|
-
})
|
|
105
|
-
test('warn calls captureException with correct level', () => {
|
|
106
|
-
const { telemetry, sentry } = makeTelemetry()
|
|
107
|
-
const error = new Error('warn error')
|
|
108
|
-
telemetry.warn(error)
|
|
109
|
-
expect(sentry.captureException).toHaveBeenCalledWith(error, { level: 'warning' })
|
|
110
|
-
})
|
|
111
|
-
test('error calls captureException with correct level', () => {
|
|
112
|
-
const { telemetry, sentry } = makeTelemetry()
|
|
113
|
-
const error = new Error('error message')
|
|
114
|
-
telemetry.error(error)
|
|
115
|
-
expect(sentry.captureException).toHaveBeenCalledWith(error, { level: 'error' })
|
|
116
|
-
})
|
|
117
|
-
})
|
|
118
|
-
})
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
import EventEmitter from '../../../../src/services/sync/utils/event-emitter'
|
|
2
|
-
type TestEvents = {
|
|
3
|
-
data: [string]
|
|
4
|
-
update: [number, boolean]
|
|
5
|
-
}
|
|
6
|
-
describe('EventEmitter', () => {
|
|
7
|
-
let emitter: EventEmitter<TestEvents>
|
|
8
|
-
beforeEach(() => {
|
|
9
|
-
emitter = new EventEmitter()
|
|
10
|
-
})
|
|
11
|
-
test('on registers a listener and returns an unsubscribe function', () => {
|
|
12
|
-
const listener = jest.fn()
|
|
13
|
-
const unsubscribe = emitter.on('data', listener)
|
|
14
|
-
expect(typeof unsubscribe).toBe('function')
|
|
15
|
-
})
|
|
16
|
-
test('emit calls all registered listeners with correct args', () => {
|
|
17
|
-
const listener = jest.fn()
|
|
18
|
-
emitter.on('data', listener)
|
|
19
|
-
emitter.emit('data', 'hello')
|
|
20
|
-
expect(listener).toHaveBeenCalledWith('hello')
|
|
21
|
-
})
|
|
22
|
-
test('off with a specific function removes only that listener', () => {
|
|
23
|
-
const first = jest.fn()
|
|
24
|
-
const second = jest.fn()
|
|
25
|
-
emitter.on('data', first)
|
|
26
|
-
emitter.on('data', second)
|
|
27
|
-
emitter.off('data', first)
|
|
28
|
-
emitter.emit('data', 'hello')
|
|
29
|
-
expect(first).not.toHaveBeenCalled()
|
|
30
|
-
expect(second).toHaveBeenCalledWith('hello')
|
|
31
|
-
})
|
|
32
|
-
test('off without a function removes all listeners for that event', () => {
|
|
33
|
-
const first = jest.fn()
|
|
34
|
-
const second = jest.fn()
|
|
35
|
-
emitter.on('data', first)
|
|
36
|
-
emitter.on('data', second)
|
|
37
|
-
emitter.off('data')
|
|
38
|
-
emitter.emit('data', 'hello')
|
|
39
|
-
expect(first).not.toHaveBeenCalled()
|
|
40
|
-
expect(second).not.toHaveBeenCalled()
|
|
41
|
-
})
|
|
42
|
-
test('unsubscribe function returned by on removes the listener', () => {
|
|
43
|
-
const listener = jest.fn()
|
|
44
|
-
const unsubscribe = emitter.on('data', listener)
|
|
45
|
-
unsubscribe()
|
|
46
|
-
emitter.emit('data', 'hello')
|
|
47
|
-
expect(listener).not.toHaveBeenCalled()
|
|
48
|
-
})
|
|
49
|
-
test('emit on an event with no listeners does nothing', () => {
|
|
50
|
-
expect(() => emitter.emit('data', 'hello')).not.toThrow()
|
|
51
|
-
})
|
|
52
|
-
test('multiple listeners on same event all receive the emit', () => {
|
|
53
|
-
const first = jest.fn()
|
|
54
|
-
const second = jest.fn()
|
|
55
|
-
const third = jest.fn()
|
|
56
|
-
emitter.on('data', first)
|
|
57
|
-
emitter.on('data', second)
|
|
58
|
-
emitter.on('data', third)
|
|
59
|
-
emitter.emit('data', 'hello')
|
|
60
|
-
expect(first).toHaveBeenCalledWith('hello')
|
|
61
|
-
expect(second).toHaveBeenCalledWith('hello')
|
|
62
|
-
expect(third).toHaveBeenCalledWith('hello')
|
|
63
|
-
})
|
|
64
|
-
})
|
|
@@ -1,245 +0,0 @@
|
|
|
1
|
-
import { createThrottleState, dropThrottle, queueThrottle } from '@/services/sync/utils/throttle'
|
|
2
|
-
|
|
3
|
-
// ---
|
|
4
|
-
|
|
5
|
-
beforeEach(() => {
|
|
6
|
-
jest.useFakeTimers()
|
|
7
|
-
})
|
|
8
|
-
|
|
9
|
-
afterEach(() => {
|
|
10
|
-
jest.useRealTimers()
|
|
11
|
-
})
|
|
12
|
-
|
|
13
|
-
// ---
|
|
14
|
-
|
|
15
|
-
describe('createThrottleState', () => {
|
|
16
|
-
test('initialises with correct defaults', () => {
|
|
17
|
-
const state = createThrottleState(1000)
|
|
18
|
-
|
|
19
|
-
expect(state.minIntervalMs).toBe(1000)
|
|
20
|
-
expect(state.isWaiting).toBe(false)
|
|
21
|
-
expect(state.current).toBeNull()
|
|
22
|
-
expect(state.next).toBeNull()
|
|
23
|
-
expect(state.lastCallTime).toBe(0)
|
|
24
|
-
})
|
|
25
|
-
})
|
|
26
|
-
|
|
27
|
-
// ---
|
|
28
|
-
|
|
29
|
-
describe('dropThrottle', () => {
|
|
30
|
-
test('calls fn immediately on first invocation', async () => {
|
|
31
|
-
const fn = jest.fn().mockResolvedValue('result')
|
|
32
|
-
const state = createThrottleState(1000)
|
|
33
|
-
const throttled = dropThrottle({ state }, fn)
|
|
34
|
-
|
|
35
|
-
const p = throttled()
|
|
36
|
-
jest.runAllTimers()
|
|
37
|
-
await p
|
|
38
|
-
|
|
39
|
-
expect(fn).toHaveBeenCalledTimes(1)
|
|
40
|
-
})
|
|
41
|
-
|
|
42
|
-
test('returns same promise for concurrent calls', async () => {
|
|
43
|
-
const fn = jest.fn().mockResolvedValue('result')
|
|
44
|
-
const state = createThrottleState(1000)
|
|
45
|
-
const throttled = dropThrottle({ state }, fn)
|
|
46
|
-
|
|
47
|
-
const p1 = throttled()
|
|
48
|
-
const p2 = throttled()
|
|
49
|
-
|
|
50
|
-
expect(p1).toBe(p2)
|
|
51
|
-
|
|
52
|
-
await jest.runAllTimersAsync()
|
|
53
|
-
|
|
54
|
-
expect(fn).toHaveBeenCalledTimes(1)
|
|
55
|
-
})
|
|
56
|
-
|
|
57
|
-
test('drops second call when one is already in-flight', async () => {
|
|
58
|
-
const fn = jest.fn().mockResolvedValue('result')
|
|
59
|
-
const state = createThrottleState(0)
|
|
60
|
-
const throttled = dropThrottle({ state }, fn)
|
|
61
|
-
|
|
62
|
-
const p1 = throttled()
|
|
63
|
-
jest.runAllTimers()
|
|
64
|
-
await p1
|
|
65
|
-
|
|
66
|
-
const p2 = throttled()
|
|
67
|
-
jest.runAllTimers()
|
|
68
|
-
await p2
|
|
69
|
-
|
|
70
|
-
expect(fn).toHaveBeenCalledTimes(2)
|
|
71
|
-
})
|
|
72
|
-
|
|
73
|
-
test('waits minIntervalMs before next call', async () => {
|
|
74
|
-
const fn = jest.fn().mockResolvedValue('ok')
|
|
75
|
-
const state = createThrottleState(500)
|
|
76
|
-
const throttled = dropThrottle({ state }, fn)
|
|
77
|
-
|
|
78
|
-
const p1 = throttled()
|
|
79
|
-
jest.runAllTimers()
|
|
80
|
-
await p1
|
|
81
|
-
|
|
82
|
-
// Second call — interval not elapsed yet, so wait is triggered
|
|
83
|
-
const p2 = throttled()
|
|
84
|
-
expect(fn).toHaveBeenCalledTimes(1)
|
|
85
|
-
|
|
86
|
-
jest.advanceTimersByTime(500)
|
|
87
|
-
await p2
|
|
88
|
-
|
|
89
|
-
expect(fn).toHaveBeenCalledTimes(2)
|
|
90
|
-
})
|
|
91
|
-
|
|
92
|
-
test('deferOnce queues next call while current is running', async () => {
|
|
93
|
-
let resolveFirst!: () => void
|
|
94
|
-
const firstPromise = new Promise<string>(res => { resolveFirst = () => res('first') })
|
|
95
|
-
const fn = jest.fn()
|
|
96
|
-
.mockReturnValueOnce(firstPromise)
|
|
97
|
-
.mockResolvedValue('second')
|
|
98
|
-
|
|
99
|
-
const state = createThrottleState(0)
|
|
100
|
-
const throttled = dropThrottle({ state, deferOnce: true }, fn)
|
|
101
|
-
|
|
102
|
-
throttled() // first call — starts wait phase
|
|
103
|
-
|
|
104
|
-
await jest.runAllTimersAsync() // flush wait → fn is now called and in-flight, isWaiting = false
|
|
105
|
-
expect(fn).toHaveBeenCalledTimes(1)
|
|
106
|
-
|
|
107
|
-
throttled() // second call — deferOnce queues it now that isWaiting is false
|
|
108
|
-
|
|
109
|
-
resolveFirst()
|
|
110
|
-
await jest.runAllTimersAsync()
|
|
111
|
-
|
|
112
|
-
expect(fn).toHaveBeenCalledTimes(2)
|
|
113
|
-
})
|
|
114
|
-
|
|
115
|
-
test('deferOnce does not queue when already waiting', () => {
|
|
116
|
-
const fn = jest.fn().mockResolvedValue('ok')
|
|
117
|
-
const state = createThrottleState(1000)
|
|
118
|
-
const throttled = dropThrottle({ state, deferOnce: true }, fn)
|
|
119
|
-
|
|
120
|
-
throttled() // starts wait, sets isWaiting = true
|
|
121
|
-
throttled() // isWaiting is true — does not queue
|
|
122
|
-
throttled() // isWaiting is true — does not queue
|
|
123
|
-
|
|
124
|
-
expect(state.next).toBeNull()
|
|
125
|
-
expect(fn).toHaveBeenCalledTimes(0) // still waiting
|
|
126
|
-
})
|
|
127
|
-
|
|
128
|
-
test('sets lastCallTime after execution', async () => {
|
|
129
|
-
const fn = jest.fn().mockResolvedValue('ok')
|
|
130
|
-
const state = createThrottleState(0)
|
|
131
|
-
const throttled = dropThrottle({ state }, fn)
|
|
132
|
-
|
|
133
|
-
const before = Date.now()
|
|
134
|
-
const p = throttled()
|
|
135
|
-
jest.runAllTimers()
|
|
136
|
-
await p
|
|
137
|
-
|
|
138
|
-
expect(state.lastCallTime).toBeGreaterThanOrEqual(before)
|
|
139
|
-
})
|
|
140
|
-
})
|
|
141
|
-
|
|
142
|
-
// ---
|
|
143
|
-
|
|
144
|
-
describe('queueThrottle', () => {
|
|
145
|
-
test('calls fn immediately on first invocation', async () => {
|
|
146
|
-
const fn = jest.fn().mockResolvedValue('result')
|
|
147
|
-
const state = createThrottleState(0)
|
|
148
|
-
const throttled = queueThrottle({ state }, fn)
|
|
149
|
-
|
|
150
|
-
const p = throttled()
|
|
151
|
-
jest.runAllTimers()
|
|
152
|
-
await p
|
|
153
|
-
|
|
154
|
-
expect(fn).toHaveBeenCalledTimes(1)
|
|
155
|
-
})
|
|
156
|
-
|
|
157
|
-
test('queues second call while first is in-flight', async () => {
|
|
158
|
-
let resolveFirst!: () => void
|
|
159
|
-
const firstDone = new Promise<string>(res => { resolveFirst = () => res('first') })
|
|
160
|
-
const fn = jest.fn()
|
|
161
|
-
.mockReturnValueOnce(firstDone)
|
|
162
|
-
.mockResolvedValue('second')
|
|
163
|
-
|
|
164
|
-
const state = createThrottleState(0)
|
|
165
|
-
const throttled = queueThrottle({ state }, fn)
|
|
166
|
-
|
|
167
|
-
throttled() // first — in-flight
|
|
168
|
-
throttled() // second — queued
|
|
169
|
-
|
|
170
|
-
expect(fn).toHaveBeenCalledTimes(1)
|
|
171
|
-
|
|
172
|
-
resolveFirst()
|
|
173
|
-
await jest.runAllTimersAsync()
|
|
174
|
-
|
|
175
|
-
expect(fn).toHaveBeenCalledTimes(2)
|
|
176
|
-
})
|
|
177
|
-
|
|
178
|
-
test('second call overwrites queued next (only one queued at a time)', async () => {
|
|
179
|
-
let resolveFirst!: () => void
|
|
180
|
-
const firstDone = new Promise<string>(res => { resolveFirst = () => res('first') })
|
|
181
|
-
const fn = jest.fn()
|
|
182
|
-
.mockReturnValueOnce(firstDone)
|
|
183
|
-
.mockResolvedValue('later')
|
|
184
|
-
|
|
185
|
-
const state = createThrottleState(0)
|
|
186
|
-
const throttled = queueThrottle({ state }, fn)
|
|
187
|
-
|
|
188
|
-
throttled()
|
|
189
|
-
throttled() // queued as next
|
|
190
|
-
throttled() // overwrites next
|
|
191
|
-
|
|
192
|
-
expect(fn).toHaveBeenCalledTimes(1)
|
|
193
|
-
expect(state.next).not.toBeNull()
|
|
194
|
-
|
|
195
|
-
resolveFirst()
|
|
196
|
-
await jest.runAllTimersAsync()
|
|
197
|
-
|
|
198
|
-
// Only one more call (the last queued), not two
|
|
199
|
-
expect(fn).toHaveBeenCalledTimes(2)
|
|
200
|
-
})
|
|
201
|
-
|
|
202
|
-
test('returns current promise for concurrent calls', () => {
|
|
203
|
-
const fn = jest.fn().mockResolvedValue('ok')
|
|
204
|
-
const state = createThrottleState(0)
|
|
205
|
-
const throttled = queueThrottle({ state }, fn)
|
|
206
|
-
|
|
207
|
-
const p1 = throttled()
|
|
208
|
-
const p2 = throttled()
|
|
209
|
-
|
|
210
|
-
expect(p1).toBe(p2)
|
|
211
|
-
})
|
|
212
|
-
|
|
213
|
-
test('respects minIntervalMs between calls', async () => {
|
|
214
|
-
const fn = jest.fn().mockResolvedValue('ok')
|
|
215
|
-
const state = createThrottleState(500)
|
|
216
|
-
const throttled = queueThrottle({ state }, fn)
|
|
217
|
-
|
|
218
|
-
const p1 = throttled()
|
|
219
|
-
jest.runAllTimers()
|
|
220
|
-
await p1
|
|
221
|
-
|
|
222
|
-
const before = Date.now()
|
|
223
|
-
jest.advanceTimersByTime(100) // only 100ms since last call
|
|
224
|
-
|
|
225
|
-
const p2 = throttled()
|
|
226
|
-
expect(fn).toHaveBeenCalledTimes(1) // not called yet — waiting for interval
|
|
227
|
-
|
|
228
|
-
jest.advanceTimersByTime(400) // now 500ms elapsed
|
|
229
|
-
await p2
|
|
230
|
-
|
|
231
|
-
expect(fn).toHaveBeenCalledTimes(2)
|
|
232
|
-
})
|
|
233
|
-
|
|
234
|
-
test('clears current after completion', async () => {
|
|
235
|
-
const fn = jest.fn().mockResolvedValue('ok')
|
|
236
|
-
const state = createThrottleState(0)
|
|
237
|
-
const throttled = queueThrottle({ state }, fn)
|
|
238
|
-
|
|
239
|
-
const p = throttled()
|
|
240
|
-
jest.runAllTimers()
|
|
241
|
-
await p
|
|
242
|
-
|
|
243
|
-
expect(state.current).toBeNull()
|
|
244
|
-
})
|
|
245
|
-
})
|
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
import { generateForumPostUrl, generatePlaylistUrl, generateContentUrl } from '../../src/services/urlBuilder'
|
|
2
|
-
jest.mock('../../src/services/config.js', () => ({
|
|
3
|
-
globalConfig: { frontendUrl: 'https://www.musora.com' }
|
|
4
|
-
}))
|
|
5
|
-
jest.mock('../../src/services/sanity.js', () => ({
|
|
6
|
-
...jest.requireActual('../../src/services/sanity.js'),
|
|
7
|
-
fetchByRailContentIds: jest.fn(),
|
|
8
|
-
}))
|
|
9
|
-
jest.mock('../../src/services/contentProgress.js', () => ({
|
|
10
|
-
getNavigateTo: jest.fn(),
|
|
11
|
-
}))
|
|
12
|
-
describe('generateForumPostUrl', () => {
|
|
13
|
-
test('returns path without domain by default', () => {
|
|
14
|
-
const result = generateForumPostUrl({ brand: 'drumeo' })
|
|
15
|
-
expect(result).toBe('/drumeo/forums/jump-to-post/')
|
|
16
|
-
})
|
|
17
|
-
test('returns full URL with domain when withDomain is true', () => {
|
|
18
|
-
const result = generateForumPostUrl({ brand: 'drumeo' }, true)
|
|
19
|
-
expect(result).toBe('https://www.musora.com/drumeo/forums/jump-to-post/')
|
|
20
|
-
})
|
|
21
|
-
})
|
|
22
|
-
|
|
23
|
-
describe('generatePlaylistUrl', () => {
|
|
24
|
-
test('returns /playlists/{id} without domain', () => {
|
|
25
|
-
const result = generatePlaylistUrl({ id: 123 })
|
|
26
|
-
expect(result).toBe('/playlists/123')
|
|
27
|
-
})
|
|
28
|
-
test('returns full URL with domain when withDomain is true', () => {
|
|
29
|
-
const result = generatePlaylistUrl({ id: 123 }, true)
|
|
30
|
-
expect(result).toBe('https://www.musora.com/playlists/123')
|
|
31
|
-
})
|
|
32
|
-
})
|
|
33
|
-
|
|
34
|
-
describe('generateContentUrl', () => {
|
|
35
|
-
test('returns /{brand}/method for type method', async () => {
|
|
36
|
-
const result = await generateContentUrl({ id: 123, type: 'method', brand: 'drumeo' })
|
|
37
|
-
expect(result).toBe('/drumeo/method')
|
|
38
|
-
})
|
|
39
|
-
test('returns # when id is missing', async () => {
|
|
40
|
-
const result = await generateContentUrl({ id: 0, type: 'song', brand: 'drumeo' })
|
|
41
|
-
expect(result).toBe('#')
|
|
42
|
-
})
|
|
43
|
-
test('returns /{brand}/lessons/{id}/live for type live', async () => {
|
|
44
|
-
const result = await generateContentUrl({ id: 123, type: 'live', brand: 'drumeo' })
|
|
45
|
-
expect(result).toBe('/drumeo/lessons/123/live')
|
|
46
|
-
})
|
|
47
|
-
test('returns /{brand}/lessons/course-collection/overview/{id} for type course-collection', async () => {
|
|
48
|
-
const result = await generateContentUrl({ id: 123, type: 'course-collection', brand: 'drumeo' })
|
|
49
|
-
expect(result).toBe('/drumeo/lessons/course-collection/overview/123')
|
|
50
|
-
})
|
|
51
|
-
test('returns /{brand}/lessons/pack/{id} for type pack', async () => {
|
|
52
|
-
const result = await generateContentUrl({ id: 123, type: 'pack', brand: 'drumeo' })
|
|
53
|
-
expect(result).toBe('/drumeo/lessons/pack/123')
|
|
54
|
-
})
|
|
55
|
-
test('returns /{brand}/lessons/pack-bundle/{id}/{navigateTo.id} for pack-bundle with navigateTo', async () => {
|
|
56
|
-
const result = await generateContentUrl({
|
|
57
|
-
id: 123,
|
|
58
|
-
type: 'pack-bundle',
|
|
59
|
-
brand: 'drumeo',
|
|
60
|
-
navigateTo: { id: 456 }
|
|
61
|
-
})
|
|
62
|
-
expect(result).toBe('/drumeo/lessons/pack-bundle/123/456')
|
|
63
|
-
})
|
|
64
|
-
test('returns /{brand}/lessons/pack-bundle/{id} for pack-bundle without navigateTo', async () => {
|
|
65
|
-
const result = await generateContentUrl({ id: 123, type: 'pack-bundle', brand: 'drumeo' })
|
|
66
|
-
expect(result).toBe('/drumeo/lessons/pack-bundle/123')
|
|
67
|
-
})
|
|
68
|
-
test('returns /{brand}/songs/transcription/{id} for type song', async () => {
|
|
69
|
-
const result = await generateContentUrl({ id: 123, type: 'song', brand: 'drumeo' })
|
|
70
|
-
expect(result).toBe('/drumeo/songs/transcription/123')
|
|
71
|
-
})
|
|
72
|
-
})
|