musora-content-services 2.167.2 → 2.167.3
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/.agent/decisions/2026-07-20-mu2-1724-songs-lessons-counts.md +33 -0
- package/.agent/decisions/2026-07-20-query-builder-tostring.md +37 -0
- package/CHANGELOG.md +8 -0
- package/package.json +2 -1
- package/src/contentTypeConfig.js +1 -0
- package/src/lib/sanity/query.ts +5 -0
- package/src/services/config.js +15 -0
- package/src/services/content/artist.ts +1 -3
- package/src/services/content/counts.ts +47 -0
- package/src/services/content/genre.ts +1 -3
- package/src/services/content/instructor.ts +1 -3
- package/src/services/userActivity.js +2 -0
- package/src/version-info.js +1 -0
- package/.claude/settings.local.json +0 -53
- package/test/initializeTests.js +0 -60
- package/test/integration/awards/award-exclusion-handling.test.ts +0 -275
- package/test/integration/awards/internal/image-utils.test.ts +0 -86
- package/test/integration/content-progress/__mocks__/mocks.ts +0 -104
- package/test/integration/content-progress/contentProgress.test.ts +0 -335
- package/test/integration/content-progress/e2eOfflineProgress.test.ts +0 -352
- package/test/integration/content-progress/e2eProgress.test.ts +0 -612
- package/test/integration/content-progress/getters.test.ts +0 -334
- package/test/integration/content-progress/helpers.test.ts +0 -263
- package/test/integration/content-progress/offlineContentProgress.test.ts +0 -226
- package/test/integration/forums.test.ts +0 -209
- package/test/integration/infrastructure/FetchRequestExecutor.test.ts +0 -88
- package/test/integration/initializeTestDB.ts +0 -80
- package/test/integration/learning-paths.test.ts +0 -854
- package/test/integration/notifications.test.ts +0 -178
- package/test/integration/progress-row/content-card.test.ts +0 -283
- package/test/integration/sync/adapters/idb-errors.test.ts +0 -144
- package/test/integration/sync/adapters/sqlite-errors.test.ts +0 -173
- package/test/integration/sync/fetch.test.ts +0 -224
- package/test/integration/sync/repositories/content-likes.test.ts +0 -99
- package/test/integration/sync/repositories/practices.test.ts +0 -179
- package/test/integration/sync/repositories/progress.test.ts +0 -245
- package/test/integration/sync/repositories/user-award-progress.static.test.ts +0 -68
- package/test/integration/sync/repositories/user-award-progress.test.ts +0 -387
- package/test/integration/sync/store/cross-user-protection.test.ts +0 -217
- package/test/integration/sync/store/store-idb.test.ts +0 -180
- package/test/integration/sync/store/store.test.ts +0 -361
- package/test/integration/user/permissions.test.js +0 -20
- package/test/integration/userActivity.test.ts +0 -102
- package/test/live/README.md +0 -29
- package/test/live/content.test.js +0 -116
- package/test/localStorageMock.js +0 -21
- package/test/log.js +0 -5
- package/test/mockData/award-definitions.js +0 -171
- package/test/mockData/mockData_fetchByRailContentIds_one_content.json +0 -35
- package/test/mockData/mockData_progress_content.json +0 -182
- package/test/mockData/mockData_sanity_progress_content.json +0 -1451
- package/test/mockData/mockData_user_practices.json +0 -9
- package/test/setupConsole.js +0 -6
- package/test/setupNetworkGuard.js +0 -3
- package/test/setupTimers.js +0 -13
- package/test/unit/HttpClient.test.ts +0 -267
- package/test/unit/awards/award-alacarte-observer.test.ts +0 -199
- package/test/unit/awards/award-auto-refresh.test.ts +0 -84
- package/test/unit/awards/award-calculations.test.ts +0 -34
- package/test/unit/awards/award-callbacks.test.ts +0 -144
- package/test/unit/awards/award-certificate-display.test.ts +0 -330
- package/test/unit/awards/award-collection-edge-cases.test.ts +0 -212
- package/test/unit/awards/award-collection-filtering.test.ts +0 -287
- package/test/unit/awards/award-completion-flow.test.ts +0 -306
- package/test/unit/awards/award-multi-lesson.test.ts +0 -243
- package/test/unit/awards/award-observer-integration.test.ts +0 -327
- package/test/unit/awards/award-query-messages.test.ts +0 -448
- package/test/unit/awards/award-user-collection.test.ts +0 -416
- package/test/unit/awards/duplicate-prevention.test.ts +0 -120
- package/test/unit/awards/helpers/completion-mock.ts +0 -56
- package/test/unit/awards/helpers/index.ts +0 -3
- package/test/unit/awards/helpers/mock-setup.ts +0 -79
- package/test/unit/awards/helpers/progress-emitter.ts +0 -45
- package/test/unit/awards/message-generator.test.ts +0 -162
- package/test/unit/content-progress/bubbleTrickle.test.ts +0 -322
- package/test/unit/content-progress/helpers.test.ts +0 -329
- package/test/unit/content-progress/navigateTo.test.ts +0 -392
- package/test/unit/contentLikes.test.ts +0 -62
- package/test/unit/contentMetaData.test.ts +0 -58
- package/test/unit/dataContext.test.ts +0 -38
- package/test/unit/dateUtils.test.ts +0 -188
- package/test/unit/endScreen.test.js +0 -712
- package/test/unit/eventsAPI.test.ts +0 -159
- package/test/unit/imageSRCBuilder.test.ts +0 -37
- package/test/unit/imageSRCVerify.test.ts +0 -155
- package/test/unit/infrastructure/DefaultHeaderProvider.test.ts +0 -39
- package/test/unit/infrastructure/sanity/ContentClient.test.ts +0 -168
- package/test/unit/infrastructure/sanity/DefaultConfigProvider.test.ts +0 -93
- package/test/unit/infrastructure/sanity/FetchQueryExecutor.test.ts +0 -174
- package/test/unit/infrastructure/sanity/SanityClient.test.ts +0 -140
- package/test/unit/lib/__snapshots__/filter.test.ts.snap +0 -5
- package/test/unit/lib/filter.test.ts +0 -1154
- package/test/unit/lib/lastUpdated.test.ts +0 -21
- package/test/unit/lib/query.test.ts +0 -1469
- package/test/unit/lib/sanity/decorators/base.test.ts +0 -368
- package/test/unit/lib/sanity/decorators/navigate-to.test.ts +0 -361
- package/test/unit/lib/sanity/decorators/need-access.test.ts +0 -89
- package/test/unit/lib/sanity/decorators/need-lifetime-upgrade.test.ts +0 -85
- package/test/unit/lib/sanity/decorators/page-type.test.ts +0 -81
- package/test/unit/playerSettings.test.ts +0 -73
- package/test/unit/progress-row/content-card.test.ts +0 -314
- package/test/unit/progress-row/playlist-card.test.ts +0 -104
- package/test/unit/progressRows.test.ts +0 -243
- package/test/unit/recommendations.test.js +0 -47
- package/test/unit/sanityQueryService.test.ts +0 -200
- package/test/unit/search.test.ts +0 -44
- package/test/unit/sentry.test.ts +0 -62
- package/test/unit/services/progress-internal/bubble.test.ts +0 -279
- package/test/unit/services/progress.test.ts +0 -396
- package/test/unit/streakMessage.test.ts +0 -251
- package/test/unit/sync/context.test.ts +0 -51
- package/test/unit/sync/effects/logout-warning.test.ts +0 -158
- package/test/unit/sync/effects/push-failure-notification.test.ts +0 -196
- package/test/unit/sync/errors/sync-errors.test.ts +0 -106
- package/test/unit/sync/errors/validators.test.ts +0 -61
- package/test/unit/sync/helpers/TestModel.ts +0 -44
- package/test/unit/sync/helpers/index.ts +0 -172
- package/test/unit/sync/manager.test.ts +0 -303
- package/test/unit/sync/models/user-award-progress.test.ts +0 -82
- package/test/unit/sync/resolver.test.ts +0 -229
- package/test/unit/sync/retry.test.ts +0 -314
- package/test/unit/sync/run-scope.test.ts +0 -23
- package/test/unit/sync/stale-record-cleanup.test.ts +0 -107
- package/test/unit/sync/store/push-coalescer.test.ts +0 -156
- package/test/unit/sync/store-configs.test.ts +0 -37
- package/test/unit/sync/telemetry/sync-telemetry.test.ts +0 -118
- package/test/unit/sync/utils/event-emitter.test.ts +0 -64
- package/test/unit/sync/utils/throttle.test.ts +0 -245
- package/test/unit/url-builder.test.ts +0 -72
- package/test/unit/user/account.test.ts +0 -66
- package/test/unit/user/permissions.test.js +0 -191
|
@@ -1,173 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for SQLiteErrorAwareAdapter and FullFailingSQLiteAdapter.
|
|
3
|
-
*
|
|
4
|
-
* WatermelonDB's SQLiteAdapter is React Native-only and can't be instantiated in Node.
|
|
5
|
-
* We mock the parent class so we can test our error-handling wrapper logic.
|
|
6
|
-
* better-sqlite3 provides ground-truth SQLite error message formats, confirming
|
|
7
|
-
* our isSQLiteFullError detection handles both the RN format and native SQLite format.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import BetterSqlite3 from 'better-sqlite3'
|
|
11
|
-
|
|
12
|
-
// Mock the RN-only SQLiteAdapter before importing our wrapper
|
|
13
|
-
jest.mock('@nozbe/watermelondb/adapters/sqlite', () => {
|
|
14
|
-
return class MockSQLiteAdapter {
|
|
15
|
-
_dispatcher = {
|
|
16
|
-
call: (_method: string, _args: any[], callback: any) => callback({ error: null }),
|
|
17
|
-
}
|
|
18
|
-
constructor(public options: any) {}
|
|
19
|
-
// Route through _dispatcher like the real RN adapter — required so
|
|
20
|
-
// FullFailingSQLiteAdapter's dispatcher patch intercepts batch calls
|
|
21
|
-
batch(operations: any[], callback: (result: any) => void) {
|
|
22
|
-
this._dispatcher.call('batch', [operations], callback)
|
|
23
|
-
}
|
|
24
|
-
get schema() { return this.options?.schema }
|
|
25
|
-
}
|
|
26
|
-
})
|
|
27
|
-
|
|
28
|
-
import SQLiteErrorAwareAdapter, {
|
|
29
|
-
FullFailingSQLiteAdapter,
|
|
30
|
-
} from '@/services/sync/adapters/sqlite'
|
|
31
|
-
|
|
32
|
-
// ---
|
|
33
|
-
|
|
34
|
-
describe('isSQLiteFullError detection via SQLiteErrorAwareAdapter.batch()', () => {
|
|
35
|
-
function makeAdapterWithDispatcherError(error: Error | null, onFullError?: jest.Mock) {
|
|
36
|
-
const adapter = new SQLiteErrorAwareAdapter(
|
|
37
|
-
{ schema: null } as any,
|
|
38
|
-
onFullError ? { onFullError } : {}
|
|
39
|
-
)
|
|
40
|
-
// Override dispatcher to return a specific error result
|
|
41
|
-
;(adapter as any)._dispatcher.call = (_method: string, _args: any[], callback: any) =>
|
|
42
|
-
callback({ error })
|
|
43
|
-
return adapter
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
test('calls onFullError for RN-style "sqlite error 13" message', done => {
|
|
47
|
-
const onFullError = jest.fn()
|
|
48
|
-
const adapter = makeAdapterWithDispatcherError(
|
|
49
|
-
new Error('sqlite error 13 (database or disk is full)'),
|
|
50
|
-
onFullError
|
|
51
|
-
)
|
|
52
|
-
|
|
53
|
-
adapter.batch([], _result => {
|
|
54
|
-
expect(onFullError).toHaveBeenCalledTimes(1)
|
|
55
|
-
done()
|
|
56
|
-
})
|
|
57
|
-
})
|
|
58
|
-
|
|
59
|
-
test('calls onFullError for "database or disk is full" message', done => {
|
|
60
|
-
const onFullError = jest.fn()
|
|
61
|
-
const adapter = makeAdapterWithDispatcherError(
|
|
62
|
-
new Error('database or disk is full'),
|
|
63
|
-
onFullError
|
|
64
|
-
)
|
|
65
|
-
|
|
66
|
-
adapter.batch([], _result => {
|
|
67
|
-
expect(onFullError).toHaveBeenCalledTimes(1)
|
|
68
|
-
done()
|
|
69
|
-
})
|
|
70
|
-
})
|
|
71
|
-
|
|
72
|
-
test('does NOT call onFullError for unrelated errors', done => {
|
|
73
|
-
const onFullError = jest.fn()
|
|
74
|
-
const adapter = makeAdapterWithDispatcherError(
|
|
75
|
-
new Error('no such table: foo'),
|
|
76
|
-
onFullError
|
|
77
|
-
)
|
|
78
|
-
|
|
79
|
-
adapter.batch([], _result => {
|
|
80
|
-
expect(onFullError).not.toHaveBeenCalled()
|
|
81
|
-
done()
|
|
82
|
-
})
|
|
83
|
-
})
|
|
84
|
-
|
|
85
|
-
test('does NOT call onFullError when no error', done => {
|
|
86
|
-
const onFullError = jest.fn()
|
|
87
|
-
const adapter = makeAdapterWithDispatcherError(null, onFullError)
|
|
88
|
-
|
|
89
|
-
adapter.batch([], _result => {
|
|
90
|
-
expect(onFullError).not.toHaveBeenCalled()
|
|
91
|
-
done()
|
|
92
|
-
})
|
|
93
|
-
})
|
|
94
|
-
|
|
95
|
-
test('still calls original callback regardless of error type', done => {
|
|
96
|
-
const adapter = makeAdapterWithDispatcherError(new Error('some error'))
|
|
97
|
-
const callback = jest.fn()
|
|
98
|
-
|
|
99
|
-
adapter.batch([], callback)
|
|
100
|
-
expect(callback).toHaveBeenCalledTimes(1)
|
|
101
|
-
done()
|
|
102
|
-
})
|
|
103
|
-
})
|
|
104
|
-
|
|
105
|
-
describe('FullFailingSQLiteAdapter', () => {
|
|
106
|
-
test('batch always fires error callback', done => {
|
|
107
|
-
const onFullError = jest.fn()
|
|
108
|
-
const adapter = new FullFailingSQLiteAdapter({ schema: null } as any, { onFullError })
|
|
109
|
-
|
|
110
|
-
adapter.batch([], result => {
|
|
111
|
-
expect(result.error).toBeInstanceOf(Error)
|
|
112
|
-
expect(result.error.message).toContain('sqlite error 13')
|
|
113
|
-
done()
|
|
114
|
-
})
|
|
115
|
-
})
|
|
116
|
-
|
|
117
|
-
test('batch calls onFullError', done => {
|
|
118
|
-
const onFullError = jest.fn()
|
|
119
|
-
const adapter = new FullFailingSQLiteAdapter({ schema: null } as any, { onFullError })
|
|
120
|
-
|
|
121
|
-
adapter.batch([], _result => {
|
|
122
|
-
expect(onFullError).toHaveBeenCalledTimes(1)
|
|
123
|
-
done()
|
|
124
|
-
})
|
|
125
|
-
})
|
|
126
|
-
|
|
127
|
-
test('non-batch methods pass through to real adapter', () => {
|
|
128
|
-
const adapter = new FullFailingSQLiteAdapter({ schema: null } as any)
|
|
129
|
-
// schema getter should work (delegated to parent/mock)
|
|
130
|
-
expect(() => adapter.schema).not.toThrow()
|
|
131
|
-
})
|
|
132
|
-
})
|
|
133
|
-
|
|
134
|
-
describe('better-sqlite3 error message format', () => {
|
|
135
|
-
// Confirms that native SQLite error messages use the "database or disk is full"
|
|
136
|
-
// phrase, which our isSQLiteFullError function checks for.
|
|
137
|
-
// We can't easily trigger SQLITE_FULL (error 13) in a test, but we can verify
|
|
138
|
-
// that other sqlite errors come through with the expected message structure,
|
|
139
|
-
// and manually construct a SQLITE_FULL error to verify detection.
|
|
140
|
-
|
|
141
|
-
test('better-sqlite3 errors are plain Error instances with message string', () => {
|
|
142
|
-
const db = new BetterSqlite3(':memory:')
|
|
143
|
-
let caught: Error | null = null
|
|
144
|
-
|
|
145
|
-
try {
|
|
146
|
-
db.exec('SELECT * FROM nonexistent_table_xyz')
|
|
147
|
-
} catch (e) {
|
|
148
|
-
caught = e as Error
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
expect(caught).toBeInstanceOf(Error)
|
|
152
|
-
expect(typeof caught!.message).toBe('string')
|
|
153
|
-
expect(caught!.message.length).toBeGreaterThan(0)
|
|
154
|
-
db.close()
|
|
155
|
-
})
|
|
156
|
-
|
|
157
|
-
test('simulated SQLITE_FULL error matches our detection logic', done => {
|
|
158
|
-
const onFullError = jest.fn()
|
|
159
|
-
const adapter = new SQLiteErrorAwareAdapter({ schema: null } as any, { onFullError })
|
|
160
|
-
|
|
161
|
-
// Construct error the way better-sqlite3 would for SQLITE_FULL (error code 13)
|
|
162
|
-
const fullError = new Error('database or disk is full') as any
|
|
163
|
-
fullError.code = 'SQLITE_FULL'
|
|
164
|
-
|
|
165
|
-
;(adapter as any)._dispatcher.call = (_method: string, _args: any[], callback: any) =>
|
|
166
|
-
callback({ error: fullError })
|
|
167
|
-
|
|
168
|
-
adapter.batch([], _result => {
|
|
169
|
-
expect(onFullError).toHaveBeenCalledWith(fullError)
|
|
170
|
-
done()
|
|
171
|
-
})
|
|
172
|
-
})
|
|
173
|
-
})
|
|
@@ -1,224 +0,0 @@
|
|
|
1
|
-
import * as fflate from 'fflate'
|
|
2
|
-
import { handlePull, type SyncPullResponse } from '@/services/sync/fetch'
|
|
3
|
-
import { makeContext } from '../../unit/sync/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,99 +0,0 @@
|
|
|
1
|
-
jest.mock('@/services/sync/manager', () => ({ default: class SyncManager {} }))
|
|
2
|
-
jest.mock('@/services/sync/repository-proxy', () => ({ db: {} }))
|
|
3
|
-
|
|
4
|
-
import { Database } from '@nozbe/watermelondb'
|
|
5
|
-
import { makeDatabase, makeStore, resetDatabase } from '../../../unit/sync/helpers/index'
|
|
6
|
-
import ContentLike from '@/services/sync/models/ContentLike'
|
|
7
|
-
import LikesRepository from '@/services/sync/repositories/content-likes'
|
|
8
|
-
|
|
9
|
-
let db: Database
|
|
10
|
-
let repo: LikesRepository
|
|
11
|
-
|
|
12
|
-
beforeEach(() => {
|
|
13
|
-
db = makeDatabase()
|
|
14
|
-
const { store } = makeStore(ContentLike, db)
|
|
15
|
-
repo = new LikesRepository(store)
|
|
16
|
-
})
|
|
17
|
-
|
|
18
|
-
afterEach(async () => {
|
|
19
|
-
await resetDatabase(db)
|
|
20
|
-
})
|
|
21
|
-
|
|
22
|
-
// ---
|
|
23
|
-
|
|
24
|
-
describe('like / unlike', () => {
|
|
25
|
-
test('like creates a record for the contentId', async () => {
|
|
26
|
-
await repo.like(100)
|
|
27
|
-
|
|
28
|
-
const result = await repo['store'].readOne(ContentLike.generateId(100))
|
|
29
|
-
expect(result).not.toBeNull()
|
|
30
|
-
expect(result!.content_id).toBe(100)
|
|
31
|
-
})
|
|
32
|
-
|
|
33
|
-
test('unlike removes the record', async () => {
|
|
34
|
-
await repo.like(200)
|
|
35
|
-
await repo.unlike(200)
|
|
36
|
-
|
|
37
|
-
const result = await repo['store'].readOne(ContentLike.generateId(200))
|
|
38
|
-
expect(result).toBeNull()
|
|
39
|
-
})
|
|
40
|
-
|
|
41
|
-
test('liking same content twice is idempotent', async () => {
|
|
42
|
-
await repo.like(300)
|
|
43
|
-
await repo.like(300)
|
|
44
|
-
|
|
45
|
-
const all = await repo['store'].readAll()
|
|
46
|
-
expect(all).toHaveLength(1)
|
|
47
|
-
})
|
|
48
|
-
})
|
|
49
|
-
|
|
50
|
-
describe('isLiked', () => {
|
|
51
|
-
test('returns true when content is liked', async () => {
|
|
52
|
-
await repo.like(400)
|
|
53
|
-
|
|
54
|
-
const result = await repo.isLiked(400)
|
|
55
|
-
expect(result.data).toBe(true)
|
|
56
|
-
})
|
|
57
|
-
|
|
58
|
-
test('returns false when content is not liked', async () => {
|
|
59
|
-
const result = await repo.isLiked(9999)
|
|
60
|
-
expect(result.data).toBe(false)
|
|
61
|
-
})
|
|
62
|
-
|
|
63
|
-
test('returns false after unliking', async () => {
|
|
64
|
-
await repo.like(500)
|
|
65
|
-
await repo.unlike(500)
|
|
66
|
-
|
|
67
|
-
const result = await repo.isLiked(500)
|
|
68
|
-
expect(result.data).toBe(false)
|
|
69
|
-
})
|
|
70
|
-
})
|
|
71
|
-
|
|
72
|
-
describe('areLiked', () => {
|
|
73
|
-
test('returns boolean array in same order as input', async () => {
|
|
74
|
-
await repo.like(600)
|
|
75
|
-
await repo.like(602)
|
|
76
|
-
// 601 not liked
|
|
77
|
-
|
|
78
|
-
const result = await repo.areLiked([600, 601, 602])
|
|
79
|
-
expect(result.data).toEqual([true, false, true])
|
|
80
|
-
})
|
|
81
|
-
|
|
82
|
-
test('returns all false when nothing liked', async () => {
|
|
83
|
-
const result = await repo.areLiked([1, 2, 3])
|
|
84
|
-
expect(result.data).toEqual([false, false, false])
|
|
85
|
-
})
|
|
86
|
-
|
|
87
|
-
test('returns all true when all liked', async () => {
|
|
88
|
-
await repo.like(700)
|
|
89
|
-
await repo.like(701)
|
|
90
|
-
|
|
91
|
-
const result = await repo.areLiked([700, 701])
|
|
92
|
-
expect(result.data).toEqual([true, true])
|
|
93
|
-
})
|
|
94
|
-
|
|
95
|
-
test('empty array returns empty array', async () => {
|
|
96
|
-
const result = await repo.areLiked([])
|
|
97
|
-
expect(result.data).toEqual([])
|
|
98
|
-
})
|
|
99
|
-
})
|
|
@@ -1,179 +0,0 @@
|
|
|
1
|
-
jest.mock('@/services/sync/manager', () => ({ default: class SyncManager {} }))
|
|
2
|
-
jest.mock('@/services/sync/repository-proxy', () => ({ db: {} }))
|
|
3
|
-
|
|
4
|
-
import { Database } from '@nozbe/watermelondb'
|
|
5
|
-
import { makeDatabase, makeStore, resetDatabase } from '../../../unit/sync/helpers/index'
|
|
6
|
-
import Practice from '@/services/sync/models/Practice'
|
|
7
|
-
import PracticesRepository from '@/services/sync/repositories/practices'
|
|
8
|
-
|
|
9
|
-
let db: Database
|
|
10
|
-
let repo: PracticesRepository
|
|
11
|
-
|
|
12
|
-
beforeEach(() => {
|
|
13
|
-
db = makeDatabase()
|
|
14
|
-
const { store } = makeStore(Practice, db)
|
|
15
|
-
repo = new PracticesRepository(store)
|
|
16
|
-
})
|
|
17
|
-
|
|
18
|
-
afterEach(async () => {
|
|
19
|
-
await resetDatabase(db)
|
|
20
|
-
})
|
|
21
|
-
|
|
22
|
-
// ---
|
|
23
|
-
|
|
24
|
-
describe('trackAutoPractice', () => {
|
|
25
|
-
test('creates auto practice on first call', async () => {
|
|
26
|
-
await repo.trackAutoPractice(100, '2024-01-15', 300)
|
|
27
|
-
|
|
28
|
-
const result = await repo.getAutoPracticesOnDate('2024-01-15')
|
|
29
|
-
expect(result.data).toHaveLength(1)
|
|
30
|
-
expect(result.data[0].duration_seconds).toBe(300)
|
|
31
|
-
expect(result.data[0].auto).toBe(true)
|
|
32
|
-
expect(result.data[0].content_id).toBe(100)
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
test('increments duration on subsequent calls for same content/date', async () => {
|
|
36
|
-
await repo.trackAutoPractice(100, '2024-01-15', 300)
|
|
37
|
-
await repo.trackAutoPractice(100, '2024-01-15', 200)
|
|
38
|
-
|
|
39
|
-
const result = await repo.getAutoPracticesOnDate('2024-01-15')
|
|
40
|
-
expect(result.data).toHaveLength(1)
|
|
41
|
-
expect(result.data[0].duration_seconds).toBe(500)
|
|
42
|
-
})
|
|
43
|
-
|
|
44
|
-
test('separate records for different dates', async () => {
|
|
45
|
-
await repo.trackAutoPractice(100, '2024-01-15', 300)
|
|
46
|
-
await repo.trackAutoPractice(100, '2024-01-16', 200)
|
|
47
|
-
|
|
48
|
-
const day1 = await repo.getAutoPracticesOnDate('2024-01-15')
|
|
49
|
-
const day2 = await repo.getAutoPracticesOnDate('2024-01-16')
|
|
50
|
-
expect(day1.data).toHaveLength(1)
|
|
51
|
-
expect(day2.data).toHaveLength(1)
|
|
52
|
-
expect(day1.data[0].duration_seconds).toBe(300)
|
|
53
|
-
expect(day2.data[0].duration_seconds).toBe(200)
|
|
54
|
-
})
|
|
55
|
-
|
|
56
|
-
test('caps duration at 59999 seconds', async () => {
|
|
57
|
-
await repo.trackAutoPractice(100, '2024-01-15', 59990)
|
|
58
|
-
await repo.trackAutoPractice(100, '2024-01-15', 100)
|
|
59
|
-
|
|
60
|
-
const result = await repo.getAutoPracticesOnDate('2024-01-15')
|
|
61
|
-
expect(result.data[0].duration_seconds).toBe(59999)
|
|
62
|
-
})
|
|
63
|
-
})
|
|
64
|
-
|
|
65
|
-
describe('recordManualPractice', () => {
|
|
66
|
-
test('creates manual practice with correct fields', async () => {
|
|
67
|
-
await repo.recordManualPractice('2024-01-20', 1800, {
|
|
68
|
-
title: 'Blues scales',
|
|
69
|
-
instrument_id: 1,
|
|
70
|
-
category_id: 2,
|
|
71
|
-
})
|
|
72
|
-
|
|
73
|
-
const result = await repo['store'].readAll()
|
|
74
|
-
expect(result).toHaveLength(1)
|
|
75
|
-
|
|
76
|
-
const practice = result[0]
|
|
77
|
-
expect(practice.auto).toBe(false)
|
|
78
|
-
expect(practice.date).toBe('2024-01-20')
|
|
79
|
-
expect(practice.duration_seconds).toBe(1800)
|
|
80
|
-
expect(practice.title).toBe('Blues scales')
|
|
81
|
-
expect(practice.instrument_id).toBe(1)
|
|
82
|
-
expect(practice.category_id).toBe(2)
|
|
83
|
-
expect(practice.id.startsWith('manual:')).toBe(true)
|
|
84
|
-
})
|
|
85
|
-
|
|
86
|
-
test('caps duration at 59999 seconds', async () => {
|
|
87
|
-
await repo.recordManualPractice('2024-01-20', 999999)
|
|
88
|
-
|
|
89
|
-
const result = await repo['store'].readAll()
|
|
90
|
-
expect(result[0].duration_seconds).toBe(59999)
|
|
91
|
-
})
|
|
92
|
-
|
|
93
|
-
test('multiple manual practices on same date create separate records', async () => {
|
|
94
|
-
await repo.recordManualPractice('2024-01-20', 600)
|
|
95
|
-
await repo.recordManualPractice('2024-01-20', 900)
|
|
96
|
-
|
|
97
|
-
const result = await repo['store'].readAll()
|
|
98
|
-
expect(result).toHaveLength(2)
|
|
99
|
-
expect(result.every(r => r.auto === false)).toBe(true)
|
|
100
|
-
})
|
|
101
|
-
})
|
|
102
|
-
|
|
103
|
-
describe('updateDetails', () => {
|
|
104
|
-
test('updates duration and title', async () => {
|
|
105
|
-
const created = await repo.recordManualPractice('2024-01-25', 600, { title: 'Old title' })
|
|
106
|
-
const id = created.data.id
|
|
107
|
-
|
|
108
|
-
await repo.updateDetails(id, { duration_seconds: 900, title: 'New title' })
|
|
109
|
-
|
|
110
|
-
const record = await repo['store'].readOne(id)
|
|
111
|
-
expect(record!.duration_seconds).toBe(900)
|
|
112
|
-
expect(record!.title).toBe('New title')
|
|
113
|
-
})
|
|
114
|
-
|
|
115
|
-
test('preserves existing values when fields not provided', async () => {
|
|
116
|
-
const created = await repo.recordManualPractice('2024-01-25', 600, {
|
|
117
|
-
title: 'Keep me',
|
|
118
|
-
category_id: 3,
|
|
119
|
-
})
|
|
120
|
-
const id = created.data.id
|
|
121
|
-
|
|
122
|
-
await repo.updateDetails(id, { duration_seconds: 1200 })
|
|
123
|
-
|
|
124
|
-
const record = await repo['store'].readOne(id)
|
|
125
|
-
expect(record!.title).toBe('Keep me')
|
|
126
|
-
expect(record!.category_id).toBe(3)
|
|
127
|
-
})
|
|
128
|
-
})
|
|
129
|
-
|
|
130
|
-
describe('getAutoPracticesOnDate', () => {
|
|
131
|
-
test('returns only auto practices for the given date', async () => {
|
|
132
|
-
await repo.trackAutoPractice(100, '2024-02-01', 300)
|
|
133
|
-
await repo.recordManualPractice('2024-02-01', 600) // manual — should not appear
|
|
134
|
-
|
|
135
|
-
const result = await repo.getAutoPracticesOnDate('2024-02-01')
|
|
136
|
-
expect(result.data).toHaveLength(1)
|
|
137
|
-
expect(result.data[0].auto).toBe(true)
|
|
138
|
-
})
|
|
139
|
-
|
|
140
|
-
test('returns empty array when no practices on date', async () => {
|
|
141
|
-
const result = await repo.getAutoPracticesOnDate('2099-01-01')
|
|
142
|
-
expect(result.data).toHaveLength(0)
|
|
143
|
-
})
|
|
144
|
-
})
|
|
145
|
-
|
|
146
|
-
describe('sumPracticeMinutesForContent', () => {
|
|
147
|
-
test('sums across multiple practice records', async () => {
|
|
148
|
-
await repo.trackAutoPractice(200, '2024-01-01', 600) // 10 min
|
|
149
|
-
await repo.trackAutoPractice(200, '2024-01-02', 900) // 15 min
|
|
150
|
-
|
|
151
|
-
const minutes = await repo.sumPracticeMinutesForContent([200])
|
|
152
|
-
expect(minutes).toBe(25)
|
|
153
|
-
})
|
|
154
|
-
|
|
155
|
-
test('sums only for specified contentIds', async () => {
|
|
156
|
-
await repo.trackAutoPractice(300, '2024-01-01', 600)
|
|
157
|
-
await repo.trackAutoPractice(301, '2024-01-01', 600)
|
|
158
|
-
|
|
159
|
-
const minutes = await repo.sumPracticeMinutesForContent([300])
|
|
160
|
-
expect(minutes).toBe(10)
|
|
161
|
-
})
|
|
162
|
-
|
|
163
|
-
test('returns 0 for empty contentIds array', async () => {
|
|
164
|
-
const minutes = await repo.sumPracticeMinutesForContent([])
|
|
165
|
-
expect(minutes).toBe(0)
|
|
166
|
-
})
|
|
167
|
-
|
|
168
|
-
test('returns 0 when no practices for contentIds', async () => {
|
|
169
|
-
const minutes = await repo.sumPracticeMinutesForContent([9999])
|
|
170
|
-
expect(minutes).toBe(0)
|
|
171
|
-
})
|
|
172
|
-
|
|
173
|
-
test('rounds to nearest minute', async () => {
|
|
174
|
-
await repo.trackAutoPractice(400, '2024-01-01', 90) // 1.5 min → rounds to 2
|
|
175
|
-
|
|
176
|
-
const minutes = await repo.sumPracticeMinutesForContent([400])
|
|
177
|
-
expect(minutes).toBe(2)
|
|
178
|
-
})
|
|
179
|
-
})
|