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.
@@ -1,25 +1,12 @@
1
1
  {
2
2
  "permissions": {
3
3
  "allow": [
4
- "Bash(npx jest *)",
5
- "Bash(npx tsc *)",
6
- "Skill(counselors)",
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(gh pr *)",
12
- "Bash(gh api *)",
13
- "Bash(mkdir -p /tmp/pr-review-v2)",
14
- "Read(//tmp/pr-review-v2/**)",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "musora-content-services",
3
- "version": "2.161.3",
3
+ "version": "2.162.0",
4
4
  "description": "A package for Musoras content services ",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
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) // no need to await.
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
+ })