musora-content-services 2.167.2 → 2.167.4

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 (140) 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/.idea/codeStyles/Project.xml +61 -0
  4. package/.idea/codeStyles/codeStyleConfig.xml +5 -0
  5. package/.idea/modules.xml +8 -0
  6. package/.idea/musora-content-services.iml +8 -0
  7. package/.idea/php.xml +19 -0
  8. package/.idea/prettier.xml +6 -0
  9. package/.idea/vcs.xml +7 -0
  10. package/.yarn/install-state.gz +0 -0
  11. package/CHANGELOG.md +10 -0
  12. package/package.json +2 -1
  13. package/src/contentTypeConfig.js +1 -0
  14. package/src/lib/sanity/query.ts +5 -0
  15. package/src/services/config.js +15 -0
  16. package/src/services/content/artist.ts +1 -3
  17. package/src/services/content/counts.ts +47 -0
  18. package/src/services/content/genre.ts +1 -3
  19. package/src/services/content/instructor.ts +1 -3
  20. package/src/services/multi-user-accounts/multi-user-accounts.ts +1 -0
  21. package/src/services/userActivity.js +2 -0
  22. package/src/version-info.js +1 -0
  23. package/.claude/settings.local.json +0 -53
  24. package/test/initializeTests.js +0 -60
  25. package/test/integration/awards/award-exclusion-handling.test.ts +0 -275
  26. package/test/integration/awards/internal/image-utils.test.ts +0 -86
  27. package/test/integration/content-progress/__mocks__/mocks.ts +0 -104
  28. package/test/integration/content-progress/contentProgress.test.ts +0 -335
  29. package/test/integration/content-progress/e2eOfflineProgress.test.ts +0 -352
  30. package/test/integration/content-progress/e2eProgress.test.ts +0 -612
  31. package/test/integration/content-progress/getters.test.ts +0 -334
  32. package/test/integration/content-progress/helpers.test.ts +0 -263
  33. package/test/integration/content-progress/offlineContentProgress.test.ts +0 -226
  34. package/test/integration/forums.test.ts +0 -209
  35. package/test/integration/infrastructure/FetchRequestExecutor.test.ts +0 -88
  36. package/test/integration/initializeTestDB.ts +0 -80
  37. package/test/integration/learning-paths.test.ts +0 -854
  38. package/test/integration/notifications.test.ts +0 -178
  39. package/test/integration/progress-row/content-card.test.ts +0 -283
  40. package/test/integration/sync/adapters/idb-errors.test.ts +0 -144
  41. package/test/integration/sync/adapters/sqlite-errors.test.ts +0 -173
  42. package/test/integration/sync/fetch.test.ts +0 -224
  43. package/test/integration/sync/repositories/content-likes.test.ts +0 -99
  44. package/test/integration/sync/repositories/practices.test.ts +0 -179
  45. package/test/integration/sync/repositories/progress.test.ts +0 -245
  46. package/test/integration/sync/repositories/user-award-progress.static.test.ts +0 -68
  47. package/test/integration/sync/repositories/user-award-progress.test.ts +0 -387
  48. package/test/integration/sync/store/cross-user-protection.test.ts +0 -217
  49. package/test/integration/sync/store/store-idb.test.ts +0 -180
  50. package/test/integration/sync/store/store.test.ts +0 -361
  51. package/test/integration/user/permissions.test.js +0 -20
  52. package/test/integration/userActivity.test.ts +0 -102
  53. package/test/live/README.md +0 -29
  54. package/test/live/content.test.js +0 -116
  55. package/test/localStorageMock.js +0 -21
  56. package/test/log.js +0 -5
  57. package/test/mockData/award-definitions.js +0 -171
  58. package/test/mockData/mockData_fetchByRailContentIds_one_content.json +0 -35
  59. package/test/mockData/mockData_progress_content.json +0 -182
  60. package/test/mockData/mockData_sanity_progress_content.json +0 -1451
  61. package/test/mockData/mockData_user_practices.json +0 -9
  62. package/test/setupConsole.js +0 -6
  63. package/test/setupNetworkGuard.js +0 -3
  64. package/test/setupTimers.js +0 -13
  65. package/test/unit/HttpClient.test.ts +0 -267
  66. package/test/unit/awards/award-alacarte-observer.test.ts +0 -199
  67. package/test/unit/awards/award-auto-refresh.test.ts +0 -84
  68. package/test/unit/awards/award-calculations.test.ts +0 -34
  69. package/test/unit/awards/award-callbacks.test.ts +0 -144
  70. package/test/unit/awards/award-certificate-display.test.ts +0 -330
  71. package/test/unit/awards/award-collection-edge-cases.test.ts +0 -212
  72. package/test/unit/awards/award-collection-filtering.test.ts +0 -287
  73. package/test/unit/awards/award-completion-flow.test.ts +0 -306
  74. package/test/unit/awards/award-multi-lesson.test.ts +0 -243
  75. package/test/unit/awards/award-observer-integration.test.ts +0 -327
  76. package/test/unit/awards/award-query-messages.test.ts +0 -448
  77. package/test/unit/awards/award-user-collection.test.ts +0 -416
  78. package/test/unit/awards/duplicate-prevention.test.ts +0 -120
  79. package/test/unit/awards/helpers/completion-mock.ts +0 -56
  80. package/test/unit/awards/helpers/index.ts +0 -3
  81. package/test/unit/awards/helpers/mock-setup.ts +0 -79
  82. package/test/unit/awards/helpers/progress-emitter.ts +0 -45
  83. package/test/unit/awards/message-generator.test.ts +0 -162
  84. package/test/unit/content-progress/bubbleTrickle.test.ts +0 -322
  85. package/test/unit/content-progress/helpers.test.ts +0 -329
  86. package/test/unit/content-progress/navigateTo.test.ts +0 -392
  87. package/test/unit/contentLikes.test.ts +0 -62
  88. package/test/unit/contentMetaData.test.ts +0 -58
  89. package/test/unit/dataContext.test.ts +0 -38
  90. package/test/unit/dateUtils.test.ts +0 -188
  91. package/test/unit/endScreen.test.js +0 -712
  92. package/test/unit/eventsAPI.test.ts +0 -159
  93. package/test/unit/imageSRCBuilder.test.ts +0 -37
  94. package/test/unit/imageSRCVerify.test.ts +0 -155
  95. package/test/unit/infrastructure/DefaultHeaderProvider.test.ts +0 -39
  96. package/test/unit/infrastructure/sanity/ContentClient.test.ts +0 -168
  97. package/test/unit/infrastructure/sanity/DefaultConfigProvider.test.ts +0 -93
  98. package/test/unit/infrastructure/sanity/FetchQueryExecutor.test.ts +0 -174
  99. package/test/unit/infrastructure/sanity/SanityClient.test.ts +0 -140
  100. package/test/unit/lib/__snapshots__/filter.test.ts.snap +0 -5
  101. package/test/unit/lib/filter.test.ts +0 -1154
  102. package/test/unit/lib/lastUpdated.test.ts +0 -21
  103. package/test/unit/lib/query.test.ts +0 -1469
  104. package/test/unit/lib/sanity/decorators/base.test.ts +0 -368
  105. package/test/unit/lib/sanity/decorators/navigate-to.test.ts +0 -361
  106. package/test/unit/lib/sanity/decorators/need-access.test.ts +0 -89
  107. package/test/unit/lib/sanity/decorators/need-lifetime-upgrade.test.ts +0 -85
  108. package/test/unit/lib/sanity/decorators/page-type.test.ts +0 -81
  109. package/test/unit/playerSettings.test.ts +0 -73
  110. package/test/unit/progress-row/content-card.test.ts +0 -314
  111. package/test/unit/progress-row/playlist-card.test.ts +0 -104
  112. package/test/unit/progressRows.test.ts +0 -243
  113. package/test/unit/recommendations.test.js +0 -47
  114. package/test/unit/sanityQueryService.test.ts +0 -200
  115. package/test/unit/search.test.ts +0 -44
  116. package/test/unit/sentry.test.ts +0 -62
  117. package/test/unit/services/progress-internal/bubble.test.ts +0 -279
  118. package/test/unit/services/progress.test.ts +0 -396
  119. package/test/unit/streakMessage.test.ts +0 -251
  120. package/test/unit/sync/context.test.ts +0 -51
  121. package/test/unit/sync/effects/logout-warning.test.ts +0 -158
  122. package/test/unit/sync/effects/push-failure-notification.test.ts +0 -196
  123. package/test/unit/sync/errors/sync-errors.test.ts +0 -106
  124. package/test/unit/sync/errors/validators.test.ts +0 -61
  125. package/test/unit/sync/helpers/TestModel.ts +0 -44
  126. package/test/unit/sync/helpers/index.ts +0 -172
  127. package/test/unit/sync/manager.test.ts +0 -303
  128. package/test/unit/sync/models/user-award-progress.test.ts +0 -82
  129. package/test/unit/sync/resolver.test.ts +0 -229
  130. package/test/unit/sync/retry.test.ts +0 -314
  131. package/test/unit/sync/run-scope.test.ts +0 -23
  132. package/test/unit/sync/stale-record-cleanup.test.ts +0 -107
  133. package/test/unit/sync/store/push-coalescer.test.ts +0 -156
  134. package/test/unit/sync/store-configs.test.ts +0 -37
  135. package/test/unit/sync/telemetry/sync-telemetry.test.ts +0 -118
  136. package/test/unit/sync/utils/event-emitter.test.ts +0 -64
  137. package/test/unit/sync/utils/throttle.test.ts +0 -245
  138. package/test/unit/url-builder.test.ts +0 -72
  139. package/test/unit/user/account.test.ts +0 -66
  140. package/test/unit/user/permissions.test.js +0 -191
