musora-content-services 2.167.1 → 2.167.3

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