musora-content-services 2.94.7 → 2.95.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 (210) hide show
  1. package/.coderabbit.yaml +0 -0
  2. package/.editorconfig +0 -0
  3. package/.github/pull_request_template.md +0 -0
  4. package/.github/workflows/conventional-commits.yaml +0 -0
  5. package/.github/workflows/docs.js.yml +0 -0
  6. package/.github/workflows/node.js.yml +0 -0
  7. package/.prettierignore +0 -0
  8. package/.prettierrc +0 -0
  9. package/CHANGELOG.md +23 -0
  10. package/CLAUDE.md +408 -0
  11. package/README.md +0 -0
  12. package/babel.config.cjs +10 -0
  13. package/jest.config.js +0 -0
  14. package/jsdoc.json +2 -1
  15. package/package.json +2 -2
  16. package/src/constants/award-assets.js +35 -0
  17. package/src/contentMetaData.js +0 -0
  18. package/src/filterBuilder.js +7 -2
  19. package/src/index.d.ts +26 -5
  20. package/src/index.js +26 -5
  21. package/src/infrastructure/http/HttpClient.ts +0 -0
  22. package/src/infrastructure/http/executors/FetchRequestExecutor.ts +0 -0
  23. package/src/infrastructure/http/index.ts +0 -0
  24. package/src/infrastructure/http/interfaces/HeaderProvider.ts +0 -0
  25. package/src/infrastructure/http/interfaces/HttpError.ts +0 -0
  26. package/src/infrastructure/http/interfaces/NetworkError.ts +0 -0
  27. package/src/infrastructure/http/interfaces/RequestExecutor.ts +0 -0
  28. package/src/infrastructure/http/interfaces/RequestOptions.ts +0 -0
  29. package/src/infrastructure/http/providers/DefaultHeaderProvider.ts +0 -0
  30. package/src/lib/brands.ts +0 -0
  31. package/src/lib/httpHelper.js +0 -0
  32. package/src/lib/lastUpdated.js +0 -0
  33. package/src/lib/sanity/query.ts +0 -0
  34. package/src/services/api/types.js +0 -0
  35. package/src/services/api/types.ts +0 -0
  36. package/src/services/awards/award-callbacks.js +126 -0
  37. package/src/services/awards/award-query.js +327 -0
  38. package/src/services/awards/internal/.indexignore +1 -0
  39. package/src/services/awards/internal/award-definitions.js +239 -0
  40. package/src/services/awards/internal/award-events.js +102 -0
  41. package/src/services/awards/internal/award-manager.js +162 -0
  42. package/src/services/awards/internal/certificate-builder.js +66 -0
  43. package/src/services/awards/internal/completion-data-generator.js +84 -0
  44. package/src/services/awards/internal/content-progress-observer.js +137 -0
  45. package/src/services/awards/internal/image-utils.js +62 -0
  46. package/src/services/awards/internal/message-generator.js +17 -0
  47. package/src/services/awards/internal/types.js +5 -0
  48. package/src/services/awards/types.d.ts +79 -0
  49. package/src/services/awards/types.js +101 -0
  50. package/src/services/config.js +24 -4
  51. package/src/services/content/artist.ts +0 -0
  52. package/src/services/content/content.ts +0 -0
  53. package/src/services/content/genre.ts +0 -0
  54. package/src/services/content/instructor.ts +0 -0
  55. package/src/services/content-org/content-org.js +0 -0
  56. package/src/services/content-org/guided-courses.ts +0 -0
  57. package/src/services/content-org/learning-paths.ts +19 -15
  58. package/src/services/content-org/playlists-types.js +0 -0
  59. package/src/services/content-org/playlists.js +0 -0
  60. package/src/services/content.js +0 -0
  61. package/src/services/contentAggregator.js +0 -0
  62. package/src/services/contentLikes.js +0 -0
  63. package/src/services/contentProgress.js +2 -1
  64. package/src/services/dataContext.js +0 -0
  65. package/src/services/dateUtils.js +0 -0
  66. package/src/services/eventsAPI.js +0 -0
  67. package/src/services/forums/forums.ts +0 -0
  68. package/src/services/forums/posts.ts +0 -0
  69. package/src/services/forums/threads.ts +0 -0
  70. package/src/services/forums/types.ts +0 -0
  71. package/src/services/gamification/awards.ts +114 -83
  72. package/src/services/gamification/gamification.js +0 -0
  73. package/src/services/imageSRCBuilder.js +0 -0
  74. package/src/services/imageSRCVerify.js +0 -0
  75. package/src/services/liveTesting.ts +0 -0
  76. package/src/services/permissions/PermissionsAdapter.ts +0 -0
  77. package/src/services/permissions/PermissionsAdapterFactory.ts +0 -0
  78. package/src/services/permissions/PermissionsV1Adapter.ts +0 -0
  79. package/src/services/permissions/PermissionsV2Adapter.ts +0 -0
  80. package/src/services/permissions/README.md +0 -0
  81. package/src/services/permissions/index.ts +0 -0
  82. package/src/services/progress-events.js +58 -0
  83. package/src/services/progress-row/method-card.js +20 -5
  84. package/src/services/railcontent.js +0 -0
  85. package/src/services/recommendations.js +0 -0
  86. package/src/services/reporting/README.md +0 -0
  87. package/src/services/reporting/reporting.ts +0 -0
  88. package/src/services/reporting/types.ts +0 -0
  89. package/src/services/sanity.js +1 -1
  90. package/src/services/sentry/.indexignore +0 -0
  91. package/src/services/sentry/index.ts +0 -0
  92. package/src/services/sync/.indexignore +0 -0
  93. package/src/services/sync/adapters/factory.ts +0 -0
  94. package/src/services/sync/adapters/lokijs.ts +0 -0
  95. package/src/services/sync/adapters/sqlite.ts +0 -0
  96. package/src/services/sync/concurrency-safety.ts +0 -0
  97. package/src/services/sync/context/index.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/errors/boundary.ts +0 -0
  107. package/src/services/sync/errors/index.ts +0 -0
  108. package/src/services/sync/fetch.ts +10 -2
  109. package/src/services/sync/index.ts +0 -0
  110. package/src/services/sync/manager.ts +6 -0
  111. package/src/services/sync/models/Base.ts +0 -0
  112. package/src/services/sync/models/ContentLike.ts +0 -0
  113. package/src/services/sync/models/ContentProgress.ts +5 -6
  114. package/src/services/sync/models/Practice.ts +0 -0
  115. package/src/services/sync/models/PracticeDayNote.ts +0 -0
  116. package/src/services/sync/models/UserAwardProgress.ts +55 -0
  117. package/src/services/sync/models/index.ts +1 -0
  118. package/src/services/sync/repositories/base.ts +0 -0
  119. package/src/services/sync/repositories/content-likes.ts +0 -0
  120. package/src/services/sync/repositories/content-progress.ts +47 -25
  121. package/src/services/sync/repositories/index.ts +1 -0
  122. package/src/services/sync/repositories/practice-day-notes.ts +0 -0
  123. package/src/services/sync/repositories/practices.ts +16 -1
  124. package/src/services/sync/repositories/user-award-progress.ts +133 -0
  125. package/src/services/sync/repository-proxy.ts +6 -0
  126. package/src/services/sync/resolver.ts +0 -0
  127. package/src/services/sync/retry.ts +12 -11
  128. package/src/services/sync/run-scope.ts +0 -0
  129. package/src/services/sync/schema/index.ts +18 -3
  130. package/src/services/sync/serializers/index.ts +0 -0
  131. package/src/services/sync/serializers/model.ts +0 -0
  132. package/src/services/sync/serializers/raw.ts +0 -0
  133. package/src/services/sync/store/index.ts +53 -8
  134. package/src/services/sync/store/push-coalescer.ts +3 -3
  135. package/src/services/sync/store-configs.ts +7 -1
  136. package/src/services/sync/strategies/base.ts +0 -0
  137. package/src/services/sync/strategies/index.ts +0 -0
  138. package/src/services/sync/strategies/initial.ts +0 -0
  139. package/src/services/sync/strategies/polling.ts +0 -0
  140. package/src/services/sync/telemetry/index.ts +0 -0
  141. package/src/services/sync/telemetry/sampling.ts +0 -0
  142. package/src/services/sync/utils/event-emitter.ts +0 -0
  143. package/src/services/sync/utils/index.ts +0 -0
  144. package/src/services/sync/utils/throttle.ts +0 -0
  145. package/src/services/sync/utils/timers.ts +0 -0
  146. package/src/services/types.js +0 -0
  147. package/src/services/user/account.ts +0 -0
  148. package/src/services/user/chat.js +0 -0
  149. package/src/services/user/interests.js +0 -0
  150. package/src/services/user/management.js +0 -0
  151. package/src/services/user/memberships.ts +0 -0
  152. package/src/services/user/notifications.js +0 -0
  153. package/src/services/user/onboarding.ts +0 -0
  154. package/src/services/user/payments.ts +0 -0
  155. package/src/services/user/permissions.js +0 -0
  156. package/src/services/user/profile.js +0 -0
  157. package/src/services/user/sessions.js +0 -0
  158. package/src/services/user/types.d.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/src/services/userActivity.js +0 -1
  162. package/test/HttpClient.test.js +6 -6
  163. package/test/awards/award-alacarte-observer.test.js +196 -0
  164. package/test/awards/award-auto-refresh.test.js +83 -0
  165. package/test/awards/award-calculations.test.js +33 -0
  166. package/test/awards/award-certificate-display.test.js +328 -0
  167. package/test/awards/award-collection-edge-cases.test.js +210 -0
  168. package/test/awards/award-collection-filtering.test.js +285 -0
  169. package/test/awards/award-completion-flow.test.js +213 -0
  170. package/test/awards/award-exclusion-handling.test.js +273 -0
  171. package/test/awards/award-multi-lesson.test.js +241 -0
  172. package/test/awards/award-observer-integration.test.js +325 -0
  173. package/test/awards/award-query-messages.test.js +438 -0
  174. package/test/awards/award-user-collection.test.js +412 -0
  175. package/test/awards/duplicate-prevention.test.js +118 -0
  176. package/test/awards/helpers/completion-mock.js +54 -0
  177. package/test/awards/helpers/index.js +3 -0
  178. package/test/awards/helpers/mock-setup.js +69 -0
  179. package/test/awards/helpers/progress-emitter.js +39 -0
  180. package/test/awards/message-generator.test.js +162 -0
  181. package/test/content.test.js +0 -0
  182. package/test/contentLikes.test.js +0 -0
  183. package/test/contentProgress.test.js +0 -0
  184. package/test/dataContext.test.js +0 -0
  185. package/test/forum.test.js +0 -0
  186. package/test/imageSRCBuilder.test.js +0 -0
  187. package/test/imageSRCVerify.test.js +0 -0
  188. package/test/initializeTests.js +6 -0
  189. package/test/learningPaths.test.js +0 -0
  190. package/test/lib/lastUpdated.test.js +0 -0
  191. package/test/live/contentProgressLive.test.js +0 -0
  192. package/test/live/railcontentLive.test.js +0 -0
  193. package/test/localStorageMock.js +0 -0
  194. package/test/log.js +0 -0
  195. package/test/mockData/award-definitions.js +171 -0
  196. package/test/mockData/mockData_fetchByRailContentIds_one_content.json +0 -0
  197. package/test/mockData/mockData_progress_content.json +0 -0
  198. package/test/mockData/mockData_sanity_progress_content.json +0 -0
  199. package/test/mockData/mockData_user_practices.json +0 -0
  200. package/test/notifications.test.js +0 -0
  201. package/test/progressRows.test.js +0 -0
  202. package/test/sanityQueryService.test.js +0 -0
  203. package/test/streakMessage.test.js +0 -0
  204. package/test/sync/models/award-database-integration.test.js +519 -0
  205. package/test/user/permissions.test.js +0 -0
  206. package/test/userActivity.test.js +0 -0
  207. package/tools/generate-index.cjs +9 -0
  208. package/.claude/settings.local.json +0 -14
  209. package/.yarnrc.yml +0 -1
  210. package/test/reporting.test.js +0 -132
