musora-content-services 2.155.0 → 2.155.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/jest.config.js +6 -3
- package/package.json +1 -1
- package/src/services/contentProgress.js +7 -11
- package/src/services/offline/progress.ts +0 -4
- package/src/services/sync/context/index.ts +7 -0
- package/src/services/sync/effects/push-failure-notification.ts +2 -2
- package/src/services/sync/retry.ts +8 -7
- package/src/services/sync/store/index.ts +0 -5
- package/src/services/sync/store/push-coalescer.ts +1 -1
- package/src/services/sync/telemetry/index.ts +1 -1
- package/test/setupTimers.js +13 -0
- package/test/unit/sync/adapters/idb-errors.test.ts +1 -1
- package/test/unit/sync/adapters/sqlite-errors.test.ts +1 -1
- package/test/unit/sync/effects/logout-warning.test.ts +158 -0
- package/test/unit/sync/effects/push-failure-notification.test.ts +196 -0
- package/test/unit/sync/fetch.test.ts +224 -0
- package/test/unit/sync/helpers/TestModel.ts +1 -1
- package/test/unit/sync/helpers/index.ts +12 -12
- package/test/unit/sync/manager.test.ts +303 -0
- package/test/unit/sync/repositories/content-likes.test.ts +4 -4
- package/test/unit/sync/repositories/practices.test.ts +4 -4
- package/test/unit/sync/repositories/progress.test.ts +4 -4
- package/test/unit/sync/repositories/user-award-progress.test.ts +387 -0
- package/test/unit/sync/resolver.test.ts +232 -0
- package/test/unit/sync/retry.test.ts +314 -0
- package/test/unit/sync/store/cross-user-protection.test.ts +217 -0
- package/test/unit/sync/store/push-coalescer.test.ts +156 -0
- package/test/unit/sync/store/store-idb.test.ts +4 -4
- package/test/unit/sync/store/store.test.ts +91 -4
- package/test/unit/sync/utils/throttle.test.ts +245 -0
- package/tsconfig.json +6 -2
- package/.claude/settings.local.json +0 -10
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
|
|
4
4
|
|
|
5
|
+
### [2.155.2](https://github.com/railroadmedia/musora-content-services/compare/v2.155.1...v2.155.2) (2026-04-22)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* remove prevSession args ([#937](https://github.com/railroadmedia/musora-content-services/issues/937)) ([2fe1dc2](https://github.com/railroadmedia/musora-content-services/commit/2fe1dc280a21e060b15567afe60a4cf959cecc90))
|
|
11
|
+
|
|
12
|
+
### [2.155.1](https://github.com/railroadmedia/musora-content-services/compare/v2.155.0...v2.155.1) (2026-04-22)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
### Bug Fixes
|
|
16
|
+
|
|
17
|
+
* export ([#936](https://github.com/railroadmedia/musora-content-services/issues/936)) ([16b78be](https://github.com/railroadmedia/musora-content-services/commit/16b78be3dca3cee8981f1347a37f1b1b00ffe691))
|
|
18
|
+
|
|
5
19
|
## [2.155.0](https://github.com/railroadmedia/musora-content-services/compare/v2.154.0...v2.155.0) (2026-04-22)
|
|
6
20
|
|
|
7
21
|
|
package/jest.config.js
CHANGED
|
@@ -112,8 +112,9 @@ export default {
|
|
|
112
112
|
// "node"
|
|
113
113
|
// ],
|
|
114
114
|
|
|
115
|
-
|
|
116
|
-
|
|
115
|
+
moduleNameMapper: {
|
|
116
|
+
'^@/(.*)$': '<rootDir>/src/$1',
|
|
117
|
+
},
|
|
117
118
|
|
|
118
119
|
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
|
|
119
120
|
modulePathIgnorePatterns: [
|
|
@@ -166,9 +167,11 @@ export default {
|
|
|
166
167
|
setupFilesAfterEnv: [
|
|
167
168
|
'dotenv/config',
|
|
168
169
|
'<rootDir>/test/setupConsole.js',
|
|
169
|
-
'<rootDir>/test/setupNetworkGuard.js'
|
|
170
|
+
'<rootDir>/test/setupNetworkGuard.js',
|
|
171
|
+
'<rootDir>/test/setupTimers.js'
|
|
170
172
|
],
|
|
171
173
|
|
|
174
|
+
|
|
172
175
|
// The number of seconds after which a test is considered as slow and reported as such in the results.
|
|
173
176
|
// slowTestThreshold: 5,
|
|
174
177
|
|
package/package.json
CHANGED
|
@@ -434,7 +434,6 @@ async function _getAllStartedOrCompleted({
|
|
|
434
434
|
* @param {int} mediaLengthSeconds - total length of video media || live event duration if livestream
|
|
435
435
|
* @param {int} currentSeconds - seconds timestamp relative to beginning of video
|
|
436
436
|
* @param {int} secondsPlayed - seconds played in this watch session (since last pause)
|
|
437
|
-
* @param {string|null} prevSession - This function records a sessionId to pass into future updates to progress on the same video
|
|
438
437
|
* @param {int|null} instrumentId - enum value of instrument id
|
|
439
438
|
* @param {int|null} categoryId - enum value of category id
|
|
440
439
|
* @param {boolean|null} isLivestream - determines livestream-specific progress handling
|
|
@@ -456,7 +455,6 @@ export async function recordWatchSession(
|
|
|
456
455
|
secondsPlayed,
|
|
457
456
|
{
|
|
458
457
|
collection,
|
|
459
|
-
prevSession,
|
|
460
458
|
instrumentId,
|
|
461
459
|
categoryId,
|
|
462
460
|
isLivestream,
|
|
@@ -473,7 +471,6 @@ export async function recordWatchSession(
|
|
|
473
471
|
* @param {int} mediaLengthSeconds - total length of video media || live event duration if livestream
|
|
474
472
|
* @param {int} currentSeconds - seconds timestamp relative to beginning of video
|
|
475
473
|
* @param {int} secondsPlayed - seconds played in this watch session (since last pause)
|
|
476
|
-
* @param {string|null} prevSession - This function records a sessionId to pass into future updates to progress on the same video
|
|
477
474
|
* @param {int|null} instrumentId - enum value of instrument id
|
|
478
475
|
* @param {int|null} categoryId - enum value of category id
|
|
479
476
|
* @param {boolean|null} isLivestream - determines livestream-specific progress handling
|
|
@@ -488,7 +485,6 @@ export async function _recordWatchSession(
|
|
|
488
485
|
secondsPlayed,
|
|
489
486
|
{
|
|
490
487
|
collection = null,
|
|
491
|
-
prevSession = null,
|
|
492
488
|
instrumentId = null,
|
|
493
489
|
categoryId = null,
|
|
494
490
|
isLivestream = false,
|
|
@@ -651,7 +647,7 @@ async function saveContentProgress(
|
|
|
651
647
|
return response
|
|
652
648
|
}
|
|
653
649
|
|
|
654
|
-
async function setStartedOrCompletedStatus(contentId, collection, isCompleted, { isOffline = false, hierarchy = null, skipPush = false, skipBubbleTrickle = false } = {}) {
|
|
650
|
+
export async function setStartedOrCompletedStatus(contentId, collection, isCompleted, { isOffline = false, hierarchy = null, skipPush = false, skipBubbleTrickle = false } = {}) {
|
|
655
651
|
const isLP = collection?.type === COLLECTION_TYPE.LEARNING_PATH
|
|
656
652
|
|
|
657
653
|
if (!isOffline) {
|
|
@@ -668,13 +664,13 @@ async function setStartedOrCompletedStatus(contentId, collection, isCompleted, {
|
|
|
668
664
|
null,
|
|
669
665
|
{skipPush: true}
|
|
670
666
|
)
|
|
671
|
-
|
|
667
|
+
|
|
672
668
|
// skip bubbling if offline
|
|
673
669
|
if (isOffline) {
|
|
674
670
|
if (!skipPush) db.contentProgress.requestPushUnsynced('save-content-progress')
|
|
675
671
|
return response
|
|
676
672
|
}
|
|
677
|
-
|
|
673
|
+
|
|
678
674
|
let allProgresses = {}
|
|
679
675
|
allProgresses[contentId] = progress
|
|
680
676
|
|
|
@@ -728,7 +724,7 @@ export async function setStartedOrCompletedStatusMany(contentIds, collection, is
|
|
|
728
724
|
if (!skipPush) db.contentProgress.requestPushUnsynced('save-content-progress')
|
|
729
725
|
return response
|
|
730
726
|
}
|
|
731
|
-
|
|
727
|
+
|
|
732
728
|
let allProgresses = Object.fromEntries(contentIds.map(id => [id, progress]))
|
|
733
729
|
|
|
734
730
|
let progresses = {}
|
|
@@ -766,16 +762,16 @@ export async function resetStatus(contentId, collection = null, { isOffline = fa
|
|
|
766
762
|
|
|
767
763
|
const progress = 0
|
|
768
764
|
const response = await db.contentProgress.eraseProgress(normalizeContentId(contentId), normalizeCollection(collection), {skipPush: true})
|
|
769
|
-
|
|
765
|
+
|
|
770
766
|
// skip bubbling if offline
|
|
771
767
|
if (isOffline) {
|
|
772
768
|
if (!skipPush) db.contentProgress.requestPushUnsynced('save-content-progress')
|
|
773
769
|
return response
|
|
774
770
|
}
|
|
775
|
-
|
|
771
|
+
|
|
776
772
|
hierarchy = await getHierarchy(contentId, collection)
|
|
777
773
|
const metadata = hierarchy.metadata || {}
|
|
778
|
-
|
|
774
|
+
|
|
779
775
|
let allProgresses = {}
|
|
780
776
|
allProgresses[contentId] = progress
|
|
781
777
|
|
|
@@ -23,7 +23,6 @@ interface HierarchyParameter {
|
|
|
23
23
|
* @param secondsPlayed - Seconds actively watched in this session
|
|
24
24
|
* @param hierarchy - Content hierarchy used to update parent progress offline
|
|
25
25
|
* @param options.collection - Collection context; defaults to self
|
|
26
|
-
* @param options.prevSession - Previous session identifier for continuity
|
|
27
26
|
* @param options.instrumentId - Instrument filter for the session
|
|
28
27
|
* @param options.categoryId - Category filter for the session
|
|
29
28
|
*/
|
|
@@ -35,12 +34,10 @@ export async function recordWatchSessionOffline(
|
|
|
35
34
|
hierarchy: HierarchyParameter,
|
|
36
35
|
{
|
|
37
36
|
collection = null,
|
|
38
|
-
prevSession = null,
|
|
39
37
|
instrumentId = null,
|
|
40
38
|
categoryId = null,
|
|
41
39
|
}: {
|
|
42
40
|
collection?: CollectionParameter|null,
|
|
43
|
-
prevSession?: string|null,
|
|
44
41
|
instrumentId?: number|null,
|
|
45
42
|
categoryId?: number|null
|
|
46
43
|
} = {}
|
|
@@ -52,7 +49,6 @@ export async function recordWatchSessionOffline(
|
|
|
52
49
|
secondsPlayed,
|
|
53
50
|
{
|
|
54
51
|
collection,
|
|
55
|
-
prevSession,
|
|
56
52
|
instrumentId,
|
|
57
53
|
categoryId,
|
|
58
54
|
isOffline: true,
|
|
@@ -17,26 +17,33 @@ type Providers = {
|
|
|
17
17
|
export default class SyncContext {
|
|
18
18
|
constructor(private providers: Providers) {}
|
|
19
19
|
|
|
20
|
+
/* istanbul ignore next */
|
|
20
21
|
start() {
|
|
21
22
|
Object.values(this.providers).forEach((p) => p.start())
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
/* istanbul ignore next */
|
|
24
26
|
stop() {
|
|
25
27
|
Object.values(this.providers).forEach((p) => p.stop())
|
|
26
28
|
}
|
|
27
29
|
|
|
30
|
+
/* istanbul ignore next */
|
|
28
31
|
get session() {
|
|
29
32
|
return this.providers.session
|
|
30
33
|
}
|
|
34
|
+
/* istanbul ignore next */
|
|
31
35
|
get connectivity() {
|
|
32
36
|
return this.providers.connectivity
|
|
33
37
|
}
|
|
38
|
+
/* istanbul ignore next */
|
|
34
39
|
get visibility() {
|
|
35
40
|
return this.providers.visibility
|
|
36
41
|
}
|
|
42
|
+
/* istanbul ignore next */
|
|
37
43
|
get tabs() {
|
|
38
44
|
return this.providers.tabs
|
|
39
45
|
}
|
|
46
|
+
/* istanbul ignore next */
|
|
40
47
|
get durability() {
|
|
41
48
|
return this.providers.durability
|
|
42
49
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type SyncEffect } from '.'
|
|
2
2
|
|
|
3
|
-
const NOTIFICATION_COOLDOWN = 60_000 * 10 // 10 mins
|
|
4
|
-
const MUTE_PERIOD = 60_000 * 60 * 3 // 3 hours
|
|
3
|
+
export const NOTIFICATION_COOLDOWN = 60_000 * 10 // 10 mins
|
|
4
|
+
export const MUTE_PERIOD = 60_000 * 60 * 3 // 3 hours
|
|
5
5
|
|
|
6
6
|
const createPushFailureNotificationEffect = (notifyCallback: (opts: { mute: () => void }) => void) => {
|
|
7
7
|
let lastNotifiedAt = 0
|
|
@@ -3,9 +3,10 @@ import { SyncResponse } from "./fetch"
|
|
|
3
3
|
import { SyncTelemetry } from "./telemetry/index"
|
|
4
4
|
|
|
5
5
|
export default class SyncRetry {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
static readonly BASE_BACKOFF = 1_000
|
|
7
|
+
static readonly MAX_BACKOFF = 8_000
|
|
8
|
+
static readonly MAX_ATTEMPTS = 4
|
|
9
|
+
|
|
9
10
|
|
|
10
11
|
private paused = false
|
|
11
12
|
private backoffUntil = 0
|
|
@@ -62,7 +63,7 @@ export default class SyncRetry {
|
|
|
62
63
|
} else {
|
|
63
64
|
if ('isRetryable' in result && result.isRetryable) {
|
|
64
65
|
this.scheduleBackoff()
|
|
65
|
-
if (attempt >=
|
|
66
|
+
if (attempt >= SyncRetry.MAX_ATTEMPTS) {
|
|
66
67
|
options.onFail?.()
|
|
67
68
|
return result
|
|
68
69
|
}
|
|
@@ -84,16 +85,16 @@ export default class SyncRetry {
|
|
|
84
85
|
private scheduleBackoff() {
|
|
85
86
|
this.failureCount++
|
|
86
87
|
|
|
87
|
-
const exponentialDelay =
|
|
88
|
+
const exponentialDelay = SyncRetry.BASE_BACKOFF * Math.pow(2, this.failureCount - 1)
|
|
88
89
|
const jitter = exponentialDelay * 0.25 * (Math.random() - 0.5)
|
|
89
90
|
const delayWithJitter = exponentialDelay + jitter
|
|
90
91
|
|
|
91
|
-
this.backoffUntil = Date.now() + Math.min(
|
|
92
|
+
this.backoffUntil = Date.now() + Math.min(SyncRetry.MAX_BACKOFF, delayWithJitter)
|
|
92
93
|
|
|
93
94
|
this.telemetry.debug('[Retry] Scheduling backoff', { failureCount: this.failureCount, backoffUntil: this.backoffUntil })
|
|
94
95
|
}
|
|
95
96
|
|
|
96
|
-
|
|
97
|
+
protected sleep(ms: number) {
|
|
97
98
|
return new Promise(resolve => setTimeout(resolve, ms))
|
|
98
99
|
}
|
|
99
100
|
}
|
|
@@ -999,11 +999,6 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
|
|
|
999
999
|
}, 'sync.cleanup')
|
|
1000
1000
|
})
|
|
1001
1001
|
}, SyncStore.CLEANUP_INTERVAL)
|
|
1002
|
-
|
|
1003
|
-
// in tests in node env, prevents the timer from keeping the process alive
|
|
1004
|
-
if (typeof (this.cleanupTimer as any).unref === 'function') {
|
|
1005
|
-
(this.cleanupTimer as any).unref()
|
|
1006
|
-
}
|
|
1007
1002
|
}
|
|
1008
1003
|
|
|
1009
1004
|
private stopCleanupTimer() {
|
|
@@ -108,7 +108,7 @@ export class SyncTelemetry {
|
|
|
108
108
|
|
|
109
109
|
this.consoleLog(SeverityLevel.DEBUG, 'info', `[trace:start] ${options.name} (${desc})`, options.attributes)
|
|
110
110
|
const result = callback(span)
|
|
111
|
-
Promise.resolve(result).finally(() => this.consoleLog(SeverityLevel.DEBUG, 'info', `[trace:end] ${options.name} (${desc})`, options.attributes))
|
|
111
|
+
Promise.resolve(result).catch(() => {}).finally(() => this.consoleLog(SeverityLevel.DEBUG, 'info', `[trace:end] ${options.name} (${desc})`, options.attributes))
|
|
112
112
|
|
|
113
113
|
return result
|
|
114
114
|
})
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const _originalSetTimeout = global.setTimeout
|
|
2
|
+
global.setTimeout = function(fn, delay, ...args) {
|
|
3
|
+
const timer = _originalSetTimeout(fn, delay, ...args)
|
|
4
|
+
timer?.unref?.()
|
|
5
|
+
return timer
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const _originalSetInterval = global.setInterval
|
|
9
|
+
global.setInterval = function(fn, delay, ...args) {
|
|
10
|
+
const timer = _originalSetInterval(fn, delay, ...args)
|
|
11
|
+
timer?.unref?.()
|
|
12
|
+
return timer
|
|
13
|
+
}
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
simulateIndexedDBUnavailable,
|
|
13
13
|
simulateIndexedDBFailure,
|
|
14
14
|
simulateIndexedDBQuotaExceeded,
|
|
15
|
-
} from '
|
|
15
|
+
} from '@/services/sync/adapters/lokijs'
|
|
16
16
|
|
|
17
17
|
// Each test gets a fresh indexedDB instance so patches don't bleed between tests
|
|
18
18
|
let originalOpen: typeof indexedDB.open
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import createLogoutWarningEffect from '@/services/sync/effects/logout-warning'
|
|
2
|
+
import type SyncStore from '@/services/sync/store/index'
|
|
3
|
+
import { makeContext } from '../helpers/index'
|
|
4
|
+
|
|
5
|
+
// ---
|
|
6
|
+
|
|
7
|
+
function makeMockStore(model: any = { table: 'test' }) {
|
|
8
|
+
let handler: ((records: any[]) => void) | null = null
|
|
9
|
+
|
|
10
|
+
const store = {
|
|
11
|
+
model,
|
|
12
|
+
collection: {
|
|
13
|
+
query: jest.fn().mockReturnValue({
|
|
14
|
+
observe: jest.fn().mockReturnValue({
|
|
15
|
+
subscribe: (h: (records: any[]) => void) => {
|
|
16
|
+
handler = h
|
|
17
|
+
return { unsubscribe: () => { handler = null } }
|
|
18
|
+
},
|
|
19
|
+
}),
|
|
20
|
+
}),
|
|
21
|
+
},
|
|
22
|
+
} as unknown as SyncStore
|
|
23
|
+
|
|
24
|
+
const push = (records: any[]) => handler?.(records)
|
|
25
|
+
|
|
26
|
+
return { store, push }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ---
|
|
30
|
+
|
|
31
|
+
describe('notification on unsynced records', () => {
|
|
32
|
+
test('notifyCallback called when unsynced records appear', () => {
|
|
33
|
+
const notify = jest.fn()
|
|
34
|
+
const { store, push } = makeMockStore()
|
|
35
|
+
|
|
36
|
+
createLogoutWarningEffect(notify)(makeContext(), [store])
|
|
37
|
+
push([{ id: 'rec-1' }])
|
|
38
|
+
|
|
39
|
+
expect(notify).toHaveBeenCalledTimes(1)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('notifyCallback called with the model class of the store', () => {
|
|
43
|
+
const notify = jest.fn()
|
|
44
|
+
const model = { table: 'content_progress' }
|
|
45
|
+
const { store, push } = makeMockStore(model)
|
|
46
|
+
|
|
47
|
+
createLogoutWarningEffect(notify)(makeContext(), [store])
|
|
48
|
+
push([{ id: 'rec-1' }])
|
|
49
|
+
|
|
50
|
+
expect(notify.mock.calls[0][0]).toContain(model)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('notifyCallback called with empty array when records go back to zero', () => {
|
|
54
|
+
const notify = jest.fn()
|
|
55
|
+
const { store, push } = makeMockStore()
|
|
56
|
+
|
|
57
|
+
createLogoutWarningEffect(notify)(makeContext(), [store])
|
|
58
|
+
push([{ id: 'rec-1' }])
|
|
59
|
+
push([])
|
|
60
|
+
|
|
61
|
+
expect(notify).toHaveBeenCalledTimes(2)
|
|
62
|
+
expect(notify.mock.calls[1][0]).toHaveLength(0)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('model removed from unsyncedModels when records cleared', () => {
|
|
66
|
+
const notify = jest.fn()
|
|
67
|
+
const model = { table: 'content_progress' }
|
|
68
|
+
const { store, push } = makeMockStore(model)
|
|
69
|
+
|
|
70
|
+
createLogoutWarningEffect(notify)(makeContext(), [store])
|
|
71
|
+
push([{ id: 'rec-1' }])
|
|
72
|
+
push([])
|
|
73
|
+
|
|
74
|
+
expect(notify.mock.calls[1][0]).not.toContain(model)
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
// ---
|
|
79
|
+
|
|
80
|
+
describe('multiple stores', () => {
|
|
81
|
+
test('tracks unsynced models across stores independently', () => {
|
|
82
|
+
const notify = jest.fn()
|
|
83
|
+
const modelA = { table: 'content_progress' }
|
|
84
|
+
const modelB = { table: 'content_likes' }
|
|
85
|
+
const storeA = makeMockStore(modelA)
|
|
86
|
+
const storeB = makeMockStore(modelB)
|
|
87
|
+
|
|
88
|
+
createLogoutWarningEffect(notify)(makeContext(), [storeA.store, storeB.store])
|
|
89
|
+
|
|
90
|
+
storeA.push([{ id: 'rec-1' }])
|
|
91
|
+
storeB.push([{ id: 'rec-2' }])
|
|
92
|
+
|
|
93
|
+
const lastCall = notify.mock.calls[notify.mock.calls.length - 1][0]
|
|
94
|
+
expect(lastCall).toContain(modelA)
|
|
95
|
+
expect(lastCall).toContain(modelB)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('clears only the model whose records were resolved', () => {
|
|
99
|
+
const notify = jest.fn()
|
|
100
|
+
const modelA = { table: 'content_progress' }
|
|
101
|
+
const modelB = { table: 'content_likes' }
|
|
102
|
+
const storeA = makeMockStore(modelA)
|
|
103
|
+
const storeB = makeMockStore(modelB)
|
|
104
|
+
|
|
105
|
+
createLogoutWarningEffect(notify)(makeContext(), [storeA.store, storeB.store])
|
|
106
|
+
|
|
107
|
+
storeA.push([{ id: 'rec-1' }])
|
|
108
|
+
storeB.push([{ id: 'rec-2' }])
|
|
109
|
+
storeA.push([]) // storeA resolved
|
|
110
|
+
|
|
111
|
+
const lastCall = notify.mock.calls[notify.mock.calls.length - 1][0]
|
|
112
|
+
expect(lastCall).not.toContain(modelA)
|
|
113
|
+
expect(lastCall).toContain(modelB)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('notifies once per store event regardless of other stores', () => {
|
|
117
|
+
const notify = jest.fn()
|
|
118
|
+
const storeA = makeMockStore({ table: 'a' })
|
|
119
|
+
const storeB = makeMockStore({ table: 'b' })
|
|
120
|
+
|
|
121
|
+
createLogoutWarningEffect(notify)(makeContext(), [storeA.store, storeB.store])
|
|
122
|
+
|
|
123
|
+
storeA.push([{ id: '1' }])
|
|
124
|
+
storeA.push([{ id: '1' }])
|
|
125
|
+
storeB.push([{ id: '2' }])
|
|
126
|
+
|
|
127
|
+
expect(notify).toHaveBeenCalledTimes(3)
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
// ---
|
|
132
|
+
|
|
133
|
+
describe('teardown', () => {
|
|
134
|
+
test('teardown unsubscribes all store subscriptions', () => {
|
|
135
|
+
const notify = jest.fn()
|
|
136
|
+
const { store, push } = makeMockStore()
|
|
137
|
+
|
|
138
|
+
const teardown = createLogoutWarningEffect(notify)(makeContext(), [store])
|
|
139
|
+
teardown()
|
|
140
|
+
push([{ id: 'rec-1' }])
|
|
141
|
+
|
|
142
|
+
expect(notify).not.toHaveBeenCalled()
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test('teardown unsubscribes all stores', () => {
|
|
146
|
+
const notify = jest.fn()
|
|
147
|
+
const storeA = makeMockStore({ table: 'a' })
|
|
148
|
+
const storeB = makeMockStore({ table: 'b' })
|
|
149
|
+
|
|
150
|
+
const teardown = createLogoutWarningEffect(notify)(makeContext(), [storeA.store, storeB.store])
|
|
151
|
+
teardown()
|
|
152
|
+
|
|
153
|
+
storeA.push([{ id: '1' }])
|
|
154
|
+
storeB.push([{ id: '2' }])
|
|
155
|
+
|
|
156
|
+
expect(notify).not.toHaveBeenCalled()
|
|
157
|
+
})
|
|
158
|
+
})
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import createPushFailureNotificationEffect, { NOTIFICATION_COOLDOWN, MUTE_PERIOD } from '@/services/sync/effects/push-failure-notification'
|
|
2
|
+
import type SyncStore from '@/services/sync/store/index'
|
|
3
|
+
import { makeContext } from '../helpers/index'
|
|
4
|
+
|
|
5
|
+
// ---
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
jest.useFakeTimers()
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
jest.useRealTimers()
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
// ---
|
|
16
|
+
|
|
17
|
+
function makeMockStore() {
|
|
18
|
+
const listeners = new Map<string, (() => void)[]>()
|
|
19
|
+
|
|
20
|
+
const store = {
|
|
21
|
+
on: jest.fn().mockImplementation((event: string, handler: () => void) => {
|
|
22
|
+
const list = listeners.get(event) ?? []
|
|
23
|
+
list.push(handler)
|
|
24
|
+
listeners.set(event, list)
|
|
25
|
+
return () => {
|
|
26
|
+
const idx = list.indexOf(handler)
|
|
27
|
+
if (idx >= 0) list.splice(idx, 1)
|
|
28
|
+
}
|
|
29
|
+
}),
|
|
30
|
+
} as unknown as SyncStore
|
|
31
|
+
|
|
32
|
+
const emit = (event: string) => listeners.get(event)?.forEach(h => h())
|
|
33
|
+
|
|
34
|
+
return { store, emit }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function makeEffect(notify = jest.fn()) {
|
|
38
|
+
const effect = createPushFailureNotificationEffect(notify)
|
|
39
|
+
return { effect, notify }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ---
|
|
43
|
+
|
|
44
|
+
describe('notification triggering', () => {
|
|
45
|
+
test('notifyCallback called when failedPush fires', () => {
|
|
46
|
+
const { store, emit } = makeMockStore()
|
|
47
|
+
const { effect, notify } = makeEffect()
|
|
48
|
+
|
|
49
|
+
effect(makeContext(), [store])
|
|
50
|
+
emit('failedPush')
|
|
51
|
+
|
|
52
|
+
expect(notify).toHaveBeenCalledTimes(1)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('notifyCallback called with mute function', () => {
|
|
56
|
+
const { store, emit } = makeMockStore()
|
|
57
|
+
const { effect, notify } = makeEffect()
|
|
58
|
+
|
|
59
|
+
effect(makeContext(), [store])
|
|
60
|
+
emit('failedPush')
|
|
61
|
+
|
|
62
|
+
expect(notify.mock.calls[0][0]).toHaveProperty('mute')
|
|
63
|
+
expect(typeof notify.mock.calls[0][0].mute).toBe('function')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('failedPush on any store triggers notification', () => {
|
|
67
|
+
const s1 = makeMockStore()
|
|
68
|
+
const s2 = makeMockStore()
|
|
69
|
+
const { effect, notify } = makeEffect()
|
|
70
|
+
|
|
71
|
+
effect(makeContext(), [s1.store, s2.store])
|
|
72
|
+
|
|
73
|
+
s2.emit('failedPush')
|
|
74
|
+
|
|
75
|
+
expect(notify).toHaveBeenCalledTimes(1)
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
// ---
|
|
80
|
+
|
|
81
|
+
describe('cooldown', () => {
|
|
82
|
+
test('second failedPush within cooldown does not notify again', () => {
|
|
83
|
+
const { store, emit } = makeMockStore()
|
|
84
|
+
const { effect, notify } = makeEffect()
|
|
85
|
+
|
|
86
|
+
effect(makeContext(), [store])
|
|
87
|
+
emit('failedPush')
|
|
88
|
+
emit('failedPush')
|
|
89
|
+
|
|
90
|
+
expect(notify).toHaveBeenCalledTimes(1)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('notifies again after cooldown expires', () => {
|
|
94
|
+
const { store, emit } = makeMockStore()
|
|
95
|
+
const { effect, notify } = makeEffect()
|
|
96
|
+
|
|
97
|
+
effect(makeContext(), [store])
|
|
98
|
+
emit('failedPush')
|
|
99
|
+
|
|
100
|
+
jest.advanceTimersByTime(NOTIFICATION_COOLDOWN)
|
|
101
|
+
emit('failedPush')
|
|
102
|
+
|
|
103
|
+
expect(notify).toHaveBeenCalledTimes(2)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('does not notify just before cooldown expires', () => {
|
|
107
|
+
const { store, emit } = makeMockStore()
|
|
108
|
+
const { effect, notify } = makeEffect()
|
|
109
|
+
|
|
110
|
+
effect(makeContext(), [store])
|
|
111
|
+
emit('failedPush')
|
|
112
|
+
|
|
113
|
+
jest.advanceTimersByTime(NOTIFICATION_COOLDOWN - 1)
|
|
114
|
+
emit('failedPush')
|
|
115
|
+
|
|
116
|
+
expect(notify).toHaveBeenCalledTimes(1)
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
// ---
|
|
121
|
+
|
|
122
|
+
describe('mute', () => {
|
|
123
|
+
test('mute() prevents further notifications', () => {
|
|
124
|
+
const { store, emit } = makeMockStore()
|
|
125
|
+
const { effect, notify } = makeEffect()
|
|
126
|
+
|
|
127
|
+
effect(makeContext(), [store])
|
|
128
|
+
emit('failedPush')
|
|
129
|
+
|
|
130
|
+
const { mute } = notify.mock.calls[0][0]
|
|
131
|
+
mute()
|
|
132
|
+
|
|
133
|
+
jest.advanceTimersByTime(NOTIFICATION_COOLDOWN)
|
|
134
|
+
emit('failedPush')
|
|
135
|
+
|
|
136
|
+
expect(notify).toHaveBeenCalledTimes(1)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('notifications resume after mute period expires', () => {
|
|
140
|
+
const { store, emit } = makeMockStore()
|
|
141
|
+
const { effect, notify } = makeEffect()
|
|
142
|
+
|
|
143
|
+
effect(makeContext(), [store])
|
|
144
|
+
emit('failedPush')
|
|
145
|
+
|
|
146
|
+
const { mute } = notify.mock.calls[0][0]
|
|
147
|
+
mute()
|
|
148
|
+
|
|
149
|
+
jest.advanceTimersByTime(MUTE_PERIOD + NOTIFICATION_COOLDOWN)
|
|
150
|
+
emit('failedPush')
|
|
151
|
+
|
|
152
|
+
expect(notify).toHaveBeenCalledTimes(2)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
test('mute does not suppress notification already in progress', () => {
|
|
156
|
+
const { store, emit } = makeMockStore()
|
|
157
|
+
const { effect, notify } = makeEffect()
|
|
158
|
+
|
|
159
|
+
effect(makeContext(), [store])
|
|
160
|
+
emit('failedPush')
|
|
161
|
+
|
|
162
|
+
expect(notify).toHaveBeenCalledTimes(1)
|
|
163
|
+
})
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
// ---
|
|
167
|
+
|
|
168
|
+
describe('teardown', () => {
|
|
169
|
+
test('teardown prevents further notifications', () => {
|
|
170
|
+
const { store, emit } = makeMockStore()
|
|
171
|
+
const { effect, notify } = makeEffect()
|
|
172
|
+
|
|
173
|
+
const teardown = effect(makeContext(), [store])
|
|
174
|
+
teardown()
|
|
175
|
+
|
|
176
|
+
jest.advanceTimersByTime(NOTIFICATION_COOLDOWN)
|
|
177
|
+
emit('failedPush')
|
|
178
|
+
|
|
179
|
+
expect(notify).not.toHaveBeenCalled()
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test('teardown unsubscribes all stores', () => {
|
|
183
|
+
const s1 = makeMockStore()
|
|
184
|
+
const s2 = makeMockStore()
|
|
185
|
+
const { effect, notify } = makeEffect()
|
|
186
|
+
|
|
187
|
+
const teardown = effect(makeContext(), [s1.store, s2.store])
|
|
188
|
+
teardown()
|
|
189
|
+
|
|
190
|
+
jest.advanceTimersByTime(NOTIFICATION_COOLDOWN)
|
|
191
|
+
s1.emit('failedPush')
|
|
192
|
+
s2.emit('failedPush')
|
|
193
|
+
|
|
194
|
+
expect(notify).not.toHaveBeenCalled()
|
|
195
|
+
})
|
|
196
|
+
})
|