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.
Files changed (38) hide show
  1. package/.github/workflows/docs.js.yml +1 -1
  2. package/CHANGELOG.md +30 -4
  3. package/jest.live.config.js +10 -0
  4. package/package.json +1 -1
  5. package/src/contentTypeConfig.js +1 -1
  6. package/src/index.d.ts +5 -0
  7. package/src/index.js +5 -0
  8. package/src/services/contentAggregator.js +2 -1
  9. package/src/services/endScreen/README.md +62 -0
  10. package/src/services/endScreen/endScreen.ts +153 -0
  11. package/src/services/endScreen/types.ts +63 -0
  12. package/src/services/forums/threads.ts +13 -2
  13. package/src/services/recommendations.js +3 -0
  14. package/src/services/sanity.js +7 -6
  15. package/src/services/sync/adapters/lokijs.ts +1 -0
  16. package/src/services/sync/resolver.ts +1 -9
  17. package/src/services/sync/store/index.ts +3 -13
  18. package/src/services/urlBuilder.ts +0 -17
  19. package/src/services/user/profile.js +1 -1
  20. package/test/unit/awards/award-callbacks.test.ts +144 -0
  21. package/test/unit/awards/internal/image-utils.test.ts +86 -0
  22. package/test/unit/endScreen.test.js +712 -0
  23. package/test/unit/infrastructure/DefaultHeaderProvider.test.ts +39 -0
  24. package/test/unit/infrastructure/FetchRequestExecutor.test.ts +88 -0
  25. package/test/unit/progress-row/playlist-card.test.ts +104 -0
  26. package/test/unit/sentry.test.ts +62 -0
  27. package/test/unit/sync/context.test.ts +51 -0
  28. package/test/unit/sync/errors/sync-errors.test.ts +106 -0
  29. package/test/unit/sync/errors/validators.test.ts +61 -0
  30. package/test/unit/sync/models/user-award-progress.test.ts +82 -0
  31. package/test/unit/sync/repositories/user-award-progress.static.test.ts +68 -0
  32. package/test/unit/sync/resolver.test.ts +6 -9
  33. package/test/unit/sync/run-scope.test.ts +23 -0
  34. package/test/unit/sync/store-configs.test.ts +37 -0
  35. package/test/unit/sync/telemetry/sync-telemetry.test.ts +118 -0
  36. package/test/unit/sync/utils/event-emitter.test.ts +64 -0
  37. package/test/unit/url-builder.test.ts +72 -0
  38. 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
+ })