musora-content-services 2.161.3 → 2.162.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/.claude/settings.local.json +7 -20
- package/CHANGELOG.md +9 -0
- package/package.json +1 -1
- package/src/index.d.ts +7 -0
- package/src/index.js +7 -0
- package/src/services/content-org/learning-paths.ts +28 -1
- package/src/services/search.ts +19 -0
- package/test/integration/learning-paths.test.ts +854 -0
- package/test/unit/search.test.ts +44 -0
|
@@ -1,25 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"permissions": {
|
|
3
3
|
"allow": [
|
|
4
|
-
"Bash(
|
|
5
|
-
"Bash(
|
|
6
|
-
"
|
|
7
|
-
"Bash(counselors ls *)",
|
|
8
|
-
"Bash(counselors groups *)",
|
|
9
|
-
"Bash(counselors run *)",
|
|
4
|
+
"Bash(rg:*)",
|
|
5
|
+
"Bash(npm run lint:*)",
|
|
6
|
+
"Bash(ls:*)",
|
|
10
7
|
"Bash(npm test *)",
|
|
11
|
-
"Bash(
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
"Bash(cat /home/alesevero/railenvironment/applications/musora-content-services/AGENTS.md)",
|
|
16
|
-
"Bash(echo \"no AGENTS.md\")",
|
|
17
|
-
"Bash(echo \"exit=$?\")",
|
|
18
|
-
"Bash(git checkout *)",
|
|
19
|
-
"Skill(pr)",
|
|
20
|
-
"Skill(create-decision)"
|
|
21
|
-
]
|
|
22
|
-
},
|
|
23
|
-
"effortLevel": "medium",
|
|
24
|
-
"model": "sonnet"
|
|
8
|
+
"Bash(npx jest *)"
|
|
9
|
+
],
|
|
10
|
+
"deny": []
|
|
11
|
+
}
|
|
25
12
|
}
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
|
|
4
4
|
|
|
5
|
+
## [2.162.0](https://github.com/railroadmedia/musora-content-services/compare/v2.161.4...v2.162.0) (2026-06-03)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* add helper for FE/MA method progress card (free method) ([#987](https://github.com/railroadmedia/musora-content-services/issues/987)) ([ce8e491](https://github.com/railroadmedia/musora-content-services/commit/ce8e49128d54ec4c3b36261eb0e550b152c3041a))
|
|
11
|
+
|
|
12
|
+
### [2.161.4](https://github.com/railroadmedia/musora-content-services/compare/v2.161.3...v2.161.4) (2026-06-02)
|
|
13
|
+
|
|
5
14
|
### [2.161.3](https://github.com/railroadmedia/musora-content-services/compare/v2.161.2...v2.161.3) (2026-06-02)
|
|
6
15
|
|
|
7
16
|
|
package/package.json
CHANGED
package/src/index.d.ts
CHANGED
|
@@ -57,6 +57,7 @@ import {
|
|
|
57
57
|
getEnrichedLearningPath,
|
|
58
58
|
getEnrichedLearningPaths,
|
|
59
59
|
getLearningPathLessonsByIds,
|
|
60
|
+
isNextLessonLocked,
|
|
60
61
|
mapContentToParent,
|
|
61
62
|
resetAllLearningPaths,
|
|
62
63
|
startLearningPath,
|
|
@@ -362,6 +363,10 @@ import {
|
|
|
362
363
|
jumpToContinueContent
|
|
363
364
|
} from './services/sanity.js';
|
|
364
365
|
|
|
366
|
+
import {
|
|
367
|
+
searchAlgolia
|
|
368
|
+
} from './services/search.ts';
|
|
369
|
+
|
|
365
370
|
import {
|
|
366
371
|
clearState
|
|
367
372
|
} from './services/state.ts';
|
|
@@ -734,6 +739,7 @@ declare module 'musora-content-services' {
|
|
|
734
739
|
isContentLiked,
|
|
735
740
|
isContentLikedByIds,
|
|
736
741
|
isNextDay,
|
|
742
|
+
isNextLessonLocked,
|
|
737
743
|
isSameDate,
|
|
738
744
|
isUserFreeTier,
|
|
739
745
|
isUsernameAvailable,
|
|
@@ -793,6 +799,7 @@ declare module 'musora-content-services' {
|
|
|
793
799
|
restoreUserActivity,
|
|
794
800
|
restoreUserPractice,
|
|
795
801
|
search,
|
|
802
|
+
searchAlgolia,
|
|
796
803
|
sendAccountSetupEmail,
|
|
797
804
|
sendPasswordResetEmail,
|
|
798
805
|
setStudentViewForUser,
|
package/src/index.js
CHANGED
|
@@ -61,6 +61,7 @@ import {
|
|
|
61
61
|
getEnrichedLearningPath,
|
|
62
62
|
getEnrichedLearningPaths,
|
|
63
63
|
getLearningPathLessonsByIds,
|
|
64
|
+
isNextLessonLocked,
|
|
64
65
|
mapContentToParent,
|
|
65
66
|
resetAllLearningPaths,
|
|
66
67
|
startLearningPath,
|
|
@@ -366,6 +367,10 @@ import {
|
|
|
366
367
|
jumpToContinueContent
|
|
367
368
|
} from './services/sanity.js';
|
|
368
369
|
|
|
370
|
+
import {
|
|
371
|
+
searchAlgolia
|
|
372
|
+
} from './services/search.ts';
|
|
373
|
+
|
|
369
374
|
import {
|
|
370
375
|
clearState
|
|
371
376
|
} from './services/state.ts';
|
|
@@ -733,6 +738,7 @@ export {
|
|
|
733
738
|
isContentLiked,
|
|
734
739
|
isContentLikedByIds,
|
|
735
740
|
isNextDay,
|
|
741
|
+
isNextLessonLocked,
|
|
736
742
|
isSameDate,
|
|
737
743
|
isUserFreeTier,
|
|
738
744
|
isUsernameAvailable,
|
|
@@ -792,6 +798,7 @@ export {
|
|
|
792
798
|
restoreUserActivity,
|
|
793
799
|
restoreUserPractice,
|
|
794
800
|
search,
|
|
801
|
+
searchAlgolia,
|
|
795
802
|
sendAccountSetupEmail,
|
|
796
803
|
sendPasswordResetEmail,
|
|
797
804
|
setStudentViewForUser,
|
|
@@ -548,7 +548,7 @@ export async function completeLearningPathIntroVideo(
|
|
|
548
548
|
let lateMethodSetup = false
|
|
549
549
|
// check if the method intro was watched elsewhere; then we have to give user active path for this brand.
|
|
550
550
|
if (anyIntroComplete && !activePath) {
|
|
551
|
-
completeMethodIntroVideo(null, brand)
|
|
551
|
+
await completeMethodIntroVideo(null, brand)
|
|
552
552
|
lateMethodSetup = true
|
|
553
553
|
}
|
|
554
554
|
|
|
@@ -663,3 +663,30 @@ export async function mapLearningPathParentsTo(objects: any[], fieldsToMap?: {
|
|
|
663
663
|
})
|
|
664
664
|
})
|
|
665
665
|
}
|
|
666
|
+
|
|
667
|
+
export function isNextLessonLocked(learningPath: fetchLearningPathLessonsResponse): boolean {
|
|
668
|
+
const allLearningPathDailies = [
|
|
669
|
+
...(learningPath?.previous_learning_path_dailies ?? []),
|
|
670
|
+
...(learningPath?.learning_path_dailies ?? []),
|
|
671
|
+
...(learningPath?.next_learning_path_dailies ?? []),
|
|
672
|
+
]
|
|
673
|
+
|
|
674
|
+
if (allLearningPathDailies.length === 0) return false
|
|
675
|
+
|
|
676
|
+
const allDailiesCompleted = allLearningPathDailies.every(
|
|
677
|
+
(lesson) => lesson?.progressStatus === 'completed'
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
if (allDailiesCompleted) {
|
|
681
|
+
const nextLesson = learningPath?.upcoming_lessons?.[0]
|
|
682
|
+
return nextLesson?.need_access === true
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const accessibleDailies = allLearningPathDailies.filter(
|
|
686
|
+
(lesson) => lesson?.need_access === false
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
if (accessibleDailies.length === allLearningPathDailies.length) return false
|
|
690
|
+
|
|
691
|
+
return accessibleDailies.every((lesson) => lesson.progressStatus === 'completed')
|
|
692
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { POST } from '../infrastructure/http/HttpClient'
|
|
2
|
+
|
|
3
|
+
export interface AlgoliaSearchRequest {
|
|
4
|
+
query?: string
|
|
5
|
+
hitsPerPage?: number
|
|
6
|
+
page?: number
|
|
7
|
+
[key: string]: unknown
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface AlgoliaSearchResponse {
|
|
11
|
+
// Shape varies by index configuration and query — MPB passes Algolia's response through unchanged
|
|
12
|
+
results: unknown[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function searchAlgolia(
|
|
16
|
+
requests: AlgoliaSearchRequest[]
|
|
17
|
+
): Promise<AlgoliaSearchResponse> {
|
|
18
|
+
return POST('/api/content/v1/search', { requests }) as Promise<AlgoliaSearchResponse>
|
|
19
|
+
}
|
|
@@ -0,0 +1,854 @@
|
|
|
1
|
+
import { initializeTestDB } from './initializeTestDB'
|
|
2
|
+
import { COLLECTION_TYPE, STATE } from '../../src/services/sync/models/ContentProgress'
|
|
3
|
+
import { LEARNING_PATH_LESSON } from '../../src/contentTypeConfig'
|
|
4
|
+
import db from '../../src/services/sync/repository-proxy'
|
|
5
|
+
import { contentStatusCompleted, getProgressState } from '../../src/services/contentProgress.js'
|
|
6
|
+
|
|
7
|
+
jest.mock('../../src/infrastructure/http/HttpClient.ts', () => ({
|
|
8
|
+
__esModule: true,
|
|
9
|
+
GET: jest.fn(),
|
|
10
|
+
PUT: jest.fn(),
|
|
11
|
+
POST: jest.fn(),
|
|
12
|
+
PATCH: jest.fn(),
|
|
13
|
+
DELETE: jest.fn(),
|
|
14
|
+
HttpClient: jest.fn(),
|
|
15
|
+
}))
|
|
16
|
+
|
|
17
|
+
jest.mock('../../src/services/sanity.js', () => ({
|
|
18
|
+
__esModule: true,
|
|
19
|
+
fetchByRailContentId: jest.fn(),
|
|
20
|
+
fetchByRailContentIds: jest.fn(),
|
|
21
|
+
fetchMethodV2Structure: jest.fn(),
|
|
22
|
+
fetchParentChildRelationshipsFor: jest.fn(),
|
|
23
|
+
hasAnyMethodV2IntroCompleted: jest.fn(),
|
|
24
|
+
devFetchAllLearningPathsAndIntroVideoIdsForDelete: jest.fn(),
|
|
25
|
+
getHierarchy: jest.fn((contentId: number) => Promise.resolve({
|
|
26
|
+
topLevelId: contentId,
|
|
27
|
+
parents: {},
|
|
28
|
+
children: {},
|
|
29
|
+
metadata: { [contentId]: { brand: 'drumeo', type: 'lesson', parent_id: 0 } },
|
|
30
|
+
})),
|
|
31
|
+
getHierarchies: jest.fn((contentIds: number[] = []) => Promise.resolve(
|
|
32
|
+
Object.fromEntries(contentIds.map(id => [id, {
|
|
33
|
+
topLevelId: id,
|
|
34
|
+
parents: {},
|
|
35
|
+
children: {},
|
|
36
|
+
metadata: { [id]: { brand: 'drumeo', type: 'lesson', parent_id: 0 } },
|
|
37
|
+
}])),
|
|
38
|
+
)),
|
|
39
|
+
getSanityDate: jest.fn((date: Date) => date.toISOString()),
|
|
40
|
+
}))
|
|
41
|
+
|
|
42
|
+
jest.mock('../../src/services/railcontent.js', () => ({
|
|
43
|
+
__esModule: true,
|
|
44
|
+
fetchLikeCount: jest.fn().mockResolvedValue(0),
|
|
45
|
+
fetchUserPermissionsData: jest.fn().mockResolvedValue({ permissions: [], isAdmin: false }),
|
|
46
|
+
}))
|
|
47
|
+
|
|
48
|
+
jest.mock('../../src/services/awards/award-query.js', () => ({
|
|
49
|
+
__esModule: true,
|
|
50
|
+
getContentAwardsByIds: jest.fn((ids: number[] = []) => Promise.resolve(
|
|
51
|
+
Object.fromEntries(ids.map(id => [id, { awards: [] }])),
|
|
52
|
+
)),
|
|
53
|
+
}))
|
|
54
|
+
|
|
55
|
+
jest.mock('../../src/services/awards/internal/content-progress-observer', () => ({
|
|
56
|
+
contentProgressObserver: {
|
|
57
|
+
start: jest.fn().mockResolvedValue(undefined),
|
|
58
|
+
stop: jest.fn(),
|
|
59
|
+
},
|
|
60
|
+
}))
|
|
61
|
+
|
|
62
|
+
jest.mock('../../src/services/progress-events', () => ({
|
|
63
|
+
emitProgressSaved: jest.fn(),
|
|
64
|
+
}))
|
|
65
|
+
|
|
66
|
+
jest.mock('../../src/services/userActivity', () => ({
|
|
67
|
+
trackUserPractice: jest.fn().mockResolvedValue(undefined),
|
|
68
|
+
}))
|
|
69
|
+
|
|
70
|
+
const HttpClient = require('../../src/infrastructure/http/HttpClient.ts')
|
|
71
|
+
const sanity = require('../../src/services/sanity.js')
|
|
72
|
+
|
|
73
|
+
const {
|
|
74
|
+
mapContentToParent,
|
|
75
|
+
isNextLessonLocked,
|
|
76
|
+
getDailySession,
|
|
77
|
+
updateDailySession,
|
|
78
|
+
getActivePath,
|
|
79
|
+
startLearningPath,
|
|
80
|
+
getEnrichedLearningPath,
|
|
81
|
+
getEnrichedLearningPaths,
|
|
82
|
+
getLearningPathLessonsByIds,
|
|
83
|
+
fetchLearningPathProgressCheckLessons,
|
|
84
|
+
fetchLearningPathLessons,
|
|
85
|
+
resetAllLearningPaths,
|
|
86
|
+
completeMethodIntroVideo,
|
|
87
|
+
completeLearningPathIntroVideo,
|
|
88
|
+
onLearningPathCompletedActions,
|
|
89
|
+
mapLearningPathParentsTo,
|
|
90
|
+
mapContentsThatWereLastProgressedFromMethod,
|
|
91
|
+
} = require('../../src/services/content-org/learning-paths.ts')
|
|
92
|
+
|
|
93
|
+
const ctx = initializeTestDB()
|
|
94
|
+
|
|
95
|
+
const lpType = COLLECTION_TYPE.LEARNING_PATH
|
|
96
|
+
|
|
97
|
+
type ApiResponses = {
|
|
98
|
+
activePath?: any
|
|
99
|
+
dailySession?: any
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function setApiResponses(r: ApiResponses) {
|
|
103
|
+
HttpClient.GET.mockImplementation((url: string) => {
|
|
104
|
+
if (url.includes('/active-path/get')) return Promise.resolve(r.activePath ?? null)
|
|
105
|
+
if (url.includes('/daily-session')) return Promise.resolve(r.dailySession ?? null)
|
|
106
|
+
return Promise.resolve(null)
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
beforeEach(() => {
|
|
111
|
+
HttpClient.GET.mockReset()
|
|
112
|
+
HttpClient.POST.mockReset()
|
|
113
|
+
sanity.fetchByRailContentId.mockReset()
|
|
114
|
+
sanity.fetchByRailContentIds.mockReset()
|
|
115
|
+
sanity.fetchMethodV2Structure.mockReset()
|
|
116
|
+
sanity.fetchParentChildRelationshipsFor.mockReset()
|
|
117
|
+
sanity.hasAnyMethodV2IntroCompleted.mockReset()
|
|
118
|
+
sanity.devFetchAllLearningPathsAndIntroVideoIdsForDelete.mockReset()
|
|
119
|
+
|
|
120
|
+
HttpClient.POST.mockResolvedValue(null)
|
|
121
|
+
setApiResponses({
|
|
122
|
+
activePath: { active_learning_path_id: 0 },
|
|
123
|
+
dailySession: { active_learning_path_id: 0, daily_session: [] },
|
|
124
|
+
})
|
|
125
|
+
sanity.fetchByRailContentId.mockResolvedValue(false)
|
|
126
|
+
sanity.fetchByRailContentIds.mockResolvedValue([])
|
|
127
|
+
sanity.fetchMethodV2Structure.mockResolvedValue({ learning_paths: [] })
|
|
128
|
+
sanity.fetchParentChildRelationshipsFor.mockResolvedValue([])
|
|
129
|
+
sanity.hasAnyMethodV2IntroCompleted.mockResolvedValue(false)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
function makeLp(id: number, children: Array<{ id: number; type?: string }> = [], extras: any = {}) {
|
|
133
|
+
return {
|
|
134
|
+
id,
|
|
135
|
+
type: lpType,
|
|
136
|
+
brand: 'drumeo',
|
|
137
|
+
children: children.map(c => ({ id: c.id, type: c.type ?? 'lesson' })),
|
|
138
|
+
...extras,
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
describe('mapContentToParent', () => {
|
|
143
|
+
test('returns null when null', () => {
|
|
144
|
+
expect(mapContentToParent(null)).toBeNull()
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
test('returns empty array unchanged', () => {
|
|
148
|
+
expect(mapContentToParent([])).toEqual([])
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
test('maps single object', () => {
|
|
152
|
+
const result = mapContentToParent(
|
|
153
|
+
{ id: 1, type: 'foo' },
|
|
154
|
+
{ lessonType: 'bar', parentContentId: 99 },
|
|
155
|
+
)
|
|
156
|
+
expect(result).toEqual({ id: 1, type: 'bar', parent_id: 99 })
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
test('maps array', () => {
|
|
160
|
+
const result = mapContentToParent(
|
|
161
|
+
[{ id: 1 }, { id: 2 }],
|
|
162
|
+
{ lessonType: 'x', parentContentId: 7 },
|
|
163
|
+
)
|
|
164
|
+
expect(result).toEqual([
|
|
165
|
+
{ id: 1, type: 'x', parent_id: 7 },
|
|
166
|
+
{ id: 2, type: 'x', parent_id: 7 },
|
|
167
|
+
])
|
|
168
|
+
})
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
describe('isNextLessonLocked', () => {
|
|
172
|
+
test('false when no dailies', () => {
|
|
173
|
+
expect(isNextLessonLocked({} as any)).toBe(false)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
test('true when all dailies completed and next lesson needs access', () => {
|
|
177
|
+
expect(isNextLessonLocked({
|
|
178
|
+
learning_path_dailies: [{ progressStatus: 'completed', need_access: true }],
|
|
179
|
+
upcoming_lessons: [{ need_access: true }],
|
|
180
|
+
} as any)).toBe(true)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test('false when all dailies completed and next lesson accessible', () => {
|
|
184
|
+
expect(isNextLessonLocked({
|
|
185
|
+
learning_path_dailies: [{ progressStatus: 'completed', need_access: false }],
|
|
186
|
+
upcoming_lessons: [{ need_access: false }],
|
|
187
|
+
} as any)).toBe(false)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
test('false when all dailies accessible', () => {
|
|
191
|
+
expect(isNextLessonLocked({
|
|
192
|
+
learning_path_dailies: [
|
|
193
|
+
{ progressStatus: 'started', need_access: false },
|
|
194
|
+
{ progressStatus: 'started', need_access: false },
|
|
195
|
+
{ progressStatus: 'started', need_access: false },
|
|
196
|
+
],
|
|
197
|
+
} as any)).toBe(false)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
test('false when a locked daily exists but its not next', () => {
|
|
201
|
+
expect(isNextLessonLocked({
|
|
202
|
+
learning_path_dailies: [
|
|
203
|
+
{ progressStatus: 'completed', need_access: false },
|
|
204
|
+
{ progressStatus: 'started', need_access: false },
|
|
205
|
+
{ progressStatus: 'started', need_access: true },
|
|
206
|
+
],
|
|
207
|
+
} as any)).toBe(false)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
test('true when all remaining dailies are locked', () => {
|
|
211
|
+
expect(isNextLessonLocked({
|
|
212
|
+
learning_path_dailies: [
|
|
213
|
+
{ progressStatus: 'completed', need_access: false },
|
|
214
|
+
{ progressStatus: 'completed', need_access: false },
|
|
215
|
+
{ progressStatus: 'started', need_access: true },
|
|
216
|
+
],
|
|
217
|
+
} as any)).toBe(true)
|
|
218
|
+
})
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
describe('getDailySession', () => {
|
|
222
|
+
test('returns response when present', async () => {
|
|
223
|
+
const resp = { active_learning_path_id: 5, daily_session: [] }
|
|
224
|
+
setApiResponses({ dailySession: resp })
|
|
225
|
+
const result = await getDailySession('drumeo', new Date('2026-01-01T10:00:00Z'), true)
|
|
226
|
+
expect(result).toEqual(resp)
|
|
227
|
+
expect(HttpClient.GET.mock.calls[0][0]).toContain('/daily-session/get?brand=drumeo')
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
test('falls back to updateDailySession when empty', async () => {
|
|
231
|
+
setApiResponses({ dailySession: '' })
|
|
232
|
+
const created = { active_learning_path_id: 9, daily_session: [] }
|
|
233
|
+
HttpClient.POST.mockResolvedValueOnce(created)
|
|
234
|
+
const result = await getDailySession('pianote', new Date('2026-01-01T10:00:00Z'), true)
|
|
235
|
+
expect(result).toEqual(created)
|
|
236
|
+
expect(HttpClient.POST).toHaveBeenCalledTimes(1)
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
test('concurrent calls share single GET (cached promise reuse)', async () => {
|
|
240
|
+
const resp = { active_learning_path_id: 5, daily_session: [] }
|
|
241
|
+
setApiResponses({ dailySession: resp })
|
|
242
|
+
const [a, b] = await Promise.all([
|
|
243
|
+
getDailySession('drumeo', new Date('2026-01-01T10:00:00Z')),
|
|
244
|
+
getDailySession('drumeo', new Date('2026-01-01T10:00:00Z')),
|
|
245
|
+
])
|
|
246
|
+
expect(a).toEqual(resp)
|
|
247
|
+
expect(b).toEqual(resp)
|
|
248
|
+
expect(HttpClient.GET).toHaveBeenCalledTimes(1)
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
test('returns null and logs when GET throws', async () => {
|
|
252
|
+
const errSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
|
|
253
|
+
HttpClient.GET.mockImplementationOnce(() => Promise.reject(new Error('boom')))
|
|
254
|
+
const result = await getDailySession('drumeo', new Date('2026-01-01T10:00:00Z'), true)
|
|
255
|
+
expect(result).toBeNull()
|
|
256
|
+
expect(errSpy).toHaveBeenCalled()
|
|
257
|
+
errSpy.mockRestore()
|
|
258
|
+
})
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
describe('updateDailySession', () => {
|
|
262
|
+
test('posts and returns response', async () => {
|
|
263
|
+
const resp = { active_learning_path_id: 7, daily_session: [] }
|
|
264
|
+
HttpClient.POST.mockResolvedValueOnce(resp)
|
|
265
|
+
setApiResponses({ dailySession: resp })
|
|
266
|
+
const result = await updateDailySession('drumeo', new Date('2026-01-01T10:00:00Z'), true)
|
|
267
|
+
expect(result).toEqual(resp)
|
|
268
|
+
const [url, body] = HttpClient.POST.mock.calls[0]
|
|
269
|
+
expect(url).toContain('/daily-session/create')
|
|
270
|
+
expect(body.brand).toBe('drumeo')
|
|
271
|
+
expect(body.keepFirstLearningPath).toBe(true)
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
test('returns null on empty-string response', async () => {
|
|
275
|
+
HttpClient.POST.mockResolvedValueOnce('')
|
|
276
|
+
setApiResponses({ dailySession: '' })
|
|
277
|
+
const result = await updateDailySession('drumeo', new Date('2026-01-01T10:00:00Z'))
|
|
278
|
+
expect(result).toBeNull()
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
test('returns null on POST error', async () => {
|
|
282
|
+
HttpClient.POST.mockRejectedValueOnce(new Error('boom'))
|
|
283
|
+
const result = await updateDailySession('drumeo', new Date('2026-01-01T10:00:00Z'))
|
|
284
|
+
expect(result).toBeNull()
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
test('defaults keepFirstLearningPath to false', async () => {
|
|
288
|
+
HttpClient.POST.mockResolvedValueOnce({ active_learning_path_id: 1, daily_session: [] })
|
|
289
|
+
await updateDailySession('drumeo', new Date('2026-01-01T10:00:00Z'))
|
|
290
|
+
expect(HttpClient.POST.mock.calls[0][1].keepFirstLearningPath).toBe(false)
|
|
291
|
+
})
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
describe('getActivePath', () => {
|
|
295
|
+
test('returns response', async () => {
|
|
296
|
+
const resp = { user_id: 1, brand: 'drumeo', active_learning_path_id: 42 }
|
|
297
|
+
setApiResponses({ activePath: resp })
|
|
298
|
+
const result = await getActivePath('drumeo', true)
|
|
299
|
+
expect(result).toEqual(resp)
|
|
300
|
+
expect(HttpClient.GET.mock.calls[0][0]).toContain('/active-path/get?brand=drumeo')
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
test('concurrent calls share single GET (cached promise reuse)', async () => {
|
|
304
|
+
const resp = { user_id: 1, brand: 'drumeo', active_learning_path_id: 7 }
|
|
305
|
+
setApiResponses({ activePath: resp })
|
|
306
|
+
const [a, b] = await Promise.all([getActivePath('drumeo'), getActivePath('drumeo')])
|
|
307
|
+
expect(a).toEqual(resp)
|
|
308
|
+
expect(b).toEqual(resp)
|
|
309
|
+
expect(HttpClient.GET).toHaveBeenCalledTimes(1)
|
|
310
|
+
})
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
describe('startLearningPath', () => {
|
|
314
|
+
test('posts and triggers GET refresh', async () => {
|
|
315
|
+
const resp = { user_id: 1, brand: 'drumeo', active_learning_path_id: 11 }
|
|
316
|
+
HttpClient.POST.mockResolvedValueOnce(resp)
|
|
317
|
+
setApiResponses({ activePath: resp })
|
|
318
|
+
const result = await startLearningPath('drumeo', 11)
|
|
319
|
+
expect(result).toEqual(resp)
|
|
320
|
+
const [url, body] = HttpClient.POST.mock.calls[0]
|
|
321
|
+
expect(url).toContain('/active-path/set')
|
|
322
|
+
expect(body).toEqual({ brand: 'drumeo', learning_path_id: 11 })
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
test('does not trigger GET refresh when POST returns falsy', async () => {
|
|
326
|
+
HttpClient.POST.mockResolvedValueOnce(null)
|
|
327
|
+
await startLearningPath('drumeo', 11)
|
|
328
|
+
const getCalls = HttpClient.GET.mock.calls.filter((c: any[]) => c[0].includes('/active-path/get'))
|
|
329
|
+
expect(getCalls).toHaveLength(0)
|
|
330
|
+
})
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
describe('resetAllLearningPaths', () => {
|
|
334
|
+
test('erases progress in db and posts reset', async () => {
|
|
335
|
+
sanity.fetchByRailContentId.mockResolvedValue(makeLp(200))
|
|
336
|
+
setApiResponses({ activePath: { active_learning_path_id: 999 } })
|
|
337
|
+
await contentStatusCompleted(100)
|
|
338
|
+
await contentStatusCompleted(200, { type: lpType, id: 200 })
|
|
339
|
+
expect(await getProgressState(100)).toBe('completed')
|
|
340
|
+
expect(await getProgressState(200, { type: lpType, id: 200 })).toBe('completed')
|
|
341
|
+
|
|
342
|
+
sanity.devFetchAllLearningPathsAndIntroVideoIdsForDelete.mockResolvedValueOnce({
|
|
343
|
+
intros: [100],
|
|
344
|
+
learning_paths: [200],
|
|
345
|
+
})
|
|
346
|
+
HttpClient.POST.mockResolvedValueOnce({})
|
|
347
|
+
|
|
348
|
+
await resetAllLearningPaths()
|
|
349
|
+
|
|
350
|
+
expect(await getProgressState(100)).toBe('')
|
|
351
|
+
expect(await getProgressState(200, { type: lpType, id: 200 })).toBe('')
|
|
352
|
+
expect(HttpClient.POST.mock.calls.some((c: any[]) => c[0].endsWith('/reset'))).toBe(true)
|
|
353
|
+
})
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
describe('getEnrichedLearningPath', () => {
|
|
357
|
+
test('returns null when fetched lp is falsy', async () => {
|
|
358
|
+
sanity.fetchByRailContentId.mockResolvedValueOnce(null)
|
|
359
|
+
const result = await getEnrichedLearningPath(1)
|
|
360
|
+
expect(result).toBeFalsy()
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
test('maps children to LEARNING_PATH_LESSON with parent_id', async () => {
|
|
364
|
+
const lp = makeLp(10, [{ id: 100 }, { id: 101 }])
|
|
365
|
+
sanity.fetchByRailContentId.mockResolvedValueOnce(lp)
|
|
366
|
+
|
|
367
|
+
const result = await getEnrichedLearningPath(10)
|
|
368
|
+
expect(result.children).toHaveLength(2)
|
|
369
|
+
expect(result.children[0]).toEqual(expect.objectContaining({
|
|
370
|
+
id: 100,
|
|
371
|
+
type: LEARNING_PATH_LESSON,
|
|
372
|
+
parent_id: 10,
|
|
373
|
+
}))
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
test('reflects real progress state from db', async () => {
|
|
377
|
+
const lp = makeLp(20, [{ id: 200 }, { id: 201 }])
|
|
378
|
+
sanity.fetchByRailContentId.mockResolvedValueOnce(lp)
|
|
379
|
+
await contentStatusCompleted(200, { type: lpType, id: 20 })
|
|
380
|
+
|
|
381
|
+
const result = await getEnrichedLearningPath(20)
|
|
382
|
+
const lesson200 = result.children.find((c: any) => c.id === 200)
|
|
383
|
+
const lesson201 = result.children.find((c: any) => c.id === 201)
|
|
384
|
+
expect(lesson200.progressStatus).toBe(STATE.COMPLETED)
|
|
385
|
+
expect(lesson201.progressStatus).toBeFalsy()
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
test('adds awards to LP parent (second addContextToLearningPaths call)', async () => {
|
|
389
|
+
const lp = makeLp(30, [{ id: 300 }])
|
|
390
|
+
sanity.fetchByRailContentId.mockResolvedValueOnce(lp)
|
|
391
|
+
const result = await getEnrichedLearningPath(30)
|
|
392
|
+
expect(result.awards).toEqual([])
|
|
393
|
+
})
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
describe('getEnrichedLearningPaths', () => {
|
|
397
|
+
test('maps each lp children', async () => {
|
|
398
|
+
const paths = [makeLp(1, [{ id: 10 }]), makeLp(2, [{ id: 20 }])]
|
|
399
|
+
sanity.fetchByRailContentIds.mockResolvedValueOnce(paths)
|
|
400
|
+
const result = await getEnrichedLearningPaths([1, 2])
|
|
401
|
+
expect(result[0].children[0].parent_id).toBe(1)
|
|
402
|
+
expect(result[1].children[0].parent_id).toBe(2)
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
test('returns empty array when fetched empty', async () => {
|
|
406
|
+
sanity.fetchByRailContentIds.mockResolvedValueOnce([])
|
|
407
|
+
const result = await getEnrichedLearningPaths([])
|
|
408
|
+
expect(result).toEqual([])
|
|
409
|
+
})
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
describe('getLearningPathLessonsByIds', () => {
|
|
413
|
+
test('filters lessons by ids', async () => {
|
|
414
|
+
const lp = makeLp(1, [{ id: 1 }, { id: 2 }, { id: 3 }])
|
|
415
|
+
sanity.fetchByRailContentId.mockResolvedValueOnce(lp)
|
|
416
|
+
const result = await getLearningPathLessonsByIds([1, 3], 1)
|
|
417
|
+
expect(result.map((l: any) => l.id)).toEqual([1, 3])
|
|
418
|
+
})
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
describe('fetchLearningPathProgressCheckLessons', () => {
|
|
422
|
+
test('returns ids that have completed progress in db', async () => {
|
|
423
|
+
await contentStatusCompleted(1)
|
|
424
|
+
await contentStatusCompleted(3)
|
|
425
|
+
const result = await fetchLearningPathProgressCheckLessons([1, 2, 3])
|
|
426
|
+
expect(result.sort()).toEqual([1, 3])
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
test('returns [] when none completed', async () => {
|
|
430
|
+
const result = await fetchLearningPathProgressCheckLessons([1, 2])
|
|
431
|
+
expect(result).toEqual([])
|
|
432
|
+
})
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
describe('fetchLearningPathLessons', () => {
|
|
436
|
+
test('returns null when learning path has no children', async () => {
|
|
437
|
+
sanity.fetchByRailContentId.mockResolvedValueOnce(makeLp(1, []))
|
|
438
|
+
const result = await fetchLearningPathLessons(1, 'drumeo', new Date())
|
|
439
|
+
expect(result).toBeNull()
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
test('returns is_active_learning_path false when not active', async () => {
|
|
443
|
+
sanity.fetchByRailContentId.mockResolvedValueOnce(makeLp(1, [{ id: 100 }]))
|
|
444
|
+
setApiResponses({ dailySession: { active_learning_path_id: 999, daily_session: [] } })
|
|
445
|
+
const result = await fetchLearningPathLessons(1, 'drumeo', new Date())
|
|
446
|
+
expect(result.is_active_learning_path).toBe(false)
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
test('categorizes dailies/completed/upcoming when active', async () => {
|
|
450
|
+
const lp = makeLp(5, [{ id: 100 }, { id: 101 }, { id: 102 }])
|
|
451
|
+
sanity.fetchByRailContentId.mockResolvedValue(lp)
|
|
452
|
+
await contentStatusCompleted(101, { type: lpType, id: 5 })
|
|
453
|
+
setApiResponses({
|
|
454
|
+
dailySession: {
|
|
455
|
+
active_learning_path_id: 5,
|
|
456
|
+
active_learning_path_created_at: '2026-01-01',
|
|
457
|
+
daily_session: [{ learning_path_id: 5, content_ids: [100] }],
|
|
458
|
+
},
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
const result = await fetchLearningPathLessons(5, 'drumeo', new Date())
|
|
462
|
+
expect(result.is_active_learning_path).toBe(true)
|
|
463
|
+
expect(result.learning_path_dailies.map((l: any) => l.id)).toEqual([100])
|
|
464
|
+
expect(result.completed_lessons.map((l: any) => l.id)).toEqual([101])
|
|
465
|
+
expect(result.upcoming_lessons.map((l: any) => l.id)).toEqual([102])
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
test('returns null when learningPath fetch resolves falsy', async () => {
|
|
469
|
+
sanity.fetchByRailContentId.mockResolvedValueOnce(null)
|
|
470
|
+
const result = await fetchLearningPathLessons(1, 'drumeo', new Date())
|
|
471
|
+
expect(result).toBeNull()
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
test('resolves previous and next learning path dailies', async () => {
|
|
475
|
+
const lpCurrent = makeLp(5, [{ id: 100 }])
|
|
476
|
+
const lpPrev = makeLp(4, [{ id: 40 }])
|
|
477
|
+
const lpNext = makeLp(6, [{ id: 60 }])
|
|
478
|
+
sanity.fetchByRailContentId.mockImplementation((id: number) => {
|
|
479
|
+
if (id === 5) return Promise.resolve(lpCurrent)
|
|
480
|
+
if (id === 4) return Promise.resolve(lpPrev)
|
|
481
|
+
if (id === 6) return Promise.resolve(lpNext)
|
|
482
|
+
return Promise.resolve(false)
|
|
483
|
+
})
|
|
484
|
+
setApiResponses({
|
|
485
|
+
dailySession: {
|
|
486
|
+
active_learning_path_id: 5,
|
|
487
|
+
daily_session: [
|
|
488
|
+
{ learning_path_id: 4, content_ids: [40] },
|
|
489
|
+
{ learning_path_id: 5, content_ids: [100] },
|
|
490
|
+
{ learning_path_id: 6, content_ids: [60] },
|
|
491
|
+
],
|
|
492
|
+
},
|
|
493
|
+
})
|
|
494
|
+
|
|
495
|
+
const result = await fetchLearningPathLessons(5, 'drumeo', new Date())
|
|
496
|
+
expect(result.previous_learning_path_id).toBe(4)
|
|
497
|
+
expect(result.previous_learning_path_dailies.map((l: any) => l.id)).toEqual([40])
|
|
498
|
+
expect(result.next_learning_path_id).toBe(6)
|
|
499
|
+
expect(result.next_learning_path_dailies.map((l: any) => l.id)).toEqual([60])
|
|
500
|
+
expect(result.next_learning_path_dailies[0].in_next_learning_path).toBe(false)
|
|
501
|
+
})
|
|
502
|
+
|
|
503
|
+
test('in_next_learning_path true when current LP is completed', async () => {
|
|
504
|
+
const lpCurrent = makeLp(5, [{ id: 100 }])
|
|
505
|
+
const lpNext = makeLp(6, [{ id: 60 }])
|
|
506
|
+
sanity.fetchByRailContentId.mockImplementation((id: number) => {
|
|
507
|
+
if (id === 5) return Promise.resolve(lpCurrent)
|
|
508
|
+
if (id === 6) return Promise.resolve(lpNext)
|
|
509
|
+
return Promise.resolve(false)
|
|
510
|
+
})
|
|
511
|
+
setApiResponses({ activePath: { active_learning_path_id: 999 } })
|
|
512
|
+
await contentStatusCompleted(5, { type: lpType, id: 5 })
|
|
513
|
+
await contentStatusCompleted(100, { type: lpType, id: 5 })
|
|
514
|
+
setApiResponses({
|
|
515
|
+
dailySession: {
|
|
516
|
+
active_learning_path_id: 5,
|
|
517
|
+
daily_session: [
|
|
518
|
+
{ learning_path_id: 5, content_ids: [100] },
|
|
519
|
+
{ learning_path_id: 6, content_ids: [60] },
|
|
520
|
+
],
|
|
521
|
+
},
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
const result = await fetchLearningPathLessons(5, 'drumeo', new Date())
|
|
525
|
+
expect(result.progressStatus).toBe(STATE.COMPLETED)
|
|
526
|
+
expect(result.next_learning_path_dailies[0].in_next_learning_path).toBe(true)
|
|
527
|
+
})
|
|
528
|
+
|
|
529
|
+
test('treats missing session.content_ids as empty array', async () => {
|
|
530
|
+
const lp = makeLp(5, [{ id: 100 }])
|
|
531
|
+
sanity.fetchByRailContentId.mockResolvedValueOnce(lp)
|
|
532
|
+
setApiResponses({
|
|
533
|
+
dailySession: {
|
|
534
|
+
active_learning_path_id: 5,
|
|
535
|
+
daily_session: [{ learning_path_id: 5 }],
|
|
536
|
+
},
|
|
537
|
+
})
|
|
538
|
+
const result = await fetchLearningPathLessons(5, 'drumeo', new Date())
|
|
539
|
+
expect(result.learning_path_dailies).toEqual([])
|
|
540
|
+
expect(result.upcoming_lessons.map((l: any) => l.id)).toEqual([100])
|
|
541
|
+
})
|
|
542
|
+
})
|
|
543
|
+
|
|
544
|
+
describe('completeMethodIntroVideo', () => {
|
|
545
|
+
test('completes intro video in db and posts active-path actions', async () => {
|
|
546
|
+
sanity.fetchMethodV2Structure.mockResolvedValueOnce({
|
|
547
|
+
learning_paths: [{ id: 50 }, { id: 51 }],
|
|
548
|
+
})
|
|
549
|
+
HttpClient.POST.mockResolvedValueOnce({ active_learning_path_id: 50 })
|
|
550
|
+
|
|
551
|
+
const result = await completeMethodIntroVideo(700, 'drumeo')
|
|
552
|
+
|
|
553
|
+
expect(result.intro_video_response).toBeTruthy()
|
|
554
|
+
expect(await getProgressState(700)).toBe('completed')
|
|
555
|
+
expect(result.active_path_response).toEqual({ active_learning_path_id: 50 })
|
|
556
|
+
const methodCall = HttpClient.POST.mock.calls.find(
|
|
557
|
+
(c: any[]) => c[0].includes('/method-intro-video-complete-actions'),
|
|
558
|
+
)
|
|
559
|
+
expect(methodCall).toBeDefined()
|
|
560
|
+
expect(methodCall[1].brand).toBe('drumeo')
|
|
561
|
+
expect(methodCall[1].learningPathId).toBe(50)
|
|
562
|
+
expect(methodCall[1].userDate).toMatch(/^\d{4}-\d{2}-\d{2}/)
|
|
563
|
+
})
|
|
564
|
+
|
|
565
|
+
test('skips intro completion when already completed', async () => {
|
|
566
|
+
await contentStatusCompleted(701)
|
|
567
|
+
sanity.fetchMethodV2Structure.mockResolvedValueOnce({
|
|
568
|
+
learning_paths: [{ id: 50 }],
|
|
569
|
+
})
|
|
570
|
+
HttpClient.POST.mockResolvedValueOnce({ active_learning_path_id: 50 })
|
|
571
|
+
|
|
572
|
+
const result = await completeMethodIntroVideo(701, 'drumeo')
|
|
573
|
+
expect(result.intro_video_response).toBeNull()
|
|
574
|
+
})
|
|
575
|
+
|
|
576
|
+
test('null intro video id skips completion', async () => {
|
|
577
|
+
sanity.fetchMethodV2Structure.mockResolvedValueOnce({
|
|
578
|
+
learning_paths: [{ id: 50 }],
|
|
579
|
+
})
|
|
580
|
+
HttpClient.POST.mockResolvedValueOnce({ active_learning_path_id: 50 })
|
|
581
|
+
const result = await completeMethodIntroVideo(null, 'drumeo')
|
|
582
|
+
expect(result.intro_video_response).toBeNull()
|
|
583
|
+
})
|
|
584
|
+
})
|
|
585
|
+
|
|
586
|
+
describe('completeLearningPathIntroVideo', () => {
|
|
587
|
+
test('resets LP progress when no lessons to import', async () => {
|
|
588
|
+
const collection = { type: lpType, id: 10 }
|
|
589
|
+
sanity.fetchByRailContentId.mockResolvedValue(makeLp(10))
|
|
590
|
+
setApiResponses({ activePath: { active_learning_path_id: 999 } })
|
|
591
|
+
await contentStatusCompleted(10, collection)
|
|
592
|
+
|
|
593
|
+
sanity.hasAnyMethodV2IntroCompleted.mockResolvedValueOnce(true)
|
|
594
|
+
setApiResponses({ activePath: { active_learning_path_id: 10 } })
|
|
595
|
+
|
|
596
|
+
const result = await completeLearningPathIntroVideo(800, 10, null, 'drumeo')
|
|
597
|
+
|
|
598
|
+
expect(result.learning_path_reset_response).toBeTruthy()
|
|
599
|
+
expect(await getProgressState(10, collection)).toBe('')
|
|
600
|
+
expect(result.intro_video_response).toBeTruthy()
|
|
601
|
+
expect(await getProgressState(800)).toBe('completed')
|
|
602
|
+
})
|
|
603
|
+
|
|
604
|
+
test('imports lessons and updates dailies when active', async () => {
|
|
605
|
+
sanity.hasAnyMethodV2IntroCompleted.mockResolvedValueOnce(true)
|
|
606
|
+
setApiResponses({
|
|
607
|
+
activePath: { active_learning_path_id: 10 },
|
|
608
|
+
dailySession: { active_learning_path_id: 10, daily_session: [] },
|
|
609
|
+
})
|
|
610
|
+
HttpClient.POST.mockResolvedValueOnce({ active_learning_path_id: 10, daily_session: [] })
|
|
611
|
+
|
|
612
|
+
const result = await completeLearningPathIntroVideo(801, 10, [301, 302], 'drumeo')
|
|
613
|
+
|
|
614
|
+
expect(result.lesson_import_response).toBeTruthy()
|
|
615
|
+
expect(await getProgressState(301, { type: lpType, id: 10 })).toBe('completed')
|
|
616
|
+
expect(await getProgressState(302, { type: lpType, id: 10 })).toBe('completed')
|
|
617
|
+
expect(result.update_dailies_response).toBeTruthy()
|
|
618
|
+
expect(await getProgressState(801)).toBe('completed')
|
|
619
|
+
})
|
|
620
|
+
|
|
621
|
+
test('lateMethodSetup: triggers completeMethodIntroVideo when anyIntroComplete and no activePath', async () => {
|
|
622
|
+
sanity.hasAnyMethodV2IntroCompleted.mockResolvedValueOnce(true)
|
|
623
|
+
setApiResponses({ activePath: null })
|
|
624
|
+
sanity.fetchMethodV2Structure.mockResolvedValue({ learning_paths: [{ id: 10 }] })
|
|
625
|
+
HttpClient.POST.mockResolvedValue({ active_learning_path_id: 10 })
|
|
626
|
+
|
|
627
|
+
await completeLearningPathIntroVideo(802, 10, null, 'drumeo')
|
|
628
|
+
|
|
629
|
+
const methodCalls = HttpClient.POST.mock.calls.filter(
|
|
630
|
+
(c: any[]) => c[0].includes('/method-intro-video-complete-actions'),
|
|
631
|
+
)
|
|
632
|
+
expect(methodCalls.length).toBeGreaterThanOrEqual(1)
|
|
633
|
+
})
|
|
634
|
+
|
|
635
|
+
test('skips late method setup when anyIntroComplete is false', async () => {
|
|
636
|
+
sanity.hasAnyMethodV2IntroCompleted.mockResolvedValueOnce(false)
|
|
637
|
+
setApiResponses({ activePath: null })
|
|
638
|
+
|
|
639
|
+
await completeLearningPathIntroVideo(803, 10, null, 'drumeo')
|
|
640
|
+
|
|
641
|
+
const methodCalls = HttpClient.POST.mock.calls.filter(
|
|
642
|
+
(c: any[]) => c[0].includes('/method-intro-video-complete-actions'),
|
|
643
|
+
)
|
|
644
|
+
expect(methodCalls).toHaveLength(0)
|
|
645
|
+
})
|
|
646
|
+
|
|
647
|
+
test('imports lessons but does not update dailies when LP is not active', async () => {
|
|
648
|
+
sanity.hasAnyMethodV2IntroCompleted.mockResolvedValueOnce(true)
|
|
649
|
+
setApiResponses({ activePath: { active_learning_path_id: 999 } })
|
|
650
|
+
|
|
651
|
+
const result = await completeLearningPathIntroVideo(804, 10, [304], 'drumeo')
|
|
652
|
+
|
|
653
|
+
expect(result.lesson_import_response).toBeTruthy()
|
|
654
|
+
expect(result.update_dailies_response).toBeUndefined()
|
|
655
|
+
const dailyPosts = HttpClient.POST.mock.calls.filter(
|
|
656
|
+
(c: any[]) => c[0].includes('/daily-session/create'),
|
|
657
|
+
)
|
|
658
|
+
expect(dailyPosts).toHaveLength(0)
|
|
659
|
+
})
|
|
660
|
+
|
|
661
|
+
test('skips updateDailySession when lateMethodSetup already set dailies', async () => {
|
|
662
|
+
sanity.hasAnyMethodV2IntroCompleted.mockResolvedValueOnce(true)
|
|
663
|
+
setApiResponses({ activePath: null })
|
|
664
|
+
sanity.fetchMethodV2Structure.mockResolvedValue({ learning_paths: [{ id: 10 }] })
|
|
665
|
+
HttpClient.POST.mockImplementation((url: string) => {
|
|
666
|
+
if (url.includes('/method-intro-video-complete-actions')) {
|
|
667
|
+
setApiResponses({ activePath: { active_learning_path_id: 10 } })
|
|
668
|
+
}
|
|
669
|
+
return Promise.resolve({ active_learning_path_id: 10, daily_session: [] })
|
|
670
|
+
})
|
|
671
|
+
|
|
672
|
+
const result = await completeLearningPathIntroVideo(805, 10, [305], 'drumeo')
|
|
673
|
+
|
|
674
|
+
expect(result.update_dailies_response).toBeUndefined()
|
|
675
|
+
const dailyPosts = HttpClient.POST.mock.calls.filter(
|
|
676
|
+
(c: any[]) => c[0].includes('/daily-session/create'),
|
|
677
|
+
)
|
|
678
|
+
expect(dailyPosts).toHaveLength(0)
|
|
679
|
+
})
|
|
680
|
+
|
|
681
|
+
test('skips intro video completion when already completed', async () => {
|
|
682
|
+
await contentStatusCompleted(806)
|
|
683
|
+
sanity.hasAnyMethodV2IntroCompleted.mockResolvedValueOnce(true)
|
|
684
|
+
setApiResponses({ activePath: { active_learning_path_id: 10 } })
|
|
685
|
+
|
|
686
|
+
const result = await completeLearningPathIntroVideo(806, 10, null, 'drumeo')
|
|
687
|
+
expect(result.intro_video_response).toBeNull()
|
|
688
|
+
})
|
|
689
|
+
})
|
|
690
|
+
|
|
691
|
+
describe('onLearningPathCompletedActions', () => {
|
|
692
|
+
test('returns early when not the active path', async () => {
|
|
693
|
+
sanity.fetchByRailContentId.mockResolvedValue(makeLp(5))
|
|
694
|
+
setApiResponses({ activePath: { active_learning_path_id: 99 } })
|
|
695
|
+
await onLearningPathCompletedActions(5)
|
|
696
|
+
const setPathCalls = HttpClient.POST.mock.calls.filter((c: any[]) => c[0].includes('/active-path/set'))
|
|
697
|
+
expect(setPathCalls).toHaveLength(0)
|
|
698
|
+
})
|
|
699
|
+
|
|
700
|
+
test('starts next published learning path and resets its intro video', async () => {
|
|
701
|
+
const lp = makeLp(5)
|
|
702
|
+
const nextLp = { ...makeLp(6), intro_video: { id: 600 } }
|
|
703
|
+
sanity.fetchByRailContentId
|
|
704
|
+
.mockResolvedValueOnce(lp)
|
|
705
|
+
.mockResolvedValueOnce(nextLp)
|
|
706
|
+
setApiResponses({ activePath: { active_learning_path_id: 5 } })
|
|
707
|
+
sanity.fetchMethodV2Structure.mockResolvedValueOnce({
|
|
708
|
+
learning_paths: [
|
|
709
|
+
{ id: 5, published_on: '2025-01-01' },
|
|
710
|
+
{ id: 6, published_on: '2025-01-02' },
|
|
711
|
+
{ id: 7, published_on: null },
|
|
712
|
+
],
|
|
713
|
+
})
|
|
714
|
+
HttpClient.POST.mockResolvedValueOnce({ active_learning_path_id: 6 })
|
|
715
|
+
await contentStatusCompleted(600)
|
|
716
|
+
|
|
717
|
+
await onLearningPathCompletedActions(5)
|
|
718
|
+
|
|
719
|
+
const setPathCalls = HttpClient.POST.mock.calls.filter((c: any[]) => c[0].includes('/active-path/set'))
|
|
720
|
+
expect(setPathCalls).toHaveLength(1)
|
|
721
|
+
expect(setPathCalls[0][1]).toEqual({ brand: 'drumeo', learning_path_id: 6 })
|
|
722
|
+
expect(await getProgressState(600)).toBe('')
|
|
723
|
+
})
|
|
724
|
+
|
|
725
|
+
test('returns when no next learning path exists', async () => {
|
|
726
|
+
sanity.fetchByRailContentId.mockResolvedValueOnce(makeLp(9))
|
|
727
|
+
setApiResponses({ activePath: { active_learning_path_id: 9 } })
|
|
728
|
+
sanity.fetchMethodV2Structure.mockResolvedValueOnce({
|
|
729
|
+
learning_paths: [{ id: 9, published_on: '2025-01-01' }],
|
|
730
|
+
})
|
|
731
|
+
await onLearningPathCompletedActions(9)
|
|
732
|
+
const setPathCalls = HttpClient.POST.mock.calls.filter((c: any[]) => c[0].includes('/active-path/set'))
|
|
733
|
+
expect(setPathCalls).toHaveLength(0)
|
|
734
|
+
})
|
|
735
|
+
|
|
736
|
+
test('returns when active LP is not in published list', async () => {
|
|
737
|
+
sanity.fetchByRailContentId.mockResolvedValueOnce(makeLp(8))
|
|
738
|
+
setApiResponses({ activePath: { active_learning_path_id: 8 } })
|
|
739
|
+
sanity.fetchMethodV2Structure.mockResolvedValueOnce({
|
|
740
|
+
learning_paths: [
|
|
741
|
+
{ id: 8, published_on: null },
|
|
742
|
+
{ id: 9, published_on: '2025-01-01' },
|
|
743
|
+
],
|
|
744
|
+
})
|
|
745
|
+
await onLearningPathCompletedActions(8)
|
|
746
|
+
const setPathCalls = HttpClient.POST.mock.calls.filter((c: any[]) => c[0].includes('/active-path/set'))
|
|
747
|
+
expect(setPathCalls).toHaveLength(0)
|
|
748
|
+
})
|
|
749
|
+
|
|
750
|
+
test('skips next LP that is not yet published', async () => {
|
|
751
|
+
sanity.fetchByRailContentId.mockResolvedValueOnce(makeLp(5))
|
|
752
|
+
setApiResponses({ activePath: { active_learning_path_id: 5 } })
|
|
753
|
+
sanity.fetchMethodV2Structure.mockResolvedValueOnce({
|
|
754
|
+
learning_paths: [
|
|
755
|
+
{ id: 5, published_on: '2025-01-01' },
|
|
756
|
+
{ id: 6, published_on: '2099-01-01' },
|
|
757
|
+
],
|
|
758
|
+
})
|
|
759
|
+
await onLearningPathCompletedActions(5)
|
|
760
|
+
const setPathCalls = HttpClient.POST.mock.calls.filter((c: any[]) => c[0].includes('/active-path/set'))
|
|
761
|
+
expect(setPathCalls).toHaveLength(0)
|
|
762
|
+
})
|
|
763
|
+
})
|
|
764
|
+
|
|
765
|
+
describe('mapLearningPathParentsTo', () => {
|
|
766
|
+
test('maps parent_id from hierarchy', async () => {
|
|
767
|
+
sanity.fetchParentChildRelationshipsFor.mockResolvedValueOnce([
|
|
768
|
+
{ railcontent_id: '100', children: [1, 2] },
|
|
769
|
+
{ railcontent_id: '200', children: [3] },
|
|
770
|
+
])
|
|
771
|
+
const result = await mapLearningPathParentsTo(
|
|
772
|
+
[{ id: 1 }, { id: 2 }, { id: 3 }],
|
|
773
|
+
{ type: true, parent_id: true },
|
|
774
|
+
)
|
|
775
|
+
expect(result).toEqual([
|
|
776
|
+
{ id: 1, type: LEARNING_PATH_LESSON, parent_id: 100 },
|
|
777
|
+
{ id: 2, type: LEARNING_PATH_LESSON, parent_id: 100 },
|
|
778
|
+
{ id: 3, type: LEARNING_PATH_LESSON, parent_id: 200 },
|
|
779
|
+
])
|
|
780
|
+
})
|
|
781
|
+
|
|
782
|
+
test('id without parent in hierarchy gets parent_id undefined', async () => {
|
|
783
|
+
sanity.fetchParentChildRelationshipsFor.mockResolvedValueOnce([
|
|
784
|
+
{ railcontent_id: '100', children: [1] },
|
|
785
|
+
])
|
|
786
|
+
const result = await mapLearningPathParentsTo(
|
|
787
|
+
[{ id: 1 }, { id: 99 }],
|
|
788
|
+
{ type: true, parent_id: true },
|
|
789
|
+
)
|
|
790
|
+
expect(result[0].parent_id).toBe(100)
|
|
791
|
+
expect(result[1].parent_id).toBeUndefined()
|
|
792
|
+
})
|
|
793
|
+
|
|
794
|
+
test('only maps type when parent_id flag is false', async () => {
|
|
795
|
+
sanity.fetchParentChildRelationshipsFor.mockResolvedValueOnce([
|
|
796
|
+
{ railcontent_id: '100', children: [1] },
|
|
797
|
+
])
|
|
798
|
+
const result = await mapLearningPathParentsTo(
|
|
799
|
+
[{ id: 1, type: 'orig' }],
|
|
800
|
+
{ type: true },
|
|
801
|
+
)
|
|
802
|
+
expect(result[0]).toEqual({ id: 1, type: LEARNING_PATH_LESSON })
|
|
803
|
+
})
|
|
804
|
+
|
|
805
|
+
test('only maps parent_id when type flag is false', async () => {
|
|
806
|
+
sanity.fetchParentChildRelationshipsFor.mockResolvedValueOnce([
|
|
807
|
+
{ railcontent_id: '100', children: [1] },
|
|
808
|
+
])
|
|
809
|
+
const result = await mapLearningPathParentsTo(
|
|
810
|
+
[{ id: 1, type: 'orig' }],
|
|
811
|
+
{ parent_id: true },
|
|
812
|
+
)
|
|
813
|
+
expect(result[0]).toEqual({ id: 1, type: 'orig', parent_id: 100 })
|
|
814
|
+
})
|
|
815
|
+
})
|
|
816
|
+
|
|
817
|
+
describe('mapContentsThatWereLastProgressedFromMethod', () => {
|
|
818
|
+
test('returns input when empty', async () => {
|
|
819
|
+
expect(await mapContentsThatWereLastProgressedFromMethod([])).toEqual([])
|
|
820
|
+
expect(await mapContentsThatWereLastProgressedFromMethod(null)).toBeNull()
|
|
821
|
+
})
|
|
822
|
+
|
|
823
|
+
test('returns input unchanged when no eligible types', async () => {
|
|
824
|
+
const input = [{ id: 1, type: 'song' }]
|
|
825
|
+
expect(await mapContentsThatWereLastProgressedFromMethod(input)).toEqual(input)
|
|
826
|
+
})
|
|
827
|
+
|
|
828
|
+
test('maps eligible ids with parent data when last accessed from method', async () => {
|
|
829
|
+
const collection = { type: lpType, id: 50 }
|
|
830
|
+
sanity.fetchByRailContentId.mockResolvedValue(makeLp(50))
|
|
831
|
+
setApiResponses({ activePath: { active_learning_path_id: 999 } })
|
|
832
|
+
await contentStatusCompleted(1, collection)
|
|
833
|
+
sanity.fetchParentChildRelationshipsFor.mockResolvedValueOnce([
|
|
834
|
+
{ railcontent_id: '50', children: [1] },
|
|
835
|
+
])
|
|
836
|
+
|
|
837
|
+
const input = [
|
|
838
|
+
{ id: 1, type: 'skill-pack-lesson' },
|
|
839
|
+
{ id: 2, type: 'song' },
|
|
840
|
+
]
|
|
841
|
+
const result = await mapContentsThatWereLastProgressedFromMethod(input)
|
|
842
|
+
expect(result[0]).toEqual({ id: 1, type: LEARNING_PATH_LESSON, parent_id: 50 })
|
|
843
|
+
expect(result[1]).toEqual({ id: 2, type: 'song' })
|
|
844
|
+
})
|
|
845
|
+
|
|
846
|
+
test('returns objects unchanged when none were last accessed from method', async () => {
|
|
847
|
+
const input = [
|
|
848
|
+
{ id: 1, type: 'skill-pack-lesson' },
|
|
849
|
+
{ id: 2, type: 'song-tutorial-lesson' },
|
|
850
|
+
]
|
|
851
|
+
const result = await mapContentsThatWereLastProgressedFromMethod(input)
|
|
852
|
+
expect(result).toEqual(input)
|
|
853
|
+
})
|
|
854
|
+
})
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { searchAlgolia } from '../../src/services/search'
|
|
2
|
+
|
|
3
|
+
const mockPost = jest.fn()
|
|
4
|
+
|
|
5
|
+
jest.mock('../../src/infrastructure/http/HttpClient', () => ({
|
|
6
|
+
POST: (...args: unknown[]) => mockPost(...args),
|
|
7
|
+
}))
|
|
8
|
+
|
|
9
|
+
describe('searchAlgolia', () => {
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
mockPost.mockReset()
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
test('posts requests to the correct endpoint', async () => {
|
|
15
|
+
mockPost.mockResolvedValue({ results: [] })
|
|
16
|
+
|
|
17
|
+
await searchAlgolia([{ query: 'drum', hitsPerPage: 5 }])
|
|
18
|
+
|
|
19
|
+
expect(mockPost).toHaveBeenCalledWith('/api/content/v1/search', {
|
|
20
|
+
requests: [{ query: 'drum', hitsPerPage: 5 }],
|
|
21
|
+
})
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('returns the response from the endpoint', async () => {
|
|
25
|
+
const mockResponse = {
|
|
26
|
+
results: [{ hits: [{ objectID: 'abc123' }], nbHits: 1 }],
|
|
27
|
+
}
|
|
28
|
+
mockPost.mockResolvedValue(mockResponse)
|
|
29
|
+
|
|
30
|
+
const result = await searchAlgolia([{ query: 'drum' }])
|
|
31
|
+
|
|
32
|
+
expect(result).toEqual(mockResponse)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('passes multiple requests', async () => {
|
|
36
|
+
mockPost.mockResolvedValue({ results: [] })
|
|
37
|
+
|
|
38
|
+
await searchAlgolia([{ query: 'drum' }, { query: 'piano' }])
|
|
39
|
+
|
|
40
|
+
expect(mockPost).toHaveBeenCalledWith('/api/content/v1/search', {
|
|
41
|
+
requests: [{ query: 'drum' }, { query: 'piano' }],
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
})
|