musora-content-services 2.167.2 → 2.167.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agent/decisions/2026-07-20-mu2-1724-songs-lessons-counts.md +33 -0
- package/.agent/decisions/2026-07-20-query-builder-tostring.md +37 -0
- package/CHANGELOG.md +8 -0
- package/package.json +2 -1
- package/src/contentTypeConfig.js +1 -0
- package/src/lib/sanity/query.ts +5 -0
- package/src/services/config.js +15 -0
- package/src/services/content/artist.ts +1 -3
- package/src/services/content/counts.ts +47 -0
- package/src/services/content/genre.ts +1 -3
- package/src/services/content/instructor.ts +1 -3
- package/src/services/userActivity.js +2 -0
- package/src/version-info.js +1 -0
- package/.claude/settings.local.json +0 -53
- package/test/initializeTests.js +0 -60
- package/test/integration/awards/award-exclusion-handling.test.ts +0 -275
- package/test/integration/awards/internal/image-utils.test.ts +0 -86
- package/test/integration/content-progress/__mocks__/mocks.ts +0 -104
- package/test/integration/content-progress/contentProgress.test.ts +0 -335
- package/test/integration/content-progress/e2eOfflineProgress.test.ts +0 -352
- package/test/integration/content-progress/e2eProgress.test.ts +0 -612
- package/test/integration/content-progress/getters.test.ts +0 -334
- package/test/integration/content-progress/helpers.test.ts +0 -263
- package/test/integration/content-progress/offlineContentProgress.test.ts +0 -226
- package/test/integration/forums.test.ts +0 -209
- package/test/integration/infrastructure/FetchRequestExecutor.test.ts +0 -88
- package/test/integration/initializeTestDB.ts +0 -80
- package/test/integration/learning-paths.test.ts +0 -854
- package/test/integration/notifications.test.ts +0 -178
- package/test/integration/progress-row/content-card.test.ts +0 -283
- package/test/integration/sync/adapters/idb-errors.test.ts +0 -144
- package/test/integration/sync/adapters/sqlite-errors.test.ts +0 -173
- package/test/integration/sync/fetch.test.ts +0 -224
- package/test/integration/sync/repositories/content-likes.test.ts +0 -99
- package/test/integration/sync/repositories/practices.test.ts +0 -179
- package/test/integration/sync/repositories/progress.test.ts +0 -245
- package/test/integration/sync/repositories/user-award-progress.static.test.ts +0 -68
- package/test/integration/sync/repositories/user-award-progress.test.ts +0 -387
- package/test/integration/sync/store/cross-user-protection.test.ts +0 -217
- package/test/integration/sync/store/store-idb.test.ts +0 -180
- package/test/integration/sync/store/store.test.ts +0 -361
- package/test/integration/user/permissions.test.js +0 -20
- package/test/integration/userActivity.test.ts +0 -102
- package/test/live/README.md +0 -29
- package/test/live/content.test.js +0 -116
- package/test/localStorageMock.js +0 -21
- package/test/log.js +0 -5
- package/test/mockData/award-definitions.js +0 -171
- package/test/mockData/mockData_fetchByRailContentIds_one_content.json +0 -35
- package/test/mockData/mockData_progress_content.json +0 -182
- package/test/mockData/mockData_sanity_progress_content.json +0 -1451
- package/test/mockData/mockData_user_practices.json +0 -9
- package/test/setupConsole.js +0 -6
- package/test/setupNetworkGuard.js +0 -3
- package/test/setupTimers.js +0 -13
- package/test/unit/HttpClient.test.ts +0 -267
- package/test/unit/awards/award-alacarte-observer.test.ts +0 -199
- package/test/unit/awards/award-auto-refresh.test.ts +0 -84
- package/test/unit/awards/award-calculations.test.ts +0 -34
- package/test/unit/awards/award-callbacks.test.ts +0 -144
- package/test/unit/awards/award-certificate-display.test.ts +0 -330
- package/test/unit/awards/award-collection-edge-cases.test.ts +0 -212
- package/test/unit/awards/award-collection-filtering.test.ts +0 -287
- package/test/unit/awards/award-completion-flow.test.ts +0 -306
- package/test/unit/awards/award-multi-lesson.test.ts +0 -243
- package/test/unit/awards/award-observer-integration.test.ts +0 -327
- package/test/unit/awards/award-query-messages.test.ts +0 -448
- package/test/unit/awards/award-user-collection.test.ts +0 -416
- package/test/unit/awards/duplicate-prevention.test.ts +0 -120
- package/test/unit/awards/helpers/completion-mock.ts +0 -56
- package/test/unit/awards/helpers/index.ts +0 -3
- package/test/unit/awards/helpers/mock-setup.ts +0 -79
- package/test/unit/awards/helpers/progress-emitter.ts +0 -45
- package/test/unit/awards/message-generator.test.ts +0 -162
- package/test/unit/content-progress/bubbleTrickle.test.ts +0 -322
- package/test/unit/content-progress/helpers.test.ts +0 -329
- package/test/unit/content-progress/navigateTo.test.ts +0 -392
- package/test/unit/contentLikes.test.ts +0 -62
- package/test/unit/contentMetaData.test.ts +0 -58
- package/test/unit/dataContext.test.ts +0 -38
- package/test/unit/dateUtils.test.ts +0 -188
- package/test/unit/endScreen.test.js +0 -712
- package/test/unit/eventsAPI.test.ts +0 -159
- package/test/unit/imageSRCBuilder.test.ts +0 -37
- package/test/unit/imageSRCVerify.test.ts +0 -155
- package/test/unit/infrastructure/DefaultHeaderProvider.test.ts +0 -39
- package/test/unit/infrastructure/sanity/ContentClient.test.ts +0 -168
- package/test/unit/infrastructure/sanity/DefaultConfigProvider.test.ts +0 -93
- package/test/unit/infrastructure/sanity/FetchQueryExecutor.test.ts +0 -174
- package/test/unit/infrastructure/sanity/SanityClient.test.ts +0 -140
- package/test/unit/lib/__snapshots__/filter.test.ts.snap +0 -5
- package/test/unit/lib/filter.test.ts +0 -1154
- package/test/unit/lib/lastUpdated.test.ts +0 -21
- package/test/unit/lib/query.test.ts +0 -1469
- package/test/unit/lib/sanity/decorators/base.test.ts +0 -368
- package/test/unit/lib/sanity/decorators/navigate-to.test.ts +0 -361
- package/test/unit/lib/sanity/decorators/need-access.test.ts +0 -89
- package/test/unit/lib/sanity/decorators/need-lifetime-upgrade.test.ts +0 -85
- package/test/unit/lib/sanity/decorators/page-type.test.ts +0 -81
- package/test/unit/playerSettings.test.ts +0 -73
- package/test/unit/progress-row/content-card.test.ts +0 -314
- package/test/unit/progress-row/playlist-card.test.ts +0 -104
- package/test/unit/progressRows.test.ts +0 -243
- package/test/unit/recommendations.test.js +0 -47
- package/test/unit/sanityQueryService.test.ts +0 -200
- package/test/unit/search.test.ts +0 -44
- package/test/unit/sentry.test.ts +0 -62
- package/test/unit/services/progress-internal/bubble.test.ts +0 -279
- package/test/unit/services/progress.test.ts +0 -396
- package/test/unit/streakMessage.test.ts +0 -251
- package/test/unit/sync/context.test.ts +0 -51
- package/test/unit/sync/effects/logout-warning.test.ts +0 -158
- package/test/unit/sync/effects/push-failure-notification.test.ts +0 -196
- package/test/unit/sync/errors/sync-errors.test.ts +0 -106
- package/test/unit/sync/errors/validators.test.ts +0 -61
- package/test/unit/sync/helpers/TestModel.ts +0 -44
- package/test/unit/sync/helpers/index.ts +0 -172
- package/test/unit/sync/manager.test.ts +0 -303
- package/test/unit/sync/models/user-award-progress.test.ts +0 -82
- package/test/unit/sync/resolver.test.ts +0 -229
- package/test/unit/sync/retry.test.ts +0 -314
- package/test/unit/sync/run-scope.test.ts +0 -23
- package/test/unit/sync/stale-record-cleanup.test.ts +0 -107
- package/test/unit/sync/store/push-coalescer.test.ts +0 -156
- package/test/unit/sync/store-configs.test.ts +0 -37
- package/test/unit/sync/telemetry/sync-telemetry.test.ts +0 -118
- package/test/unit/sync/utils/event-emitter.test.ts +0 -64
- package/test/unit/sync/utils/throttle.test.ts +0 -245
- package/test/unit/url-builder.test.ts +0 -72
- package/test/unit/user/account.test.ts +0 -66
- package/test/unit/user/permissions.test.js +0 -191
|
@@ -1,144 +0,0 @@
|
|
|
1
|
-
import type { AwardCallbackPayload, ProgressCallbackPayload, UnregisterFunction } from '../../../src/services/awards/types.d.ts'
|
|
2
|
-
import { registerAwardCallback, registerProgressCallback } from '../../../src/services/awards/award-callbacks.js'
|
|
3
|
-
import { awardEvents } from '../../../src/services/awards/internal/award-events.js'
|
|
4
|
-
jest.mock('../../../src/services/awards/award-query.js', () => ({
|
|
5
|
-
...jest.requireActual('../../../src/services/awards/award-query.js'),
|
|
6
|
-
getBadgeFields: jest.fn().mockReturnValue({
|
|
7
|
-
badge: 'https://cdn.example.com/badge.png',
|
|
8
|
-
badge_rear: 'https://cdn.example.com/badge_rear.png',
|
|
9
|
-
badge_logo: 'https://cdn.example.com/logo.png',
|
|
10
|
-
badge_template: 'template_front',
|
|
11
|
-
badge_template_rear: 'template_rear',
|
|
12
|
-
badge_template_unearned: 'template_unearned',
|
|
13
|
-
})
|
|
14
|
-
}))
|
|
15
|
-
interface AwardGrantedEmitPayload {
|
|
16
|
-
awardId: string
|
|
17
|
-
definition: {
|
|
18
|
-
name: string
|
|
19
|
-
brand: string
|
|
20
|
-
content_type: string
|
|
21
|
-
type: string
|
|
22
|
-
is_active: boolean
|
|
23
|
-
}
|
|
24
|
-
completionData: {
|
|
25
|
-
completed_at: string
|
|
26
|
-
days_user_practiced: number
|
|
27
|
-
practice_minutes: number
|
|
28
|
-
content_title: string
|
|
29
|
-
}
|
|
30
|
-
popupMessage: string
|
|
31
|
-
}
|
|
32
|
-
const mockPayload: AwardGrantedEmitPayload = {
|
|
33
|
-
awardId: 'award-123',
|
|
34
|
-
definition: {
|
|
35
|
-
name: 'Test Award',
|
|
36
|
-
brand: 'drumeo',
|
|
37
|
-
content_type: 'guided-course',
|
|
38
|
-
type: 'content-award',
|
|
39
|
-
is_active: true,
|
|
40
|
-
},
|
|
41
|
-
completionData: {
|
|
42
|
-
completed_at: '2024-01-01T00:00:00Z',
|
|
43
|
-
days_user_practiced: 14,
|
|
44
|
-
practice_minutes: 180,
|
|
45
|
-
content_title: 'Blues Foundations',
|
|
46
|
-
},
|
|
47
|
-
popupMessage: 'Congratulations!'
|
|
48
|
-
}
|
|
49
|
-
describe('registerAwardCallback', () => {
|
|
50
|
-
let unregister: UnregisterFunction
|
|
51
|
-
|
|
52
|
-
afterEach(() => {
|
|
53
|
-
unregister?.()
|
|
54
|
-
awardEvents.removeAllListeners()
|
|
55
|
-
})
|
|
56
|
-
|
|
57
|
-
test('throws if callback is not a function', () => {
|
|
58
|
-
expect(() => registerAwardCallback('not a function' as any)).toThrow(
|
|
59
|
-
'registerAwardCallback requires a function'
|
|
60
|
-
)
|
|
61
|
-
})
|
|
62
|
-
|
|
63
|
-
test('returns an unregister function', () => {
|
|
64
|
-
unregister = registerAwardCallback(jest.fn())
|
|
65
|
-
expect(typeof unregister).toBe('function')
|
|
66
|
-
})
|
|
67
|
-
|
|
68
|
-
test('callback is invoked with correctly shaped award object when awardGranted fires', async () => {
|
|
69
|
-
const callback = jest.fn() as jest.MockedFunction<(award: AwardCallbackPayload) => void>
|
|
70
|
-
unregister = registerAwardCallback(callback)
|
|
71
|
-
awardEvents.emitAwardGranted(mockPayload)
|
|
72
|
-
await new Promise(resolve => setTimeout(resolve, 0))
|
|
73
|
-
expect(callback).toHaveBeenCalledWith(expect.objectContaining({
|
|
74
|
-
awardId: 'award-123',
|
|
75
|
-
name: 'Test Award',
|
|
76
|
-
brand: 'drumeo',
|
|
77
|
-
contentType: 'guided-course',
|
|
78
|
-
hasCertificate: true,
|
|
79
|
-
isCompleted: true,
|
|
80
|
-
completedAt: '2024-01-01T00:00:00Z',
|
|
81
|
-
completionData: expect.objectContaining({
|
|
82
|
-
content_title: 'Blues Foundations',
|
|
83
|
-
days_user_practiced: 14,
|
|
84
|
-
practice_minutes: 180,
|
|
85
|
-
message: 'Congratulations!',
|
|
86
|
-
})
|
|
87
|
-
}))
|
|
88
|
-
})
|
|
89
|
-
|
|
90
|
-
test('callback is not invoked after unregister is called', async () => {
|
|
91
|
-
const callback = jest.fn()
|
|
92
|
-
unregister = registerAwardCallback(callback)
|
|
93
|
-
unregister()
|
|
94
|
-
awardEvents.emitAwardGranted(mockPayload)
|
|
95
|
-
await new Promise(resolve => setTimeout(resolve, 0))
|
|
96
|
-
expect(callback).not.toHaveBeenCalled()
|
|
97
|
-
})
|
|
98
|
-
|
|
99
|
-
test('registering a second callback replaces the first', async () => {
|
|
100
|
-
const firstCallback = jest.fn()
|
|
101
|
-
const secondCallback = jest.fn()
|
|
102
|
-
registerAwardCallback(firstCallback)
|
|
103
|
-
unregister = registerAwardCallback(secondCallback)
|
|
104
|
-
awardEvents.emitAwardGranted(mockPayload)
|
|
105
|
-
await new Promise(resolve => setTimeout(resolve, 0))
|
|
106
|
-
expect(firstCallback).not.toHaveBeenCalled()
|
|
107
|
-
expect(secondCallback).toHaveBeenCalled()
|
|
108
|
-
})
|
|
109
|
-
|
|
110
|
-
test('throws if callback is not a function', () => {
|
|
111
|
-
expect(() => registerProgressCallback('not a function' as any)).toThrow(
|
|
112
|
-
'registerProgressCallback requires a function'
|
|
113
|
-
)
|
|
114
|
-
})
|
|
115
|
-
|
|
116
|
-
test('returns an unregister function', () => {
|
|
117
|
-
unregister = registerProgressCallback(jest.fn())
|
|
118
|
-
expect(typeof unregister).toBe('function')
|
|
119
|
-
})
|
|
120
|
-
|
|
121
|
-
test('callback is invoked with awardId and progressPercentage when awardProgress fires', () => {
|
|
122
|
-
const callback = jest.fn() as jest.MockedFunction<(progress: ProgressCallbackPayload) => void>
|
|
123
|
-
unregister = registerProgressCallback(callback)
|
|
124
|
-
awardEvents.emitAwardProgress({ awardId: 'award-123', progressPercentage: 50 })
|
|
125
|
-
expect(callback).toHaveBeenCalledWith({
|
|
126
|
-
awardId: 'award-123',
|
|
127
|
-
progressPercentage: 50,
|
|
128
|
-
})
|
|
129
|
-
})
|
|
130
|
-
})
|
|
131
|
-
|
|
132
|
-
describe('registerProgressCallback', () => {
|
|
133
|
-
let unregister: UnregisterFunction
|
|
134
|
-
|
|
135
|
-
afterEach(() => {
|
|
136
|
-
unregister?.()
|
|
137
|
-
awardEvents.removeAllListeners()
|
|
138
|
-
})
|
|
139
|
-
|
|
140
|
-
test.todo('throws if callback is not a function')
|
|
141
|
-
test.todo('returns an unregister function')
|
|
142
|
-
test.todo('callback is invoked with awardId and progressPercentage when awardProgress fires')
|
|
143
|
-
test.todo('callback is not invoked after unregister is called')
|
|
144
|
-
})
|
|
@@ -1,330 +0,0 @@
|
|
|
1
|
-
import { buildCertificateData } from '../../../src/services/awards/internal/certificate-builder.js'
|
|
2
|
-
import { mockAwardDefinitions, getAwardById } from '../../mockData/award-definitions.js'
|
|
3
|
-
import { globalConfig } from '../../../src/services/config.js'
|
|
4
|
-
|
|
5
|
-
jest.mock('../../../src/services/sanity.js', () => ({
|
|
6
|
-
...jest.requireActual('../../../src/services/sanity'),
|
|
7
|
-
default: {
|
|
8
|
-
fetch: jest.fn()
|
|
9
|
-
},
|
|
10
|
-
fetchSanity: jest.fn()
|
|
11
|
-
}))
|
|
12
|
-
|
|
13
|
-
jest.mock('../../../src/services/user/management.js', () => ({
|
|
14
|
-
getUserData: jest.fn()
|
|
15
|
-
}))
|
|
16
|
-
|
|
17
|
-
jest.mock('../../../src/services/sync/repository-proxy.ts', () => {
|
|
18
|
-
const mockFns = {
|
|
19
|
-
userAwardProgress: {
|
|
20
|
-
getByAwardId: jest.fn()
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
return { default: mockFns, ...mockFns }
|
|
24
|
-
})
|
|
25
|
-
|
|
26
|
-
import sanityClient, { fetchSanity } from '../../../src/services/sanity.js'
|
|
27
|
-
import { getUserData } from '../../../src/services/user/management.js'
|
|
28
|
-
import db_ from '../../../src/services/sync/repository-proxy'
|
|
29
|
-
const db = db_ as any
|
|
30
|
-
import { awardDefinitions } from '../../../src/services/awards/internal/award-definitions.js'
|
|
31
|
-
|
|
32
|
-
describe('Award Certificate Display - E2E Scenarios', () => {
|
|
33
|
-
const testAward = getAwardById('0238b1e5-ebee-42b3-9390-91467d113575')
|
|
34
|
-
const awardId = testAward._id
|
|
35
|
-
|
|
36
|
-
beforeEach(async () => {
|
|
37
|
-
jest.clearAllMocks()
|
|
38
|
-
|
|
39
|
-
globalConfig.sessionConfig = {
|
|
40
|
-
userId: 12345,
|
|
41
|
-
token: 'test-token'
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
sanityClient.fetch = jest.fn().mockResolvedValue(mockAwardDefinitions)
|
|
45
|
-
fetchSanity.mockResolvedValue(mockAwardDefinitions)
|
|
46
|
-
|
|
47
|
-
db.userAwardProgress = {
|
|
48
|
-
getByAwardId: jest.fn()
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
getUserData.mockResolvedValue({
|
|
52
|
-
id: 12345,
|
|
53
|
-
display_name: 'John Doe',
|
|
54
|
-
name: 'John Doe',
|
|
55
|
-
email: 'john@example.com'
|
|
56
|
-
})
|
|
57
|
-
|
|
58
|
-
await awardDefinitions.refresh()
|
|
59
|
-
})
|
|
60
|
-
|
|
61
|
-
afterEach(() => {
|
|
62
|
-
awardDefinitions.clear()
|
|
63
|
-
})
|
|
64
|
-
|
|
65
|
-
describe('Scenario: Display complete certificate for earned award', () => {
|
|
66
|
-
beforeEach(() => {
|
|
67
|
-
db.userAwardProgress.getByAwardId.mockResolvedValue({
|
|
68
|
-
data: {
|
|
69
|
-
award_id: awardId,
|
|
70
|
-
progress_percentage: 100,
|
|
71
|
-
completed_at: Math.floor(Date.now() / 1000) - 86400 * 3,
|
|
72
|
-
completion_data: {
|
|
73
|
-
content_title: 'Adrian Guided Course Test',
|
|
74
|
-
completed_at: new Date().toISOString(),
|
|
75
|
-
days_user_practiced: 14,
|
|
76
|
-
practice_minutes: 180
|
|
77
|
-
},
|
|
78
|
-
},
|
|
79
|
-
})
|
|
80
|
-
})
|
|
81
|
-
|
|
82
|
-
test('returns complete certificate data with all fields', async () => {
|
|
83
|
-
const certificate = await buildCertificateData(awardId)
|
|
84
|
-
|
|
85
|
-
expect(certificate).toMatchObject({
|
|
86
|
-
userId: 12345,
|
|
87
|
-
userName: 'John Doe',
|
|
88
|
-
completedAt: expect.any(Number),
|
|
89
|
-
awardId: awardId,
|
|
90
|
-
awardType: 'content-award',
|
|
91
|
-
awardTitle: testAward.name,
|
|
92
|
-
popupMessage: expect.any(String),
|
|
93
|
-
certificateMessage: expect.any(String),
|
|
94
|
-
ribbonImage: expect.any(String),
|
|
95
|
-
awardImage: testAward.award,
|
|
96
|
-
badgeImage: testAward.badge,
|
|
97
|
-
brandLogo: expect.any(String),
|
|
98
|
-
musoraLogo: expect.any(String),
|
|
99
|
-
musoraBgLogo: expect.any(String),
|
|
100
|
-
instructorName: testAward.instructor_name
|
|
101
|
-
})
|
|
102
|
-
})
|
|
103
|
-
|
|
104
|
-
test('includes correct user information from globalConfig and getUserData', async () => {
|
|
105
|
-
const certificate = await buildCertificateData(awardId)
|
|
106
|
-
|
|
107
|
-
expect(certificate.userId).toBe(12345)
|
|
108
|
-
expect(certificate.userName).toBe('John Doe')
|
|
109
|
-
})
|
|
110
|
-
|
|
111
|
-
test('includes correct award information from Sanity', async () => {
|
|
112
|
-
const certificate = await buildCertificateData(awardId)
|
|
113
|
-
|
|
114
|
-
expect(certificate.awardId).toBe(awardId)
|
|
115
|
-
expect(certificate.awardTitle).toBe('Adrian Guided Course Test Award')
|
|
116
|
-
expect(certificate.badgeImage).toBe(testAward.badge)
|
|
117
|
-
expect(certificate.awardImage).toBe(testAward.award)
|
|
118
|
-
expect(certificate.instructorName).toBe('Aaron Graham')
|
|
119
|
-
})
|
|
120
|
-
|
|
121
|
-
test('includes client-generated popup message', async () => {
|
|
122
|
-
const certificate = await buildCertificateData(awardId)
|
|
123
|
-
|
|
124
|
-
expect(certificate.popupMessage).toContain('Adrian Guided Course Test')
|
|
125
|
-
expect(certificate.popupMessage).toContain('180 minutes')
|
|
126
|
-
expect(certificate.popupMessage).toContain('14 days')
|
|
127
|
-
})
|
|
128
|
-
|
|
129
|
-
test('includes client-generated certificate message', async () => {
|
|
130
|
-
const certificate = await buildCertificateData(awardId)
|
|
131
|
-
|
|
132
|
-
expect(certificate.certificateMessage).toContain('180 minutes')
|
|
133
|
-
expect(certificate.certificateMessage).toContain('Adrian Guided Course Test')
|
|
134
|
-
expect(certificate.certificateMessage).toContain('Well Done!')
|
|
135
|
-
})
|
|
136
|
-
|
|
137
|
-
test('includes brand logo based on award brand', async () => {
|
|
138
|
-
const certificate = await buildCertificateData(awardId)
|
|
139
|
-
|
|
140
|
-
expect(certificate.brandLogo).toBeDefined()
|
|
141
|
-
expect(typeof certificate.brandLogo).toBe('string')
|
|
142
|
-
})
|
|
143
|
-
|
|
144
|
-
test('includes completion date from WatermelonDB', async () => {
|
|
145
|
-
const certificate = await buildCertificateData(awardId)
|
|
146
|
-
|
|
147
|
-
expect(certificate.completedAt).toBeDefined()
|
|
148
|
-
const completedDate = new Date(certificate.completedAt)
|
|
149
|
-
expect(completedDate).toBeInstanceOf(Date)
|
|
150
|
-
expect(completedDate.getTime()).toBeLessThanOrEqual(Date.now())
|
|
151
|
-
})
|
|
152
|
-
})
|
|
153
|
-
|
|
154
|
-
describe('Scenario: Certificate with custom award text', () => {
|
|
155
|
-
const awardWithCustomText = getAwardById('0f49cb6a-1b23-4628-968e-15df02ffad7f')
|
|
156
|
-
|
|
157
|
-
beforeEach(() => {
|
|
158
|
-
db.userAwardProgress.getByAwardId.mockResolvedValue({
|
|
159
|
-
data: {
|
|
160
|
-
award_id: awardWithCustomText._id,
|
|
161
|
-
progress_percentage: 100,
|
|
162
|
-
completed_at: Math.floor(Date.now() / 1000),
|
|
163
|
-
completion_data: {
|
|
164
|
-
content_title: 'Enrolling w/ Kickoff, has product GC (EC)',
|
|
165
|
-
completed_at: new Date().toISOString(),
|
|
166
|
-
days_user_practiced: 10,
|
|
167
|
-
practice_minutes: 200
|
|
168
|
-
},
|
|
169
|
-
},
|
|
170
|
-
})
|
|
171
|
-
})
|
|
172
|
-
|
|
173
|
-
test('includes custom text in certificate message', async () => {
|
|
174
|
-
const certificate = await buildCertificateData(awardWithCustomText._id)
|
|
175
|
-
|
|
176
|
-
expect(certificate.certificateMessage).toContain('Huzzah congratz')
|
|
177
|
-
expect(certificate.certificateMessage).toContain('200 minutes')
|
|
178
|
-
expect(certificate.certificateMessage).toContain('Enrolling w/ Kickoff, has product GC (EC)')
|
|
179
|
-
})
|
|
180
|
-
})
|
|
181
|
-
|
|
182
|
-
describe('Scenario: Certificate with null instructor signature', () => {
|
|
183
|
-
const awardWithoutSignature = getAwardById('0f49cb6a-1b23-4628-968e-15df02ffad7f')
|
|
184
|
-
|
|
185
|
-
beforeEach(() => {
|
|
186
|
-
db.userAwardProgress.getByAwardId.mockResolvedValue({
|
|
187
|
-
data: {
|
|
188
|
-
award_id: awardWithoutSignature._id,
|
|
189
|
-
progress_percentage: 100,
|
|
190
|
-
completed_at: Math.floor(Date.now() / 1000),
|
|
191
|
-
completion_data: {
|
|
192
|
-
content_title: 'Test Course',
|
|
193
|
-
completed_at: new Date().toISOString(),
|
|
194
|
-
days_user_practiced: 5,
|
|
195
|
-
practice_minutes: 100
|
|
196
|
-
},
|
|
197
|
-
},
|
|
198
|
-
})
|
|
199
|
-
})
|
|
200
|
-
|
|
201
|
-
test('handles null instructor signature gracefully', async () => {
|
|
202
|
-
const certificate = await buildCertificateData(awardWithoutSignature._id)
|
|
203
|
-
|
|
204
|
-
expect(certificate.instructorName).toBe('Lisa Witt')
|
|
205
|
-
})
|
|
206
|
-
})
|
|
207
|
-
|
|
208
|
-
describe('Scenario: User with no display_name falls back to name', () => {
|
|
209
|
-
beforeEach(() => {
|
|
210
|
-
getUserData.mockResolvedValue({
|
|
211
|
-
id: 12345,
|
|
212
|
-
display_name: null,
|
|
213
|
-
name: 'Jane Smith',
|
|
214
|
-
email: 'jane@example.com'
|
|
215
|
-
})
|
|
216
|
-
|
|
217
|
-
db.userAwardProgress.getByAwardId.mockResolvedValue({
|
|
218
|
-
data: {
|
|
219
|
-
award_id: awardId,
|
|
220
|
-
progress_percentage: 100,
|
|
221
|
-
completed_at: Math.floor(Date.now() / 1000),
|
|
222
|
-
completion_data: {
|
|
223
|
-
content_title: 'Test Course',
|
|
224
|
-
completed_at: new Date().toISOString(),
|
|
225
|
-
days_user_practiced: 7,
|
|
226
|
-
practice_minutes: 150
|
|
227
|
-
},
|
|
228
|
-
},
|
|
229
|
-
})
|
|
230
|
-
})
|
|
231
|
-
|
|
232
|
-
test('uses name field when display_name is null', async () => {
|
|
233
|
-
const certificate = await buildCertificateData(awardId)
|
|
234
|
-
|
|
235
|
-
expect(certificate.userName).toBe('Jane Smith')
|
|
236
|
-
})
|
|
237
|
-
})
|
|
238
|
-
|
|
239
|
-
describe('Scenario: User with neither display_name nor name', () => {
|
|
240
|
-
beforeEach(() => {
|
|
241
|
-
getUserData.mockResolvedValue({
|
|
242
|
-
id: 12345,
|
|
243
|
-
display_name: null,
|
|
244
|
-
name: null,
|
|
245
|
-
email: 'anonymous@example.com'
|
|
246
|
-
})
|
|
247
|
-
|
|
248
|
-
db.userAwardProgress.getByAwardId.mockResolvedValue({
|
|
249
|
-
data: {
|
|
250
|
-
award_id: awardId,
|
|
251
|
-
progress_percentage: 100,
|
|
252
|
-
completed_at: Math.floor(Date.now() / 1000),
|
|
253
|
-
completion_data: {
|
|
254
|
-
content_title: 'Test Course',
|
|
255
|
-
completed_at: new Date().toISOString(),
|
|
256
|
-
days_user_practiced: 3,
|
|
257
|
-
practice_minutes: 90
|
|
258
|
-
},
|
|
259
|
-
},
|
|
260
|
-
})
|
|
261
|
-
})
|
|
262
|
-
|
|
263
|
-
test('falls back to "User" when no name available', async () => {
|
|
264
|
-
const certificate = await buildCertificateData(awardId)
|
|
265
|
-
|
|
266
|
-
expect(certificate.userName).toBe('User')
|
|
267
|
-
})
|
|
268
|
-
})
|
|
269
|
-
|
|
270
|
-
describe('Scenario: Error handling', () => {
|
|
271
|
-
test('throws error when award definition not found', async () => {
|
|
272
|
-
await expect(
|
|
273
|
-
buildCertificateData('non-existent-award-id')
|
|
274
|
-
).rejects.toThrow('Award definition not found')
|
|
275
|
-
})
|
|
276
|
-
|
|
277
|
-
test('throws error when completion data not found in local DB', async () => {
|
|
278
|
-
db.userAwardProgress.getByAwardId.mockResolvedValue({
|
|
279
|
-
data: null
|
|
280
|
-
})
|
|
281
|
-
|
|
282
|
-
await expect(
|
|
283
|
-
buildCertificateData(awardId)
|
|
284
|
-
).rejects.toThrow('Completion data not found')
|
|
285
|
-
})
|
|
286
|
-
|
|
287
|
-
test('throws error when completion_data is missing', async () => {
|
|
288
|
-
db.userAwardProgress.getByAwardId.mockResolvedValue({
|
|
289
|
-
data: {
|
|
290
|
-
award_id: awardId,
|
|
291
|
-
progress_percentage: 100,
|
|
292
|
-
completed_at: Math.floor(Date.now() / 1000),
|
|
293
|
-
completion_data: null
|
|
294
|
-
},
|
|
295
|
-
})
|
|
296
|
-
|
|
297
|
-
await expect(
|
|
298
|
-
buildCertificateData(awardId)
|
|
299
|
-
).rejects.toThrow('Completion data not found')
|
|
300
|
-
})
|
|
301
|
-
})
|
|
302
|
-
|
|
303
|
-
describe('Scenario: Learning path vs guided course message differences', () => {
|
|
304
|
-
const learningPathAward = getAwardById('361f3034-c6c9-45f7-bbfb-0d58dbe14411')
|
|
305
|
-
|
|
306
|
-
beforeEach(() => {
|
|
307
|
-
db.userAwardProgress.getByAwardId.mockResolvedValue({
|
|
308
|
-
data: {
|
|
309
|
-
award_id: learningPathAward._id,
|
|
310
|
-
progress_percentage: 100,
|
|
311
|
-
completed_at: Math.floor(Date.now() / 1000),
|
|
312
|
-
completion_data: {
|
|
313
|
-
content_title: 'Learn To Play The Drums',
|
|
314
|
-
completed_at: new Date().toISOString(),
|
|
315
|
-
days_user_practiced: 30,
|
|
316
|
-
practice_minutes: 600
|
|
317
|
-
},
|
|
318
|
-
},
|
|
319
|
-
})
|
|
320
|
-
})
|
|
321
|
-
|
|
322
|
-
test('generates learning path message for learning path award', async () => {
|
|
323
|
-
const certificate = await buildCertificateData(learningPathAward._id)
|
|
324
|
-
|
|
325
|
-
expect(certificate.popupMessage).toContain('Learn To Play The Drums')
|
|
326
|
-
expect(certificate.popupMessage).toContain('600 minutes')
|
|
327
|
-
expect(certificate.popupMessage).toContain('30 days')
|
|
328
|
-
})
|
|
329
|
-
})
|
|
330
|
-
})
|
|
@@ -1,212 +0,0 @@
|
|
|
1
|
-
import { contentProgressObserver } from '../../../src/services/awards/internal/content-progress-observer.js'
|
|
2
|
-
import { awardEvents } from '../../../src/services/awards/internal/award-events.js'
|
|
3
|
-
import { mockAwardDefinitions } from '../../mockData/award-definitions.js'
|
|
4
|
-
import { setupDefaultMocks, setupAwardEventListeners } from './helpers/index'
|
|
5
|
-
import { COLLECTION_TYPE, emitProgress, waitForDebounce } from './helpers/progress-emitter'
|
|
6
|
-
|
|
7
|
-
jest.mock('../../../src/services/sanity.js', () => ({
|
|
8
|
-
...jest.requireActual('../../../src/services/sanity'),
|
|
9
|
-
default: { fetch: jest.fn() },
|
|
10
|
-
fetchSanity: jest.fn()
|
|
11
|
-
}))
|
|
12
|
-
|
|
13
|
-
jest.mock('../../../src/services/sync/repository-proxy.ts', () => {
|
|
14
|
-
const mockFns = {
|
|
15
|
-
contentProgress: {
|
|
16
|
-
getOneProgressByContentId: jest.fn(),
|
|
17
|
-
getSomeProgressByContentIds: jest.fn(),
|
|
18
|
-
queryOne: jest.fn(),
|
|
19
|
-
queryAll: jest.fn()
|
|
20
|
-
},
|
|
21
|
-
practices: {
|
|
22
|
-
sumPracticeMinutesForContent: jest.fn()
|
|
23
|
-
},
|
|
24
|
-
userAwardProgress: {
|
|
25
|
-
hasCompletedAward: jest.fn(),
|
|
26
|
-
recordAwardProgress: jest.fn(),
|
|
27
|
-
getByAwardId: jest.fn()
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
return { default: mockFns, ...mockFns }
|
|
31
|
-
})
|
|
32
|
-
|
|
33
|
-
import sanityClient, { fetchSanity } from '../../../src/services/sanity.js'
|
|
34
|
-
import db_ from '../../../src/services/sync/repository-proxy'
|
|
35
|
-
const db = db_ as any
|
|
36
|
-
import { awardDefinitions } from '../../../src/services/awards/internal/award-definitions.js'
|
|
37
|
-
|
|
38
|
-
describe('Award Collection Filtering - Edge Cases', () => {
|
|
39
|
-
let listeners
|
|
40
|
-
|
|
41
|
-
beforeEach(async () => {
|
|
42
|
-
jest.clearAllMocks()
|
|
43
|
-
awardEvents.removeAllListeners()
|
|
44
|
-
|
|
45
|
-
sanityClient.fetch = jest.fn().mockResolvedValue(mockAwardDefinitions)
|
|
46
|
-
setupDefaultMocks(db, fetchSanity)
|
|
47
|
-
|
|
48
|
-
await awardDefinitions.refresh()
|
|
49
|
-
|
|
50
|
-
listeners = setupAwardEventListeners(awardEvents)
|
|
51
|
-
|
|
52
|
-
await contentProgressObserver.start()
|
|
53
|
-
})
|
|
54
|
-
|
|
55
|
-
afterEach(() => {
|
|
56
|
-
awardEvents.removeAllListeners()
|
|
57
|
-
awardDefinitions.clear()
|
|
58
|
-
contentProgressObserver.stop()
|
|
59
|
-
})
|
|
60
|
-
|
|
61
|
-
describe('Child in collection with no awards', () => {
|
|
62
|
-
test('gracefully handles child with no matching awards', async () => {
|
|
63
|
-
emitProgress({
|
|
64
|
-
contentId: 999999,
|
|
65
|
-
collectionType: 'skill-pack',
|
|
66
|
-
collectionId: 999000
|
|
67
|
-
})
|
|
68
|
-
await waitForDebounce()
|
|
69
|
-
|
|
70
|
-
expect(listeners.granted).not.toHaveBeenCalled()
|
|
71
|
-
expect(listeners.progress).not.toHaveBeenCalled()
|
|
72
|
-
})
|
|
73
|
-
})
|
|
74
|
-
|
|
75
|
-
describe('Collection type case sensitivity for learning paths', () => {
|
|
76
|
-
test('wrong case LP collection type falls back to a la carte', async () => {
|
|
77
|
-
emitProgress({
|
|
78
|
-
contentId: 418004,
|
|
79
|
-
collectionType: 'Learning-Path-V2',
|
|
80
|
-
collectionId: 418010
|
|
81
|
-
})
|
|
82
|
-
await waitForDebounce()
|
|
83
|
-
|
|
84
|
-
expect(listeners.granted).toHaveBeenCalled()
|
|
85
|
-
})
|
|
86
|
-
|
|
87
|
-
test('case mismatch in non-LP still triggers (a la carte)', async () => {
|
|
88
|
-
emitProgress({
|
|
89
|
-
contentId: 418001,
|
|
90
|
-
collectionType: 'SKILL-PACK',
|
|
91
|
-
collectionId: 418000
|
|
92
|
-
})
|
|
93
|
-
await waitForDebounce()
|
|
94
|
-
|
|
95
|
-
expect(listeners.granted).toHaveBeenCalled()
|
|
96
|
-
})
|
|
97
|
-
})
|
|
98
|
-
|
|
99
|
-
describe('Debouncing per collection', () => {
|
|
100
|
-
test('rapid completion of children debounces per award content_id', async () => {
|
|
101
|
-
emitProgress({
|
|
102
|
-
contentId: 418001,
|
|
103
|
-
collectionType: 'skill-pack',
|
|
104
|
-
collectionId: 418000
|
|
105
|
-
})
|
|
106
|
-
emitProgress({
|
|
107
|
-
contentId: 418002,
|
|
108
|
-
collectionType: 'skill-pack',
|
|
109
|
-
collectionId: 418000
|
|
110
|
-
})
|
|
111
|
-
|
|
112
|
-
await waitForDebounce()
|
|
113
|
-
|
|
114
|
-
expect(db.userAwardProgress.recordAwardProgress).toHaveBeenCalledTimes(1)
|
|
115
|
-
})
|
|
116
|
-
|
|
117
|
-
test('rapid completion of children in different contexts processes separately', async () => {
|
|
118
|
-
emitProgress({
|
|
119
|
-
contentId: 418004,
|
|
120
|
-
collectionType: COLLECTION_TYPE.LEARNING_PATH,
|
|
121
|
-
collectionId: 418010
|
|
122
|
-
})
|
|
123
|
-
emitProgress({
|
|
124
|
-
contentId: 416448
|
|
125
|
-
})
|
|
126
|
-
|
|
127
|
-
await waitForDebounce()
|
|
128
|
-
|
|
129
|
-
expect(listeners.granted.mock.calls.length).toBeGreaterThanOrEqual(2)
|
|
130
|
-
const awardIds = listeners.granted.mock.calls.map(call => call[0].awardId)
|
|
131
|
-
expect(awardIds).toContain('b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e')
|
|
132
|
-
expect(awardIds).toContain('0238b1e5-ebee-42b3-9390-91467d113575')
|
|
133
|
-
})
|
|
134
|
-
})
|
|
135
|
-
|
|
136
|
-
describe('Observer state management', () => {
|
|
137
|
-
test('stop clears debounce timers', async () => {
|
|
138
|
-
emitProgress({
|
|
139
|
-
contentId: 418001,
|
|
140
|
-
collectionType: 'skill-pack',
|
|
141
|
-
collectionId: 418000
|
|
142
|
-
})
|
|
143
|
-
|
|
144
|
-
contentProgressObserver.stop()
|
|
145
|
-
|
|
146
|
-
expect(contentProgressObserver.debounceTimers.size).toBe(0)
|
|
147
|
-
})
|
|
148
|
-
|
|
149
|
-
test('stop clears processing content IDs', async () => {
|
|
150
|
-
emitProgress({
|
|
151
|
-
contentId: 418001,
|
|
152
|
-
collectionType: 'skill-pack',
|
|
153
|
-
collectionId: 418000
|
|
154
|
-
})
|
|
155
|
-
|
|
156
|
-
contentProgressObserver.stop()
|
|
157
|
-
|
|
158
|
-
expect(contentProgressObserver.processingContentIds.size).toBe(0)
|
|
159
|
-
})
|
|
160
|
-
|
|
161
|
-
test('restart after stop works correctly', async () => {
|
|
162
|
-
contentProgressObserver.stop()
|
|
163
|
-
await contentProgressObserver.start()
|
|
164
|
-
|
|
165
|
-
emitProgress({
|
|
166
|
-
contentId: 418001,
|
|
167
|
-
collectionType: 'skill-pack',
|
|
168
|
-
collectionId: 418000
|
|
169
|
-
})
|
|
170
|
-
await waitForDebounce()
|
|
171
|
-
|
|
172
|
-
expect(listeners.granted).toHaveBeenCalled()
|
|
173
|
-
})
|
|
174
|
-
})
|
|
175
|
-
|
|
176
|
-
describe('Collection ID type handling', () => {
|
|
177
|
-
test('collection ID as number matches correctly for LP', async () => {
|
|
178
|
-
emitProgress({
|
|
179
|
-
contentId: 418004,
|
|
180
|
-
collectionType: COLLECTION_TYPE.LEARNING_PATH,
|
|
181
|
-
collectionId: 418010
|
|
182
|
-
})
|
|
183
|
-
await waitForDebounce()
|
|
184
|
-
|
|
185
|
-
expect(listeners.granted).toHaveBeenCalledWith(
|
|
186
|
-
expect.objectContaining({ awardId: 'b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e' })
|
|
187
|
-
)
|
|
188
|
-
})
|
|
189
|
-
|
|
190
|
-
test('LP collection ID mismatch with correct type fails', async () => {
|
|
191
|
-
emitProgress({
|
|
192
|
-
contentId: 418004,
|
|
193
|
-
collectionType: COLLECTION_TYPE.LEARNING_PATH,
|
|
194
|
-
collectionId: 999999
|
|
195
|
-
})
|
|
196
|
-
await waitForDebounce()
|
|
197
|
-
|
|
198
|
-
expect(listeners.granted).not.toHaveBeenCalled()
|
|
199
|
-
})
|
|
200
|
-
|
|
201
|
-
test('non-LP collection ID mismatch still triggers (a la carte)', async () => {
|
|
202
|
-
emitProgress({
|
|
203
|
-
contentId: 418001,
|
|
204
|
-
collectionType: 'skill-pack',
|
|
205
|
-
collectionId: 418002
|
|
206
|
-
})
|
|
207
|
-
await waitForDebounce()
|
|
208
|
-
|
|
209
|
-
expect(listeners.granted).toHaveBeenCalled()
|
|
210
|
-
})
|
|
211
|
-
})
|
|
212
|
-
})
|