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
@@ -0,0 +1,224 @@
1
+ import * as fflate from 'fflate'
2
+ import { handlePull, type SyncPullResponse } from '@/services/sync/fetch'
3
+ import { makeContext } from './helpers/index'
4
+ import type { EpochMs } from '@/services/sync/index'
5
+
6
+ // ---
7
+
8
+ const USER_ID = 1
9
+ const TOKEN = 1700000001000 as EpochMs
10
+ const TABLE = 'content_progress'
11
+
12
+ function gzipBase64(data: object): string {
13
+ const bytes = fflate.strToU8(JSON.stringify(data))
14
+ const compressed = fflate.gzipSync(bytes)
15
+ let binary = ''
16
+ compressed.forEach(b => { binary += String.fromCharCode(b) })
17
+ return btoa(binary)
18
+ }
19
+
20
+ function rawPullBody(overrides: object = {}) {
21
+ return {
22
+ meta: { since: null, max_updated_at: TOKEN, timestamp: TOKEN },
23
+ entries: [],
24
+ ...overrides,
25
+ }
26
+ }
27
+
28
+ function mockResponse(body: string, headers: Record<string, string> = {}, status = 200) {
29
+ return new Response(body, {
30
+ status,
31
+ headers: { 'X-Sync-Intended-User-Id': String(USER_ID), ...headers },
32
+ })
33
+ }
34
+
35
+ function makePull() {
36
+ return handlePull(() => new Request('https://api.example.com/sync'))
37
+ }
38
+
39
+ function callPull(pull: ReturnType<typeof handlePull>, lastFetchToken: any = null) {
40
+ return pull(TABLE, 1, USER_ID, makeContext(), new AbortController().signal, lastFetchToken)
41
+ }
42
+
43
+ // ---
44
+
45
+ describe('plain JSON response', () => {
46
+ test('parses entries from JSON body', async () => {
47
+ const entry = { record: { id: 'rec-1' }, meta: { ids: { id: 'rec-1' }, lifecycle: { created_at: TOKEN, updated_at: TOKEN, deleted_at: null } } }
48
+ jest.spyOn(global, 'fetch').mockResolvedValueOnce(
49
+ mockResponse(JSON.stringify(rawPullBody({ entries: [entry] })))
50
+ )
51
+
52
+ const result = await callPull(makePull())
53
+
54
+ expect(result).toMatchObject({ ok: true, entries: expect.arrayContaining([entry]) })
55
+ })
56
+
57
+ test('returns token from max_updated_at', async () => {
58
+ jest.spyOn(global, 'fetch').mockResolvedValueOnce(
59
+ mockResponse(JSON.stringify(rawPullBody()))
60
+ )
61
+
62
+ const result = await callPull(makePull())
63
+
64
+ expect(result).toMatchObject({ ok: true, token: TOKEN })
65
+ })
66
+
67
+ test('falls back to timestamp when max_updated_at is null', async () => {
68
+ const timestamp = 1700000009999 as EpochMs
69
+ jest.spyOn(global, 'fetch').mockResolvedValueOnce(
70
+ mockResponse(JSON.stringify({ meta: { since: null, max_updated_at: null, timestamp }, entries: [] }))
71
+ )
72
+
73
+ const result = await callPull(makePull())
74
+
75
+ expect(result).toMatchObject({ ok: true, token: timestamp })
76
+ })
77
+ })
78
+
79
+ // ---
80
+
81
+ describe('gzip-base64 response', () => {
82
+ test('decompresses and parses entries', async () => {
83
+ const entry = { record: { id: 'gz-1' }, meta: { ids: { id: 'gz-1' }, lifecycle: { created_at: TOKEN, updated_at: TOKEN, deleted_at: null } } }
84
+ jest.spyOn(global, 'fetch').mockResolvedValueOnce(
85
+ mockResponse(
86
+ gzipBase64(rawPullBody({ entries: [entry] })),
87
+ { 'X-Sync-Content-Encoding': 'gzip-base64' }
88
+ )
89
+ )
90
+
91
+ const result = await callPull(makePull())
92
+
93
+ expect(result).toMatchObject({ ok: true, entries: expect.arrayContaining([entry]) })
94
+ })
95
+
96
+ test('entry data survives compression round-trip', async () => {
97
+ const entry = {
98
+ record: { id: 'gz-2', value: 'hello', score: 42 },
99
+ meta: { ids: { id: 'gz-2' }, lifecycle: { created_at: TOKEN, updated_at: TOKEN, deleted_at: null } },
100
+ }
101
+ jest.spyOn(global, 'fetch').mockResolvedValueOnce(
102
+ mockResponse(
103
+ gzipBase64(rawPullBody({ entries: [entry] })),
104
+ { 'X-Sync-Content-Encoding': 'gzip-base64' }
105
+ )
106
+ )
107
+
108
+ const result = await callPull(makePull())
109
+
110
+ expect(result).toMatchObject({ ok: true, entries: [{ record: { value: 'hello', score: 42 } }] })
111
+ })
112
+
113
+ test('returns correct token from compressed payload', async () => {
114
+ jest.spyOn(global, 'fetch').mockResolvedValueOnce(
115
+ mockResponse(
116
+ gzipBase64(rawPullBody()),
117
+ { 'X-Sync-Content-Encoding': 'gzip-base64' }
118
+ )
119
+ )
120
+
121
+ const result = await callPull(makePull())
122
+
123
+ expect(result).toMatchObject({ ok: true, token: TOKEN })
124
+ })
125
+
126
+ test('large payload survives compression', async () => {
127
+ const entries = Array.from({ length: 500 }, (_, i) => ({
128
+ record: { id: `rec-${i}`, value: `value-${i}`.repeat(20) },
129
+ meta: { ids: { id: `rec-${i}` }, lifecycle: { created_at: TOKEN, updated_at: TOKEN, deleted_at: null } },
130
+ }))
131
+ jest.spyOn(global, 'fetch').mockResolvedValueOnce(
132
+ mockResponse(
133
+ gzipBase64(rawPullBody({ entries })),
134
+ { 'X-Sync-Content-Encoding': 'gzip-base64' }
135
+ )
136
+ )
137
+
138
+ const result = await callPull(makePull())
139
+
140
+ expect(result).toMatchObject({ ok: true, entries: expect.arrayContaining([expect.objectContaining({ record: expect.objectContaining({ id: 'rec-0' }) })]) })
141
+ expect((result as Extract<SyncPullResponse, { ok: true }>).entries).toHaveLength(500)
142
+ })
143
+ })
144
+
145
+ // ---
146
+
147
+ describe('since query param', () => {
148
+ test('no since param on first pull', async () => {
149
+ const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValueOnce(
150
+ mockResponse(JSON.stringify(rawPullBody()))
151
+ )
152
+
153
+ await callPull(makePull(), null)
154
+
155
+ const url = (fetchSpy.mock.calls[0][0] as Request).url
156
+ expect(new URL(url).searchParams.get('since')).toBeNull()
157
+ })
158
+
159
+ test('since param set to previous token on subsequent pull', async () => {
160
+ const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValueOnce(
161
+ mockResponse(JSON.stringify(rawPullBody()))
162
+ )
163
+
164
+ await callPull(makePull(), TOKEN)
165
+
166
+ const url = (fetchSpy.mock.calls[0][0] as Request).url
167
+ expect(new URL(url).searchParams.get('since')).toBe(String(TOKEN))
168
+ })
169
+ })
170
+
171
+ // ---
172
+
173
+ describe('request headers', () => {
174
+ test('outgoing request includes gzip-base64 accept encoding header', async () => {
175
+ const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValueOnce(
176
+ mockResponse(JSON.stringify(rawPullBody()))
177
+ )
178
+
179
+ const pull = handlePull(() => new Request('https://api.example.com/sync', {
180
+ headers: { 'X-Sync-Accept-Encoding': 'gzip-base64' }
181
+ }))
182
+ await callPull(pull)
183
+
184
+ const req = fetchSpy.mock.calls[0][0] as Request
185
+ expect(req.headers.get('X-Sync-Accept-Encoding')).toBe('gzip-base64')
186
+ })
187
+ })
188
+
189
+ // ---
190
+
191
+ describe('failure responses', () => {
192
+ test('network TypeError returns retryable fetch failure', async () => {
193
+ jest.spyOn(global, 'fetch').mockRejectedValueOnce(new TypeError('network error'))
194
+
195
+ const result = await callPull(makePull())
196
+
197
+ expect(result).toMatchObject({ ok: false, failureType: 'fetch', isRetryable: true })
198
+ })
199
+
200
+ test('AbortError returns abort failure', async () => {
201
+ const abortError = new DOMException('aborted', 'AbortError')
202
+ jest.spyOn(global, 'fetch').mockRejectedValueOnce(abortError)
203
+
204
+ const result = await callPull(makePull())
205
+
206
+ expect(result).toMatchObject({ ok: false, failureType: 'abort' })
207
+ })
208
+
209
+ test('5xx returns retryable fetch failure', async () => {
210
+ jest.spyOn(global, 'fetch').mockResolvedValueOnce(new Response(null, { status: 500 }))
211
+
212
+ const result = await callPull(makePull())
213
+
214
+ expect(result).toMatchObject({ ok: false, failureType: 'fetch', isRetryable: true })
215
+ })
216
+
217
+ test('4xx (non-retryable) returns non-retryable fetch failure', async () => {
218
+ jest.spyOn(global, 'fetch').mockResolvedValueOnce(new Response(null, { status: 403 }))
219
+
220
+ const result = await callPull(makePull())
221
+
222
+ expect(result).toMatchObject({ ok: false, failureType: 'fetch', isRetryable: false })
223
+ })
224
+ })
@@ -1,7 +1,7 @@
1
1
  import { appSchema, tableSchema } from '@nozbe/watermelondb'
