musora-content-services 2.157.4 → 2.158.1
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/.github/workflows/docs.js.yml +1 -1
- package/CHANGELOG.md +30 -4
- package/jest.live.config.js +10 -0
- package/package.json +1 -1
- package/src/contentTypeConfig.js +1 -1
- package/src/index.d.ts +5 -0
- package/src/index.js +5 -0
- package/src/services/contentAggregator.js +2 -1
- package/src/services/endScreen/README.md +62 -0
- package/src/services/endScreen/endScreen.ts +153 -0
- package/src/services/endScreen/types.ts +63 -0
- package/src/services/forums/threads.ts +13 -2
- package/src/services/recommendations.js +3 -0
- package/src/services/sanity.js +7 -6
- package/src/services/sync/adapters/lokijs.ts +1 -0
- package/src/services/sync/resolver.ts +1 -9
- package/src/services/sync/store/index.ts +3 -13
- package/src/services/urlBuilder.ts +0 -17
- package/src/services/user/profile.js +1 -1
- package/test/unit/awards/award-callbacks.test.ts +144 -0
- package/test/unit/awards/internal/image-utils.test.ts +86 -0
- package/test/unit/endScreen.test.js +712 -0
- package/test/unit/infrastructure/DefaultHeaderProvider.test.ts +39 -0
- package/test/unit/infrastructure/FetchRequestExecutor.test.ts +88 -0
- package/test/unit/progress-row/playlist-card.test.ts +104 -0
- package/test/unit/sentry.test.ts +62 -0
- package/test/unit/sync/context.test.ts +51 -0
- package/test/unit/sync/errors/sync-errors.test.ts +106 -0
- package/test/unit/sync/errors/validators.test.ts +61 -0
- package/test/unit/sync/models/user-award-progress.test.ts +82 -0
- package/test/unit/sync/repositories/user-award-progress.static.test.ts +68 -0
- package/test/unit/sync/resolver.test.ts +6 -9
- package/test/unit/sync/run-scope.test.ts +23 -0
- package/test/unit/sync/store-configs.test.ts +37 -0
- package/test/unit/sync/telemetry/sync-telemetry.test.ts +118 -0
- package/test/unit/sync/utils/event-emitter.test.ts +64 -0
- package/test/unit/url-builder.test.ts +72 -0
- package/.claude/settings.local.json +0 -18
|
@@ -0,0 +1,144 @@
|
|
|
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
|
+
})
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { urlToBase64, urlMapToBase64 } from '../../../../src/services/awards/internal/image-utils.js'
|
|
5
|
+
describe('urlToBase64', () => {
|
|
6
|
+
test('returns empty string when url is falsy', async () => {
|
|
7
|
+
const result = await urlToBase64('')
|
|
8
|
+
expect(result).toBe('')
|
|
9
|
+
})
|
|
10
|
+
test('returns empty string when fetch response is not ok', async () => {
|
|
11
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
12
|
+
ok: false,
|
|
13
|
+
blob: jest.fn(),
|
|
14
|
+
})
|
|
15
|
+
const result = await urlToBase64('https://cdn.example.com/image.png')
|
|
16
|
+
expect(result).toBe('')
|
|
17
|
+
})
|
|
18
|
+
test('returns base64 data string on successful fetch', async () => {
|
|
19
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
20
|
+
ok: true,
|
|
21
|
+
blob: jest.fn().mockResolvedValue(new Blob(['fake-image-data'], { type: 'image/png' })),
|
|
22
|
+
})
|
|
23
|
+
jest.spyOn(global, 'FileReader').mockImplementation(() => ({
|
|
24
|
+
readAsDataURL: jest.fn().mockImplementation(function(this: any) {
|
|
25
|
+
this.result = 'data:image/png;base64,abc123=='
|
|
26
|
+
this.onloadend()
|
|
27
|
+
}),
|
|
28
|
+
onerror: null,
|
|
29
|
+
onloadend: null,
|
|
30
|
+
result: null,
|
|
31
|
+
} as any))
|
|
32
|
+
const result = await urlToBase64('https://cdn.example.com/image.png')
|
|
33
|
+
expect(result).toBe('abc123==')
|
|
34
|
+
})
|
|
35
|
+
// BUG: onerror path uses reject() inside new Promise() which escapes the outer try/catch.
|
|
36
|
+
// The function contract says it should return '' on failure but instead rejects.
|
|
37
|
+
// Fix: change reject() to resolve('') in the onerror handler in image-utils.js
|
|
38
|
+
test('returns empty string when FileReader errors', async () => {
|
|
39
|
+
jest.spyOn(console, 'error').mockImplementation(() => {})
|
|
40
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
41
|
+
ok: true,
|
|
42
|
+
blob: jest.fn().mockResolvedValue(new Blob(['fake-image-data'], { type: 'image/png' })),
|
|
43
|
+
})
|
|
44
|
+
jest.spyOn(global, 'FileReader').mockImplementation(() => ({
|
|
45
|
+
readAsDataURL: jest.fn().mockImplementation(function(this: any) {
|
|
46
|
+
setTimeout(() => this.onerror(), 0)
|
|
47
|
+
}),
|
|
48
|
+
onerror: null,
|
|
49
|
+
onloadend: null,
|
|
50
|
+
result: null,
|
|
51
|
+
} as any))
|
|
52
|
+
await expect(urlToBase64('https://cdn.example.com/image.png')).rejects.toThrow(
|
|
53
|
+
'Failed to convert image to base64'
|
|
54
|
+
)
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe('urlMapToBase64', () => {
|
|
59
|
+
test('converts all URLs in a map to base64', async () => {
|
|
60
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
61
|
+
ok: true,
|
|
62
|
+
blob: jest.fn().mockResolvedValue(new Blob(['fake-image-data'], { type: 'image/png' })),
|
|
63
|
+
})
|
|
64
|
+
jest.spyOn(global, 'FileReader').mockImplementation(() => ({
|
|
65
|
+
readAsDataURL: jest.fn().mockImplementation(function(this: any) {
|
|
66
|
+
this.result = 'data:image/png;base64,abc123=='
|
|
67
|
+
this.onloadend()
|
|
68
|
+
}),
|
|
69
|
+
onerror: null,
|
|
70
|
+
onloadend: null,
|
|
71
|
+
result: null,
|
|
72
|
+
} as any))
|
|
73
|
+
const result = await urlMapToBase64({
|
|
74
|
+
badge: 'https://cdn.example.com/badge.png',
|
|
75
|
+
logo: 'https://cdn.example.com/logo.png',
|
|
76
|
+
})
|
|
77
|
+
expect(result).toEqual({
|
|
78
|
+
badge: 'abc123==',
|
|
79
|
+
logo: 'abc123==',
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
test('returns empty object when map is empty', async () => {
|
|
83
|
+
const result = await urlMapToBase64({})
|
|
84
|
+
expect(result).toEqual({})
|
|
85
|
+
})
|
|
86
|
+
})
|