@@ -65,7 +65,7 @@ describe('HttpClient', () => {
65
65
  expect(mockRequestExecutor.execute).toHaveBeenCalledWith(
66
66
  `${baseUrl}${url}`,
67
67
  expect.objectContaining({
68
- method: 'get',
68
+ method: 'GET',
69
69
  headers: expect.objectContaining({
70
70
  Authorization: `Bearer ${token}`,
71
71
  }),
@@ -82,7 +82,7 @@ describe('HttpClient', () => {
82
82
  expect(mockRequestExecutor.execute).toHaveBeenCalledWith(
83
83
  `${baseUrl}${url}`,
84
84
  expect.objectContaining({
85
- method: 'post',
85
+ method: 'POST',
86
86
  headers: expect.objectContaining({
87
87
  Authorization: `Bearer ${token}`,
88
88
  }),
@@ -100,7 +100,7 @@ describe('HttpClient', () => {
100
100
  expect(mockRequestExecutor.execute).toHaveBeenCalledWith(
101
101
  `${baseUrl}${url}`,
102
102
  expect.objectContaining({
103
- method: 'put',
103
+ method: 'PUT',
104
104
  headers: expect.objectContaining({
105
105
  Authorization: `Bearer ${token}`,
106
106
  }),
@@ -118,7 +118,7 @@ describe('HttpClient', () => {
118
118
  expect(mockRequestExecutor.execute).toHaveBeenCalledWith(
119
119
  `${baseUrl}${url}`,
120
120
  expect.objectContaining({
121
- method: 'patch',
121
+ method: 'PATCH',
122
122
  headers: expect.objectContaining({
123
123
  Authorization: `Bearer ${token}`,
124
124
  }),
@@ -135,7 +135,7 @@ describe('HttpClient', () => {
135
135
  expect(mockRequestExecutor.execute).toHaveBeenCalledWith(
136
136
  `${baseUrl}${url}`,
137
137
  expect.objectContaining({
138
- method: 'delete',
138
+ method: 'DELETE',
139
139
  headers: expect.objectContaining({
140
140
  Authorization: `Bearer ${token}`,
141
141
  }),
@@ -249,7 +249,7 @@ describe('HttpClient', () => {
249
249
  await expect(httpClient.get('/test')).rejects.toMatchObject({
250
250
  message: 'Network error',
251
251
  url: '/test',
252
- method: 'get',
252
+ method: 'GET',
253
253
  originalError: networkError,
254
254
  })
255
255
  })
@@ -0,0 +1,196 @@
1
+ import { contentProgressObserver } from '../../src/services/awards/internal/content-progress-observer'
2
+ import { awardEvents } from '../../src/services/awards/internal/award-events'
3
+ import { mockAwardDefinitions, getAwardByContentId } from '../mockData/award-definitions'
4
+ import { setupDefaultMocks, setupAwardEventListeners } from './helpers'
5
+ import { mockCompletionStates, mockAllCompleted } from './helpers/completion-mock'
6
+ import { COLLECTION_TYPE, emitAlaCarteProgress, emitProgress, waitForDebounce } from './helpers/progress-emitter'
7
+
8
+ jest.mock('../../src/services/sanity', () => ({
9
+ default: { fetch: jest.fn() },
10
+ fetchSanity: jest.fn()
11
+ }))
12
+
13
+ jest.mock('../../src/services/sync/repository-proxy', () => {
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'
34
+ import db from '../../src/services/sync/repository-proxy'
35
+ import { awardDefinitions } from '../../src/services/awards/internal/award-definitions'
36
+
37
+ describe('Award Observer - A La Carte Progress (null collection)', () => {
38
+ let listeners
39
+
40
+ beforeEach(async () => {
41
+ jest.clearAllMocks()
42
+ awardEvents.removeAllListeners()
43
+
44
+ sanityClient.fetch = jest.fn().mockResolvedValue(mockAwardDefinitions)
45
+ setupDefaultMocks(db, fetchSanity)
46
+
47
+ await awardDefinitions.refresh()
48
+
49
+ listeners = setupAwardEventListeners(awardEvents)
50
+
51
+ await contentProgressObserver.start()
52
+ })
53
+
54
+ afterEach(() => {
55
+ awardEvents.removeAllListeners()
56
+ awardDefinitions.clear()
57
+ contentProgressObserver.stop()
58
+ })
59
+
60
+ describe('A la carte progress triggers award evaluation', () => {
61
+ const testAward = getAwardByContentId(417049)
62
+
63
+ test('finds awards when progress has null collection type', async () => {
64
+ emitAlaCarteProgress(417045)
65
+ await waitForDebounce()
66
+
67
+ expect(listeners.granted).toHaveBeenCalled()
68
+ const payload = listeners.granted.mock.calls[0][0]
69
+ expect(payload).toHaveProperty('awardId', testAward._id)
70
+ })
71
+
72
+ test('emits awardProgress for partial completion with null collection', async () => {
73
+ mockCompletionStates(db, [417045])
74
+
75
+ emitAlaCarteProgress(417045)
76
+ await waitForDebounce()
77
+
78
+ expect(listeners.progress).toHaveBeenCalled()
79
+ const payload = listeners.progress.mock.calls[0][0]
80
+ expect(payload).toHaveProperty('awardId', testAward._id)
81
+ expect(payload).toHaveProperty('progressPercentage')
82
+ })
83
+ })
84
+
85
+ describe('A la carte grants award when all children completed', () => {
86
+ const testAward = getAwardByContentId(416442)
87
+
88
+ test('grants award for guided course with null collection progress', async () => {
89
+ emitAlaCarteProgress(416444)
90
+ await waitForDebounce()
91
+
92
+ expect(listeners.granted).toHaveBeenCalled()
93
+ const payload = listeners.granted.mock.calls[0][0]
94
+ expect(payload).toHaveProperty('awardId', testAward._id)
95
+ expect(payload).toHaveProperty('completionData')
96
+ })
97
+
98
+ test('includes completion data in granted event', async () => {
99
+ emitAlaCarteProgress(416444)
100
+ await waitForDebounce()
101
+
102
+ const payload = listeners.granted.mock.calls[0][0]
103
+ expect(payload.completionData).toMatchObject({
104
+ content_title: expect.any(String),
105
+ completed_at: expect.any(String),
106
+ days_user_practiced: expect.any(Number),
107
+ practice_minutes: 200
108
+ })
109
+ })
110
+ })
111
+
112
+ describe('A la carte progress matches multiple awards', () => {
113
+ test('finds all awards containing the child content id', async () => {
114
+ const sharedChildId = 418003
115
+
116
+ mockAllCompleted(db)
117
+
118
+ emitAlaCarteProgress(sharedChildId)
119
+ await waitForDebounce()
120
+
121
+ expect(db.userAwardProgress.recordAwardProgress).toHaveBeenCalled()
122
+ })
123
+ })
124
+
125
+ describe('A la carte debouncing', () => {
126
+ beforeEach(() => {
127
+ mockCompletionStates(db, [])
128
+ })
129
+
130
+ test('debounces multiple rapid a la carte updates', async () => {
131
+ emitAlaCarteProgress(416451)
132
+ emitAlaCarteProgress(416453)
133
+ emitAlaCarteProgress(416454)
134
+
135
+ await waitForDebounce()
136
+
137
+ expect(db.userAwardProgress.recordAwardProgress).toHaveBeenCalledTimes(1)
138
+ })
139
+ })
140
+
141
+ describe('A la carte does not match unrelated content', () => {
142
+ test('ignores content not in any award child_ids', async () => {
143
+ emitAlaCarteProgress(999999)
144
+ await waitForDebounce()
145
+
146
+ expect(listeners.progress).not.toHaveBeenCalled()
147
+ expect(listeners.granted).not.toHaveBeenCalled()
148
+ })
149
+ })
150
+
151
+ describe('A la carte already completed award', () => {
152
+ beforeEach(() => {
153
+ db.userAwardProgress.hasCompletedAward.mockResolvedValue(true)
154
+ })
155
+
156
+ test('does not re-grant already completed award', async () => {
157
+ emitAlaCarteProgress(417045)
158
+ await waitForDebounce()
159
+
160
+ expect(listeners.granted).not.toHaveBeenCalled()
161
+ })
162
+ })
163
+
164
+ describe('A la carte vs collection-scoped progress handling', () => {
165
+ test('a la carte progress finds awards regardless of award content_type', async () => {
166
+ emitAlaCarteProgress(417045)
167
+ await waitForDebounce()
168
+
169
+ expect(listeners.granted).toHaveBeenCalled()
170
+ })
171
+
172
+ test('non-LP collection context still triggers awards (a la carte)', async () => {
173
+ emitProgress({
174
+ contentId: 417045,
175
+ collectionType: 'skill-pack',
176
+ collectionId: 999999
177
+ })
178
+ await waitForDebounce()
179
+
180
+ expect(listeners.granted).toHaveBeenCalled()
181
+ })
182
+
183
+ test('LP collection context requires matching collection', async () => {
184
+ listeners.granted.mockClear()
185
+
186
+ emitProgress({
187
+ contentId: 418004,
188
+ collectionType: COLLECTION_TYPE.LEARNING_PATH,
189
+ collectionId: 999999
190
+ })
191
+ await waitForDebounce()
192
+
193
+ expect(listeners.granted).not.toHaveBeenCalled()
194
+ })
195
+ })
196
+ })
@@ -0,0 +1,83 @@
1
+ import { awardDefinitions } from '../../src/services/awards/internal/award-definitions'
2
+
3
+ jest.mock('../../src/services/sanity', () => ({
4
+ default: {
5
+ fetch: jest.fn()
6
+ },
7
+ fetchSanity: jest.fn()
8
+ }))
9
+
10
+ import { fetchSanity } from '../../src/services/sanity'
11
+
12
+ describe('Award Definitions Cache', () => {
13
+ const mockAwards = [
14
+ {
15
+ _id: 'test-award-1',
16
+ name: 'Test Award',
17
+ brand: 'drumeo',
18
+ content_id: 12345,
19
+ is_active: true,
20
+ content_type: 'guided-course'
21
+ }
22
+ ]
23
+
24
+ beforeEach(() => {
25
+ jest.clearAllMocks()
26
+ awardDefinitions.clear()
27
+ fetchSanity.mockResolvedValue(mockAwards)
28
+ })
29
+
30
+ afterEach(() => {
31
+ awardDefinitions.clear()
32
+ })
33
+
34
+ test('loads award definitions on first access', async () => {
35
+ const awards = await awardDefinitions.getAll()
36
+
37
+ expect(fetchSanity).toHaveBeenCalled()
38
+ expect(awards).toHaveLength(1)
39
+ expect(awards[0]._id).toBe('test-award-1')
40
+ })
41
+
42
+ test('uses cached definitions within refresh window', async () => {
43
+ await awardDefinitions.getAll()
44
+
45
+ fetchSanity.mockClear()
46
+
47
+ const awards = await awardDefinitions.getAll()
48
+
49
+ expect(fetchSanity).not.toHaveBeenCalled()
50
+ expect(awards).toHaveLength(1)
51
+ })
52
+
53
+ test('refreshes award definitions when cache expires', async () => {
54
+ awardDefinitions.lastFetch = Date.now() - (25 * 60 * 60 * 1000)
55
+
56
+ const awards = await awardDefinitions.getAll()
57
+
58
+ expect(fetchSanity).toHaveBeenCalled()
59
+ expect(awards).toHaveLength(1)
60
+
61
+ fetchSanity.mockClear()
62
+
63
+ const cachedAwards = await awardDefinitions.getAll()
64
+
65
+ expect(fetchSanity).not.toHaveBeenCalled()
66
+ expect(cachedAwards).toHaveLength(1)
67
+ })
68
+
69
+ test('does not refetch when called rapidly within cache window', async () => {
70
+ await awardDefinitions.getAll()
71
+ const callCountAfterFirst = fetchSanity.mock.calls.length
72
+
73
+ await Promise.all([
74
+ awardDefinitions.getAll(),
75
+ awardDefinitions.getAll(),
76
+ awardDefinitions.getAll(),
77
+ awardDefinitions.getAll(),
78
+ awardDefinitions.getAll()
79
+ ])
80
+
81
+ expect(fetchSanity.mock.calls.length).toBe(callCountAfterFirst)
82
+ })
83
+ })
@@ -0,0 +1,33 @@
1
+ import { getEligibleChildIds } from '../../src/services/awards/internal/award-definitions'
2
+
3
+ jest.mock('../../src/services/sanity', () => ({
4
+ default: {
5
+ fetch: jest.fn()
6
+ },
7
+ fetchSanity: jest.fn()
8
+ }))
9
+
10
+ describe('Award Calculations', () => {
11
+ describe('getEligibleChildIds', () => {
12
+ test('returns all child_ids from award definition', () => {
13
+ const award = { child_ids: [1, 2, 3, 4] }
14
+ expect(getEligibleChildIds(award)).toEqual([1, 2, 3, 4])
15
+ })
16
+
17
+ test('returns empty array for empty child_ids', () => {
18
+ const award = { child_ids: [] }
19
+ expect(getEligibleChildIds(award)).toEqual([])
20
+ })
21
+
22
+ test('returns empty array for undefined child_ids', () => {
23
+ const award = {}
24
+ expect(getEligibleChildIds(award)).toEqual([])
25
+ })
26
+
27
+ test('returns empty array for null child_ids', () => {
28
+ const award = { child_ids: null }
29
+ expect(getEligibleChildIds(award)).toEqual([])
30
+ })
31
+ })
32
+
33
+ })
@@ -0,0 +1,328 @@
1
+ import { buildCertificateData } from '../../src/services/awards/internal/certificate-builder'
2
+ import { mockAwardDefinitions, getAwardById } from '../mockData/award-definitions'
3
+ import { globalConfig } from '../../src/services/config'
4
+
5
+ jest.mock('../../src/services/sanity', () => ({
6
+ default: {
7
+ fetch: jest.fn()
8
+ },
9
+ fetchSanity: jest.fn()
10
+ }))
11
+
12
+ jest.mock('../../src/services/user/management', () => ({
13
+ getUserData: jest.fn()
14
+ }))
15
+
16
+ jest.mock('../../src/services/sync/repository-proxy', () => {
17
+ const mockFns = {
18
+ userAwardProgress: {
19
+ getByAwardId: jest.fn()
20
+ }
21
+ }
22
+ return { default: mockFns, ...mockFns }
23
+ })
24
+
25
+ import sanityClient, { fetchSanity } from '../../src/services/sanity'
26
+ import { getUserData } from '../../src/services/user/management'
27
+ import db from '../../src/services/sync/repository-proxy'
28
+ import { awardDefinitions } from '../../src/services/awards/internal/award-definitions'
29
+
30
+ describe('Award Certificate Display - E2E Scenarios', () => {
31
+ const testAward = getAwardById('0238b1e5-ebee-42b3-9390-91467d113575')
32
+ const awardId = testAward._id
33
+
34
+ beforeEach(async () => {
35
+ jest.clearAllMocks()
36
+
37
+ globalConfig.sessionConfig = {
38
+ userId: 12345,
39
+ token: 'test-token'
40
+ }
41
+
42
+ sanityClient.fetch = jest.fn().mockResolvedValue(mockAwardDefinitions)
43
+ fetchSanity.mockResolvedValue(mockAwardDefinitions)
44
+
45
+ db.userAwardProgress = {
46
+ getByAwardId: jest.fn()
47
+ }
48
+
49
+ getUserData.mockResolvedValue({
50
+ id: 12345,
51
+ display_name: 'John Doe',
52
+ name: 'John Doe',
53
+ email: 'john@example.com'
54
+ })
55
+
56
+ await awardDefinitions.refresh()
57
+ })
58
+
59
+ afterEach(() => {
60
+ awardDefinitions.clear()
61
+ })
62
+
63
+ describe('Scenario: Display complete certificate for earned award', () => {
64
+ beforeEach(() => {
65
+ db.userAwardProgress.getByAwardId.mockResolvedValue({
66
+ data: {
67
+ award_id: awardId,
68
+ progress_percentage: 100,
69
+ completed_at: Math.floor(Date.now() / 1000) - 86400 * 3,
70
+ completion_data: {
71
+ content_title: 'Adrian Guided Course Test',
72
+ completed_at: new Date().toISOString(),
73
+ days_user_practiced: 14,
74
+ practice_minutes: 180
75
+ },
76
+ },
77
+ })
78
+ })
79
+
80
+ test('returns complete certificate data with all fields', async () => {
81
+ const certificate = await buildCertificateData(awardId)
82
+
83
+ expect(certificate).toMatchObject({
84
+ userId: 12345,
85
+ userName: 'John Doe',
86
+ completedAt: expect.any(String),
87
+ awardId: awardId,
88
+ awardType: 'content-award',
89
+ awardTitle: testAward.name,
90
+ popupMessage: expect.any(String),
91
+ certificateMessage: expect.any(String),
92
+ ribbonImage: expect.any(String),
93
+ awardImage: testAward.award,
94
+ badgeImage: testAward.badge,
95
+ brandLogo: expect.any(String),
96
+ musoraLogo: expect.any(String),
97
+ musoraBgLogo: expect.any(String),
98
+ instructorName: testAward.instructor_name
99
+ })
100
+ })
101
+
102
+ test('includes correct user information from globalConfig and getUserData', async () => {
103
+ const certificate = await buildCertificateData(awardId)
104
+
105
+ expect(certificate.userId).toBe(12345)
106
+ expect(certificate.userName).toBe('John Doe')
107
+ })
108
+
109
+ test('includes correct award information from Sanity', async () => {
110
+ const certificate = await buildCertificateData(awardId)
111
+
112
+ expect(certificate.awardId).toBe(awardId)
113
+ expect(certificate.awardTitle).toBe('Adrian Guided Course Test Award')
114
+ expect(certificate.badgeImage).toBe(testAward.badge)
115
+ expect(certificate.awardImage).toBe(testAward.award)
116
+ expect(certificate.instructorName).toBe('Aaron Graham')
117
+ })
118
+
119
+ test('includes client-generated popup message', async () => {
120
+ const certificate = await buildCertificateData(awardId)
121
+
122
+ expect(certificate.popupMessage).toContain('Adrian Guided Course Test')
123
+ expect(certificate.popupMessage).toContain('180 minutes')
124
+ expect(certificate.popupMessage).toContain('14 days')
125
+ })
126
+
127
+ test('includes client-generated certificate message', async () => {
128
+ const certificate = await buildCertificateData(awardId)
129
+
130
+ expect(certificate.certificateMessage).toContain('180 minutes')
131
+ expect(certificate.certificateMessage).toContain('Adrian Guided Course Test')
132
+ expect(certificate.certificateMessage).toContain('Well Done!')
133
+ })
134
+
135
+ test('includes brand logo based on award brand', async () => {
136
+ const certificate = await buildCertificateData(awardId)
137
+
138
+ expect(certificate.brandLogo).toBeDefined()
139
+ expect(typeof certificate.brandLogo).toBe('string')
140
+ })
141
+
142
+ test('includes completion date from WatermelonDB', async () => {
143
+ const certificate = await buildCertificateData(awardId)
144
+
145
+ expect(certificate.completedAt).toBeDefined()
146
+ const completedDate = new Date(certificate.completedAt)
147
+ expect(completedDate).toBeInstanceOf(Date)
148
+ expect(completedDate.getTime()).toBeLessThanOrEqual(Date.now())
149
+ })
150
+ })
151
+
152
+ describe('Scenario: Certificate with custom award text', () => {
153
+ const awardWithCustomText = getAwardById('0f49cb6a-1b23-4628-968e-15df02ffad7f')
154
+
155
+ beforeEach(() => {
156
+ db.userAwardProgress.getByAwardId.mockResolvedValue({
157
+ data: {
158
+ award_id: awardWithCustomText._id,
159
+ progress_percentage: 100,
160
+ completed_at: Math.floor(Date.now() / 1000),
161
+ completion_data: {
162
+ content_title: 'Enrolling w/ Kickoff, has product GC (EC)',
163
+ completed_at: new Date().toISOString(),
164
+ days_user_practiced: 10,
165
+ practice_minutes: 200
166
+ },
167
+ },
168
+ })
169
+ })
170
+
171
+ test('includes custom text in certificate message', async () => {
172
+ const certificate = await buildCertificateData(awardWithCustomText._id)
173
+
174
+ expect(certificate.certificateMessage).toContain('Huzzah congratz')
175
+ expect(certificate.certificateMessage).toContain('200 minutes')
176
+ expect(certificate.certificateMessage).toContain('Enrolling w/ Kickoff, has product GC (EC)')
177
+ })
178
+ })
179
+
180
+ describe('Scenario: Certificate with null instructor signature', () => {
181
+ const awardWithoutSignature = getAwardById('0f49cb6a-1b23-4628-968e-15df02ffad7f')
182
+
183
+ beforeEach(() => {
184
+ db.userAwardProgress.getByAwardId.mockResolvedValue({
185
+ data: {
186
+ award_id: awardWithoutSignature._id,
187
+ progress_percentage: 100,
188
+ completed_at: Math.floor(Date.now() / 1000),
189
+ completion_data: {
190
+ content_title: 'Test Course',
191
+ completed_at: new Date().toISOString(),
192
+ days_user_practiced: 5,
193
+ practice_minutes: 100
194
+ },
195
+ },
196
+ })
197
+ })
198
+
199
+ test('handles null instructor signature gracefully', async () => {
200
+ const certificate = await buildCertificateData(awardWithoutSignature._id)
201
+
202
+ expect(certificate.instructorName).toBe('Lisa Witt')
203
+ })
204
+ })
205
+
206
+ describe('Scenario: User with no display_name falls back to name', () => {
207
+ beforeEach(() => {
208
+ getUserData.mockResolvedValue({
209
+ id: 12345,
210
+ display_name: null,
211
+ name: 'Jane Smith',
212
+ email: 'jane@example.com'
213
+ })
214
+
215
+ db.userAwardProgress.getByAwardId.mockResolvedValue({
216
+ data: {
217
+ award_id: awardId,
218
+ progress_percentage: 100,
219
+ completed_at: Math.floor(Date.now() / 1000),
220
+ completion_data: {
221
+ content_title: 'Test Course',
222
+ completed_at: new Date().toISOString(),
223
+ days_user_practiced: 7,
224
+ practice_minutes: 150
225
+ },
226
+ },
227
+ })
228
+ })
229
+
230
+ test('uses name field when display_name is null', async () => {
231
+ const certificate = await buildCertificateData(awardId)
232
+
233
+ expect(certificate.userName).toBe('Jane Smith')
234
+ })
235
+ })
236
+
237
+ describe('Scenario: User with neither display_name nor name', () => {
238
+ beforeEach(() => {
239
+ getUserData.mockResolvedValue({
240
+ id: 12345,
241
+ display_name: null,
242
+ name: null,
243
+ email: 'anonymous@example.com'
244
+ })
245
+
246
+ db.userAwardProgress.getByAwardId.mockResolvedValue({
247
+ data: {
248
+ award_id: awardId,
249
+ progress_percentage: 100,
250
+ completed_at: Math.floor(Date.now() / 1000),
251
+ completion_data: {
252
+ content_title: 'Test Course',
253
+ completed_at: new Date().toISOString(),
254
+ days_user_practiced: 3,
255
+ practice_minutes: 90
256
+ },
257
+ },
258
+ })
259
+ })
260
+
261
+ test('falls back to "User" when no name available', async () => {
262
+ const certificate = await buildCertificateData(awardId)
263
+
264
+ expect(certificate.userName).toBe('User')
265
+ })
266
+ })
267
+
268
+ describe('Scenario: Error handling', () => {
269
+ test('throws error when award definition not found', async () => {
270
+ await expect(
271
+ buildCertificateData('non-existent-award-id')
272
+ ).rejects.toThrow('Award definition not found')
273
+ })
274
+
275
+ test('throws error when completion data not found in local DB', async () => {
276
+ db.userAwardProgress.getByAwardId.mockResolvedValue({
277
+ data: null
278
+ })
279
+
280
+ await expect(
281
+ buildCertificateData(awardId)
282
+ ).rejects.toThrow('Completion data not found')
283
+ })
284
+
285
+ test('throws error when completion_data is missing', async () => {
286
+ db.userAwardProgress.getByAwardId.mockResolvedValue({
287
+ data: {
288
+ award_id: awardId,
289
+ progress_percentage: 100,
290
+ completed_at: Math.floor(Date.now() / 1000),
291
+ completion_data: null
292
+ },
293
+ })
294
+
295
+ await expect(
296
+ buildCertificateData(awardId)
297
+ ).rejects.toThrow('Completion data not found')
298
+ })
299
+ })
300
+
301
+ describe('Scenario: Learning path vs guided course message differences', () => {
302
+ const learningPathAward = getAwardById('361f3034-c6c9-45f7-bbfb-0d58dbe14411')
303
+
304
+ beforeEach(() => {
305
+ db.userAwardProgress.getByAwardId.mockResolvedValue({
306
+ data: {
307
+ award_id: learningPathAward._id,
308
+ progress_percentage: 100,
309
+ completed_at: Math.floor(Date.now() / 1000),
310
+ completion_data: {
311
+ content_title: 'Learn To Play The Drums',
312
+ completed_at: new Date().toISOString(),
313
+ days_user_practiced: 30,
314
+ practice_minutes: 600
315
+ },
316
+ },
317
+ })
318
+ })
319
+
320
+ test('generates learning path message for learning path award', async () => {
321
+ const certificate = await buildCertificateData(learningPathAward._id)
322
+
323
+ expect(certificate.popupMessage).toContain('Learn To Play The Drums')
324
+ expect(certificate.popupMessage).toContain('600 minutes')
325
+ expect(certificate.popupMessage).toContain('30 days')
326
+ })
327
+ })
328
+ })