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
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { awardManager } from '../../src/services/awards/internal/award-manager'
|
|
2
|
+
import { awardEvents } from '../../src/services/awards/internal/award-events'
|
|
3
|
+
import { mockAwardDefinitions, getAwardByContentId } from '../mockData/award-definitions'
|
|
4
|
+
import { globalConfig } from '../../src/services/config'
|
|
5
|
+
import { LocalStorageMock } from '../localStorageMock'
|
|
6
|
+
import { setupDefaultMocks, setupAwardEventListeners } from './helpers'
|
|
7
|
+
import { mockCompletionStates, mockAllCompleted, mockNoneCompleted } from './helpers/completion-mock'
|
|
8
|
+
|
|
9
|
+
jest.mock('../../src/services/sanity', () => ({
|
|
10
|
+
default: { fetch: jest.fn() },
|
|
11
|
+
fetchSanity: jest.fn()
|
|
12
|
+
}))
|
|
13
|
+
|
|
14
|
+
jest.mock('../../src/services/railcontent', () => ({
|
|
15
|
+
...jest.requireActual('../../src/services/railcontent'),
|
|
16
|
+
fetchUserPermissionsData: jest.fn().mockResolvedValue({ permissions: [108, 91, 92], isAdmin: false })
|
|
17
|
+
}))
|
|
18
|
+
|
|
19
|
+
jest.mock('../../src/services/sync/repository-proxy', () => {
|
|
20
|
+
const mockFns = {
|
|
21
|
+
contentProgress: {
|
|
22
|
+
getOneProgressByContentId: jest.fn(),
|
|
23
|
+
getSomeProgressByContentIds: jest.fn(),
|
|
24
|
+
queryOne: jest.fn(),
|
|
25
|
+
queryAll: jest.fn()
|
|
26
|
+
},
|
|
27
|
+
practices: {
|
|
28
|
+
sumPracticeMinutesForContent: jest.fn()
|
|
29
|
+
},
|
|
30
|
+
userAwardProgress: {
|
|
31
|
+
hasCompletedAward: jest.fn(),
|
|
32
|
+
recordAwardProgress: jest.fn(),
|
|
33
|
+
getByAwardId: jest.fn(),
|
|
34
|
+
completeAward: jest.fn()
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return { default: mockFns, ...mockFns }
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
import sanityClient, { fetchSanity } from '../../src/services/sanity'
|
|
41
|
+
import db from '../../src/services/sync/repository-proxy'
|
|
42
|
+
import { awardDefinitions } from '../../src/services/awards/internal/award-definitions'
|
|
43
|
+
|
|
44
|
+
describe('Award Content Exclusion Handling - E2E Scenarios', () => {
|
|
45
|
+
let listeners
|
|
46
|
+
|
|
47
|
+
beforeEach(async () => {
|
|
48
|
+
jest.clearAllMocks()
|
|
49
|
+
globalConfig.localStorage = new LocalStorageMock()
|
|
50
|
+
awardEvents.removeAllListeners()
|
|
51
|
+
|
|
52
|
+
sanityClient.fetch = jest.fn().mockResolvedValue(mockAwardDefinitions)
|
|
53
|
+
setupDefaultMocks(db, fetchSanity, { practiceMinutes: 150 })
|
|
54
|
+
|
|
55
|
+
db.userAwardProgress.completeAward = jest.fn().mockResolvedValue({ data: {}, status: 'synced' })
|
|
56
|
+
|
|
57
|
+
db.contentProgress.getSomeProgressByContentIds.mockResolvedValue({
|
|
58
|
+
data: [{ created_at: Math.floor(Date.now() / 1000) - 86400 * 7 }]
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
await awardDefinitions.refresh()
|
|
62
|
+
|
|
63
|
+
listeners = setupAwardEventListeners(awardEvents)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
afterEach(() => {
|
|
67
|
+
awardEvents.removeAllListeners()
|
|
68
|
+
awardDefinitions.clear()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
describe('Scenario: Guided course with excluded intro video (416446 - 1 eligible lesson)', () => {
|
|
72
|
+
const testAward = getAwardByContentId(416446)
|
|
73
|
+
const courseId = 416446
|
|
74
|
+
|
|
75
|
+
test('completing the single eligible lesson grants award at 100%', async () => {
|
|
76
|
+
mockAllCompleted(db)
|
|
77
|
+
|
|
78
|
+
await awardManager.onContentCompleted(courseId)
|
|
79
|
+
|
|
80
|
+
expect(db.userAwardProgress.recordAwardProgress).toHaveBeenCalledWith(
|
|
81
|
+
expect.any(String),
|
|
82
|
+
100,
|
|
83
|
+
expect.objectContaining({
|
|
84
|
+
completedAt: expect.any(Number),
|
|
85
|
+
immediate: true
|
|
86
|
+
})
|
|
87
|
+
)
|
|
88
|
+
expect(listeners.granted).toHaveBeenCalledTimes(1)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('shows 0% progress when eligible lesson not completed', async () => {
|
|
92
|
+
mockNoneCompleted(db)
|
|
93
|
+
|
|
94
|
+
await awardManager.onContentCompleted(courseId)
|
|
95
|
+
|
|
96
|
+
expect(db.userAwardProgress.recordAwardProgress).toHaveBeenCalledWith(
|
|
97
|
+
testAward._id,
|
|
98
|
+
0,
|
|
99
|
+
expect.objectContaining({
|
|
100
|
+
progressData: expect.any(Object)
|
|
101
|
+
})
|
|
102
|
+
)
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
describe('Scenario: Course with 4 eligible lessons (417049 - intro excluded)', () => {
|
|
107
|
+
const testAward = getAwardByContentId(417049)
|
|
108
|
+
const courseId = 417049
|
|
109
|
+
|
|
110
|
+
test('completing 1 of 4 lessons shows 25% progress', async () => {
|
|
111
|
+
mockCompletionStates(db, [417045])
|
|
112
|
+
|
|
113
|
+
await awardManager.onContentCompleted(courseId)
|
|
114
|
+
|
|
115
|
+
expect(db.userAwardProgress.recordAwardProgress).toHaveBeenCalledWith(
|
|
116
|
+
testAward._id,
|
|
117
|
+
25,
|
|
118
|
+
expect.objectContaining({
|
|
119
|
+
progressData: expect.any(Object)
|
|
120
|
+
})
|
|
121
|
+
)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test('completing 2 of 4 lessons shows 50% progress', async () => {
|
|
125
|
+
mockCompletionStates(db, [417045, 417046])
|
|
126
|
+
|
|
127
|
+
await awardManager.onContentCompleted(courseId)
|
|
128
|
+
|
|
129
|
+
expect(db.userAwardProgress.recordAwardProgress).toHaveBeenCalledWith(
|
|
130
|
+
testAward._id,
|
|
131
|
+
50,
|
|
132
|
+
expect.objectContaining({
|
|
133
|
+
progressData: expect.any(Object)
|
|
134
|
+
})
|
|
135
|
+
)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('must complete all 4 eligible lessons to earn award', async () => {
|
|
139
|
+
mockCompletionStates(db, [417045, 417046, 417047, 417048])
|
|
140
|
+
|
|
141
|
+
await awardManager.onContentCompleted(courseId)
|
|
142
|
+
|
|
143
|
+
expect(db.userAwardProgress.recordAwardProgress).toHaveBeenCalledWith(
|
|
144
|
+
expect.any(String),
|
|
145
|
+
100,
|
|
146
|
+
expect.objectContaining({
|
|
147
|
+
completedAt: expect.any(Number),
|
|
148
|
+
immediate: true
|
|
149
|
+
})
|
|
150
|
+
)
|
|
151
|
+
expect(listeners.granted).toHaveBeenCalledTimes(1)
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
describe('Scenario: Large course with 23 eligible lessons (416464 - intro excluded)', () => {
|
|
156
|
+
const testAward = getAwardByContentId(416464)
|
|
157
|
+
const courseId = 416464
|
|
158
|
+
const eligibleLessons = [
|
|
159
|
+
416467, 416468, 416469, 416470, 416471, 416472, 416473,
|
|
160
|
+
416474, 416475, 416476, 416477, 416478, 416479, 416480, 416481,
|
|
161
|
+
416482, 416483, 416484, 416485, 416486, 416487, 416488, 416489
|
|
162
|
+
]
|
|
163
|
+
|
|
164
|
+
test('shows 0% progress when no eligible lessons completed', async () => {
|
|
165
|
+
mockNoneCompleted(db)
|
|
166
|
+
|
|
167
|
+
await awardManager.onContentCompleted(courseId)
|
|
168
|
+
|
|
169
|
+
expect(db.userAwardProgress.recordAwardProgress).toHaveBeenCalledWith(
|
|
170
|
+
testAward._id,
|
|
171
|
+
0,
|
|
172
|
+
expect.objectContaining({
|
|
173
|
+
progressData: expect.any(Object)
|
|
174
|
+
})
|
|
175
|
+
)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
test('completing 12 of 23 lessons shows ~52% progress', async () => {
|
|
179
|
+
mockCompletionStates(db, eligibleLessons.slice(0, 12))
|
|
180
|
+
|
|
181
|
+
await awardManager.onContentCompleted(courseId)
|
|
182
|
+
|
|
183
|
+
expect(db.userAwardProgress.recordAwardProgress).toHaveBeenCalledWith(
|
|
184
|
+
testAward._id,
|
|
185
|
+
52,
|
|
186
|
+
expect.objectContaining({
|
|
187
|
+
progressData: expect.any(Object)
|
|
188
|
+
})
|
|
189
|
+
)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
test('must complete all 23 eligible lessons to earn award', async () => {
|
|
193
|
+
mockCompletionStates(db, eligibleLessons)
|
|
194
|
+
|
|
195
|
+
await awardManager.onContentCompleted(courseId)
|
|
196
|
+
|
|
197
|
+
expect(db.userAwardProgress.recordAwardProgress).toHaveBeenCalledWith(
|
|
198
|
+
expect.any(String),
|
|
199
|
+
100,
|
|
200
|
+
expect.objectContaining({
|
|
201
|
+
completedAt: expect.any(Number),
|
|
202
|
+
immediate: true
|
|
203
|
+
})
|
|
204
|
+
)
|
|
205
|
+
expect(listeners.granted).toHaveBeenCalledTimes(1)
|
|
206
|
+
})
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
describe('Scenario: Course type without excluded content (417039 - all 3 lessons count)', () => {
|
|
210
|
+
const testAward = getAwardByContentId(417039)
|
|
211
|
+
const courseId = 417039
|
|
212
|
+
|
|
213
|
+
test('first lesson counts toward progress', async () => {
|
|
214
|
+
mockCompletionStates(db, [417035])
|
|
215
|
+
|
|
216
|
+
await awardManager.onContentCompleted(courseId)
|
|
217
|
+
|
|
218
|
+
expect(db.userAwardProgress.recordAwardProgress).toHaveBeenCalledWith(
|
|
219
|
+
testAward._id,
|
|
220
|
+
33,
|
|
221
|
+
expect.objectContaining({
|
|
222
|
+
progressData: expect.any(Object)
|
|
223
|
+
})
|
|
224
|
+
)
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
test('all 3 lessons must be completed to earn award', async () => {
|
|
228
|
+
mockAllCompleted(db)
|
|
229
|
+
|
|
230
|
+
await awardManager.onContentCompleted(courseId)
|
|
231
|
+
|
|
232
|
+
expect(db.userAwardProgress.recordAwardProgress).toHaveBeenCalledWith(
|
|
233
|
+
expect.any(String),
|
|
234
|
+
100,
|
|
235
|
+
expect.objectContaining({
|
|
236
|
+
completedAt: expect.any(Number),
|
|
237
|
+
immediate: true
|
|
238
|
+
})
|
|
239
|
+
)
|
|
240
|
+
})
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
describe('Scenario: Learning path content type (417140)', () => {
|
|
244
|
+
const testAward = getAwardByContentId(417140)
|
|
245
|
+
const courseId = 417140
|
|
246
|
+
|
|
247
|
+
test('all child content counts toward progress', async () => {
|
|
248
|
+
mockCompletionStates(db, testAward.child_ids.slice(0, 11))
|
|
249
|
+
|
|
250
|
+
await awardManager.onContentCompleted(courseId)
|
|
251
|
+
|
|
252
|
+
expect(db.userAwardProgress.recordAwardProgress).toHaveBeenCalledWith(
|
|
253
|
+
testAward._id,
|
|
254
|
+
50,
|
|
255
|
+
expect.objectContaining({
|
|
256
|
+
progressData: expect.any(Object)
|
|
257
|
+
})
|
|
258
|
+
)
|
|
259
|
+
})
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
describe('Scenario: Skill pack content type (418000)', () => {
|
|
263
|
+
const courseId = 418000
|
|
264
|
+
|
|
265
|
+
test('completing all lessons grants award', async () => {
|
|
266
|
+
mockAllCompleted(db)
|
|
267
|
+
|
|
268
|
+
await awardManager.onContentCompleted(courseId)
|
|
269
|
+
|
|
270
|
+
expect(listeners.granted).toHaveBeenCalledTimes(1)
|
|
271
|
+
})
|
|
272
|
+
})
|
|
273
|
+
})
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { awardManager } from '../../src/services/awards/internal/award-manager'
|
|
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, mockNoneCompleted } from './helpers/completion-mock'
|
|
6
|
+
|
|
7
|
+
jest.mock('../../src/services/sanity', () => ({
|
|
8
|
+
default: { fetch: jest.fn() },
|
|
9
|
+
fetchSanity: jest.fn()
|
|
10
|
+
}))
|
|
11
|
+
|
|
12
|
+
jest.mock('../../src/services/sync/repository-proxy', () => {
|
|
13
|
+
const mockFns = {
|
|
14
|
+
contentProgress: {
|
|
15
|
+
getOneProgressByContentId: jest.fn(),
|
|
16
|
+
getSomeProgressByContentIds: jest.fn(),
|
|
17
|
+
queryOne: jest.fn(),
|
|
18
|
+
queryAll: jest.fn()
|
|
19
|
+
},
|
|
20
|
+
practices: {
|
|
21
|
+
sumPracticeMinutesForContent: jest.fn()
|
|
22
|
+
},
|
|
23
|
+
userAwardProgress: {
|
|
24
|
+
hasCompletedAward: jest.fn(),
|
|
25
|
+
recordAwardProgress: jest.fn(),
|
|
26
|
+
getByAwardId: jest.fn()
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return { default: mockFns, ...mockFns }
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
import sanityClient, { fetchSanity } from '../../src/services/sanity'
|
|
33
|
+
import db from '../../src/services/sync/repository-proxy'
|
|
34
|
+
import { awardDefinitions } from '../../src/services/awards/internal/award-definitions'
|
|
35
|
+
|
|
36
|
+
describe('Award Progress Calculation', () => {
|
|
37
|
+
let listeners
|
|
38
|
+
|
|
39
|
+
beforeEach(async () => {
|
|
40
|
+
jest.clearAllMocks()
|
|
41
|
+
awardEvents.removeAllListeners()
|
|
42
|
+
|
|
43
|
+
sanityClient.fetch = jest.fn().mockResolvedValue(mockAwardDefinitions)
|
|
44
|
+
setupDefaultMocks(db, fetchSanity, { practiceMinutes: 120 })
|
|
45
|
+
|
|
46
|
+
db.contentProgress.getSomeProgressByContentIds.mockResolvedValue({
|
|
47
|
+
data: [{ created_at: Math.floor(Date.now() / 1000) - 86400 * 5 }]
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
await awardDefinitions.refresh()
|
|
51
|
+
|
|
52
|
+
listeners = setupAwardEventListeners(awardEvents)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
awardEvents.removeAllListeners()
|
|
57
|
+
awardDefinitions.clear()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe('Single lesson course (content_id: 416446, child_ids: [416448])', () => {
|
|
61
|
+
const award = getAwardByContentId(416446)
|
|
62
|
+
const childId = 416448
|
|
63
|
+
|
|
64
|
+
test('grants award at 100% when the single lesson is completed', async () => {
|
|
65
|
+
mockAllCompleted(db)
|
|
66
|
+
|
|
67
|
+
await awardManager.onContentCompleted(childId)
|
|
68
|
+
|
|
69
|
+
expect(db.userAwardProgress.recordAwardProgress).toHaveBeenCalledWith(
|
|
70
|
+
award._id,
|
|
71
|
+
100,
|
|
72
|
+
expect.objectContaining({
|
|
73
|
+
completedAt: expect.any(Number),
|
|
74
|
+
completionData: expect.objectContaining({
|
|
75
|
+
content_title: award.content_title,
|
|
76
|
+
days_user_practiced: expect.any(Number),
|
|
77
|
+
practice_minutes: expect.any(Number)
|
|
78
|
+
}),
|
|
79
|
+
progressData: {
|
|
80
|
+
completedLessonIds: [childId],
|
|
81
|
+
totalLessons: 1,
|
|
82
|
+
completedCount: 1
|
|
83
|
+
},
|
|
84
|
+
immediate: true
|
|
85
|
+
})
|
|
86
|
+
)
|
|
87
|
+
expect(listeners.granted).toHaveBeenCalledTimes(1)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('records 0% progress when the lesson is not completed', async () => {
|
|
91
|
+
mockNoneCompleted(db)
|
|
92
|
+
|
|
93
|
+
await awardManager.onContentCompleted(childId)
|
|
94
|
+
|
|
95
|
+
expect(db.userAwardProgress.recordAwardProgress).toHaveBeenCalledWith(
|
|
96
|
+
award._id,
|
|
97
|
+
0,
|
|
98
|
+
expect.objectContaining({
|
|
99
|
+
progressData: {
|
|
100
|
+
completedLessonIds: [],
|
|
101
|
+
totalLessons: 1,
|
|
102
|
+
completedCount: 0
|
|
103
|
+
}
|
|
104
|
+
})
|
|
105
|
+
)
|
|
106
|
+
expect(listeners.granted).not.toHaveBeenCalled()
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
describe('10-lesson course (content_id: 416450, child_ids: 10 lessons)', () => {
|
|
111
|
+
const award = getAwardByContentId(416450)
|
|
112
|
+
const childIds = award.child_ids
|
|
113
|
+
|
|
114
|
+
test('calculates 50% progress when 5 of 10 lessons are completed', async () => {
|
|
115
|
+
mockCompletionStates(db, childIds.slice(0, 5))
|
|
116
|
+
|
|
117
|
+
await awardManager.onContentCompleted(childIds[0])
|
|
118
|
+
|
|
119
|
+
expect(db.userAwardProgress.recordAwardProgress).toHaveBeenCalledWith(
|
|
120
|
+
award._id,
|
|
121
|
+
50,
|
|
122
|
+
expect.objectContaining({
|
|
123
|
+
progressData: expect.objectContaining({
|
|
124
|
+
totalLessons: 10,
|
|
125
|
+
completedCount: 5
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test('calculates 90% progress when 9 of 10 lessons are completed', async () => {
|
|
132
|
+
mockCompletionStates(db, childIds.slice(0, 9))
|
|
133
|
+
|
|
134
|
+
await awardManager.onContentCompleted(childIds[0])
|
|
135
|
+
|
|
136
|
+
expect(db.userAwardProgress.recordAwardProgress).toHaveBeenCalledWith(
|
|
137
|
+
award._id,
|
|
138
|
+
90,
|
|
139
|
+
expect.objectContaining({
|
|
140
|
+
progressData: expect.objectContaining({
|
|
141
|
+
totalLessons: 10,
|
|
142
|
+
completedCount: 9
|
|
143
|
+
})
|
|
144
|
+
})
|
|
145
|
+
)
|
|
146
|
+
expect(listeners.granted).not.toHaveBeenCalled()
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
test('grants award when all 10 lessons are completed', async () => {
|
|
150
|
+
mockAllCompleted(db)
|
|
151
|
+
|
|
152
|
+
await awardManager.onContentCompleted(childIds[0])
|
|
153
|
+
|
|
154
|
+
expect(db.userAwardProgress.recordAwardProgress).toHaveBeenCalledWith(
|
|
155
|
+
award._id,
|
|
156
|
+
100,
|
|
157
|
+
expect.objectContaining({
|
|
158
|
+
completedAt: expect.any(Number),
|
|
159
|
+
progressData: expect.objectContaining({
|
|
160
|
+
totalLessons: 10,
|
|
161
|
+
completedCount: 10
|
|
162
|
+
}),
|
|
163
|
+
immediate: true
|
|
164
|
+
})
|
|
165
|
+
)
|
|
166
|
+
expect(listeners.granted).toHaveBeenCalledTimes(1)
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
describe('22-lesson learning path (content_id: 417140)', () => {
|
|
171
|
+
const award = getAwardByContentId(417140)
|
|
172
|
+
const childIds = award.child_ids
|
|
173
|
+
|
|
174
|
+
test('calculates correct percentage for partial completion (11/22 = 50%)', async () => {
|
|
175
|
+
mockCompletionStates(db, childIds.slice(0, 11))
|
|
176
|
+
|
|
177
|
+
await awardManager.onContentCompleted(childIds[0])
|
|
178
|
+
|
|
179
|
+
expect(db.userAwardProgress.recordAwardProgress).toHaveBeenCalledWith(
|
|
180
|
+
award._id,
|
|
181
|
+
50,
|
|
182
|
+
expect.objectContaining({
|
|
183
|
+
progressData: expect.objectContaining({
|
|
184
|
+
totalLessons: 22,
|
|
185
|
+
completedCount: 11
|
|
186
|
+
})
|
|
187
|
+
})
|
|
188
|
+
)
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
test('does not grant award when 21 of 22 lessons completed (95%)', async () => {
|
|
192
|
+
mockCompletionStates(db, childIds.slice(0, 21))
|
|
193
|
+
|
|
194
|
+
await awardManager.onContentCompleted(childIds[0])
|
|
195
|
+
|
|
196
|
+
expect(db.userAwardProgress.recordAwardProgress).toHaveBeenCalledWith(
|
|
197
|
+
award._id,
|
|
198
|
+
95,
|
|
199
|
+
expect.objectContaining({
|
|
200
|
+
progressData: expect.objectContaining({
|
|
201
|
+
totalLessons: 22,
|
|
202
|
+
completedCount: 21
|
|
203
|
+
})
|
|
204
|
+
})
|
|
205
|
+
)
|
|
206
|
+
expect(listeners.granted).not.toHaveBeenCalled()
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
test('grants award with correct popup message when all 22 lessons completed', async () => {
|
|
210
|
+
mockAllCompleted(db)
|
|
211
|
+
|
|
212
|
+
await awardManager.onContentCompleted(childIds[0])
|
|
213
|
+
|
|
214
|
+
expect(listeners.granted).toHaveBeenCalledTimes(1)
|
|
215
|
+
|
|
216
|
+
const payload = listeners.granted.mock.calls[0][0]
|
|
217
|
+
expect(payload.awardId).toBe(award._id)
|
|
218
|
+
expect(payload.popupMessage).toContain(award.content_title)
|
|
219
|
+
expect(payload.popupMessage).toContain('120 minutes')
|
|
220
|
+
expect(payload.completionData).toEqual(expect.objectContaining({
|
|
221
|
+
content_title: award.content_title,
|
|
222
|
+
practice_minutes: 120
|
|
223
|
+
}))
|
|
224
|
+
})
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
describe('Award already completed', () => {
|
|
228
|
+
const award = getAwardByContentId(416446)
|
|
229
|
+
const childId = 416448
|
|
230
|
+
|
|
231
|
+
test('skips evaluation when award is already completed', async () => {
|
|
232
|
+
db.userAwardProgress.hasCompletedAward.mockResolvedValue(true)
|
|
233
|
+
mockAllCompleted(db)
|
|
234
|
+
|
|
235
|
+
await awardManager.onContentCompleted(childId)
|
|
236
|
+
|
|
237
|
+
expect(db.userAwardProgress.recordAwardProgress).not.toHaveBeenCalled()
|
|
238
|
+
expect(listeners.granted).not.toHaveBeenCalled()
|
|
239
|
+
})
|
|
240
|
+
})
|
|
241
|
+
})
|