2
2
  import { Database } from '@nozbe/watermelondb'
3
3
  import LokiJSAdapter from '@nozbe/watermelondb/adapters/lokijs'
4
- import BaseModel from '../../../../src/services/sync/models/Base'
4
+ import BaseModel from '@/services/sync/models/Base'
5
5
 
6
6
  export const TEST_TABLE = 'test_items'
7
7
 
@@ -1,22 +1,22 @@
1
1
  import { Database } from '@nozbe/watermelondb'
2
2
  import LokiJSAdapter from '@nozbe/watermelondb/adapters/lokijs'
3
- import { SyncTelemetry, SeverityLevel } from '../../../../src/services/sync/telemetry/index'
4
- import SyncContext from '../../../../src/services/sync/context/index'
3
+ import { SyncTelemetry, SeverityLevel } from '@/services/sync/telemetry/index'
4
+ import SyncContext from '@/services/sync/context/index'
5
5
  import {
6
6
  BaseSessionProvider,
7
7
  BaseConnectivityProvider,
8
8
  BaseVisibilityProvider,
9
9
  NullTabsProvider,
10
10
  BaseDurabilityProvider,
11
- } from '../../../../src/services/sync/context/providers/index'
12
- import SyncRetry from '../../../../src/services/sync/retry'
13
- import SyncRunScope from '../../../../src/services/sync/run-scope'
14
- import SyncStore, { type SyncStoreConfig } from '../../../../src/services/sync/store/index'
15
- import schema from '../../../../src/services/sync/schema/index'
16
- import * as modelClasses from '../../../../src/services/sync/models/index'
17
- import type BaseModel from '../../../../src/services/sync/models/Base'
18
- import type { ModelClass, SyncUserScope } from '../../../../src/services/sync/index'
19
- import type { SyncPullResponse, SyncPushResponse } from '../../../../src/services/sync/fetch'
11
+ } from '@/services/sync/context/providers/index'
12
+ import SyncRetry from '@/services/sync/retry'
13
+ import SyncRunScope from '@/services/sync/run-scope'
14
+ import SyncStore, { type SyncStoreConfig } from '@/services/sync/store/index'
15
+ import schema from '@/services/sync/schema/index'
16
+ import * as modelClasses from '@/services/sync/models/index'
17
+ import type BaseModel from '@/services/sync/models/Base'
18
+ import type { ModelClass, SyncUserScope } from '@/services/sync/index'
19
+ import type { SyncPullResponse, SyncPushResponse } from '@/services/sync/fetch'
20
20
 
