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.
- 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/.idea/codeStyles/Project.xml +61 -0
- package/.idea/codeStyles/codeStyleConfig.xml +5 -0
- package/.idea/modules.xml +8 -0
- package/.idea/musora-content-services.iml +8 -0
- package/.idea/php.xml +19 -0
- package/.idea/prettier.xml +6 -0
- package/.idea/vcs.xml +7 -0
- package/.yarn/install-state.gz +0 -0
- package/CHANGELOG.md +10 -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/multi-user-accounts/multi-user-accounts.ts +1 -0
- 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,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
|
-
})
|