musora-content-services 2.165.4 → 2.166.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/CHANGELOG.md CHANGED
@@ -2,6 +2,25 @@
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.166.0](https://github.com/railroadmedia/musora-content-services/compare/v2.165.5...v2.166.0) (2026-07-02)
6
+
7
+
8
+ ### Features
9
+
10
+ * **BEHLPT-186:** subscription upgrade endpoints ([#1003](https://github.com/railroadmedia/musora-content-services/issues/1003)) ([9da55f2](https://github.com/railroadmedia/musora-content-services/commit/9da55f24708976190317a3b69381acbccce2f33c))
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **BR-671:** fix Latin/World style search ([#1002](https://github.com/railroadmedia/musora-content-services/issues/1002)) ([25fdff8](https://github.com/railroadmedia/musora-content-services/commit/25fdff80c6279e8568ea057ac250f7e057717ccf))
16
+
17
+ ### [2.165.5](https://github.com/railroadmedia/musora-content-services/compare/v2.165.4...v2.165.5) (2026-06-25)
18
+
19
+
20
+ ### Bug Fixes
21
+
22
+ * **recommender:** omit web credentials on request ([#1001](https://github.com/railroadmedia/musora-content-services/issues/1001)) ([d03179a](https://github.com/railroadmedia/musora-content-services/commit/d03179a829e1048304b42493c34743999272da7a))
23
+
5
24
  ### [2.165.4](https://github.com/railroadmedia/musora-content-services/compare/v2.165.3...v2.165.4) (2026-06-19)
6
25
 
7
26
  ### [2.165.3](https://github.com/railroadmedia/musora-content-services/compare/v2.165.1...v2.165.3) (2026-06-19)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "musora-content-services",
3
- "version": "2.165.4",
3
+ "version": "2.166.0",
4
4
  "description": "A package for Musoras content services ",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -876,7 +876,14 @@ function createTypeConditions(lessonTypes) {
876
876
  * Filter handler registry - maps filter keys to their handler functions
877
877
  */
878
878
  const filterHandlers = {
879
- style: (value) => `"${value}" in genre[]->name`,
879
+ style: (value) => {
880
+ const hasMultipleStyles = value.includes('/')
881
+ const styles = [value]
882
+ if (hasMultipleStyles) {
883
+ styles.push(...value.split('/').map((s) => s.trim()))
884
+ }
885
+ return styles.map((style) => `"${style}" in genre[]->name`).join(' || ')
886
+ },
880
887
 
881
888
  difficulty: (value) => {
882
889
  return `difficulty_string == "${value}"`
@@ -17,17 +17,20 @@ export class HttpClient {
17
17
  private token: string | null
18
18
  private headerProvider: HeaderProvider
19
19
  private requestExecutor: RequestExecutor
20
+ private credentials: RequestCredentials
20
21
 
21
22
  constructor(
22
23
  baseUrl: string = '',
23
- token: string | null = null,
24
- headerProvider: HeaderProvider = new DefaultHeaderProvider(),
25
- requestExecutor: RequestExecutor = new FetchRequestExecutor()
24
+ token?: string | null,
25
+ headerProvider?: HeaderProvider,
26
+ requestExecutor?: RequestExecutor,
27
+ credentials?: RequestCredentials
26
28
  ) {
27
29
  this.baseUrl = baseUrl || globalConfig?.baseUrl || ''
28
30
  this.token = token || globalConfig?.sessionConfig?.token || null
29
- this.headerProvider = headerProvider
30
- this.requestExecutor = requestExecutor
31
+ this.headerProvider = headerProvider || new DefaultHeaderProvider()
32
+ this.requestExecutor = requestExecutor || new FetchRequestExecutor()
33
+ this.credentials = credentials || 'include'
31
34
  }
32
35
 
33
36
  public setToken(token: string): void {
@@ -35,7 +38,7 @@ export class HttpClient {
35
38
  }
36
39
 
37
40
  public clearToken(): void {
38
- this.token = null;
41
+ this.token = null
39
42
  }
40
43
 
41
44
  public async get<T>(url: string, options: _RequestOptions = {}): Promise<T> {
@@ -101,7 +104,7 @@ export class HttpClient {
101
104
  const options: RequestOptions = {
102
105
  method,
103
106
  headers,
104
- credentials: 'include',
107
+ credentials: this.credentials,
105
108
  }
106
109
 
107
110
  if (cache) {
@@ -20,7 +20,9 @@ export interface InviteResponse {
20
20
  can_be_accepted: boolean
21
21
  is_account_valid: boolean
22
22
  is_invite_active: boolean
23
- can_user_join: boolean
23
+ can_user_join: boolean // deprecated in favour of is_user_part_of_existing_mua which is more descriptive
24
+ is_user_active_subscriber: boolean
25
+ is_user_part_of_existing_mua: boolean
24
26
  primary_user_name: string
25
27
  product_name: string
26
28
  // These fields leak user information and are excluded entirely for the public endpoint
@@ -1,6 +1,13 @@
1
1
  import * as state from './state'
2
2
  import * as collections from './collections'
3
+ import * as utils from './utils'
3
4
 
4
- export const Progress = { ...state, ...collections }
5
+ export const Progress = { ...state, ...collections, ...utils }
5
6
 
6
- export type { ProgressContentFilter, ProgressQueryOptions, StartedOrCompletedOptions } from './types'
7
+ export type {
8
+ ProgressContentFilter,
9
+ ProgressQueryOptions,
10
+ ProgressSnapshot,
11
+ RecordIdParts,
12
+ StartedOrCompletedOptions,
13
+ } from './types'
@@ -0,0 +1,138 @@
1
+ import { db } from '../../sync'
2
+ import { CollectionParameter } from '../../sync/models/ContentProgress'
3
+ import type { Hierarchy, ProgressMetadata, ProgressSnapshot } from '../types'
4
+ import { getByIds } from './queries'
5
+
6
+ const MAX_DEPTH = 3
7
+
8
+ export const filterOutNegativeProgress = (
9
+ progresses: Record<number, number>,
10
+ existingProgresses: Record<number, ProgressSnapshot>
11
+ ): Record<number, number> =>
12
+ Object.fromEntries(
13
+ Object.entries(progresses).filter(
14
+ ([id, progress]) => progress >= (existingProgresses[Number(id)]?.progress ?? 0)
15
+ )
16
+ )
17
+
18
+ export const getChildrenToDepth = (parentId: number, hierarchy: Hierarchy, depth = 1): number[] => {
19
+ const childIds = hierarchy.children[parentId] ?? []
20
+ let allChildrenIds: number[] = [...childIds]
21
+ childIds.forEach((id) => {
22
+ allChildrenIds = allChildrenIds.concat(getChildrenToDepth(id, hierarchy, depth - 1))
23
+ })
24
+ return allChildrenIds
25
+ }
26
+
27
+ export const getAncestorAndSiblingIds = (
28
+ hierarchy: Hierarchy,
29
+ contentId: number,
30
+ depth = 1
31
+ ): number[] => {
32
+ if (depth > MAX_DEPTH) return []
33
+
34
+ const parentId = hierarchy?.parents?.[contentId]
35
+ if (!parentId) return []
36
+
37
+ if (parentId === contentId) return []
38
+
39
+ const siblingIds = hierarchy?.children?.[parentId] ?? []
40
+ const allIds = [
41
+ ...siblingIds,
42
+ parentId,
43
+ ...getAncestorAndSiblingIds(hierarchy, parentId, depth + 1),
44
+ ]
45
+ return [...new Set(allIds)]
46
+ }
47
+
48
+ export const averageProgressesFor = (
49
+ hierarchy: Hierarchy,
50
+ contentId: number,
51
+ progressData: Map<number, number>,
52
+ depth = 1
53
+ ): Record<number, number> => {
54
+ if (depth > MAX_DEPTH) return {}
55
+
56
+ const parentId = hierarchy?.parents?.[contentId]
57
+ if (!parentId) return {}
58
+
59
+ const parentChildProgress =
60
+ hierarchy?.children?.[parentId]?.map((childId) => progressData.get(childId) ?? 0) ?? []
61
+
62
+ const avgParentProgress =
63
+ parentChildProgress.length > 0
64
+ ? Math.round(parentChildProgress.reduce((a, b) => a + b, 0) / parentChildProgress.length)
65
+ : 0
66
+
67
+ return {
68
+ ...averageProgressesFor(hierarchy, parentId, progressData, depth + 1),
69
+ [parentId]: avgParentProgress,
70
+ }
71
+ }
72
+
73
+ export const trickleProgress = (
74
+ hierarchy: Hierarchy,
75
+ contentId: number,
76
+ progress: number
77
+ ): Record<number, number> => {
78
+ const descendantIds = getChildrenToDepth(contentId, hierarchy, MAX_DEPTH)
79
+ return Object.fromEntries(descendantIds.map((id) => [id, progress]))
80
+ }
81
+
82
+ export const bubbleProgress = async (
83
+ hierarchy: Hierarchy,
84
+ contentId: number,
85
+ collection?: CollectionParameter
86
+ ): Promise<Record<number, number>> => {
87
+ const ids = getAncestorAndSiblingIds(hierarchy, contentId)
88
+ const progresses = await getByIds(ids, (p) => p.progress_percent, 0, collection)
89
+ return averageProgressesFor(hierarchy, contentId, progresses)
90
+ }
91
+
92
+ export const computeBubbleTrickleProgresses = async (
93
+ contentId: number,
94
+ progress: number,
95
+ hierarchy: Hierarchy,
96
+ collection?: CollectionParameter,
97
+ { bubble = true, trickle = true }: { bubble?: boolean; trickle?: boolean } = {}
98
+ ): Promise<Record<number, number>> => ({
99
+ ...(trickle ? trickleProgress(hierarchy, contentId, progress) : {}),
100
+ ...(bubble ? await bubbleProgress(hierarchy, contentId, collection) : {}),
101
+ })
102
+
103
+ export interface BubbleAndTrickleOptions {
104
+ isResetAction?: boolean
105
+ accessedDirectly?: boolean
106
+ }
107
+
108
+ export const bubbleAndTrickleProgressesSafely = async (
109
+ progresses: Record<number, number>,
110
+ metadata: Record<number, ProgressMetadata>,
111
+ options: BubbleAndTrickleOptions = { accessedDirectly: true },
112
+ collection?: CollectionParameter
113
+ ): Promise<void> => {
114
+ let eraseProgresses: Record<number, number> = {}
115
+ let activeProgresses = progresses
116
+
117
+ if (options.isResetAction) {
118
+ eraseProgresses = Object.fromEntries(
119
+ Object.entries(progresses).filter(([, pct]) => pct === 0)
120
+ ) as Record<number, number>
121
+ activeProgresses = Object.fromEntries(
122
+ Object.entries(progresses).filter(([, pct]) => pct > 0)
123
+ ) as Record<number, number>
124
+ }
125
+
126
+ if (Object.keys(activeProgresses).length > 0) {
127
+ await db.contentProgress.recordProgressMany(activeProgresses, collection, metadata, {
128
+ skipPush: true,
129
+ accessedDirectly: options.accessedDirectly,
130
+ allowRegression: true,
131
+ })
132
+ }
133
+
134
+ if (Object.keys(eraseProgresses).length > 0) {
135
+ const eraseIds = Object.keys(eraseProgresses).map(Number)
136
+ await db.contentProgress.eraseProgressMany(eraseIds, collection, { skipPush: true })
137
+ }
138
+ }
@@ -1,9 +1,12 @@
1
1
  import { db } from '../../sync'
2
- import { CollectionParameter } from '../../sync/models/ContentProgress'
2
+ import type { ModelSerialized } from '../../sync/serializers'
3
+ import ContentProgress, { CollectionParameter } from '../../sync/models/ContentProgress'
4
+
5
+ type Selector<V> = (p: ModelSerialized<ContentProgress>) => V
3
6
 
4
7
  export const getByIds = async <V>(
5
8
  contentIds: number[],
6
- dataKey: string,
9
+ selector: Selector<V>,
7
10
  defaultValue: V,
8
11
  collection?: CollectionParameter
9
12
  ): Promise<Map<number, V>> => {
@@ -12,7 +15,7 @@ export const getByIds = async <V>(
12
15
  const progress = new Map(contentIds.map((id) => [id, defaultValue]))
13
16
  await db.contentProgress.getSomeProgressByContentIds(contentIds, collection).then((r) => {
14
17
  r.data.forEach((p) => {
15
- progress.set(p.content_id, p[dataKey] ?? defaultValue)
18
+ progress.set(p.content_id, selector(p) ?? defaultValue)
16
19
  })
17
20
  })
18
21
  return progress
@@ -20,24 +23,39 @@ export const getByIds = async <V>(
20
23
 
21
24
  export const getById = async <V>(
22
25
  contentId: number,
23
- dataKey: string,
26
+ selector: Selector<V>,
24
27
  defaultValue: V,
25
28
  collection?: CollectionParameter
26
29
  ): Promise<V> => {
27
30
  if (!contentId) return defaultValue
28
31
  return db.contentProgress
29
32
  .getOneProgressByContentId(contentId, collection)
30
- .then((r) => r.data?.[dataKey] ?? defaultValue)
33
+ .then((r) => (r.data ? selector(r.data) : defaultValue) ?? defaultValue)
31
34
  }
32
35
 
33
- export const getByRecordIds = async <V>(ids: string[], dataKey: string, defaultValue: V) => {
36
+ export const getByRecordIds = async <V>(ids: string[], selector: Selector<V>, defaultValue: V) => {
34
37
  const progress = Object.fromEntries(ids.map((id) => [id, defaultValue]))
35
38
 
36
39
  await db.contentProgress.getSomeProgressByRecordIds(ids).then((r) => {
37
40
  r.data.forEach((p) => {
38
- progress[p.id] = p[dataKey] ?? defaultValue
41
+ progress[p.id] = selector(p) ?? defaultValue
39
42
  })
40
43
  })
41
44
 
42
45
  return progress
43
46
  }
47
+
48
+ export const queryById =
49
+ <V>(selector: Selector<V>, defaultValue: V) =>
50
+ (contentId: number, collection?: CollectionParameter) =>
51
+ getById(contentId, selector, defaultValue, collection)
52
+
53
+ export const queryByIds =
54
+ <V>(selector: Selector<V>, defaultValue: V) =>
55
+ (contentIds: number[], collection?: CollectionParameter) =>
56
+ getByIds(contentIds, selector, defaultValue, collection)
57
+
58
+ export const queryByRecordIds =
59
+ <V>(selector: Selector<V>, defaultValue: V) =>
60
+ (ids: string[]) =>
61
+ getByRecordIds(ids, selector, defaultValue)
@@ -1,20 +1,32 @@
1
1
  import { db } from '../sync'
2
- import { COLLECTION_TYPE, CollectionParameter } from '../sync/models/ContentProgress'
3
- import { getById, getByIds, getByRecordIds } from './internal/queries'
2
+ import type { ModelSerialized } from '../sync/serializers'
3
+ import ContentProgress, { COLLECTION_TYPE, CollectionParameter } from '../sync/models/ContentProgress'
4
+ import { queryById, queryByIds, queryByRecordIds } from './internal/queries'
5
+ import type { ProgressSnapshot } from './types'
4
6
 
5
- export const state = async (contentId: number, collection?: CollectionParameter) =>
6
- getById(contentId, 'state', '', collection)
7
-
8
- export const stateByIds = async (contentIds: number[], collection?: CollectionParameter) =>
9
- getByIds(contentIds, 'state', '', collection)
10
-
11
- export const stateByRecordIds = async (ids: string[]) => getByRecordIds(ids, 'state', '')
7
+ const buildSnapshotMap = <K extends string | number>(
8
+ ids: K[],
9
+ records: ModelSerialized<ContentProgress>[],
10
+ keyOf: (p: ModelSerialized<ContentProgress>) => K,
11
+ lastUpdateOf: (p: ModelSerialized<ContentProgress>) => number
12
+ ): Map<K, ProgressSnapshot> => {
13
+ const overrides = new Map(
14
+ records.map((p) => [
15
+ keyOf(p),
16
+ { last_update: lastUpdateOf(p), progress: p.progress_percent, status: p.state as string },
17
+ ])
18
+ )
19
+ return new Map(
20
+ ids.map((id) => [id, overrides.get(id) ?? { last_update: 0, progress: 0, status: '' }])
21
+ )
22
+ }
12
23
 
13
- export const playbackPositionByIds = async (contentIds: number[], collection?: CollectionParameter) =>
14
- getByIds(contentIds, 'resume_time_seconds', 0, collection)
24
+ export const state = queryById((p) => p.state as string, '')
25
+ export const stateByIds = queryByIds((p) => p.state as string, '')
26
+ export const stateByRecordIds = queryByRecordIds((p) => p.state as string, '')
15
27
 
16
- export const playbackPositionByRecordIds = async (ids: string[]) =>
17
- getByRecordIds(ids, 'resume_time_seconds', 0)
28
+ export const playbackPositionByIds = queryByIds((p) => p.resume_time_seconds ?? 0, 0)
29
+ export const playbackPositionByRecordIds = queryByRecordIds((p) => p.resume_time_seconds ?? 0, 0)
18
30
 
19
31
  export const lastInteractedOf = (
20
32
  contentIds: number[],
@@ -48,3 +60,23 @@ export const incompleteLesson = (
48
60
 
49
61
  return ids[0]
50
62
  }
63
+
64
+ export const snapshotByIds = async (
65
+ contentIds: number[],
66
+ collection?: CollectionParameter
67
+ ): Promise<Map<number, ProgressSnapshot>> =>
68
+ db.contentProgress
69
+ .getSomeProgressByContentIds(contentIds, collection)
70
+ .then((r) => buildSnapshotMap(contentIds, r.data, (p) => p.content_id, (p) => p.last_interacted_a_la_carte))
71
+
72
+ export const snapshotByRecordIds = async (
73
+ ids: string[]
74
+ ): Promise<Record<string, ProgressSnapshot>> =>
75
+ db.contentProgress
76
+ .getSomeProgressByRecordIds(ids)
77
+ .then((r) => Object.fromEntries(buildSnapshotMap(ids, r.data, (p) => p.id, (p) => p.updated_at)))
78
+
79
+ export const methodAccessedIds = async (contentIds: number[]): Promise<number[]> =>
80
+ db.contentProgress
81
+ .getSomeProgressWhereLastAccessedFromMethod(contentIds)
82
+ .then((r) => r.data.map((record) => record.content_id))
@@ -1,3 +1,29 @@
1
+ export interface ProgressMetadata {
2
+ brand: string
3
+ parent_id: number
4
+ type: string
5
+ }
6
+
7
+ export interface Hierarchy {
8
+ parents: Record<number, number>
9
+ children: Record<number, number[]>
10
+ metadata?: Record<number, ProgressMetadata>
11
+ }
12
+
13
+ export interface RecordIdParts {
14
+ contentId: number
15
+ collection: {
16
+ type: string
17
+ id: number
18
+ }
19
+ }
20
+
21
+ export interface ProgressSnapshot {
22
+ last_update: number
23
+ progress: number
24
+ status: string
25
+ }
26
+
1
27
  export interface ProgressContentFilter {
2
28
  aLaCarte?: boolean
3
29
  learningPaths?: boolean
@@ -0,0 +1,28 @@
1
+ import {
2
+ COLLECTION_ID_SELF,
3
+ COLLECTION_TYPE,
4
+ CollectionParameter,
5
+ } from '../sync/models/ContentProgress'
6
+ import type { RecordIdParts } from './types'
7
+
8
+ export const generateRecordId = (
9
+ contentId: number,
10
+ collection?: CollectionParameter
11
+ ): string | null => {
12
+ return `${contentId}:${collection?.type || COLLECTION_TYPE.SELF}:${collection?.id || COLLECTION_ID_SELF}`
13
+ }
14
+
15
+ export const extractFromRecordId = (recordId: string): RecordIdParts => {
16
+ const parts = recordId.split(':')
17
+ const contentId = Number(parts[0])
18
+ const collectionType = parts[1]
19
+ const collectionId = Number(parts[2])
20
+
21
+ return {
22
+ contentId,
23
+ collection: {
24
+ type: collectionType || COLLECTION_TYPE.SELF,
25
+ id: collectionId || COLLECTION_ID_SELF,
26
+ },
27
+ }
28
+ }
@@ -4,7 +4,6 @@
4
4
 
5
5
  import { globalConfig } from './config.js'
6
6
  import { GET, HttpClient } from '../infrastructure/http/HttpClient.ts'
7
- import { fetchByRailContentIds } from './sanity.js'
8
7
 
9
8
  /**
10
9
  * Exported functions that are excluded from index generation.
@@ -14,7 +13,7 @@ import { fetchByRailContentIds } from './sanity.js'
14
13
  const excludeFromGeneratedIndex = []
15
14
 
16
15
  const RECOMMENDER_URL = 'https://recommender.musora.com'
17
- const recommenderClient = new HttpClient(RECOMMENDER_URL)
16
+ const recommenderClient = new HttpClient(RECOMMENDER_URL, null, null, null, 'omit')
18
17
 
19
18
  /**
20
19
  * Fetches similar content to the provided content id
@@ -32,23 +31,23 @@ export async function fetchSimilarItems(content_id, brand, count = 10) {
32
31
  if (!content_id) {
33
32
  return []
34
33
  }
35
- content_id = parseInt(content_id)
36
- const data = {
37
- brand: brand,
38
- content_ids: content_id,
39
- num_similar: count + 1,
40
- page_size: count + 1,
41
- page: 1,
42
- exclude_interacted: true
43
- }
44
- const url = `/similar_items/`
45
- try {
46
- const response = await recommenderClient.post(url, data)
47
- return response['similar_items'].filter((item) => item !== content_id).slice(0, count)
48
- } catch (error) {
49
- console.error('Fetch error:', error)
50
- return null
51
- }
34
+ content_id = parseInt(content_id)
35
+ const data = {
36
+ brand: brand,
37
+ content_ids: content_id,
38
+ num_similar: count + 1,
39
+ page_size: count + 1,
40
+ page: 1,
41
+ exclude_interacted: true,
42
+ }
43
+ const url = `/similar_items/`
44
+ try {
45
+ const response = await recommenderClient.post(url, data)
46
+ return response['similar_items'].filter((item) => item !== content_id).slice(0, count)
47
+ } catch (error) {
48
+ console.error('Fetch error:', error)
49
+ return null
50
+ }
52
51
  }
53
52
 
54
53
  /**
@@ -134,9 +133,10 @@ export async function rankItems(brand, content_ids) {
134
133
  export async function recommendations(brand, { section = '', contentTypes = [] } = {}) {
135
134
  section = section.toUpperCase().replace('-', '_')
136
135
  const sectionString = section ? `&section=${section}` : ''
137
- const contentTypesString = contentTypes.length > 0
138
- ? contentTypes.map(type => `&content_types[]=${encodeURIComponent(type)}`).join('')
139
- : ''
136
+ const contentTypesString =
137
+ contentTypes.length > 0
138
+ ? contentTypes.map((type) => `&content_types[]=${encodeURIComponent(type)}`).join('')
139
+ : ''
140
140
  const url = `/api/content/v1/recommendations?brand=${brand}${sectionString}${contentTypesString}`
141
141
  return await GET(url)
142
142
  }
@@ -148,6 +148,7 @@ export async function fetchRechargeTokens(): Promise<RechargeTokens> {
148
148
  * Upgrades the user's subscription or provides a prefilled add-to-cart URL.
149
149
  *
150
150
  * @param {boolean} featureFlag - MultiUserAccount feature Flag - default false
151
+ * @param {string} sku- product sku to upgrade to, required if feature flag is set
151
152
  *
152
153
  * @returns {Promise<UpgradeSubscriptionResponse>} A promise that resolves to an object containing either:
153
154
  * - {string} action - The action performed (e.g., 'instant_upgrade').
@@ -163,10 +164,11 @@ export async function fetchRechargeTokens(): Promise<RechargeTokens> {
163
164
  * .then(response => console.log(response))
164
165
  * .catch(error => console.error(error));
165
166
  */
166
- export async function upgradeSubscription(featureFlag = false): Promise<UpgradeSubscriptionResponse> {
167
+ export async function upgradeSubscription(featureFlag = false, sku): Promise<UpgradeSubscriptionResponse> {
167
168
  let featureFlagValue = featureFlag ? 1 : 0
169
+ let skuValue = sku ? `&sku=${sku}` : ''
168
170
  const httpClient = new HttpClient(globalConfig.baseUrl)
169
- return httpClient.get<UpgradeSubscriptionResponse>(`${baseUrl}/v1/update-subscription?${multiUserAccountFeatureFlag}=${featureFlagValue}`)
171
+ return httpClient.get<UpgradeSubscriptionResponse>(`${baseUrl}/v1/update-subscription?${multiUserAccountFeatureFlag}=${featureFlagValue}${skuValue}`)
170
172
  }
171
173
 
172
174
  /**
@@ -35,6 +35,22 @@ describe('HttpClient', () => {
35
35
  })
36
36
  })
37
37
 
38
+ describe('credentials', () => {
39
+ test('should default to include', async () => {
40
+ const client = new HttpClient(baseUrl, token, mockHeaderProvider, mockRequestExecutor)
41
+ await client.get('/test')
42
+ const options = mockRequestExecutor.execute.mock.calls[0][1]
43
+ expect(options.credentials).toBe('include')
44
+ })
45
+
46
+ test('should use provided credentials value', async () => {
47
+ const client = new HttpClient(baseUrl, token, mockHeaderProvider, mockRequestExecutor, 'omit')
48
+ await client.get('/test')
49
+ const options = mockRequestExecutor.execute.mock.calls[0][1]
50
+ expect(options.credentials).toBe('omit')
51
+ })
52
+ })
53
+
38
54
  describe('setToken', () => {
39
55
  test('should update the token', () => {
40
56
  const newToken = 'new-test-token'
@@ -0,0 +1,47 @@
1
+ import { fetchSimilarItems } from '../../src/services/recommendations.js'
2
+
3
+ jest.mock('../../src/infrastructure/http/HttpClient.ts', () => {
4
+ const mockPost = jest.fn()
5
+ const MockHttpClient = jest.fn().mockImplementation(() => ({ post: mockPost }))
6
+ MockHttpClient._mockPost = mockPost
7
+ return { HttpClient: MockHttpClient, GET: jest.fn() }
8
+ })
9
+
10
+ const mockPost = () => require('../../src/infrastructure/http/HttpClient.ts').HttpClient._mockPost
11
+
12
+ describe('fetchSimilarItems', () => {
13
+ beforeEach(() => {
14
+ mockPost().mockReset()
15
+ })
16
+
17
+ test('returns empty array when content_id is falsy', async () => {
18
+ expect(await fetchSimilarItems(null, 'drumeo')).toEqual([])
19
+ expect(await fetchSimilarItems(0, 'drumeo')).toEqual([])
20
+ expect(await fetchSimilarItems('', 'drumeo')).toEqual([])
21
+ })
22
+
23
+ test('parses string content_id to integer for filtering', async () => {
24
+ mockPost().mockResolvedValue({ similar_items: [42, 99] })
25
+ const result = await fetchSimilarItems('42', 'drumeo', 10)
26
+ expect(result).not.toContain(42)
27
+ expect(result).toEqual([99])
28
+ })
29
+
30
+ test('filters out the requested content_id from results', async () => {
31
+ mockPost().mockResolvedValue({ similar_items: [1, 2, 3] })
32
+ const result = await fetchSimilarItems(2, 'drumeo', 10)
33
+ expect(result).toEqual([1, 3])
34
+ })
35
+
36
+ test('respects count limit', async () => {
37
+ mockPost().mockResolvedValue({ similar_items: [10, 20, 30, 40, 50, 60] })
38
+ const result = await fetchSimilarItems(99, 'drumeo', 3)
39
+ expect(result).toHaveLength(3)
40
+ })
41
+
42
+ test('returns null on error', async () => {
43
+ mockPost().mockRejectedValue(new Error('network failure'))
44
+ const result = await fetchSimilarItems(1, 'drumeo')
45
+ expect(result).toBeNull()
46
+ })
47
+ })
@@ -0,0 +1,279 @@
1
+ let mockProgressRecords: any[] = []
2
+ let mockRecordProgressMany = jest.fn()
3
+ let mockEraseProgressMany = jest.fn()
4
+
5
+ const repoMocks = {
6
+ contentProgress: {
7
+ getSomeProgressByContentIds: jest.fn().mockImplementation((contentIds: number[]) => {
8
+ const records = mockProgressRecords.filter((r) => contentIds.includes(r.content_id))
9
+ return Promise.resolve({ data: records })
10
+ }),
11
+ recordProgressMany: mockRecordProgressMany,
12
+ eraseProgressMany: mockEraseProgressMany,
13
+ },
14
+ }
15
+
16
+ jest.mock('../../../../src/services/sync/repository-proxy', () => ({
17
+ __esModule: true,
18
+ default: repoMocks,
19
+ ...repoMocks,
20
+ }))
21
+
22
+ import {
23
+ averageProgressesFor,
24
+ bubbleAndTrickleProgressesSafely,
25
+ bubbleProgress,
26
+ computeBubbleTrickleProgresses,
27
+ filterOutNegativeProgress,
28
+ getAncestorAndSiblingIds,
29
+ getChildrenToDepth,
30
+ trickleProgress,
31
+ } from '../../../../src/services/progress/internal/bubble'
32
+ import type { Hierarchy } from '../../../../src/services/progress/types'
33
+ import { COLLECTION_TYPE } from '../../../../src/services/sync/models/ContentProgress'
34
+
35
+ beforeEach(() => {
36
+ jest.clearAllMocks()
37
+ mockProgressRecords = []
38
+ mockRecordProgressMany.mockResolvedValue({ data: null })
39
+ mockEraseProgressMany.mockResolvedValue({ data: null })
40
+ })
41
+
42
+ describe('filterOutNegativeProgress', () => {
43
+ test('drops entry when new progress is less than existing', () => {
44
+ const result = filterOutNegativeProgress(
45
+ { 101: 30 },
46
+ { 101: { progress: 70, last_update: 0, status: 'started' } }
47
+ )
48
+ expect(result).not.toHaveProperty('101')
49
+ })
50
+
51
+ test('keeps entry when new progress equals existing', () => {
52
+ const result = filterOutNegativeProgress(
53
+ { 101: 50 },
54
+ { 101: { progress: 50, last_update: 0, status: 'started' } }
55
+ )
56
+ expect(result[101]).toBe(50)
57
+ })
58
+
59
+ test('keeps entry when new progress is greater than existing', () => {
60
+ const result = filterOutNegativeProgress(
61
+ { 101: 80 },
62
+ { 101: { progress: 50, last_update: 0, status: 'started' } }
63
+ )
64
+ expect(result[101]).toBe(80)
65
+ })
66
+
67
+ test('keeps entry when no existing snapshot', () => {
68
+ const result = filterOutNegativeProgress({ 101: 10 }, {})
69
+ expect(result[101]).toBe(10)
70
+ })
71
+
72
+ test('drops only entries below existing, keeps others in mixed set', () => {
73
+ const result = filterOutNegativeProgress(
74
+ { 101: 20, 102: 80, 103: 40 },
75
+ {
76
+ 101: { progress: 70, last_update: 0, status: 'started' },
77
+ 102: { progress: 20, last_update: 0, status: 'started' },
78
+ 103: { progress: 0, last_update: 0, status: '' },
79
+ }
80
+ )
81
+ expect(result).not.toHaveProperty('101')
82
+ expect(result[102]).toBe(80)
83
+ expect(result[103]).toBe(40)
84
+ })
85
+
86
+ test('returns new object, does not mutate input', () => {
87
+ const progresses = { 101: 10 }
88
+ const result = filterOutNegativeProgress(progresses, {
89
+ 101: { progress: 50, last_update: 0, status: 'started' },
90
+ })
91
+ expect(result).not.toBe(progresses)
92
+ expect(progresses[101]).toBe(10)
93
+ })
94
+ })
95
+
96
+ describe('getChildrenToDepth', () => {
97
+ const hierarchy: Hierarchy = {
98
+ parents: { 2: 1, 3: 1, 4: 2 },
99
+ children: { 1: [2, 3], 2: [4], 3: [], 4: [] },
100
+ }
101
+
102
+ test('returns direct children', () => {
103
+ expect(getChildrenToDepth(1, hierarchy)).toEqual(expect.arrayContaining([2, 3]))
104
+ })
105
+
106
+ test('returns empty when no children', () => {
107
+ expect(getChildrenToDepth(4, hierarchy)).toEqual([])
108
+ })
109
+
110
+ test('returns empty for unknown parent', () => {
111
+ expect(getChildrenToDepth(99, hierarchy)).toEqual([])
112
+ })
113
+ })
114
+
115
+ describe('getAncestorAndSiblingIds', () => {
116
+ const hierarchy: Hierarchy = {
117
+ parents: { 2: 1, 3: 1, 4: 2, 5: 2 },
118
+ children: { 1: [2, 3], 2: [4, 5] },
119
+ }
120
+
121
+ test('returns siblings and parent for child node', () => {
122
+ const result = getAncestorAndSiblingIds(hierarchy, 4)
123
+ expect(result).toEqual(expect.arrayContaining([5, 2]))
124
+ })
125
+
126
+ test('returns empty for root', () => {
127
+ expect(getAncestorAndSiblingIds(hierarchy, 1)).toEqual([])
128
+ })
129
+
130
+ test('handles circular dependency without infinite loop', () => {
131
+ const circular: Hierarchy = { parents: { 1: 1 }, children: { 1: [] } }
132
+ expect(getAncestorAndSiblingIds(circular, 1)).toEqual([])
133
+ })
134
+ })
135
+
136
+ describe('averageProgressesFor', () => {
137
+ const hierarchy: Hierarchy = {
138
+ parents: { 2: 1, 3: 1, 4: 2, 5: 2 },
139
+ children: { 1: [2, 3], 2: [4, 5] },
140
+ }
141
+
142
+ test('averages child progresses up the chain', () => {
143
+ const progress = new Map<number, number>([
144
+ [4, 100],
145
+ [5, 0],
146
+ [2, 50],
147
+ [3, 50],
148
+ ])
149
+ const result = averageProgressesFor(hierarchy, 4, progress)
150
+ expect(result[2]).toBe(50)
151
+ expect(result[1]).toBe(50)
152
+ })
153
+
154
+ test('returns empty for root', () => {
155
+ expect(averageProgressesFor(hierarchy, 1, new Map())).toEqual({})
156
+ })
157
+
158
+ test('treats missing child progress as 0', () => {
159
+ const progress = new Map<number, number>([[4, 80]])
160
+ const result = averageProgressesFor(hierarchy, 4, progress)
161
+ expect(result[2]).toBe(40)
162
+ })
163
+ })
164
+
165
+ describe('trickleProgress', () => {
166
+ const hierarchy: Hierarchy = {
167
+ parents: { 2: 1, 3: 1, 4: 2 },
168
+ children: { 1: [2, 3], 2: [4], 3: [], 4: [] },
169
+ }
170
+
171
+ test('returns descendants with given progress', () => {
172
+ const result = trickleProgress(hierarchy, 1, 75)
173
+ expect(result[2]).toBe(75)
174
+ expect(result[3]).toBe(75)
175
+ })
176
+
177
+ test('returns empty when no descendants', () => {
178
+ expect(trickleProgress(hierarchy, 4, 100)).toEqual({})
179
+ })
180
+ })
181
+
182
+ describe('bubbleProgress', () => {
183
+ const hierarchy: Hierarchy = {
184
+ parents: { 2: 1, 3: 1 },
185
+ children: { 1: [2, 3] },
186
+ }
187
+
188
+ test('aggregates sibling progresses into parent', async () => {
189
+ mockProgressRecords = [
190
+ { content_id: 2, progress_percent: 100 },
191
+ { content_id: 3, progress_percent: 0 },
192
+ ]
193
+ const result = await bubbleProgress(hierarchy, 2)
194
+ expect(result[1]).toBe(50)
195
+ })
196
+ })
197
+
198
+ describe('computeBubbleTrickleProgresses', () => {
199
+ const hierarchy: Hierarchy = {
200
+ parents: { 2: 1 },
201
+ children: { 1: [2], 2: [] },
202
+ }
203
+
204
+ test('combines bubble and trickle', async () => {
205
+ mockProgressRecords = [{ content_id: 2, progress_percent: 80 }]
206
+ const result = await computeBubbleTrickleProgresses(2, 80, hierarchy)
207
+ expect(result[1]).toBe(80)
208
+ })
209
+
210
+ test('respects { bubble: false }', async () => {
211
+ mockProgressRecords = [{ content_id: 2, progress_percent: 80 }]
212
+ const result = await computeBubbleTrickleProgresses(2, 80, hierarchy, undefined, {
213
+ bubble: false,
214
+ })
215
+ expect(result).not.toHaveProperty('1')
216
+ })
217
+
218
+ test('respects { trickle: false }', async () => {
219
+ const result = await computeBubbleTrickleProgresses(1, 50, hierarchy, undefined, {
220
+ trickle: false,
221
+ })
222
+ expect(result).not.toHaveProperty('2')
223
+ })
224
+ })
225
+
226
+ describe('bubbleAndTrickleProgressesSafely', () => {
227
+ test('passes positive progresses to recordProgressMany', async () => {
228
+ await bubbleAndTrickleProgressesSafely({ 1: 50, 2: 100 }, {})
229
+ expect(mockRecordProgressMany).toHaveBeenCalledWith(
230
+ { 1: 50, 2: 100 },
231
+ undefined,
232
+ {},
233
+ { skipPush: true, accessedDirectly: true, allowRegression: true }
234
+ )
235
+ expect(mockEraseProgressMany).not.toHaveBeenCalled()
236
+ })
237
+
238
+ test('isResetAction separates zero progresses into eraseProgressMany', async () => {
239
+ await bubbleAndTrickleProgressesSafely(
240
+ { 1: 50, 2: 0, 3: 0 },
241
+ {},
242
+ { isResetAction: true }
243
+ )
244
+ expect(mockRecordProgressMany).toHaveBeenCalledWith(
245
+ { 1: 50 },
246
+ undefined,
247
+ {},
248
+ expect.objectContaining({ allowRegression: true })
249
+ )
250
+ expect(mockEraseProgressMany).toHaveBeenCalledWith([2, 3], undefined, { skipPush: true })
251
+ })
252
+
253
+ test('forwards collection argument', async () => {
254
+ const collection = { type: COLLECTION_TYPE.LEARNING_PATH, id: 10 }
255
+ await bubbleAndTrickleProgressesSafely({ 1: 50 }, {}, {}, collection)
256
+ expect(mockRecordProgressMany).toHaveBeenCalledWith(
257
+ { 1: 50 },
258
+ collection,
259
+ {},
260
+ expect.anything()
261
+ )
262
+ })
263
+
264
+ test('forwards accessedDirectly option', async () => {
265
+ await bubbleAndTrickleProgressesSafely({ 1: 50 }, {}, { accessedDirectly: false })
266
+ expect(mockRecordProgressMany).toHaveBeenCalledWith(
267
+ { 1: 50 },
268
+ undefined,
269
+ {},
270
+ expect.objectContaining({ accessedDirectly: false })
271
+ )
272
+ })
273
+
274
+ test('skips both calls when all empty', async () => {
275
+ await bubbleAndTrickleProgressesSafely({}, {})
276
+ expect(mockRecordProgressMany).not.toHaveBeenCalled()
277
+ expect(mockEraseProgressMany).not.toHaveBeenCalled()
278
+ })
279
+ })
@@ -5,6 +5,7 @@ let mockStarted: any = { data: [] }
5
5
  let mockCompleted: any = { data: [] }
6
6
  let mockCompletedByContentIds: any = { data: [] }
7
7
  let mockStartedOrCompleted: any = { data: [] }
8
+ let mockMethodAccessed: any = { data: [] }
8
9
 
9
10
  const repoMocks = {
10
11
  contentProgress: {
@@ -29,6 +30,9 @@ const repoMocks = {
29
30
  .fn()
30
31
  .mockImplementation(() => Promise.resolve(mockCompletedByContentIds)),
31
32
  startedOrCompleted: jest.fn().mockImplementation(() => Promise.resolve(mockStartedOrCompleted)),
33
+ getSomeProgressWhereLastAccessedFromMethod: jest
34
+ .fn()
35
+ .mockImplementation(() => Promise.resolve(mockMethodAccessed)),
32
36
  },
33
37
  }
34
38
 
@@ -39,7 +43,7 @@ jest.mock('../../../src/services/sync/repository-proxy', () => ({
39
43
  }))
40
44
 
41
45
  import { Progress } from '../../../src/services/progress'
42
- import { COLLECTION_TYPE } from '../../../src/services/sync/models/ContentProgress'
46
+ import { COLLECTION_ID_SELF, COLLECTION_TYPE } from '../../../src/services/sync/models/ContentProgress'
43
47
 
44
48
  beforeEach(() => {
45
49
  jest.clearAllMocks()
@@ -50,6 +54,7 @@ beforeEach(() => {
50
54
  mockCompleted = { data: [] }
51
55
  mockCompletedByContentIds = { data: [] }
52
56
  mockStartedOrCompleted = { data: [] }
57
+ mockMethodAccessed = { data: [] }
53
58
  })
54
59
 
55
60
  describe('Progress.state', () => {
@@ -298,3 +303,94 @@ describe('Progress.allStartedOrCompleted', () => {
298
303
  expect(args.updatedAfter).toBeLessThanOrEqual(after)
299
304
  })
300
305
  })
306
+
307
+ describe('Progress.snapshotByIds', () => {
308
+ test('returns snapshot keyed by content_id with defaults for missing', async () => {
309
+ mockProgressRecords = [
310
+ { content_id: 1, last_interacted_a_la_carte: 111, progress_percent: 50, state: 'started' },
311
+ ]
312
+ const result = await Progress.snapshotByIds([1, 2])
313
+ expect(result.get(1)).toEqual({ last_update: 111, progress: 50, status: 'started' })
314
+ expect(result.get(2)).toEqual({ last_update: 0, progress: 0, status: '' })
315
+ })
316
+
317
+ test('returns empty defaults when no records match', async () => {
318
+ const result = await Progress.snapshotByIds([99])
319
+ expect(result.get(99)).toEqual({ last_update: 0, progress: 0, status: '' })
320
+ })
321
+ })
322
+
323
+ describe('Progress.snapshotByRecordIds', () => {
324
+ test('returns snapshot keyed by record id with defaults for missing', async () => {
325
+ mockRecordsById = {
326
+ '1:self:0': { id: '1:self:0', updated_at: 222, progress_percent: 75, state: 'completed' },
327
+ }
328
+ const result = await Progress.snapshotByRecordIds(['1:self:0', '2:self:0'])
329
+ expect(result['1:self:0']).toEqual({ last_update: 222, progress: 75, status: 'completed' })
330
+ expect(result['2:self:0']).toEqual({ last_update: 0, progress: 0, status: '' })
331
+ })
332
+ })
333
+
334
+ describe('Progress.methodAccessedIds', () => {
335
+ test('returns content_ids from records', async () => {
336
+ mockMethodAccessed = { data: [{ content_id: 10 }, { content_id: 20 }] }
337
+ const result = await Progress.methodAccessedIds([10, 20, 30])
338
+ expect(result).toEqual([10, 20])
339
+ })
340
+
341
+ test('returns empty array when no records', async () => {
342
+ const result = await Progress.methodAccessedIds([1])
343
+ expect(result).toEqual([])
344
+ })
345
+
346
+ test('forwards contentIds to repository', async () => {
347
+ await Progress.methodAccessedIds([5, 6])
348
+ expect(
349
+ repoMocks.contentProgress.getSomeProgressWhereLastAccessedFromMethod
350
+ ).toHaveBeenCalledWith([5, 6])
351
+ })
352
+ })
353
+
354
+ describe('Progress.generateRecordId', () => {
355
+ test('no collection uses SELF defaults', () => {
356
+ expect(Progress.generateRecordId(123)).toBe(
357
+ `123:${COLLECTION_TYPE.SELF}:${COLLECTION_ID_SELF}`
358
+ )
359
+ })
360
+
361
+ test('LP collection returns correct format', () => {
362
+ expect(
363
+ Progress.generateRecordId(123, { type: COLLECTION_TYPE.LEARNING_PATH, id: 456 })
364
+ ).toBe('123:learning-path-v2:456')
365
+ })
366
+ })
367
+
368
+ describe('Progress.extractFromRecordId', () => {
369
+ test('self record id parses correctly', () => {
370
+ expect(Progress.extractFromRecordId('123:self:0')).toEqual({
371
+ contentId: 123,
372
+ collection: { type: 'self', id: 0 },
373
+ })
374
+ })
375
+
376
+ test('LP record id parses correctly', () => {
377
+ expect(Progress.extractFromRecordId('456:learning-path-v2:789')).toEqual({
378
+ contentId: 456,
379
+ collection: { type: 'learning-path-v2', id: 789 },
380
+ })
381
+ })
382
+
383
+ test('missing collection type defaults to SELF', () => {
384
+ expect(Progress.extractFromRecordId('123::')).toEqual({
385
+ contentId: 123,
386
+ collection: { type: COLLECTION_TYPE.SELF, id: COLLECTION_ID_SELF },
387
+ })
388
+ })
389
+
390
+ test('missing collection id defaults to COLLECTION_ID_SELF', () => {
391
+ expect(Progress.extractFromRecordId('123:self:')).toEqual({
392
+ contentId: 123,
393
+ collection: { type: 'self', id: COLLECTION_ID_SELF },
394
+ })
395
+ })
396
+ })