21
21
  // --- Sentry / Telemetry ---
22
22
 
@@ -44,7 +44,7 @@ function makeDummySentry() {
44
44
  trace: jest.fn(),
45
45
  fatal: jest.fn(),
46
46
  fmt: jest.fn(),
47
- } as any,
47
+ },
48
48
  }
49
49
  }
50
50
 
@@ -0,0 +1,303 @@
1
+ import SyncManager from '@/services/sync/manager'
2
+ import { SyncTelemetry } from '@/services/sync/telemetry/index'
3
+ import { SyncError } from '@/services/sync/errors/index'
4
+ import BaseModel from '@/services/sync/models/Base'
5
+ import createStoreConfigs from '@/services/sync/store-configs'
6
+ import { makeTelemetry, makeContext, makeUserScope, makeDatabase } from './helpers/index'
7
+ import type { ModelClass, SyncUserScope } from '@/services/sync/index'
8
+
9
+ class DummyModelA extends BaseModel { static table = 'dummy_a' }
10
+ class DummyModelB extends BaseModel { static table = 'dummy_b' }
11
+ class UnknownModel extends BaseModel { static table = 'not_a_real_table' }
12
+
13
+ jest.mock('../../../src/services/awards/internal/content-progress-observer', () => ({
14
+ contentProgressObserver: {
15
+ start: jest.fn().mockResolvedValue(undefined),
16
+ stop: jest.fn(),
17
+ },
18
+ }))
19
+
20
+ // ---
21
+
22
+ function makeMockStrategy() {
23
+ return {
24
+ start: jest.fn(),
25
+ stop: jest.fn(),
26
+ onTrigger: jest.fn().mockReturnThis(),
27
+ }
28
+ }
29
+
30
+ function makeMockEffect() {
31
+ const teardown = jest.fn()
32
+ const effect = jest.fn().mockReturnValue(teardown)
33
+ return { effect, teardown }
34
+ }
35
+
36
+ function buildManager(opts: {
37
+ userScope?: SyncUserScope
38
+ destroy?: jest.Mock
39
+ abort?: jest.Mock
40
+ } = {}) {
41
+ const userScope = opts.userScope ?? makeUserScope()
42
+ const context = makeContext()
43
+ const teardownDatabase = {
44
+ ...(opts.destroy && { destroy: opts.destroy }),
45
+ ...(opts.abort && { abort: opts.abort }),
46
+ }
47
+
48
+ return new SyncManager(userScope, context, () => makeDatabase(), teardownDatabase)
49
+ }
50
+
51
+ function assignManager(manager: SyncManager) {
52
+ return SyncManager.assignAndSetupInstance(manager)
53
+ }
54
+
55
+ function setupManager(opts: Parameters<typeof buildManager>[0] = {}) {
56
+ const manager = buildManager(opts)
57
+ const teardown = assignManager(manager)
58
+ return { manager, teardown }
59
+ }
60
+
61
+ // ---
62
+
63
+ beforeEach(() => {
64
+ SyncTelemetry.setInstance(makeTelemetry(makeUserScope()))
65
+ })
66
+
67
+ afterEach(() => {
68
+ SyncTelemetry.clearInstance()
69
+ ;(SyncManager as any).instance = null
70
+ })
71
+
72
+ // ---
73
+
74
+ describe('singleton lifecycle', () => {
75
+ test('getInstance throws before any setup', () => {
76
+ expect(() => SyncManager.getInstance()).toThrow(SyncError)
77
+ })
78
+
79
+ test('getInstanceOrNull returns null before any setup', () => {
80
+ expect(SyncManager.getInstanceOrNull()).toBeNull()
81
+ })
82
+
83
+ test('getInstance returns manager after setup', async () => {
84
+ const { manager, teardown } = setupManager()
85
+ expect(SyncManager.getInstance()).toBe(manager)
86
+ await teardown()
87
+ })
88
+
89
+ test('getInstanceOrNull returns manager after setup', async () => {
90
+ const { manager, teardown } = setupManager()
91
+ expect(SyncManager.getInstanceOrNull()).toBe(manager)
92
+ await teardown()
93
+ })
94
+
95
+ test('assignAndSetupInstance throws if called while instance exists', async () => {
96
+ const { teardown } = setupManager()
97
+ expect(() => assignManager(buildManager())).toThrow(SyncError)
98
+ await teardown()
99
+ })
100
+
101
+ test('getInstance throws after teardown', async () => {
102
+ const { teardown } = setupManager()
103
+ await teardown()
104
+ expect(() => SyncManager.getInstance()).toThrow(SyncError)
105
+ })
106
+
107
+ test('getInstanceOrNull returns null after teardown', async () => {
108
+ const { teardown } = setupManager()
109
+ await teardown()
110
+ expect(SyncManager.getInstanceOrNull()).toBeNull()
111
+ })
112
+
113
+ test('new instance can be set up after teardown', async () => {
114
+ const { teardown: t1 } = setupManager()
115
+ await t1()
116
+
117
+ const { manager: m2, teardown: t2 } = setupManager()
118
+ expect(SyncManager.getInstance()).toBe(m2)
119
+ await t2()
120
+ })
121
+ })
122
+
123
+ // ---
124
+
125
+ describe('store registry', () => {
126
+ test('all configured model tables have stores', async () => {
127
+ const { manager, teardown } = setupManager()
128
+ const configuredTables = createStoreConfigs().map(c => c.model.table)
129
+ const registeredTables = Object.keys(manager.getAllStores())
130
+
131
+ expect(registeredTables.sort()).toEqual(configuredTables.sort())
132
+ await teardown()
133
+ })
134
+
135
+ test('getStore returns store for a registered model', async () => {
136
+ const { manager, teardown } = setupManager()
137
+ const anyConfiguredModel = createStoreConfigs()[0].model as unknown as ModelClass
138
+ expect(manager.getStore(anyConfiguredModel)).toBeDefined()
139
+ await teardown()
140
+ })
141
+
142
+ test('getStore throws SyncError for unregistered model', async () => {
143
+ const { manager, teardown } = setupManager()
144
+ expect(() => manager.getStore(UnknownModel)).toThrow(SyncError)
145
+ await teardown()
146
+ })
147
+ })
148
+
149
+ // ---
150
+
151
+ describe('getId', () => {
152
+ test('successive instances get different ids', async () => {
153
+ const { manager: m1, teardown: t1 } = setupManager()
154
+ const id1 = m1.getId()
155
+ await t1()
156
+
157
+ const { manager: m2, teardown: t2 } = setupManager()
158
+ const id2 = m2.getId()
159
+ await t2()
160
+
161
+ expect(id1).not.toBe(id2)
162
+ })
163
+ })
164
+
165
+ // ---
166
+
167
+ describe('strategy registration', () => {
168
+ test('strategy.start called during setup', async () => {
169
+ const manager = buildManager()
170
+ const strategy = makeMockStrategy()
171
+ manager.registerStrategies([DummyModelA], [strategy])
172
+ const teardown = assignManager(manager)
173
+
174
+ expect(strategy.start).toHaveBeenCalledTimes(1)
175
+ await teardown()
176
+ })
177
+
178
+ test('strategy.stop called during teardown', async () => {
179
+ const manager = buildManager()
180
+ const strategy = makeMockStrategy()
181
+ manager.registerStrategies([DummyModelA], [strategy])
182
+ const teardown = assignManager(manager)
183
+ await teardown()
184
+
185
+ expect(strategy.stop).toHaveBeenCalledTimes(1)
186
+ })
187
+
188
+ test('onTrigger called once per registered model', async () => {
189
+ const manager = buildManager()
190
+ const strategy = makeMockStrategy()
191
+ manager.registerStrategies([DummyModelA, DummyModelB], [strategy])
192
+ const teardown = assignManager(manager)
193
+
194
+ expect(strategy.onTrigger).toHaveBeenCalledTimes(2)
195
+ await teardown()
196
+ })
197
+
198
+ test('multiple strategies all started and stopped', async () => {
199
+ const manager = buildManager()
200
+ const s1 = makeMockStrategy()
201
+ const s2 = makeMockStrategy()
202
+ manager.registerStrategies([DummyModelA], [s1, s2])
203
+ const teardown = assignManager(manager)
204
+ await teardown()
205
+
206
+ expect(s1.start).toHaveBeenCalledTimes(1)
207
+ expect(s2.start).toHaveBeenCalledTimes(1)
208
+ expect(s1.stop).toHaveBeenCalledTimes(1)
209
+ expect(s2.stop).toHaveBeenCalledTimes(1)
210
+ })
211
+ })
212
+
213
+ // ---
214
+
215
+ describe('effect registration', () => {
216
+ test('effect called with context and stores during setup', async () => {
217
+ const manager = buildManager()
218
+ const { effect, teardown: effectTeardown } = makeMockEffect()
219
+ manager.registerEffects([DummyModelA], [effect])
220
+ const teardown = assignManager(manager)
221
+
222
+ expect(effect).toHaveBeenCalledTimes(1)
223
+ expect(effect).toHaveBeenCalledWith(
224
+ expect.anything(),
225
+ expect.any(Array)
226
+ )
227
+
228
+ await teardown()
229
+ })
230
+
231
+ test('effect receives one store per registered model', async () => {
232
+ const manager = buildManager()
233
+ const { effect } = makeMockEffect()
234
+ manager.registerEffects([DummyModelA, DummyModelB], [effect])
235
+ const teardown = assignManager(manager)
236
+
237
+ const [, stores] = effect.mock.calls[0]
238
+ expect(stores).toHaveLength(2)
239
+
240
+ await teardown()
241
+ })
242
+
243
+ test('effect teardown called during manager teardown', async () => {
244
+ const manager = buildManager()
245
+ const { effect, teardown: effectTeardown } = makeMockEffect()
246
+ manager.registerEffects([DummyModelA], [effect])
247
+ const teardown = assignManager(manager)
248
+ await teardown()
249
+
250
+ expect(effectTeardown).toHaveBeenCalledTimes(1)
251
+ })
252
+ })
253
+
254
+ // ---
255
+
256
+ describe('teardown modes', () => {
257
+ test('reset mode resolves', async () => {
258
+ const { teardown } = setupManager()
259
+ await expect(teardown('reset')).resolves.toBeUndefined()
260
+ })
261
+
262
+ test('default mode (no arg) resolves', async () => {
263
+ const { teardown } = setupManager()
264
+ await expect(teardown()).resolves.toBeUndefined()
265
+ })
266
+
267
+ test('destroyOrReset calls destroy with dbName and adapter', async () => {
268
+ const destroy = jest.fn().mockResolvedValue(undefined)
269
+ const { teardown } = setupManager({ destroy })
270
+ await expect(teardown('destroyOrReset')).resolves.toBeUndefined()
271
+ expect(destroy).toHaveBeenCalledTimes(1)
272
+ expect(destroy).toHaveBeenCalledWith(expect.any(String), expect.anything())
273
+ })
274
+
275
+ test('abortWrites rejects when no implementation provided', async () => {
276
+ const { teardown } = setupManager()
277
+ await expect(teardown('abortWrites')).rejects.toThrow(
278
+ 'Cannot abort writes to database - implementation not provided'
279
+ )
280
+ })
281
+
282
+ test('abortWrites calls abort with underlying adapter', async () => {
283
+ const abort = jest.fn().mockResolvedValue(undefined)
284
+ const { teardown } = setupManager({ abort })
285
+ await expect(teardown('abortWrites')).resolves.toBeUndefined()
286
+ expect(abort).toHaveBeenCalledTimes(1)
287
+ })
288
+
289
+ test('concurrent teardown calls both resolve', async () => {
290
+ const { teardown } = setupManager()
291
+ await expect(Promise.all([teardown(), teardown()])).resolves.toBeDefined()
292
+ })
293
+ })
294
+
295
+ // ---
296
+
297
+ describe('abort', () => {
298
+ test('does not throw', async () => {
299
+ const { manager, teardown } = setupManager()
300
+ expect(() => manager.abort('test reason')).not.toThrow()
301
+ await teardown()
302
+ })
303
+ })
@@ -1,10 +1,10 @@
1
- jest.mock('../../../../src/services/sync/manager', () => ({ default: class SyncManager {} }))
2
- jest.mock('../../../../src/services/sync/repository-proxy', () => ({ db: {} }))
1
+ jest.mock('@/services/sync/manager', () => ({ default: class SyncManager {} }))
2
+ jest.mock('@/services/sync/repository-proxy', () => ({ db: {} }))
3
3
 
