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
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
jest.mock('@/services/sync/manager', () => ({ default: class SyncManager {} }))
|
|
2
|
+
jest.mock('@/services/sync/repository-proxy', () => ({ db: {} }))
|
|
3
|
+
jest.mock('../../../../src/services/awards/internal/award-definitions', () => ({
|
|
4
|
+
awardDefinitions: {
|
|
5
|
+
getByContentId: jest.fn(),
|
|
6
|
+
getByContentIds: jest.fn(),
|
|
7
|
+
},
|
|
8
|
+
}))
|
|
9
|
+
|
|
10
|
+
import { Database } from '@nozbe/watermelondb'
|
|
11
|
+
import { makeDatabase, makeStore, resetDatabase } from '../helpers/index'
|
|
12
|
+
import UserAwardProgress from '@/services/sync/models/UserAwardProgress'
|
|
13
|
+
import UserAwardProgressRepository from '@/services/sync/repositories/user-award-progress'
|
|
14
|
+
import type { CompletionData, AwardDefinition } from '../../../../src/services/awards/types'
|
|
15
|
+
|
|
16
|
+
let db: Database
|
|
17
|
+
let repo: UserAwardProgressRepository
|
|
18
|
+
|
|
19
|
+
const COMPLETION_DATA: CompletionData = {
|
|
20
|
+
content_title: 'Test Course',
|
|
21
|
+
completed_at: '2024-01-15T10:00:00.000Z',
|
|
22
|
+
days_user_practiced: 30,
|
|
23
|
+
practice_minutes: 600,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function upsertAward(
|
|
27
|
+
awardId: string,
|
|
28
|
+
progressPct: number,
|
|
29
|
+
overrides: Partial<{
|
|
30
|
+
completedAt: string | null
|
|
31
|
+
completionData: CompletionData | null
|
|
32
|
+
progressData: any
|
|
33
|
+
}> = {}
|
|
34
|
+
) {
|
|
35
|
+
return repo['store'].upsertOne(awardId, r => {
|
|
36
|
+
r.award_id = awardId
|
|
37
|
+
r.progress_percentage = progressPct
|
|
38
|
+
if (overrides.completedAt !== undefined) {
|
|
39
|
+
r.completed_at = overrides.completedAt
|
|
40
|
+
}
|
|
41
|
+
if (overrides.completionData !== undefined) {
|
|
42
|
+
r.completion_data = overrides.completionData
|
|
43
|
+
}
|
|
44
|
+
if (overrides.progressData !== undefined) {
|
|
45
|
+
r.progress_data = overrides.progressData
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
db = makeDatabase()
|
|
52
|
+
const { store } = makeStore(UserAwardProgress, db)
|
|
53
|
+
repo = new UserAwardProgressRepository(store)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
afterEach(async () => {
|
|
57
|
+
await resetDatabase(db)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
// ---
|
|
61
|
+
|
|
62
|
+
describe('static helpers', () => {
|
|
63
|
+
test('isCompleted returns true when completed_at set and progress is 100', () => {
|
|
64
|
+
expect(UserAwardProgressRepository.isCompleted({
|
|
65
|
+
completed_at: '2024-01-15T10:00:00.000Z',
|
|
66
|
+
progress_percentage: 100,
|
|
67
|
+
})).toBe(true)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('isCompleted returns false when progress is 100 but completed_at is null', () => {
|
|
71
|
+
expect(UserAwardProgressRepository.isCompleted({
|
|
72
|
+
completed_at: null,
|
|
73
|
+
progress_percentage: 100,
|
|
74
|
+
})).toBe(false)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('isCompleted returns false when completed_at set but progress is not 100', () => {
|
|
78
|
+
expect(UserAwardProgressRepository.isCompleted({
|
|
79
|
+
completed_at: '2024-01-15T10:00:00.000Z',
|
|
80
|
+
progress_percentage: 50,
|
|
81
|
+
})).toBe(false)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('isInProgress returns true when progress >= 0 and not completed', () => {
|
|
85
|
+
expect(UserAwardProgressRepository.isInProgress({
|
|
86
|
+
completed_at: null,
|
|
87
|
+
progress_percentage: 50,
|
|
88
|
+
})).toBe(true)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('isInProgress returns false when completed', () => {
|
|
92
|
+
expect(UserAwardProgressRepository.isInProgress({
|
|
93
|
+
completed_at: '2024-01-15T10:00:00.000Z',
|
|
94
|
+
progress_percentage: 100,
|
|
95
|
+
})).toBe(false)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('completedAtDate returns Date for valid ISO string', () => {
|
|
99
|
+
const result = UserAwardProgressRepository.completedAtDate({
|
|
100
|
+
completed_at: '2024-01-15T10:00:00.000Z',
|
|
101
|
+
})
|
|
102
|
+
expect(result).toBeInstanceOf(Date)
|
|
103
|
+
expect(result!.getFullYear()).toBe(2024)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('completedAtDate returns null when completed_at is null', () => {
|
|
107
|
+
expect(UserAwardProgressRepository.completedAtDate({ completed_at: null })).toBeNull()
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
// ---
|
|
112
|
+
|
|
113
|
+
describe('getAll', () => {
|
|
114
|
+
test('returns all records', async () => {
|
|
115
|
+
await upsertAward('award-1', 50)
|
|
116
|
+
await upsertAward('award-2', 100, { completedAt: '2024-01-15T10:00:00.000Z' })
|
|
117
|
+
|
|
118
|
+
const result = await repo.getAll()
|
|
119
|
+
expect(result.data).toHaveLength(2)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('onlyCompleted filters to completed records', async () => {
|
|
123
|
+
await upsertAward('award-1', 50)
|
|
124
|
+
await upsertAward('award-2', 100, { completedAt: '2024-01-15T10:00:00.000Z' })
|
|
125
|
+
await upsertAward('award-3', 100, { completedAt: '2024-01-16T10:00:00.000Z' })
|
|
126
|
+
|
|
127
|
+
const result = await repo.getAll({ onlyCompleted: true })
|
|
128
|
+
expect(result.data).toHaveLength(2)
|
|
129
|
+
expect(result.data.every(r => r.completed_at !== null)).toBe(true)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test('respects limit', async () => {
|
|
133
|
+
await Promise.all(['a', 'b', 'c', 'd'].map(id => upsertAward(id, 50)))
|
|
134
|
+
|
|
135
|
+
const result = await repo.getAll({ limit: 2 })
|
|
136
|
+
expect(result.data).toHaveLength(2)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('returns empty when no records', async () => {
|
|
140
|
+
const result = await repo.getAll()
|
|
141
|
+
expect(result.data).toHaveLength(0)
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
// ---
|
|
146
|
+
|
|
147
|
+
describe('getCompleted', () => {
|
|
148
|
+
test('returns only records with completed_at set', async () => {
|
|
149
|
+
await upsertAward('award-1', 50)
|
|
150
|
+
await upsertAward('award-2', 100, { completedAt: '2024-01-15T10:00:00.000Z' })
|
|
151
|
+
|
|
152
|
+
const result = await repo.getCompleted()
|
|
153
|
+
expect(result.data).toHaveLength(1)
|
|
154
|
+
expect(result.data[0].award_id).toBe('award-2')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test('respects limit', async () => {
|
|
158
|
+
await upsertAward('award-1', 100, { completedAt: '2024-01-15T10:00:00.000Z' })
|
|
159
|
+
await upsertAward('award-2', 100, { completedAt: '2024-01-16T10:00:00.000Z' })
|
|
160
|
+
await upsertAward('award-3', 100, { completedAt: '2024-01-17T10:00:00.000Z' })
|
|
161
|
+
|
|
162
|
+
const result = await repo.getCompleted(2)
|
|
163
|
+
expect(result.data).toHaveLength(2)
|
|
164
|
+
})
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
// ---
|
|
168
|
+
|
|
169
|
+
describe('getInProgress', () => {
|
|
170
|
+
test('returns records with progress > 0 and no completed_at', async () => {
|
|
171
|
+
await upsertAward('in-progress', 50)
|
|
172
|
+
await upsertAward('completed', 100, { completedAt: '2024-01-15T10:00:00.000Z' })
|
|
173
|
+
await upsertAward('not-started', 0)
|
|
174
|
+
|
|
175
|
+
const result = await repo.getInProgress()
|
|
176
|
+
const ids = result.data.map(r => r.award_id)
|
|
177
|
+
expect(ids).toContain('in-progress')
|
|
178
|
+
expect(ids).not.toContain('completed')
|
|
179
|
+
expect(ids).not.toContain('not-started')
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test('respects limit', async () => {
|
|
183
|
+
await Promise.all(['a', 'b', 'c'].map(id => upsertAward(id, 50)))
|
|
184
|
+
|
|
185
|
+
const result = await repo.getInProgress(2)
|
|
186
|
+
expect(result.data).toHaveLength(2)
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
// ---
|
|
191
|
+
|
|
192
|
+
describe('getByAwardId / getByAwardIds', () => {
|
|
193
|
+
test('getByAwardId returns matching record', async () => {
|
|
194
|
+
await upsertAward('award-abc', 75)
|
|
195
|
+
|
|
196
|
+
const result = await repo.getByAwardId('award-abc')
|
|
197
|
+
expect(result.data).not.toBeNull()
|
|
198
|
+
expect(result.data!.award_id).toBe('award-abc')
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
test('getByAwardId returns null for missing id', async () => {
|
|
202
|
+
const result = await repo.getByAwardId('nonexistent')
|
|
203
|
+
expect(result.data).toBeNull()
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
test('getByAwardIds returns all matching records', async () => {
|
|
207
|
+
await upsertAward('award-1', 25)
|
|
208
|
+
await upsertAward('award-2', 50)
|
|
209
|
+
await upsertAward('award-3', 75)
|
|
210
|
+
|
|
211
|
+
const result = await repo.getByAwardIds(['award-1', 'award-3'])
|
|
212
|
+
expect(result.data).toHaveLength(2)
|
|
213
|
+
expect(result.data.map(r => r.award_id).sort()).toEqual(['award-1', 'award-3'])
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
test('getByAwardIds returns empty for empty input', async () => {
|
|
217
|
+
const result = await repo.getByAwardIds([])
|
|
218
|
+
expect(result.data).toHaveLength(0)
|
|
219
|
+
})
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
// ---
|
|
223
|
+
|
|
224
|
+
describe('hasCompletedAward', () => {
|
|
225
|
+
test('returns true when award is completed', async () => {
|
|
226
|
+
await upsertAward('award-done', 100, { completedAt: '2024-01-15T10:00:00.000Z' })
|
|
227
|
+
|
|
228
|
+
expect(await repo.hasCompletedAward('award-done')).toBe(true)
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
test('returns false when award has progress but not completed', async () => {
|
|
232
|
+
await upsertAward('award-partial', 80)
|
|
233
|
+
|
|
234
|
+
expect(await repo.hasCompletedAward('award-partial')).toBe(false)
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
test('returns false when progress is 100 but completed_at is null', async () => {
|
|
238
|
+
await upsertAward('award-no-date', 100)
|
|
239
|
+
|
|
240
|
+
expect(await repo.hasCompletedAward('award-no-date')).toBe(false)
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
test('returns false when award does not exist', async () => {
|
|
244
|
+
expect(await repo.hasCompletedAward('nonexistent')).toBe(false)
|
|
245
|
+
})
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
// ---
|
|
249
|
+
|
|
250
|
+
describe('recordAwardProgress', () => {
|
|
251
|
+
test('creates record with progress percentage', async () => {
|
|
252
|
+
await repo.recordAwardProgress('award-x', 60)
|
|
253
|
+
|
|
254
|
+
const result = await repo.getByAwardId('award-x')
|
|
255
|
+
expect(result.data!.progress_percentage).toBe(60)
|
|
256
|
+
expect(result.data!.award_id).toBe('award-x')
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
test('sets completedAt when provided', async () => {
|
|
260
|
+
const completedAt = '2024-06-01T12:00:00.000Z'
|
|
261
|
+
await repo.recordAwardProgress('award-x', 100, { completedAt })
|
|
262
|
+
|
|
263
|
+
const result = await repo.getByAwardId('award-x')
|
|
264
|
+
expect(result.data!.completed_at).toBe(completedAt)
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
test('sets completionData when provided', async () => {
|
|
268
|
+
await repo.recordAwardProgress('award-x', 100, {
|
|
269
|
+
completedAt: COMPLETION_DATA.completed_at,
|
|
270
|
+
completionData: COMPLETION_DATA,
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
const result = await repo.getByAwardId('award-x')
|
|
274
|
+
expect(result.data!.completion_data).toEqual(COMPLETION_DATA)
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
test('sets progressData when provided', async () => {
|
|
278
|
+
const progressData = { lesson_ids: [1, 2, 3], current: 2 }
|
|
279
|
+
await repo.recordAwardProgress('award-x', 50, { progressData })
|
|
280
|
+
|
|
281
|
+
const result = await repo.getByAwardId('award-x')
|
|
282
|
+
expect(result.data!.progress_data).toEqual(progressData)
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
test('updates existing record on second call', async () => {
|
|
286
|
+
await repo.recordAwardProgress('award-x', 40)
|
|
287
|
+
await repo.recordAwardProgress('award-x', 80)
|
|
288
|
+
|
|
289
|
+
const result = await repo.getByAwardId('award-x')
|
|
290
|
+
expect(result.data!.progress_percentage).toBe(80)
|
|
291
|
+
})
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
// ---
|
|
295
|
+
|
|
296
|
+
describe('completeAward', () => {
|
|
297
|
+
test('sets progress to 100 and records completionData', async () => {
|
|
298
|
+
await repo.completeAward('award-x', COMPLETION_DATA)
|
|
299
|
+
|
|
300
|
+
const result = await repo.getByAwardId('award-x')
|
|
301
|
+
expect(result.data!.progress_percentage).toBe(100)
|
|
302
|
+
expect(result.data!.completion_data).toEqual(COMPLETION_DATA)
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
test('sets completed_at to an ISO date string', async () => {
|
|
306
|
+
const before = new Date().toISOString()
|
|
307
|
+
await repo.completeAward('award-x', COMPLETION_DATA)
|
|
308
|
+
const after = new Date().toISOString()
|
|
309
|
+
|
|
310
|
+
const result = await repo.getByAwardId('award-x')
|
|
311
|
+
const completedAt = result.data!.completed_at!
|
|
312
|
+
expect(completedAt >= before).toBe(true)
|
|
313
|
+
expect(completedAt <= after).toBe(true)
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
test('hasCompletedAward returns true after completeAward', async () => {
|
|
317
|
+
await repo.completeAward('award-x', COMPLETION_DATA)
|
|
318
|
+
expect(await repo.hasCompletedAward('award-x')).toBe(true)
|
|
319
|
+
})
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
// ---
|
|
323
|
+
|
|
324
|
+
describe('getAwardsForContent', () => {
|
|
325
|
+
test('returns definitions and matching progress map', async () => {
|
|
326
|
+
const { awardDefinitions } = await import('../../../../src/services/awards/internal/award-definitions')
|
|
327
|
+
const mockDefinitions: AwardDefinition[] = [
|
|
328
|
+
{ _id: 'award-1', name: 'Test Award', content_id: 100, is_active: true, logo: null, badge: null, badge_rear: null, award: 'completion', content_type: 'lesson', type: 'course-completion', brand: 'drumeo', content_title: 'Test', award_custom_text: null, instructor_name: null, child_ids: [] },
|
|
329
|
+
]
|
|
330
|
+
;(awardDefinitions.getByContentId as jest.Mock).mockResolvedValue(mockDefinitions)
|
|
331
|
+
|
|
332
|
+
await upsertAward('award-1', 50)
|
|
333
|
+
|
|
334
|
+
const result = await repo.getAwardsForContent(100)
|
|
335
|
+
expect(result.definitions).toEqual(mockDefinitions)
|
|
336
|
+
expect(result.progress.get('award-1')).toBeDefined()
|
|
337
|
+
expect(result.progress.get('award-1')!.progress_percentage).toBe(50)
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
test('returns empty progress map when no progress recorded', async () => {
|
|
341
|
+
const { awardDefinitions } = await import('../../../../src/services/awards/internal/award-definitions')
|
|
342
|
+
;(awardDefinitions.getByContentId as jest.Mock).mockResolvedValue([
|
|
343
|
+
{ _id: 'award-1', name: 'Test Award', content_id: 100, is_active: true, logo: null, badge: null, badge_rear: null, award: 'completion', content_type: 'lesson', type: 'course-completion', brand: 'drumeo', content_title: 'Test', award_custom_text: null, instructor_name: null, child_ids: [] },
|
|
344
|
+
])
|
|
345
|
+
|
|
346
|
+
const result = await repo.getAwardsForContent(100)
|
|
347
|
+
expect(result.definitions).toHaveLength(1)
|
|
348
|
+
expect(result.progress.size).toBe(0)
|
|
349
|
+
})
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
// ---
|
|
353
|
+
|
|
354
|
+
describe('getAwardsForContentMany', () => {
|
|
355
|
+
test('returns map keyed by contentId with definitions and progress', async () => {
|
|
356
|
+
const { awardDefinitions } = await import('../../../../src/services/awards/internal/award-definitions')
|
|
357
|
+
const defsByContent = new Map([
|
|
358
|
+
[100, [{ _id: 'award-100', name: 'Award 100', content_id: 100, is_active: true, logo: null, badge: null, badge_rear: null, award: 'completion', content_type: 'lesson', type: 'course-completion', brand: 'drumeo', content_title: 'Course 100', award_custom_text: null, instructor_name: null, child_ids: [] }]],
|
|
359
|
+
[200, [{ _id: 'award-200', name: 'Award 200', content_id: 200, is_active: true, logo: null, badge: null, badge_rear: null, award: 'completion', content_type: 'lesson', type: 'course-completion', brand: 'drumeo', content_title: 'Course 200', award_custom_text: null, instructor_name: null, child_ids: [] }]],
|
|
360
|
+
])
|
|
361
|
+
;(awardDefinitions.getByContentIds as jest.Mock).mockResolvedValue(defsByContent)
|
|
362
|
+
|
|
363
|
+
await upsertAward('award-100', 75)
|
|
364
|
+
|
|
365
|
+
const result = await repo.getAwardsForContentMany([100, 200])
|
|
366
|
+
expect(result.size).toBe(2)
|
|
367
|
+
expect(result.get(100)!.progress.get('award-100')!.progress_percentage).toBe(75)
|
|
368
|
+
expect(result.get(200)!.progress.size).toBe(0)
|
|
369
|
+
})
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
// ---
|
|
373
|
+
|
|
374
|
+
describe('deleteAllAwards', () => {
|
|
375
|
+
test('returns deletedCount of 0 when no awards', async () => {
|
|
376
|
+
const result = await repo.deleteAllAwards()
|
|
377
|
+
expect(result.deletedCount).toBe(0)
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
test('returns correct deletedCount', async () => {
|
|
381
|
+
await upsertAward('award-1', 50)
|
|
382
|
+
await upsertAward('award-2', 100, { completedAt: '2024-01-15T10:00:00.000Z' })
|
|
383
|
+
|
|
384
|
+
const result = await repo.deleteAllAwards()
|
|
385
|
+
expect(result.deletedCount).toBe(2)
|
|
386
|
+
})
|
|
387
|
+
})
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import SyncResolver, { updatedAtComparator } from '@/services/sync/resolver'
|
|
2
|
+
import type { SyncEntry, SyncEntryNonDeleted, EpochMs } from '@/services/sync/index'
|
|
3
|
+
import type BaseModel from '@/services/sync/models/Base'
|
|
4
|
+
|
|
5
|
+
// ---
|
|
6
|
+
|
|
7
|
+
const T = 1700000000000
|
|
8
|
+
|
|
9
|
+
function makeEntry(id: string, overrides: { updatedAt?: number; deletedAt?: number | null } = {}): SyncEntry {
|
|
10
|
+
const updatedAt = overrides.updatedAt ?? T
|
|
11
|
+
const deletedAt = overrides.deletedAt !== undefined ? overrides.deletedAt : null
|
|
12
|
+
return {
|
|
13
|
+
record: deletedAt ? null : ({ id } as unknown as BaseModel),
|
|
14
|
+
meta: {
|
|
15
|
+
ids: { id },
|
|
16
|
+
lifecycle: {
|
|
17
|
+
created_at: updatedAt as EpochMs,
|
|
18
|
+
updated_at: updatedAt as EpochMs,
|
|
19
|
+
deleted_at: deletedAt as EpochMs | null,
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function makeLocal(id: string, updatedAt = T): BaseModel {
|
|
26
|
+
return { id, updated_at: updatedAt } as unknown as BaseModel
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ---
|
|
30
|
+
|
|
31
|
+
describe('updatedAtComparator', () => {
|
|
32
|
+
test('returns SERVER when server updated_at is greater than local', () => {
|
|
33
|
+
const server = makeEntry('x', { updatedAt: T + 1 })
|
|
34
|
+
const local = makeLocal('x', T)
|
|
35
|
+
expect(updatedAtComparator(server as unknown as SyncEntryNonDeleted, local)).toBe('SERVER')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('returns SERVER when timestamps are equal', () => {
|
|
39
|
+
const server = makeEntry('x', { updatedAt: T })
|
|
40
|
+
const local = makeLocal('x', T)
|
|
41
|
+
expect(updatedAtComparator(server as unknown as SyncEntryNonDeleted, local)).toBe('SERVER')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('returns LOCAL when local updated_at is greater than server', () => {
|
|
45
|
+
const server = makeEntry('x', { updatedAt: T - 1 })
|
|
46
|
+
const local = makeLocal('x', T)
|
|
47
|
+
expect(updatedAtComparator(server as unknown as SyncEntryNonDeleted, local)).toBe('LOCAL')
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
// ---
|
|
52
|
+
|
|
53
|
+
describe('againstNone', () => {
|
|
54
|
+
test('non-deleted entry queued for create', () => {
|
|
55
|
+
const resolver = new SyncResolver()
|
|
56
|
+
const entry = makeEntry('rec-1')
|
|
57
|
+
|
|
58
|
+
resolver.againstNone(entry)
|
|
59
|
+
|
|
60
|
+
expect(resolver.result.entriesForCreate).toHaveLength(1)
|
|
61
|
+
expect(resolver.result.entriesForCreate[0]).toBe(entry)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('deleted entry ignored', () => {
|
|
65
|
+
const resolver = new SyncResolver()
|
|
66
|
+
resolver.againstNone(makeEntry('rec-1', { deletedAt: T }))
|
|
67
|
+
|
|
68
|
+
const { entriesForCreate, tuplesForUpdate, idsForDestroy, recordsForSynced } = resolver.result
|
|
69
|
+
expect(entriesForCreate).toHaveLength(0)
|
|
70
|
+
expect(tuplesForUpdate).toHaveLength(0)
|
|
71
|
+
expect(idsForDestroy).toHaveLength(0)
|
|
72
|
+
expect(recordsForSynced).toHaveLength(0)
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
// ---
|
|
77
|
+
|
|
78
|
+
describe('againstSynced', () => {
|
|
79
|
+
test('server deleted → idsForDestroy', () => {
|
|
80
|
+
const resolver = new SyncResolver()
|
|
81
|
+
const local = makeLocal('rec-1', T)
|
|
82
|
+
|
|
83
|
+
resolver.againstSynced(local, makeEntry('rec-1', { deletedAt: T }))
|
|
84
|
+
|
|
85
|
+
expect(resolver.result.idsForDestroy).toContain('rec-1')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('server newer → tuplesForUpdate', () => {
|
|
89
|
+
const resolver = new SyncResolver()
|
|
90
|
+
const local = makeLocal('rec-1', T)
|
|
91
|
+
const server = makeEntry('rec-1', { updatedAt: T + 1 })
|
|
92
|
+
|
|
93
|
+
resolver.againstSynced(local, server)
|
|
94
|
+
|
|
95
|
+
expect(resolver.result.tuplesForUpdate).toHaveLength(1)
|
|
96
|
+
expect(resolver.result.tuplesForUpdate[0][0]).toBe(local)
|
|
97
|
+
expect(resolver.result.tuplesForUpdate[0][1]).toBe(server)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('server older → no action (stale pull ignored)', () => {
|
|
101
|
+
const resolver = new SyncResolver()
|
|
102
|
+
resolver.againstSynced(makeLocal('rec-1', T + 1), makeEntry('rec-1', { updatedAt: T }))
|
|
103
|
+
|
|
104
|
+
const { tuplesForUpdate, idsForDestroy, recordsForSynced } = resolver.result
|
|
105
|
+
expect(tuplesForUpdate).toHaveLength(0)
|
|
106
|
+
expect(idsForDestroy).toHaveLength(0)
|
|
107
|
+
expect(recordsForSynced).toHaveLength(0)
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
// ---
|
|
112
|
+
|
|
113
|
+
describe('againstCreated', () => {
|
|
114
|
+
test('server deleted → idsForDestroy (local changes discarded)', () => {
|
|
115
|
+
const resolver = new SyncResolver()
|
|
116
|
+
resolver.againstCreated(makeLocal('rec-1', T + 1), makeEntry('rec-1', { deletedAt: T }))
|
|
117
|
+
|
|
118
|
+
expect(resolver.result.idsForDestroy).toContain('rec-1')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('server newer → tuplesForUpdate', () => {
|
|
122
|
+
const resolver = new SyncResolver()
|
|
123
|
+
const local = makeLocal('rec-1', T)
|
|
124
|
+
const server = makeEntry('rec-1', { updatedAt: T + 1 })
|
|
125
|
+
|
|
126
|
+
resolver.againstCreated(local, server)
|
|
127
|
+
|
|
128
|
+
expect(resolver.result.tuplesForUpdate).toHaveLength(1)
|
|
129
|
+
expect(resolver.result.tuplesForUpdate[0][1]).toBe(server)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test('server older (clock skew) → recordsForSynced', () => {
|
|
133
|
+
const resolver = new SyncResolver()
|
|
134
|
+
const local = makeLocal('rec-1', T + 1)
|
|
135
|
+
|
|
136
|
+
resolver.againstCreated(local, makeEntry('rec-1', { updatedAt: T }))
|
|
137
|
+
|
|
138
|
+
expect(resolver.result.recordsForSynced).toContain(local)
|
|
139
|
+
expect(resolver.result.tuplesForUpdate).toHaveLength(0)
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
// ---
|
|
144
|
+
|
|
145
|
+
describe('againstUpdated', () => {
|
|
146
|
+
test('server deleted → idsForDestroy (local changes discarded)', () => {
|
|
147
|
+
const resolver = new SyncResolver()
|
|
148
|
+
resolver.againstUpdated(makeLocal('rec-1', T + 1), makeEntry('rec-1', { deletedAt: T }))
|
|
149
|
+
|
|
150
|
+
expect(resolver.result.idsForDestroy).toContain('rec-1')
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
test('server newer → tuplesForUpdate', () => {
|
|
154
|
+
const resolver = new SyncResolver()
|
|
155
|
+
const local = makeLocal('rec-1', T)
|
|
156
|
+
const server = makeEntry('rec-1', { updatedAt: T + 1 })
|
|
157
|
+
|
|
158
|
+
resolver.againstUpdated(local, server)
|
|
159
|
+
|
|
160
|
+
expect(resolver.result.tuplesForUpdate).toHaveLength(1)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('server older (clock skew) → recordsForSynced', () => {
|
|
164
|
+
const resolver = new SyncResolver()
|
|
165
|
+
const local = makeLocal('rec-1', T + 1)
|
|
166
|
+
|
|
167
|
+
resolver.againstUpdated(local, makeEntry('rec-1', { updatedAt: T }))
|
|
168
|
+
|
|
169
|
+
expect(resolver.result.recordsForSynced).toContain(local)
|
|
170
|
+
expect(resolver.result.tuplesForUpdate).toHaveLength(0)
|
|
171
|
+
})
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
// ---
|
|
175
|
+
|
|
176
|
+
describe('againstDeleted', () => {
|
|
177
|
+
test('server also deleted → idsForDestroy', () => {
|
|
178
|
+
const resolver = new SyncResolver()
|
|
179
|
+
resolver.againstDeleted(makeLocal('rec-1', T), makeEntry('rec-1', { deletedAt: T }))
|
|
180
|
+
|
|
181
|
+
expect(resolver.result.idsForDestroy).toContain('rec-1')
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
test('server updated_at >= local → tuplesForRestore', () => {
|
|
185
|
+
const resolver = new SyncResolver()
|
|
186
|
+
const local = makeLocal('rec-1', T)
|
|
187
|
+
const server = makeEntry('rec-1', { updatedAt: T })
|
|
188
|
+
|
|
189
|
+
resolver.againstDeleted(local, server)
|
|
190
|
+
|
|
191
|
+
expect(resolver.result.tuplesForRestore).toHaveLength(1)
|
|
192
|
+
expect(resolver.result.tuplesForRestore[0][0]).toBe(local)
|
|
193
|
+
expect(resolver.result.tuplesForRestore[0][1]).toBe(server)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
test('server older than local deleted_at → idsForDestroy (delete wins)', () => {
|
|
197
|
+
const resolver = new SyncResolver()
|
|
198
|
+
resolver.againstDeleted(makeLocal('rec-1', T + 1), makeEntry('rec-1', { updatedAt: T }))
|
|
199
|
+
|
|
200
|
+
expect(resolver.result.idsForDestroy).toContain('rec-1')
|
|
201
|
+
expect(resolver.result.tuplesForRestore).toHaveLength(0)
|
|
202
|
+
})
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
// ---
|
|
206
|
+
|
|
207
|
+
describe('custom comparator', () => {
|
|
208
|
+
test('custom comparator overrides default updated_at logic', () => {
|
|
209
|
+
const alwaysLocal = () => 'LOCAL' as const
|
|
210
|
+
const resolver = new SyncResolver(alwaysLocal)
|
|
211
|
+
const local = makeLocal('rec-1', T)
|
|
212
|
+
const server = makeEntry('rec-1', { updatedAt: T + 9999 })
|
|
213
|
+
|
|
214
|
+
resolver.againstSynced(local, server)
|
|
215
|
+
|
|
216
|
+
expect(resolver.result.tuplesForUpdate).toHaveLength(0)
|
|
217
|
+
expect(resolver.result.recordsForSynced).toHaveLength(0)
|
|
218
|
+
expect(resolver.result.idsForDestroy).toHaveLength(0)
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
test('custom comparator SERVER wins over newer local', () => {
|
|
222
|
+
const alwaysServer = () => 'SERVER' as const
|
|
223
|
+
const resolver = new SyncResolver(alwaysServer)
|
|
224
|
+
const local = makeLocal('rec-1', T + 9999)
|
|
225
|
+
const server = makeEntry('rec-1', { updatedAt: T })
|
|
226
|
+
|
|
227
|
+
resolver.againstSynced(local, server)
|
|
228
|
+
|
|
229
|
+
expect(resolver.result.tuplesForUpdate).toHaveLength(1)
|
|
230
|
+
})
|
|
231
|
+
})
|
|
232
|
+
|