musora-content-services 2.167.2 → 2.167.3

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