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