musora-content-services 2.157.2 → 2.158.0

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 (194) hide show
  1. package/.claude/settings.local.json +17 -5
  2. package/.coderabbit.yaml +0 -0
  3. package/.editorconfig +0 -0
  4. package/.github/pull_request_template.md +0 -0
  5. package/.github/workflows/conventional-commits.yaml +0 -0
  6. package/.github/workflows/docs.js.yml +1 -1
  7. package/.prettierignore +0 -0
  8. package/.prettierrc +0 -0
  9. package/CHANGELOG.md +30 -0
  10. package/CLAUDE.md +0 -0
  11. package/README.md +0 -0
  12. package/babel.config.cjs +0 -0
  13. package/jest.live.config.js +10 -0
  14. package/package.json +1 -1
  15. package/src/constants/award-assets.js +0 -0
  16. package/src/constants/membership-permissions.ts +0 -0
  17. package/src/contentMetaData.js +0 -0
  18. package/src/contentTypeConfig.js +1 -1
  19. package/src/filterBuilder.js +0 -0
  20. package/src/index.d.ts +5 -0
  21. package/src/index.js +5 -0
  22. package/src/infrastructure/http/HttpClient.ts +0 -0
  23. package/src/infrastructure/http/executors/FetchRequestExecutor.ts +0 -0
  24. package/src/infrastructure/http/index.ts +0 -0
  25. package/src/infrastructure/http/interfaces/HeaderProvider.ts +0 -0
  26. package/src/infrastructure/http/interfaces/HttpError.ts +0 -0
  27. package/src/infrastructure/http/interfaces/NetworkError.ts +0 -0
  28. package/src/infrastructure/http/interfaces/RequestExecutor.ts +0 -0
  29. package/src/infrastructure/http/interfaces/RequestOptions.ts +0 -0
  30. package/src/infrastructure/http/providers/DefaultHeaderProvider.ts +0 -0
  31. package/src/lib/ads/monoid.ts +0 -0
  32. package/src/lib/ads/semigroup.ts +0 -0
  33. package/src/lib/brands.ts +0 -0
  34. package/src/lib/lastUpdated.js +0 -0
  35. package/src/lib/sanity/field-access.ts +0 -0
  36. package/src/lib/sanity/query.ts +0 -0
  37. package/src/services/api/types.js +0 -0
  38. package/src/services/api/types.ts +0 -0
  39. package/src/services/awards/award-callbacks.js +0 -0
  40. package/src/services/awards/award-query.js +0 -0
  41. package/src/services/awards/internal/.indexignore +0 -0
  42. package/src/services/awards/internal/award-definitions.js +0 -0
  43. package/src/services/awards/internal/award-events.js +0 -0
  44. package/src/services/awards/internal/award-manager.js +0 -0
  45. package/src/services/awards/internal/certificate-builder.js +0 -0
  46. package/src/services/awards/internal/completion-data-generator.js +0 -0
  47. package/src/services/awards/internal/content-progress-observer.js +0 -0
  48. package/src/services/awards/internal/image-utils.js +0 -0
  49. package/src/services/awards/internal/message-generator.js +0 -0
  50. package/src/services/awards/internal/types.js +0 -0
  51. package/src/services/awards/types.d.ts +0 -0
  52. package/src/services/awards/types.js +0 -0
  53. package/src/services/config.js +0 -0
  54. package/src/services/content/artist.ts +0 -0
  55. package/src/services/content/content.ts +0 -0
  56. package/src/services/content/genre.ts +0 -0
  57. package/src/services/content/instructor.ts +0 -0
  58. package/src/services/content-org/content-org.js +0 -0
  59. package/src/services/content-org/playlists-types.js +0 -0
  60. package/src/services/content-org/playlists.js +0 -0
  61. package/src/services/contentAggregator.js +0 -0
  62. package/src/services/contentLikes.js +0 -0
  63. package/src/services/dataContext.js +0 -0
  64. package/src/services/dateUtils.js +0 -0
  65. package/src/services/endScreen/README.md +62 -0
  66. package/src/services/endScreen/endScreen.ts +153 -0
  67. package/src/services/endScreen/types.ts +63 -0
  68. package/src/services/eventsAPI.js +0 -0
  69. package/src/services/forums/categories.ts +0 -0
  70. package/src/services/forums/forums.ts +0 -0
  71. package/src/services/forums/posts.ts +0 -0
  72. package/src/services/forums/threads.ts +13 -2
  73. package/src/services/forums/types.ts +0 -0
  74. package/src/services/gamification/awards.ts +0 -0
  75. package/src/services/gamification/gamification.js +0 -0
  76. package/src/services/imageSRCBuilder.js +0 -0
  77. package/src/services/imageSRCVerify.js +0 -0
  78. package/src/services/liveTesting.ts +0 -0
  79. package/src/services/permissions/PermissionsAdapterFactory.ts +0 -0
  80. package/src/services/permissions/README.md +0 -0
  81. package/src/services/progress-events.js +0 -0
  82. package/src/services/progress-row/base.js +0 -0
  83. package/src/services/progress-row/rows/.indexignore +0 -0
  84. package/src/services/progress-row/rows/content-card.js +0 -0
  85. package/src/services/progress-row/rows/playlist-card.js +0 -0
  86. package/src/services/railcontent.js +0 -0
  87. package/src/services/recommendations.js +3 -0
  88. package/src/services/reporting/README.md +0 -0
  89. package/src/services/reporting/types.ts +0 -0
  90. package/src/services/sanity.js +7 -6
  91. package/src/services/sentry/.indexignore +0 -0
  92. package/src/services/sentry/index.ts +0 -0
  93. package/src/services/state.ts +0 -0
  94. package/src/services/sync/.indexignore +0 -0
  95. package/src/services/sync/adapters/factory.ts +0 -0
  96. package/src/services/sync/adapters/lokijs.ts +1 -0
  97. package/src/services/sync/adapters/sqlite.ts +0 -0
  98. package/src/services/sync/context/providers/base.ts +0 -0
  99. package/src/services/sync/context/providers/connectivity.ts +0 -0
  100. package/src/services/sync/context/providers/durability.ts +0 -0
  101. package/src/services/sync/context/providers/index.ts +0 -0
  102. package/src/services/sync/context/providers/session.ts +0 -0
  103. package/src/services/sync/context/providers/tabs.ts +0 -0
  104. package/src/services/sync/context/providers/visibility.ts +0 -0
  105. package/src/services/sync/database/factory.ts +0 -0
  106. package/src/services/sync/debug.ts +0 -0
  107. package/src/services/sync/effects/index.ts +0 -0
  108. package/src/services/sync/effects/logout-warning.ts +0 -0
  109. package/src/services/sync/errors/boundary.ts +0 -0
  110. package/src/services/sync/errors/index.ts +0 -0
  111. package/src/services/sync/errors/validators.ts +0 -0
  112. package/src/services/sync/fetch.ts +0 -0
  113. package/src/services/sync/index.ts +0 -0
  114. package/src/services/sync/manager.ts +0 -0
  115. package/src/services/sync/models/Base.ts +0 -0
  116. package/src/services/sync/models/ContentLike.ts +0 -0
  117. package/src/services/sync/models/ContentProgress.ts +0 -0
  118. package/src/services/sync/models/Practice.ts +0 -0
  119. package/src/services/sync/models/PracticeDayNote.ts +0 -0
  120. package/src/services/sync/models/UserAwardProgress.ts +0 -0
  121. package/src/services/sync/models/index.ts +0 -0
  122. package/src/services/sync/repositories/content-likes.ts +0 -0
  123. package/src/services/sync/repositories/content-progress.ts +0 -0
  124. package/src/services/sync/repositories/index.ts +0 -0
  125. package/src/services/sync/repositories/practice-day-notes.ts +0 -0
  126. package/src/services/sync/repositories/practices.ts +0 -0
  127. package/src/services/sync/repositories/user-award-progress.ts +0 -0
  128. package/src/services/sync/repository-proxy.ts +0 -0
  129. package/src/services/sync/resolver.ts +0 -0
  130. package/src/services/sync/run-scope.ts +0 -0
  131. package/src/services/sync/schema/index.ts +0 -0
  132. package/src/services/sync/serializers/index.ts +0 -0
  133. package/src/services/sync/serializers/model.ts +0 -0
  134. package/src/services/sync/serializers/raw.ts +0 -0
  135. package/src/services/sync/store/index.ts +2 -6
  136. package/src/services/sync/store-configs.ts +0 -0
  137. package/src/services/sync/strategies/base.ts +0 -0
  138. package/src/services/sync/strategies/index.ts +0 -0
  139. package/src/services/sync/strategies/initial.ts +0 -0
  140. package/src/services/sync/strategies/polling.ts +0 -0
  141. package/src/services/sync/telemetry/flood-prevention.ts +0 -0
  142. package/src/services/sync/telemetry/sampling.ts +0 -0
  143. package/src/services/sync/utils/event-emitter.ts +0 -0
  144. package/src/services/sync/utils/index.ts +0 -0
  145. package/src/services/sync/utils/throttle.ts +0 -0
  146. package/src/services/sync/utils/timers.ts +0 -0
  147. package/src/services/types.js +0 -0
  148. package/src/services/urlBuilder.ts +0 -17
  149. package/src/services/user/account.ts +0 -0
  150. package/src/services/user/chat.js +0 -0
  151. package/src/services/user/interests.js +0 -0
  152. package/src/services/user/management.js +0 -0
  153. package/src/services/user/memberships.ts +0 -0
  154. package/src/services/user/notifications.js +0 -0
  155. package/src/services/user/payments.ts +0 -0
  156. package/src/services/user/profile.js +1 -1
  157. package/src/services/user/sessions.js +0 -0
  158. package/src/services/user/streakCalculator.ts +0 -0
  159. package/src/services/user/types.js +0 -0
  160. package/src/services/user/user-management-system.js +0 -0
  161. package/test/SKIPPED_TESTS.md +0 -0
  162. package/test/initializeTests.js +0 -0
  163. package/test/integration/content.test.js +0 -0
  164. package/test/integration/contentProgress.test.js +0 -0
  165. package/test/integration/forum.test.js +0 -0
  166. package/test/integration/sanityQueryService.test.js +0 -0
  167. package/test/localStorageMock.js +0 -0
  168. package/test/log.js +0 -0
  169. package/test/mockData/award-definitions.js +0 -0
  170. package/test/mockData/mockData_fetchByRailContentIds_one_content.json +0 -0
  171. package/test/mockData/mockData_progress_content.json +0 -0
  172. package/test/mockData/mockData_sanity_progress_content.json +0 -0
  173. package/test/mockData/mockData_user_practices.json +0 -0
  174. package/test/setupConsole.js +0 -0
  175. package/test/setupNetworkGuard.js +0 -0
  176. package/test/unit/awards/award-callbacks.test.ts +144 -0
  177. package/test/unit/awards/internal/image-utils.test.ts +86 -0
  178. package/test/unit/endScreen.test.js +712 -0
  179. package/test/unit/infrastructure/DefaultHeaderProvider.test.ts +39 -0
  180. package/test/unit/infrastructure/FetchRequestExecutor.test.ts +88 -0
  181. package/test/unit/lib/__snapshots__/filter.test.ts.snap +0 -0
  182. package/test/unit/lib/query.test.ts +0 -0
  183. package/test/unit/progress-row/playlist-card.test.ts +104 -0
  184. package/test/unit/sentry.test.ts +62 -0
  185. package/test/unit/sync/context.test.ts +51 -0
  186. package/test/unit/sync/errors/sync-errors.test.ts +106 -0
  187. package/test/unit/sync/errors/validators.test.ts +61 -0
  188. package/test/unit/sync/models/user-award-progress.test.ts +82 -0
  189. package/test/unit/sync/repositories/user-award-progress.static.test.ts +68 -0
  190. package/test/unit/sync/run-scope.test.ts +23 -0
  191. package/test/unit/sync/store-configs.test.ts +37 -0
  192. package/test/unit/sync/telemetry/sync-telemetry.test.ts +118 -0
  193. package/test/unit/sync/utils/event-emitter.test.ts +64 -0
  194. package/test/unit/url-builder.test.ts +72 -0