@@ -1,178 +0,0 @@
1
- import { initializeTestService } from '../initializeTests.js'
2
- import * as UserNotifications from "../../src/services/user/notifications.js";
3
-
4
- jest.mock('../../src/infrastructure/http/HttpClient.ts', () => ({
5
- GET: jest.fn(),
6
- POST: jest.fn(),
7
- PUT: jest.fn(),
8
- PATCH: jest.fn(),
9
- DELETE: jest.fn(),
10
- HttpClient: jest.fn(),
11
- }))
12
-
13
- jest.mock('../../src/services/railcontent.js', () => ({
14
- fetchUserPermissionsData: jest.fn(() => ({ permissions: [78, 91, 92], isAdmin: false })),
15
- fetchHandler: jest.fn(),
16
- }))
17
-
18
- jest.mock('../../src/services/eventsAPI.js', () => ({
19
- __esModule: true,
20
- default: {
21
- pauseLiveEventCheck: jest.fn().mockResolvedValue(undefined),
22
- }
23
- }))
24
-
25
- import { GET, PUT, DELETE } from '../../src/infrastructure/http/HttpClient'
26
-
27
- const baseUrl = `/api/notifications`
28
-
29
- describe('UserNotifications module', function () {
30
- beforeEach(() => {
31
- initializeTestService()
32
- })
33
-
34
- describe('fetchNotifications', () => {
35
- it('calls GET with correct url', async () => {
36
- GET.mockResolvedValueOnce([{ id: 1 }])
37
-
38
- const result = await UserNotifications.fetchNotifications({
39
- limit: 5,
40
- onlyUnread: true,
41
- page: 2,
42
- })
43
-
44
- expect(GET).toHaveBeenCalledWith(`${baseUrl}/v1?limit=5&page=2&unread=1`)
45
- expect(result).toEqual([{ id: 1 }])
46
- })
47
- })
48
-
49
- describe('markNotificationAsRead', () => {
50
- it('throws if notificationId not provided', async () => {
51
- await expect(UserNotifications.markNotificationAsRead()).rejects.toThrow('notificationId is required')
52
- })
53
-
54
- it('calls PUT with correct url', async () => {
55
- PUT.mockResolvedValueOnce({ success: true })
56
-
57
- const result = await UserNotifications.markNotificationAsRead(123)
58
- expect(PUT).toHaveBeenCalledWith(`${baseUrl}/v1/read?id=123`, null)
59
- expect(result).toEqual({ success: true })
60
- })
61
- })
62
-
63
- describe('markAllNotificationsAsRead', () => {
64
- it('calls PUT with correct url', async () => {
65
- PUT.mockResolvedValueOnce({ success: true })
66
-
67
- const result = await UserNotifications.markAllNotificationsAsRead()
68
- expect(PUT).toHaveBeenCalledWith(`${baseUrl}/v1/read`, null)
69
- expect(result).toEqual({ success: true })
70
- })
71
- })
72
-
73
- describe('markNotificationAsUnread', () => {
74
- it('throws if notificationId not provided', async () => {
75
- await expect(UserNotifications.markNotificationAsUnread()).rejects.toThrow('notificationId is required')
76
- })
77
-
78
- it('calls PUT with correct url', async () => {
79
- PUT.mockResolvedValueOnce({ success: true })
80
-
81
- const result = await UserNotifications.markNotificationAsUnread(456)
82
- expect(PUT).toHaveBeenCalledWith(`${baseUrl}/v1/unread?id=456`, null)
83
- expect(result).toEqual({ success: true })
84
- })
85
- })
86
-
87
- describe('deleteNotification', () => {
88
- it('throws if notificationId not provided', async () => {
89
- await expect(UserNotifications.deleteNotification()).rejects.toThrow('notificationId is required')
90
- })
91
-
92
- it('calls DELETE with correct url', async () => {
93
- DELETE.mockResolvedValueOnce({ success: true })
94
-
95
- const result = await UserNotifications.deleteNotification(789)
96
- expect(DELETE).toHaveBeenCalledWith(`${baseUrl}/v1/789`)
97
- expect(result).toEqual({ success: true })
98
- })
99
- })
100
-
101
- describe('fetchUnreadCount', () => {
102
- it('returns unread count when data is greater than 0', async () => {
103
- GET.mockResolvedValueOnce({ data: 42 })
104
-
105
- const result = await UserNotifications.fetchUnreadCount()
106
- expect(GET).toHaveBeenCalledWith(`${baseUrl}/v1/unread-count`)
107
- expect(result).toEqual({ data: 42 })
108
- })
109
- })
110
-
111
- describe('fetchNotificationSettings', () => {
112
- it('returns empty object if settings is falsy or not object', async () => {
113
- GET.mockResolvedValueOnce(null)
114
- expect(await UserNotifications.fetchNotificationSettings()).toEqual({})
115
-
116
- GET.mockResolvedValueOnce('string')
117
- expect(await UserNotifications.fetchNotificationSettings()).toEqual({})
118
- })
119
-
120
- it('returns transformed settings grouped by brand', async () => {
121
- GET.mockResolvedValueOnce({
122
- drumeo: {
123
- new_lessons_and_features: { channel: 'email', value: true, brand: 'drumeo' },
124
- membership_perks_promotions: { channel: 'push', value: false, brand: 'drumeo' },
125
- },
126
- pianote: {
127
- membership_perks_promotions: { channel: 'email', value: true, brand: 'pianote' },
128
- },
129
- })
130
-
131
- const result = await UserNotifications.fetchNotificationSettings()
132
-
133
- expect(result).toEqual({
134
- drumeo: [
135
- { name: 'new_lessons_and_features', channel: 'email', value: true, brand: 'drumeo' },
136
- { name: 'membership_perks_promotions', channel: 'push', value: false, brand: 'drumeo' },
137
- ],
138
- pianote: [{ name: 'membership_perks_promotions', channel: 'email', value: true, brand: 'pianote' }],
139
- })
140
- })
141
- })
142
-
143
- describe('updateNotificationSetting', () => {
144
- it('throws if settingName not provided', async () => {
145
- await expect(UserNotifications.updateNotificationSetting({ brand: 'drumeo' })).rejects.toThrow(
146
- 'The "settingName" parameter is required.'
147
- )
148
- })
149
-
150
- it('throws if no channels provided', async () => {
151
- await expect(
152
- UserNotifications.updateNotificationSetting({ brand: 'drumeo', settingName: 'new_lesson' })
153
- ).rejects.toThrow('At least one channel (email, push, or bell) must be provided.')
154
- })
155
-
156
- it('calls PUT with correct payload and url', async () => {
157
- PUT.mockResolvedValueOnce({ success: true })
158
-
159
- const result = await UserNotifications.updateNotificationSetting({
160
- brand: 'drumeo',
161
- settingName: 'membership_perks_promotions',
162
- email: true,
163
- push: false,
164
- })
165
-
166
- expect(PUT).toHaveBeenCalledWith(
167
- '/api/notifications/v1/settings',
168
- {
169
- settings: [
170
- { name: 'membership_perks_promotions', channel: 'email', value: true, brand: 'drumeo' },
171
- { name: 'membership_perks_promotions', channel: 'push', value: false, brand: 'drumeo' },
172
- ],
173
- }
174
- )
175
- expect(result).toEqual({ success: true })
176
- })
177
- })
178
- })
@@ -1,283 +0,0 @@
1
- import { initializeTestDB } from '../initializeTestDB'
2
- import db from '../../../src/services/sync/repository-proxy'
3
-
4
- jest.mock('../../../src/services/sanity.js', () => ({
5
- ...jest.requireActual('../../../src/services/sanity.js'),
6
- fetchShows: jest.fn(),
7
- fetchByRailContentIds: jest.fn(),
8
- }))
9
- jest.mock('../../../src/services/contentAggregator.js', () => ({
10
- addContextToContent: jest.fn(),
11
- }))
12
- jest.mock('../../../src/services/content-org/learning-paths.ts', () =>
13
- require('../content-progress/__mocks__/mocks').mockLearningPaths(),
14
- )
15
- jest.mock('../../../src/services/awards/internal/content-progress-observer', () =>
16
- require('../content-progress/__mocks__/mocks').mockContentProgressObserver(),
17
- )
18
- jest.mock('../../../src/services/progress-events', () =>
19
- require('../content-progress/__mocks__/mocks').mockProgressEvents(),
20
- )
21
-
22
- import {
23
- processContentItem,
24
- getCompletedChildren,
25
- } from '../../../src/services/progress-row/rows/content-card.js'
26
- import { addContextToContent } from '../../../src/services/contentAggregator.js'
27
-
28
- const lessonMeta = { brand: 'drumeo', type: 'lesson', parent_id: 0 }
29
-
30
- initializeTestDB()
31
-
32
- describe('getCompletedChildren', () => {
33
- describe('show contentType', () => {
34
- test('counts completed shows from addContextToContent result', async () => {
35
- ;(addContextToContent as jest.Mock).mockResolvedValue([
36
- { id: 1, progressStatus: 'completed' },
37
- { id: 2, progressStatus: 'started' },
38
- { id: 3, progressStatus: 'completed' },
39
- { id: 4, progressStatus: 'not-started' },
40
- ])
41
- const content = { type: 'boot-camp', brand: 'drumeo' }
42
- const result = await getCompletedChildren(content, 'show')
43
- expect(result).toEqual({ completedChildren: 2, allChildren: 4 })
44
- })
45
-
46
- test('returns zero counts when no shows returned', async () => {
47
- ;(addContextToContent as jest.Mock).mockResolvedValue([])
48
- const content = { type: 'boot-camp', brand: 'drumeo' }
49
- const result = await getCompletedChildren(content, 'show')
50
- expect(result).toEqual({ completedChildren: 0, allChildren: 0 })
51
- })
52
-
53
- test('returns zero completed when no shows have completed status', async () => {
54
- ;(addContextToContent as jest.Mock).mockResolvedValue([
55
- { id: 1, progressStatus: 'started' },
56
- { id: 2, progressStatus: 'not-started' },
57
- ])
58
- const content = { type: 'boot-camp', brand: 'drumeo' }
59
- const result = await getCompletedChildren(content, 'show')
60
- expect(result).toEqual({ completedChildren: 0, allChildren: 2 })
61
- })
62
- })
63
-
64
- describe('non-show with children', () => {
65
- test('counts completed children from real DB progress', async () => {
66
- await db.contentProgress.recordProgress(101, null, 100, lessonMeta, undefined, { skipPush: true })
67
- await db.contentProgress.recordProgress(102, null, 30, lessonMeta, undefined, { skipPush: true })
68
- await db.contentProgress.recordProgress(103, null, 100, lessonMeta, undefined, { skipPush: true })
69
-
70
- const content = {
71
- type: 'course',
72
- children: [{ id: 101 }, { id: 102 }, { id: 103 }],
73
- }
74
- const result = await getCompletedChildren(content, 'course')
75
- expect(result).toEqual({ completedChildren: 2, allChildren: 3 })
76
- })
77
-
78
- test('recursively flattens nested children to leaf ids', async () => {
79
- await db.contentProgress.recordProgress(201, null, 100, lessonMeta, undefined, { skipPush: true })
80
- await db.contentProgress.recordProgress(202, null, 100, lessonMeta, undefined, { skipPush: true })
81
- await db.contentProgress.recordProgress(203, null, 50, lessonMeta, undefined, { skipPush: true })
82
-
83
- const content = {
84
- type: 'course',
85
- children: [
86
- { id: 201 },
87
- {
88
- id: 999,
89
- children: [{ id: 202 }, { id: 203 }],
90
- },
91
- ],
92
- }
93
- const result = await getCompletedChildren(content, 'course')
94
- expect(result).toEqual({ completedChildren: 2, allChildren: 3 })
95
- })
96
-
97
- test('returns zero when no children have progress recorded', async () => {
98
- const content = {
99
- type: 'course',
100
- children: [{ id: 901 }, { id: 902 }],
101
- }
102
- const result = await getCompletedChildren(content, 'course')
103
- expect(result).toEqual({ completedChildren: 0, allChildren: 2 })
104
- })
105
- })
106
-
107
- describe('non-show without children', () => {
108
- test('returns zero counts when children is undefined', async () => {
109
- const content = { type: 'song' }
110
- const result = await getCompletedChildren(content, 'transcription')
111
- expect(result).toEqual({ completedChildren: 0, allChildren: 0 })
112
- })
113
-
114
- test('returns zero counts when children is empty array', async () => {
115
- const content = { type: 'song', children: [] }
116
- const result = await getCompletedChildren(content, 'transcription')
117
- expect(result).toEqual({ completedChildren: 0, allChildren: 0 })
118
- })
119
- })
120
- })
121
-
122
- describe('processContentItem', () => {
123
- test('returns correctly shaped object for a song with no children', async () => {
124
- const content = {
125
- id: 500,
126
- type: 'song',
127
- brand: 'drumeo',
128
- title: 'Test Song',
129
- thumbnail: 'thumb.jpg',
130
- progressPercentage: 40,
131
- progressStatus: 'started',
132
- progressTimestamp: 1700000000,
133
- difficulty_string: 'Beginner',
134
- artist_name: 'The Band',
135
- slug: 'test-song',
136
- navigateTo: 'child-slug',
137
- }
138
- const result = await processContentItem(content)
139
- expect(result).toMatchObject({
140
- id: 500,
141
- progressType: 'content',
142
- header: 'transcription',
143
- pinned: false,
144
- progressTimestamp: 1700000000,
145
- body: {
146
- progressPercent: 40,
147
- thumbnail: 'thumb.jpg',
148
- title: 'Test Song',
149
- isLive: false,
150
- brand: 'drumeo',
151
- badge: null,
152
- badge_rear: null,
153
- badge_logo: null,
154
- badge_template: null,
155
- badge_template_rear: null,
156
- isLocked: false,
157
- subtitle: 'Beginner • The Band',
158
- },
159
- cta: {
160
- text: 'Continue',
161
- action: {
162
- type: 'song',
163
- brand: 'drumeo',
164
- id: 500,
165
- slug: 'test-song',
166
- child: 'child-slug',
167
- },
168
- },
169
- })
170
- })
171
-
172
- test('sets progressPercent to undefined when isLive=true', async () => {
173
- const content = {
174
- id: 1,
175
- type: 'song',
176
- brand: 'drumeo',
177
- progressPercentage: 80,
178
- isLive: true,
179
- }
180
- const result = await processContentItem(content)
181
- expect(result.body.progressPercent).toBeUndefined()
182
- expect(result.body.isLive).toBe(true)
183
- })
184
-
185
- test('isLive defaults to false when missing', async () => {
186
- const content = { id: 2, type: 'song', brand: 'drumeo', progressPercentage: 50 }
187
- const result = await processContentItem(content)
188
- expect(result.body.isLive).toBe(false)
189
- expect(result.body.progressPercent).toBe(50)
190
- })
191
-
192
- test('pinned defaults to false when missing', async () => {
193
- const content = { id: 3, type: 'song', brand: 'drumeo' }
194
- const result = await processContentItem(content)
195
- expect(result.pinned).toBe(false)
196
- })
197
-
198
- test('pinned reflects content.pinned when set', async () => {
199
- const content = { id: 4, type: 'song', brand: 'drumeo', pinned: true }
200
- const result = await processContentItem(content)
201
- expect(result.pinned).toBe(true)
202
- })
203
-
204
- test('isLocked is false when need_access is missing', async () => {
205
- const content = { id: 5, type: 'song', brand: 'drumeo' }
206
- const result = await processContentItem(content)
207
- expect(result.body.isLocked).toBe(false)
208
- })
209
-
210
- test('isLocked is true when need_access is true', async () => {
211
- const content = { id: 6, type: 'song', brand: 'drumeo', need_access: true }
212
- const result = await processContentItem(content)
213
- expect(result.body.isLocked).toBe(true)
214
- })
215
-
216
- test('passes through badge fields when set', async () => {
217
- const content = {
218
- id: 7,
219
- type: 'song',
220
- brand: 'drumeo',
221
- badge: 'badge.png',
222
- badge_rear: 'badge-rear.png',
223
- badge_logo: 'logo.png',
224
- badge_template: 'tpl',
225
- badge_template_rear: 'tpl-rear',
226
- }
227
- const result = await processContentItem(content)
228
- expect(result.body).toMatchObject({
229
- badge: 'badge.png',
230
- badge_rear: 'badge-rear.png',
231
- badge_logo: 'logo.png',
232
- badge_template: 'tpl',
233
- badge_template_rear: 'tpl-rear',
234
- })
235
- })
236
-
237
- test('populates completed_children/all_children for course with children', async () => {
238
- await db.contentProgress.recordProgress(701, null, 100, lessonMeta, undefined, { skipPush: true })
239
- await db.contentProgress.recordProgress(702, null, 100, lessonMeta, undefined, { skipPush: true })
240
- await db.contentProgress.recordProgress(703, null, 25, lessonMeta, undefined, { skipPush: true })
241
-
242
- const content: any = {
243
- id: 700,
244
- type: 'course',
245
- brand: 'drumeo',
246
- children: [{ id: 701 }, { id: 702 }, { id: 703 }],
247
- }
248
- const result = await processContentItem(content)
249
- expect(content.completed_children).toBe(2)
250
- expect(content.all_children).toBe(3)
251
- expect(result.body.subtitle).toBe('2 of 3 Lessons Complete')
252
- })
253
-
254
- test('uses contentType from getFormattedType in header and cta resolution', async () => {
255
- const content = {
256
- id: 8,
257
- type: 'guided-course',
258
- brand: 'drumeo',
259
- progressStatus: 'not-started',
260
- }
261
- const result = await processContentItem(content)
262
- expect(result.header).toBe('guided course')
263
- expect(result.cta.text).toBe('Start Course')
264
- })
265
-
266
- test('cta action mirrors content identity fields', async () => {
267
- const content = {
268
- id: 9,
269
- type: 'song',
270
- brand: 'pianote',
271
- slug: 'my-slug',
272
- navigateTo: 'next-child',
273
- }
274
- const result = await processContentItem(content)
275
- expect(result.cta.action).toEqual({
276
- type: 'song',
277
- brand: 'pianote',
278
- id: 9,
279
- slug: 'my-slug',
280
- child: 'next-child',
281
- })
282
- })
283
- })
@@ -1,144 +0,0 @@
1
- /**
2
- * @jest-environment jsdom
3
- */
4
-
5
- // Replace jsdom's incomplete IndexedDB with a spec-compliant fake
6
- import 'fake-indexeddb/auto'
7
-
8
- import {
9
- patchIndexedDBOpenErrors,
10
- patchIndexedDBDeleteDatabaseErrors,
11
- simulateIndexedDBDelay,
12
- simulateIndexedDBUnavailable,
13
- simulateIndexedDBFailure,
14
- simulateIndexedDBQuotaExceeded,
15
- } from '@/services/sync/adapters/lokijs'
16
-
17
- // Each test gets a fresh indexedDB instance so patches don't bleed between tests
18
- let originalOpen: typeof indexedDB.open
19
- let originalDeleteDatabase: typeof indexedDB.deleteDatabase
20
-
21
- beforeEach(() => {
22
- originalOpen = indexedDB.open.bind(indexedDB)
23
- originalDeleteDatabase = indexedDB.deleteDatabase.bind(indexedDB)
24
- })
25
-
26
- afterEach(() => {
27
- // Restore original methods after each test
28
- ;(indexedDB as any).open = originalOpen
29
- ;(indexedDB as any).deleteDatabase = originalDeleteDatabase
30
- })
31
-
32
- // ---
33
-
34
- describe('patchIndexedDBOpenErrors', () => {
35
- test('fires callback when open request errors', done => {
36
- patchIndexedDBOpenErrors((error, _event) => {
37
- expect(error).not.toBeNull()
38
- done()
39
- })
40
-
41
- // Force an error by opening with a downgrade attempt
42
- const req = indexedDB.open('test-db', 1)
43
- req.onsuccess = () => {
44
- // Now try to open with lower version to trigger an error via another mechanism
45
- // Actually trigger via a direct error simulation instead
46
- }
47
-
48
- // Simplest: patch is in place, manually fire an open error by opening the same db
49
- // at a lower version after it exists at version 1
50
- req.onsuccess = () => {
51
- req.result.close()
52
- // Open at lower version → versionchange error in strict IDB
53
- // In fakeIndexedDB this won't error the same way; test by calling stored onerror
54
- // The patch intercepts onerror — trigger it via the proxy
55
- ;(indexedDB as any).open = originalOpen // restore so next open works normally
56
-
57
- // Verify patch was applied by checking it wraps the error handler
58
- done()
59
- }
60
-
61
- req.onerror = () => done()
62
- })
63
-
64
- test('still calls original onerror handler', done => {
65
- const originalHandler = jest.fn()
66
-
67
- patchIndexedDBOpenErrors((_error, _event) => {
68
- // patch callback called
69
- })
70
-
71
- const req = indexedDB.open(`test-onerror-${Date.now()}`)
72
- const proxiedReq = req as any
73
-
74
- // Simulate an error event through the proxy
75
- proxiedReq.onerror = originalHandler
76
-
77
- // Synthesize an error event on the real request
78
- const errorEvent = new Event('error')
79
- Object.defineProperty(errorEvent, 'target', {
80
- value: { error: new DOMException('Test error', 'UnknownError') },
81
- configurable: true,
82
- })
83
- ;(req as any).onerror?.(errorEvent)
84
-
85
- expect(originalHandler).toHaveBeenCalled()
86
- done()
87
- })
88
- })
89
-
90
- describe('simulateIndexedDBUnavailable', () => {
91
- test('causes indexedDB.open to throw synchronously', () => {
92
- simulateIndexedDBUnavailable()
93
-
94
- expect(() => {
95
- indexedDB.open('test-unavailable')
96
- }).toThrow(DOMException)
97
- })
98
- })
99
-
100
- describe('simulateIndexedDBQuotaExceeded', () => {
101
- test('triggers QuotaExceededError after open call', done => {
102
- simulateIndexedDBQuotaExceeded()
103
-
104
- const req = indexedDB.open(`test-quota-${Date.now()}`)
105
- req.onerror = event => {
106
- const error = (event.target as any)?.error
107
- expect(error?.name).toBe('QuotaExceededError')
108
- done()
109
- }
110
-
111
- // Timeout safety
112
- setTimeout(() => done(new Error('QuotaExceeded event not fired in time')), 1000)
113
- })
114
- })
115
-
116
- describe('simulateIndexedDBFailure', () => {
117
- test('triggers an error after the specified delay', done => {
118
- simulateIndexedDBFailure(50) // 50ms delay for test speed
119
-
120
- const req = indexedDB.open(`test-failure-${Date.now()}`)
121
- req.onerror = event => {
122
- const error = (event.target as any)?.error
123
- expect(error).toBeInstanceOf(DOMException)
124
- done()
125
- }
126
-
127
- setTimeout(() => done(new Error('Failure event not fired in time')), 2000)
128
- })
129
- })
130
-
131
- describe('patchIndexedDBDeleteDatabaseErrors', () => {
132
- test('returns a proxy request immediately, real delete fires after 1ms', done => {
133
- patchIndexedDBDeleteDatabaseErrors()
134
-
135
- // The patched deleteDatabase should return immediately (a proxy)
136
- const req = indexedDB.deleteDatabase(`test-delete-${Date.now()}`)
137
- expect(req).toBeDefined()
138
-
139
- // Set handler on proxy — it should fire after the real request completes
140
- req.onsuccess = () => done()
141
-
142
- setTimeout(() => done(new Error('Delete did not complete')), 1000)
143
- })
144
- })