4
4
  import { Database } from '@nozbe/watermelondb'
5
5
  import { makeDatabase, makeStore, resetDatabase } from '../helpers/index'
6
- import ContentLike from '../../../../src/services/sync/models/ContentLike'
7
- import LikesRepository from '../../../../src/services/sync/repositories/content-likes'
6
+ import ContentLike from '@/services/sync/models/ContentLike'
7
+ import LikesRepository from '@/services/sync/repositories/content-likes'
8
8
 
9
9
  let db: Database
10
10
  let repo: LikesRepository
@@ -1,10 +1,10 @@
1
- jest.mock('../../../../src/services/sync/manager', () => ({ default: class SyncManager {} }))
2
- jest.mock('../../../../src/services/sync/repository-proxy', () => ({ db: {} }))
1
+ jest.mock('@/services/sync/manager', () => ({ default: class SyncManager {} }))
2
+ jest.mock('@/services/sync/repository-proxy', () => ({ db: {} }))
3
3
 
4
4
  import { Database } from '@nozbe/watermelondb'
5
5
  import { makeDatabase, makeStore, resetDatabase } from '../helpers/index'
6
- import Practice from '../../../../src/services/sync/models/Practice'
7
- import PracticesRepository from '../../../../src/services/sync/repositories/practices'
6
+ import Practice from '@/services/sync/models/Practice'
7
+ import PracticesRepository from '@/services/sync/repositories/practices'
8
8
 
