musora-content-services 2.161.4 → 2.162.1

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.
@@ -20,6 +20,22 @@
20
20
  "Skill(create-decision)"
21
21
  ]
22
22
  },
23
+ "model": "sonnet",
23
24
  "effortLevel": "medium",
24
- "model": "sonnet"
25
+ "enabledMcpjsonServers": [
26
+ "atlassian",
27
+ "figma",
28
+ "google-workspace",
29
+ "snowflake",
30
+ "aws",
31
+ "hex",
32
+ "sanity",
33
+ "mysql",
34
+ "slack",
35
+ "langfuse",
36
+ "chrome-devtools",
37
+ "railway",
38
+ "github",
39
+ "asana"
40
+ ]
25
41
  }
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.1](https://github.com/railroadmedia/musora-content-services/compare/v2.162.0...v2.162.1) (2026-06-04)
6
+
7
+ ## [2.162.0](https://github.com/railroadmedia/musora-content-services/compare/v2.161.4...v2.162.0) (2026-06-03)
8
+
9
+
10
+ ### Features
11
+
12
+ * 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))
13
+
5
14
  ### [2.161.4](https://github.com/railroadmedia/musora-content-services/compare/v2.161.3...v2.161.4) (2026-06-02)
6
15
 
7
16
  ### [2.161.3](https://github.com/railroadmedia/musora-content-services/compare/v2.161.2...v2.161.3) (2026-06-02)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "musora-content-services",
3
- "version": "2.161.4",
3
+ "version": "2.162.1",
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,
@@ -176,7 +177,8 @@ import {
176
177
  likePost,
177
178
  search,
178
179
  unlikePost,
179
- updatePost
180
+ updatePost,
181
+ whoLikedPost
180
182
  } from './services/forums/posts.ts';
181
183
 
