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.
- package/.coderabbit.yaml +0 -0
- package/.editorconfig +0 -0
- package/.github/pull_request_template.md +0 -0
- package/.github/workflows/conventional-commits.yaml +0 -0
- package/.github/workflows/docs.js.yml +0 -0
- package/.github/workflows/node.js.yml +0 -0
- package/.prettierignore +0 -0
- package/.prettierrc +0 -0
- package/CHANGELOG.md +23 -0
- package/CLAUDE.md +408 -0
- package/README.md +0 -0
- package/babel.config.cjs +10 -0
- package/jest.config.js +0 -0
- package/jsdoc.json +2 -1
- package/package.json +2 -2
- package/src/constants/award-assets.js +35 -0
- package/src/contentMetaData.js +0 -0
- package/src/filterBuilder.js +7 -2
- package/src/index.d.ts +26 -5
- package/src/index.js +26 -5
- package/src/infrastructure/http/HttpClient.ts +0 -0
- package/src/infrastructure/http/executors/FetchRequestExecutor.ts +0 -0
- package/src/infrastructure/http/index.ts +0 -0
- package/src/infrastructure/http/interfaces/HeaderProvider.ts +0 -0
- package/src/infrastructure/http/interfaces/HttpError.ts +0 -0
- package/src/infrastructure/http/interfaces/NetworkError.ts +0 -0
- package/src/infrastructure/http/interfaces/RequestExecutor.ts +0 -0
- package/src/infrastructure/http/interfaces/RequestOptions.ts +0 -0
- package/src/infrastructure/http/providers/DefaultHeaderProvider.ts +0 -0
- package/src/lib/brands.ts +0 -0
- package/src/lib/httpHelper.js +0 -0
- package/src/lib/lastUpdated.js +0 -0
- package/src/lib/sanity/query.ts +0 -0
- package/src/services/api/types.js +0 -0
- package/src/services/api/types.ts +0 -0
- package/src/services/awards/award-callbacks.js +126 -0
- package/src/services/awards/award-query.js +327 -0
- package/src/services/awards/internal/.indexignore +1 -0
- package/src/services/awards/internal/award-definitions.js +239 -0
- package/src/services/awards/internal/award-events.js +102 -0
- package/src/services/awards/internal/award-manager.js +162 -0
- package/src/services/awards/internal/certificate-builder.js +66 -0
- package/src/services/awards/internal/completion-data-generator.js +84 -0
- package/src/services/awards/internal/content-progress-observer.js +137 -0
- package/src/services/awards/internal/image-utils.js +62 -0
- package/src/services/awards/internal/message-generator.js +17 -0
- package/src/services/awards/internal/types.js +5 -0
- package/src/services/awards/types.d.ts +79 -0
- package/src/services/awards/types.js +101 -0
- package/src/services/config.js +24 -4
- package/src/services/content/artist.ts +0 -0
- package/src/services/content/content.ts +0 -0
- package/src/services/content/genre.ts +0 -0
- package/src/services/content/instructor.ts +0 -0
- package/src/services/content-org/content-org.js +0 -0
- package/src/services/content-org/guided-courses.ts +0 -0
- package/src/services/content-org/learning-paths.ts +19 -15
- package/src/services/content-org/playlists-types.js +0 -0
- package/src/services/content-org/playlists.js +0 -0
- package/src/services/content.js +0 -0
- package/src/services/contentAggregator.js +0 -0
- package/src/services/contentLikes.js +0 -0
- package/src/services/contentProgress.js +2 -1
- package/src/services/dataContext.js +0 -0
- package/src/services/dateUtils.js +0 -0
- package/src/services/eventsAPI.js +0 -0
- package/src/services/forums/forums.ts +0 -0
- package/src/services/forums/posts.ts +0 -0
- package/src/services/forums/threads.ts +0 -0
- package/src/services/forums/types.ts +0 -0
- package/src/services/gamification/awards.ts +114 -83
- package/src/services/gamification/gamification.js +0 -0
- package/src/services/imageSRCBuilder.js +0 -0
- package/src/services/imageSRCVerify.js +0 -0
- package/src/services/liveTesting.ts +0 -0
- package/src/services/permissions/PermissionsAdapter.ts +0 -0
- package/src/services/permissions/PermissionsAdapterFactory.ts +0 -0
- package/src/services/permissions/PermissionsV1Adapter.ts +0 -0
- package/src/services/permissions/PermissionsV2Adapter.ts +0 -0
- package/src/services/permissions/README.md +0 -0
- package/src/services/permissions/index.ts +0 -0
- package/src/services/progress-events.js +58 -0
- package/src/services/progress-row/method-card.js +20 -5
- package/src/services/railcontent.js +0 -0
- package/src/services/recommendations.js +0 -0
- package/src/services/reporting/README.md +0 -0
- package/src/services/reporting/reporting.ts +0 -0
- package/src/services/reporting/types.ts +0 -0
- package/src/services/sanity.js +1 -1
- package/src/services/sentry/.indexignore +0 -0
- package/src/services/sentry/index.ts +0 -0
- package/src/services/sync/.indexignore +0 -0
- package/src/services/sync/adapters/factory.ts +0 -0
- package/src/services/sync/adapters/lokijs.ts +0 -0
- package/src/services/sync/adapters/sqlite.ts +0 -0
- package/src/services/sync/concurrency-safety.ts +0 -0
- package/src/services/sync/context/index.ts +0 -0
- package/src/services/sync/context/providers/base.ts +0 -0
- package/src/services/sync/context/providers/connectivity.ts +0 -0
- package/src/services/sync/context/providers/durability.ts +0 -0
- package/src/services/sync/context/providers/index.ts +0 -0
- package/src/services/sync/context/providers/session.ts +0 -0
- package/src/services/sync/context/providers/tabs.ts +0 -0
- package/src/services/sync/context/providers/visibility.ts +0 -0
- package/src/services/sync/database/factory.ts +0 -0
- package/src/services/sync/errors/boundary.ts +0 -0
- package/src/services/sync/errors/index.ts +0 -0
- package/src/services/sync/fetch.ts +10 -2
- package/src/services/sync/index.ts +0 -0
- package/src/services/sync/manager.ts +6 -0
- package/src/services/sync/models/Base.ts +0 -0
- package/src/services/sync/models/ContentLike.ts +0 -0
- package/src/services/sync/models/ContentProgress.ts +5 -6
- package/src/services/sync/models/Practice.ts +0 -0
- package/src/services/sync/models/PracticeDayNote.ts +0 -0
- package/src/services/sync/models/UserAwardProgress.ts +55 -0
- package/src/services/sync/models/index.ts +1 -0
- package/src/services/sync/repositories/base.ts +0 -0
- package/src/services/sync/repositories/content-likes.ts +0 -0
- package/src/services/sync/repositories/content-progress.ts +47 -25
- package/src/services/sync/repositories/index.ts +1 -0
- package/src/services/sync/repositories/practice-day-notes.ts +0 -0
- package/src/services/sync/repositories/practices.ts +16 -1
- package/src/services/sync/repositories/user-award-progress.ts +133 -0
- package/src/services/sync/repository-proxy.ts +6 -0
- package/src/services/sync/resolver.ts +0 -0
- package/src/services/sync/retry.ts +12 -11
- package/src/services/sync/run-scope.ts +0 -0
- package/src/services/sync/schema/index.ts +18 -3
- package/src/services/sync/serializers/index.ts +0 -0
- package/src/services/sync/serializers/model.ts +0 -0
- package/src/services/sync/serializers/raw.ts +0 -0
- package/src/services/sync/store/index.ts +53 -8
- package/src/services/sync/store/push-coalescer.ts +3 -3
- package/src/services/sync/store-configs.ts +7 -1
- package/src/services/sync/strategies/base.ts +0 -0
- package/src/services/sync/strategies/index.ts +0 -0
- package/src/services/sync/strategies/initial.ts +0 -0
- package/src/services/sync/strategies/polling.ts +0 -0
- package/src/services/sync/telemetry/index.ts +0 -0
- package/src/services/sync/telemetry/sampling.ts +0 -0
- package/src/services/sync/utils/event-emitter.ts +0 -0
- package/src/services/sync/utils/index.ts +0 -0
- package/src/services/sync/utils/throttle.ts +0 -0
- package/src/services/sync/utils/timers.ts +0 -0
- package/src/services/types.js +0 -0
- package/src/services/user/account.ts +0 -0
- package/src/services/user/chat.js +0 -0
- package/src/services/user/interests.js +0 -0
- package/src/services/user/management.js +0 -0
- package/src/services/user/memberships.ts +0 -0
- package/src/services/user/notifications.js +0 -0
- package/src/services/user/onboarding.ts +0 -0
- package/src/services/user/payments.ts +0 -0
- package/src/services/user/permissions.js +0 -0
- package/src/services/user/profile.js +0 -0
- package/src/services/user/sessions.js +0 -0
- package/src/services/user/types.d.ts +0 -0
- package/src/services/user/types.js +0 -0
- package/src/services/user/user-management-system.js +0 -0
- package/src/services/userActivity.js +0 -1
- package/test/HttpClient.test.js +6 -6
- package/test/awards/award-alacarte-observer.test.js +196 -0
- package/test/awards/award-auto-refresh.test.js +83 -0
- package/test/awards/award-calculations.test.js +33 -0
- package/test/awards/award-certificate-display.test.js +328 -0
- package/test/awards/award-collection-edge-cases.test.js +210 -0
- package/test/awards/award-collection-filtering.test.js +285 -0
- package/test/awards/award-completion-flow.test.js +213 -0
- package/test/awards/award-exclusion-handling.test.js +273 -0
- package/test/awards/award-multi-lesson.test.js +241 -0
- package/test/awards/award-observer-integration.test.js +325 -0
- package/test/awards/award-query-messages.test.js +438 -0
- package/test/awards/award-user-collection.test.js +412 -0
- package/test/awards/duplicate-prevention.test.js +118 -0
- package/test/awards/helpers/completion-mock.js +54 -0
- package/test/awards/helpers/index.js +3 -0
- package/test/awards/helpers/mock-setup.js +69 -0
- package/test/awards/helpers/progress-emitter.js +39 -0
- package/test/awards/message-generator.test.js +162 -0
- package/test/content.test.js +0 -0
- package/test/contentLikes.test.js +0 -0
- package/test/contentProgress.test.js +0 -0
- package/test/dataContext.test.js +0 -0
- package/test/forum.test.js +0 -0
- package/test/imageSRCBuilder.test.js +0 -0
- package/test/imageSRCVerify.test.js +0 -0
- package/test/initializeTests.js +6 -0
- package/test/learningPaths.test.js +0 -0
- package/test/lib/lastUpdated.test.js +0 -0
- package/test/live/contentProgressLive.test.js +0 -0
- package/test/live/railcontentLive.test.js +0 -0
- package/test/localStorageMock.js +0 -0
- package/test/log.js +0 -0
- package/test/mockData/award-definitions.js +171 -0
- package/test/mockData/mockData_fetchByRailContentIds_one_content.json +0 -0
- package/test/mockData/mockData_progress_content.json +0 -0
- package/test/mockData/mockData_sanity_progress_content.json +0 -0
- package/test/mockData/mockData_user_practices.json +0 -0
- package/test/notifications.test.js +0 -0
- package/test/progressRows.test.js +0 -0
- package/test/sanityQueryService.test.js +0 -0
- package/test/streakMessage.test.js +0 -0
- package/test/sync/models/award-database-integration.test.js +519 -0
- package/test/user/permissions.test.js +0 -0
- package/test/userActivity.test.js +0 -0
- package/tools/generate-index.cjs +9 -0
- package/.claude/settings.local.json +0 -14
- package/.yarnrc.yml +0 -1
- package/test/reporting.test.js +0 -132
package/test/HttpClient.test.js
CHANGED
|
@@ -65,7 +65,7 @@ describe('HttpClient', () => {
|
|
|
65
65
|
expect(mockRequestExecutor.execute).toHaveBeenCalledWith(
|
|
66
66
|
`${baseUrl}${url}`,
|
|
67
67
|
expect.objectContaining({
|
|
68
|
-
method: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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
|
+
})
|