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.
Files changed (131) hide show
  1. package/.agent/decisions/2026-07-20-mu2-1724-songs-lessons-counts.md +33 -0
  2. package/.agent/decisions/2026-07-20-query-builder-tostring.md +37 -0
  3. package/CHANGELOG.md +8 -0
  4. package/package.json +2 -1
  5. package/src/contentTypeConfig.js +1 -0
  6. package/src/lib/sanity/query.ts +5 -0
  7. package/src/services/config.js +15 -0
  8. package/src/services/content/artist.ts +1 -3
  9. package/src/services/content/counts.ts +47 -0
  10. package/src/services/content/genre.ts +1 -3
  11. package/src/services/content/instructor.ts +1 -3
  12. package/src/services/userActivity.js +2 -0
  13. package/src/version-info.js +1 -0
  14. package/.claude/settings.local.json +0 -53
  15. package/test/initializeTests.js +0 -60
  16. package/test/integration/awards/award-exclusion-handling.test.ts +0 -275
  17. package/test/integration/awards/internal/image-utils.test.ts +0 -86
  18. package/test/integration/content-progress/__mocks__/mocks.ts +0 -104
  19. package/test/integration/content-progress/contentProgress.test.ts +0 -335
  20. package/test/integration/content-progress/e2eOfflineProgress.test.ts +0 -352
  21. package/test/integration/content-progress/e2eProgress.test.ts +0 -612
  22. package/test/integration/content-progress/getters.test.ts +0 -334
  23. package/test/integration/content-progress/helpers.test.ts +0 -263
  24. package/test/integration/content-progress/offlineContentProgress.test.ts +0 -226
  25. package/test/integration/forums.test.ts +0 -209
  26. package/test/integration/infrastructure/FetchRequestExecutor.test.ts +0 -88
  27. package/test/integration/initializeTestDB.ts +0 -80
  28. package/test/integration/learning-paths.test.ts +0 -854
  29. package/test/integration/notifications.test.ts +0 -178
  30. package/test/integration/progress-row/content-card.test.ts +0 -283
  31. package/test/integration/sync/adapters/idb-errors.test.ts +0 -144
  32. package/test/integration/sync/adapters/sqlite-errors.test.ts +0 -173
  33. package/test/integration/sync/fetch.test.ts +0 -224
  34. package/test/integration/sync/repositories/content-likes.test.ts +0 -99
  35. package/test/integration/sync/repositories/practices.test.ts +0 -179
  36. package/test/integration/sync/repositories/progress.test.ts +0 -245
  37. package/test/integration/sync/repositories/user-award-progress.static.test.ts +0 -68
  38. package/test/integration/sync/repositories/user-award-progress.test.ts +0 -387
  39. package/test/integration/sync/store/cross-user-protection.test.ts +0 -217
  40. package/test/integration/sync/store/store-idb.test.ts +0 -180
  41. package/test/integration/sync/store/store.test.ts +0 -361
  42. package/test/integration/user/permissions.test.js +0 -20
  43. package/test/integration/userActivity.test.ts +0 -102
  44. package/test/live/README.md +0 -29
  45. package/test/live/content.test.js +0 -116
  46. package/test/localStorageMock.js +0 -21
  47. package/test/log.js +0 -5
  48. package/test/mockData/award-definitions.js +0 -171
  49. package/test/mockData/mockData_fetchByRailContentIds_one_content.json +0 -35
  50. package/test/mockData/mockData_progress_content.json +0 -182
  51. package/test/mockData/mockData_sanity_progress_content.json +0 -1451
  52. package/test/mockData/mockData_user_practices.json +0 -9
  53. package/test/setupConsole.js +0 -6
  54. package/test/setupNetworkGuard.js +0 -3
  55. package/test/setupTimers.js +0 -13
  56. package/test/unit/HttpClient.test.ts +0 -267
  57. package/test/unit/awards/award-alacarte-observer.test.ts +0 -199
  58. package/test/unit/awards/award-auto-refresh.test.ts +0 -84
  59. package/test/unit/awards/award-calculations.test.ts +0 -34
  60. package/test/unit/awards/award-callbacks.test.ts +0 -144
  61. package/test/unit/awards/award-certificate-display.test.ts +0 -330
  62. package/test/unit/awards/award-collection-edge-cases.test.ts +0 -212
  63. package/test/unit/awards/award-collection-filtering.test.ts +0 -287
  64. package/test/unit/awards/award-completion-flow.test.ts +0 -306
  65. package/test/unit/awards/award-multi-lesson.test.ts +0 -243
  66. package/test/unit/awards/award-observer-integration.test.ts +0 -327
  67. package/test/unit/awards/award-query-messages.test.ts +0 -448
  68. package/test/unit/awards/award-user-collection.test.ts +0 -416
  69. package/test/unit/awards/duplicate-prevention.test.ts +0 -120
  70. package/test/unit/awards/helpers/completion-mock.ts +0 -56
  71. package/test/unit/awards/helpers/index.ts +0 -3
  72. package/test/unit/awards/helpers/mock-setup.ts +0 -79
  73. package/test/unit/awards/helpers/progress-emitter.ts +0 -45
  74. package/test/unit/awards/message-generator.test.ts +0 -162
  75. package/test/unit/content-progress/bubbleTrickle.test.ts +0 -322
  76. package/test/unit/content-progress/helpers.test.ts +0 -329
  77. package/test/unit/content-progress/navigateTo.test.ts +0 -392
  78. package/test/unit/contentLikes.test.ts +0 -62
  79. package/test/unit/contentMetaData.test.ts +0 -58
  80. package/test/unit/dataContext.test.ts +0 -38
  81. package/test/unit/dateUtils.test.ts +0 -188
  82. package/test/unit/endScreen.test.js +0 -712
  83. package/test/unit/eventsAPI.test.ts +0 -159
  84. package/test/unit/imageSRCBuilder.test.ts +0 -37
  85. package/test/unit/imageSRCVerify.test.ts +0 -155
  86. package/test/unit/infrastructure/DefaultHeaderProvider.test.ts +0 -39
  87. package/test/unit/infrastructure/sanity/ContentClient.test.ts +0 -168
  88. package/test/unit/infrastructure/sanity/DefaultConfigProvider.test.ts +0 -93
  89. package/test/unit/infrastructure/sanity/FetchQueryExecutor.test.ts +0 -174
  90. package/test/unit/infrastructure/sanity/SanityClient.test.ts +0 -140
  91. package/test/unit/lib/__snapshots__/filter.test.ts.snap +0 -5
  92. package/test/unit/lib/filter.test.ts +0 -1154
  93. package/test/unit/lib/lastUpdated.test.ts +0 -21
  94. package/test/unit/lib/query.test.ts +0 -1469
  95. package/test/unit/lib/sanity/decorators/base.test.ts +0 -368
  96. package/test/unit/lib/sanity/decorators/navigate-to.test.ts +0 -361
  97. package/test/unit/lib/sanity/decorators/need-access.test.ts +0 -89
  98. package/test/unit/lib/sanity/decorators/need-lifetime-upgrade.test.ts +0 -85
  99. package/test/unit/lib/sanity/decorators/page-type.test.ts +0 -81
  100. package/test/unit/playerSettings.test.ts +0 -73
  101. package/test/unit/progress-row/content-card.test.ts +0 -314
  102. package/test/unit/progress-row/playlist-card.test.ts +0 -104
  103. package/test/unit/progressRows.test.ts +0 -243
  104. package/test/unit/recommendations.test.js +0 -47
  105. package/test/unit/sanityQueryService.test.ts +0 -200
  106. package/test/unit/search.test.ts +0 -44
  107. package/test/unit/sentry.test.ts +0 -62
  108. package/test/unit/services/progress-internal/bubble.test.ts +0 -279
  109. package/test/unit/services/progress.test.ts +0 -396
  110. package/test/unit/streakMessage.test.ts +0 -251
  111. package/test/unit/sync/context.test.ts +0 -51
  112. package/test/unit/sync/effects/logout-warning.test.ts +0 -158
  113. package/test/unit/sync/effects/push-failure-notification.test.ts +0 -196
  114. package/test/unit/sync/errors/sync-errors.test.ts +0 -106
  115. package/test/unit/sync/errors/validators.test.ts +0 -61
  116. package/test/unit/sync/helpers/TestModel.ts +0 -44
  117. package/test/unit/sync/helpers/index.ts +0 -172
  118. package/test/unit/sync/manager.test.ts +0 -303
  119. package/test/unit/sync/models/user-award-progress.test.ts +0 -82
  120. package/test/unit/sync/resolver.test.ts +0 -229
  121. package/test/unit/sync/retry.test.ts +0 -314
  122. package/test/unit/sync/run-scope.test.ts +0 -23
  123. package/test/unit/sync/stale-record-cleanup.test.ts +0 -107
  124. package/test/unit/sync/store/push-coalescer.test.ts +0 -156
  125. package/test/unit/sync/store-configs.test.ts +0 -37
  126. package/test/unit/sync/telemetry/sync-telemetry.test.ts +0 -118
  127. package/test/unit/sync/utils/event-emitter.test.ts +0 -64
  128. package/test/unit/sync/utils/throttle.test.ts +0 -245
  129. package/test/unit/url-builder.test.ts +0 -72
  130. package/test/unit/user/account.test.ts +0 -66
  131. 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
- })