182
184
  import {
@@ -493,6 +495,11 @@ import {
493
495
  updateUserPractice
494
496
  } from './services/userActivity.js';
495
497
 
498
+ import {
499
+ whoLikedComment,
500
+ whoLikedContent
501
+ } from './services/whoLiked.ts';
502
+
496
503
  import {
497
504
  default as EventsAPI
498
505
  } from './services/eventsAPI';
@@ -738,6 +745,7 @@ declare module 'musora-content-services' {
738
745
  isContentLiked,
739
746
  isContentLikedByIds,
740
747
  isNextDay,
748
+ isNextLessonLocked,
741
749
  isSameDate,
742
750
  isUserFreeTier,
743
751
  isUsernameAvailable,
@@ -845,6 +853,9 @@ declare module 'musora-content-services' {
845
853
  userOnboardingForBrand,
846
854
  verifyImageSRC,
847
855
  verifyLocalDataContext,
856
+ whoLikedComment,
857
+ whoLikedContent,
858
+ whoLikedPost,
848
859
  }
849
860
  }
850
861
 
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,
@@ -180,7 +181,8 @@ import {
180
181
  likePost,
181
182
  search,
182
183
  unlikePost,
183
- updatePost
184
+ updatePost,
185
+ whoLikedPost
184
186
  } from './services/forums/posts.ts';
185
187
 
186
188
  import {
@@ -497,6 +499,11 @@ import {
497
499
  updateUserPractice
498
500
  } from './services/userActivity.js';
499
501
 
502
+ import {
503
+ whoLikedComment,
504
+ whoLikedContent
505
+ } from './services/whoLiked.ts';
506
+
500
507
  export {
501
508
  PermissionsAdapter,
502
509
  PermissionsV1Adapter,
@@ -737,6 +744,7 @@ export {
737
744
  isContentLiked,
738
745
  isContentLikedByIds,
739
746
  isNextDay,
747
+ isNextLessonLocked,
740
748
  isSameDate,
741
749
  isUserFreeTier,
742
750
  isUsernameAvailable,
@@ -844,6 +852,9 @@ export {
844
852
  userOnboardingForBrand,
845
853
  verifyImageSRC,
846
854
  verifyLocalDataContext,
855
+ whoLikedComment,
856
+ whoLikedContent,
857
+ whoLikedPost,
847
858
  };
848
859
 
849
860
  export default EventsAPI
@@ -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
+ }
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import { HttpClient } from '../../infrastructure/http/HttpClient'
5
5
  import { globalConfig } from '../config.js'
6
- import { ForumPost } from './types'
6
+ import { ForumPost, WhoLikedParams, WhoLikedResponse } from './types'
7
7
  import { PaginatedResponse } from '../api/types'
8
8
  import { markThreadAsRead } from './threads'
9
9
 
@@ -128,6 +128,33 @@ export async function likePost(postId: number, brand: string): Promise<void> {
128
128
  })
129
129
  }
130
130
 
131
+ /**
132
+ * Fetch the list of users who liked a forum post.
133
+ *
134
+ * @param {number} postId - The ID of the post.
135
+ * @param {string} brand - The brand context (e.g., "drumeo", "singeo").
136
+ * @param {WhoLikedParams} [params] - Optional pagination parameters.
137
+ * @returns {Promise<WhoLikedResponse>} - A promise that resolves to the paginated list of likers.
138
+ * @throws {HttpError} - If the request fails.
139
+ */
140
+ export async function whoLikedPost(
141
+ postId: number,
142
+ brand: string,
143
+ params: WhoLikedParams = {}
144
+ ): Promise<WhoLikedResponse> {
145
+ const httpClient = new HttpClient(globalConfig.baseUrl)
146
+ const queryObj: Record<string, string> = {
147
+ brand,
148
+ ...Object.fromEntries(
149
+ Object.entries({ page: 1, limit: 20, ...params })
150
+ .filter(([_, v]) => v !== undefined && v !== null)
151
+ .map(([k, v]) => [k, String(v)])
152
+ ),
153
+ }
154
+ const query = new URLSearchParams(queryObj).toString()
155
+ return httpClient.get<WhoLikedResponse>(`${baseUrl}/v1/posts/${postId}/likes?${query}`)
156
+ }
157
+
131
158
  /**
132
159
  * Unlike a forum post.
133
160
  *
@@ -1,3 +1,26 @@
1
+ export interface WhoLikedParams {
2
+ page?: number
3
+ limit?: number
4
+ }
5
+
6
+ export interface Liker {
7
+ user_id: number
8
+ display_name: string
9
+ profile_picture_url: string | null
10
+ access_level: string
11
+ liked_at: string
12
+ liked_at_diff: string | null
13
+ }
14
+
15
+ export interface WhoLikedResponse {
16
+ data: Liker[]
17
+ meta: {
18
+ total: number
19
+ current_page: number
20
+ last_page: number
21
+ }
22
+ }
23
+
1
24
  export interface ForumUser {
2
25
  id: number
3
26
  display_name: string
@@ -0,0 +1,52 @@
1
+ /**
2
+ * @module WhoLiked
3
+ */
4
+ import { HttpClient } from '../infrastructure/http/HttpClient'
5
+ import { globalConfig } from './config.js'
6
+ import { Liker, WhoLikedParams, WhoLikedResponse } from './forums/types'
7
+
8
+ export type { Liker, WhoLikedParams, WhoLikedResponse }
9
+
10
+ const baseUrl = `/api/content/v1`
11
+
12
+ /**
13
+ * Fetch the list of users who liked a lesson comment.
14
+ *
15
+ * @param {number} commentId - The ID of the comment.
16
+ * @param {WhoLikedParams} [params] - Optional pagination parameters.
17
+ * @returns {Promise<WhoLikedResponse>} - Paginated list of likers.
18
+ * @throws {HttpError} - If the request fails.
19
+ */
20
+ export async function whoLikedComment(
21
+ commentId: number,
22
+ params: WhoLikedParams = {}
23
+ ): Promise<WhoLikedResponse> {
24
+ const httpClient = new HttpClient(globalConfig.baseUrl)
25
+ const query = new URLSearchParams(
26
+ Object.fromEntries(
27
+ Object.entries({ page: 1, limit: 20, ...params }).map(([k, v]) => [k, String(v)])
28
+ )
29
+ ).toString()
30
+ return httpClient.get<WhoLikedResponse>(`${baseUrl}/comments/${commentId}/likes?${query}`)
31
+ }
32
+
33
+ /**
34
+ * Fetch the list of users who liked a content item (lesson, song, etc.).
35
+ *
36
+ * @param {number} contentId - The ID of the content item.
37
+ * @param {WhoLikedParams} [params] - Optional pagination parameters.
38
+ * @returns {Promise<WhoLikedResponse>} - Paginated list of likers.
39
+ * @throws {HttpError} - If the request fails.
40
+ */
41
+ export async function whoLikedContent(
42
+ contentId: number,
43
+ params: WhoLikedParams = {}
44
+ ): Promise<WhoLikedResponse> {
45
+ const httpClient = new HttpClient(globalConfig.baseUrl)
46
+ const query = new URLSearchParams(
47
+ Object.fromEntries(
48
+ Object.entries({ page: 1, limit: 20, ...params }).map(([k, v]) => [k, String(v)])
49
+ )
50
+ ).toString()
51
+ return httpClient.get<WhoLikedResponse>(`${baseUrl}/content/${contentId}/likes?${query}`)
52
+ }
@@ -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
+ })