9
9
  let db: Database
10
10
  let repo: PracticesRepository
@@ -1,7 +1,7 @@
1
1
  // Break circular dep: sync/index → manager → award chain → UserAwardProgressRepository
2
2
  // extends SyncRepository before it's defined in the module evaluation order.
3
- jest.mock('../../../../src/services/sync/manager', () => ({ default: class SyncManager {} }))
4
- jest.mock('../../../../src/services/sync/repository-proxy', () => ({ db: {} }))
3
+ jest.mock('@/services/sync/manager', () => ({ default: class SyncManager {} }))
4
+ jest.mock('@/services/sync/repository-proxy', () => ({ db: {} }))
5
5
 
6
6
  import { Database } from '@nozbe/watermelondb'
7
7
  import { makeDatabase, makeStore, resetDatabase } from '../helpers/index'
@@ -9,8 +9,8 @@ import ContentProgress, {
9
9
  COLLECTION_TYPE,
10
10
  COLLECTION_ID_SELF,
11
11
  STATE,
12
- } from '../../../../src/services/sync/models/ContentProgress'
13
- import ProgressRepository from '../../../../src/services/sync/repositories/content-progress'
12
+ } from '@/services/sync/models/ContentProgress'
13
+ import ProgressRepository from '@/services/sync/repositories/content-progress'
14
14
 
15
15
  let db: Database
16
16
  let repo: ProgressRepository