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.
Files changed (33) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/jest.config.js +6 -3
  3. package/package.json +1 -1
  4. package/src/services/contentProgress.js +7 -11
  5. package/src/services/offline/progress.ts +0 -4
  6. package/src/services/sync/context/index.ts +7 -0
  7. package/src/services/sync/effects/push-failure-notification.ts +2 -2
  8. package/src/services/sync/retry.ts +8 -7
  9. package/src/services/sync/store/index.ts +0 -5
  10. package/src/services/sync/store/push-coalescer.ts +1 -1
  11. package/src/services/sync/telemetry/index.ts +1 -1
  12. package/test/setupTimers.js +13 -0
  13. package/test/unit/sync/adapters/idb-errors.test.ts +1 -1
  14. package/test/unit/sync/adapters/sqlite-errors.test.ts +1 -1
  15. package/test/unit/sync/effects/logout-warning.test.ts +158 -0
  16. package/test/unit/sync/effects/push-failure-notification.test.ts +196 -0
  17. package/test/unit/sync/fetch.test.ts +224 -0
  18. package/test/unit/sync/helpers/TestModel.ts +1 -1
  19. package/test/unit/sync/helpers/index.ts +12 -12
  20. package/test/unit/sync/manager.test.ts +303 -0
  21. package/test/unit/sync/repositories/content-likes.test.ts +4 -4
  22. package/test/unit/sync/repositories/practices.test.ts +4 -4
  23. package/test/unit/sync/repositories/progress.test.ts +4 -4
  24. package/test/unit/sync/repositories/user-award-progress.test.ts +387 -0
  25. package/test/unit/sync/resolver.test.ts +232 -0
  26. package/test/unit/sync/retry.test.ts +314 -0
  27. package/test/unit/sync/store/cross-user-protection.test.ts +217 -0
  28. package/test/unit/sync/store/push-coalescer.test.ts +156 -0
  29. package/test/unit/sync/store/store-idb.test.ts +4 -4
  30. package/test/unit/sync/store/store.test.ts +91 -4
  31. package/test/unit/sync/utils/throttle.test.ts +245 -0
  32. package/tsconfig.json +6 -2
  33. 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
- // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
116
- // moduleNameMapper: {},
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "musora-content-services",
3
- "version": "2.155.0",
3
+ "version": "2.155.2",
4
4
  "description": "A package for Musoras content services ",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -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
- private readonly BASE_BACKOFF = 1_000
7
- private readonly MAX_BACKOFF = 8_000
8
- private readonly MAX_ATTEMPTS = 4
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 >= this.MAX_ATTEMPTS) {
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 = this.BASE_BACKOFF * Math.pow(2, this.failureCount - 1)
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(this.MAX_BACKOFF, delayWithJitter)
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
- private sleep(ms: number) {
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() {
@@ -38,7 +38,7 @@ export default class PushCoalescer {
38
38
  }
39
39
 
40
40
  const cleanup = () => this.intents.splice(this.intents.indexOf(intent), 1)
41
- intent.promise.finally(cleanup)
41
+ intent.promise.finally(cleanup).catch(() => {})
42
42
 
43
43
  this.intents.push(intent)
44
44
 
@@ -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 '../../../../src/services/sync/adapters/lokijs'
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
@@ -27,7 +27,7 @@ jest.mock('@nozbe/watermelondb/adapters/sqlite', () => {
27
27
 
28
28
  import SQLiteErrorAwareAdapter, {
29
29
  FullFailingSQLiteAdapter,
30
- } from '../../../../src/services/sync/adapters/sqlite'
30
+ } from '@/services/sync/adapters/sqlite'
31
31
 
32
32
  // ---
33
33
 
@@ -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
+ })