File without changes
File without changes
File without changes
@@ -37,6 +37,9 @@ export async function fetchSimilarItems(content_id, brand, count = 10) {
37
37
  brand: brand,
38
38
  content_ids: content_id,
39
39
  num_similar: count + 1,
40
+ page_size: count + 1,
41
+ page: 1,
42
+ exclude_interacted: true
40
43
  }
41
44
  const url = `/similar_items/`
42
45
  try {
File without changes
File without changes
@@ -1146,20 +1146,22 @@ export async function fetchSiblingContent(railContentId, brand = null) {
1146
1146
  */
1147
1147
  export async function fetchRelatedLessons(railContentId) {
1148
1148
  const defaultFilterFields = `_type==^._type && brand == ^.brand && railcontent_id != ${railContentId}`
1149
-
1149
+ const params = {
1150
+ showMembershipRestrictedContent: true,
1151
+ availableContentStatuses: ['published']
1152
+ }
1150
1153
  const filterSameArtist = await new FilterBuilder(
1151
1154
  `${defaultFilterFields} && references(^.artist->_id)`,
1152
- { showMembershipRestrictedContent: true }
1155
+ params
1153
1156
  ).buildFilter()
1154
1157
  const filterSameGenre = await new FilterBuilder(
1155
1158
  `${defaultFilterFields} && references(^.genre[]->_id)`,
1156
- { showMembershipRestrictedContent: true }
1159
+ params
1157
1160
  ).buildFilter()
1158
1161
  const filterSameDifficulty = await new FilterBuilder(
1159
1162
  `${defaultFilterFields} && difficulty == ^.difficulty`,
1160
- { showMembershipRestrictedContent: true }
1163
+ params
1161
1164
  ).buildFilter()
1162
-
1163
1165
  const queryFields = getFieldsForContentType()
1164
1166
 
1165
1167
  const query = `*[railcontent_id == ${railContentId}]{
@@ -1169,7 +1171,6 @@ export async function fetchRelatedLessons(railContentId) {
1169
1171
  ...(*[${filterSameGenre}]{${queryFields}}|order(published_on desc, title asc)[0...10]),
1170
1172
  ...(*[${filterSameDifficulty}]{${queryFields}}|order(published_on desc, title asc)[0...10]),
1171
1173
  ])[0...10]}`
1172
-
1173
1174
  return await fetchSanity(query, false, { processNeedAccess: true })
1174
1175
  }
1175
1176
 
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -25,6 +25,7 @@ export default class LokiPersistenceErrorAwareAdapter extends (LokiJSAdapter as
25
25
  }
26
26
 
27
27
  _overrideSaveDatabase(onPersistenceError?: (err: Error) => void) {
28
+ if (!this._driver) return
28
29
  const driver = this._driver
29
30
  const persistenceAdapter = driver.loki.persistenceAdapter
30
31
  const oldSaveDatabase = persistenceAdapter.saveDatabase;
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -184,12 +184,8 @@ export default class SyncStore<TModel extends BaseModel = BaseModel> {
184
184
  async pushRecordIdsImpatiently(ids: RecordId[], span?: Span) {
185
185
  const records = await this.queryMaybeDeletedRecords(Q.where('id', Q.oneOf(ids)))
186
186
 
187
- return await this.pushCoalescer.push(
188
- records,
189
- queueThrottle({ state: this.pushThrottleState }, () => {
190
- return this.executePush(records, span)
191
- })
192
- )
187
+ // don't use coalescer or throttle - otherwise it could pick up on currently in-flight retrying (i.e., not impatient) requests
188
+ return this.executePush(records, span)
193
189
  }
194
190
 
195
191
  async readAll() {
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -97,10 +97,6 @@ const CONTENT_TYPES_WITHOUT_OVERVIEW = ['course', 'guided-course']
97
97
  * @example
98
98
  * generateContentUrl({ id: 456, type: 'course-part', parentId: 789, brand: 'pianote' })
99
99
  * // Returns: "/pianote/lessons/course/789/456"
100
- *
101
- * @example
102
- * generateContentUrl({ id: 123, type: 'pack-bundle', navigateTo: { id: 456 }, brand: 'guitareo' })
103
- * // Returns: "/guitareo/lessons/pack/123/456"
104
100
  */
105
101
  export async function generateContentUrl({
106
102
  id,
@@ -128,18 +124,6 @@ export async function generateContentUrl({
128
124
  return `/${brand}/lessons/course-collection/overview/${id}`
129
125
  }
130
126
 
131
- if (type === 'pack') {
132
- return `/${brand}/lessons/pack/overview/${id}`
133
- }
134
-
135
- if (type === 'pack-bundle') {
136
- if (navigateTo?.id) {
137
- return `/${brand}/lessons/pack/${id}/${navigateTo.id}`
138
- }
139
- // Fallback to overview if navigateTo is missing
140
- return `/${brand}/lessons/pack/overview/${id}`
141
- }
142
-
143
127
  // Recursive helper to fetch navigateTo with optional deep fetching
144
128
  const fetchNavigateToRecursive = async (contentId: number | string, shouldGoDeeper: boolean): Promise<any> => {
145
129
  const content = await fetchByRailContentIds([contentId])
@@ -209,7 +193,6 @@ export async function generateContentUrl({
209
193
  'course-lesson': 'course',
210
194
  'guided-course-lesson': 'course',
211
195
  'guided-course': 'course',
212
- 'pack-bundle-lesson': 'pack',
213
196
  'documentary-lesson': 'documentary',
214
197
  'skill-pack-lesson': 'skill-pack',
215
198
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -28,7 +28,7 @@ export async function otherStats(userId = globalConfig.sessionConfig.userId) {
28
28
  type: 'week',
29
29
  length: longestStreaks.longestWeeklyStreak,
30
30
  },
31
- total_practice_time: longestStreaks.totalPracticeSeconds,
31
+ total_practice_time: longestStreaks.totalPracticeSeconds + (stats.v1_practice_time ?? 0),
32
32
  }
33
33
  }
34
34
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
package/test/log.js CHANGED
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -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
+ })