musora-content-services 2.165.4 → 2.165.5

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.
@@ -0,0 +1,52 @@
1
+ {
2
+ "permissions": {
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 *)",
10
+ "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
+ "mcp__github__pull_request_read",
22
+ "mcp__github__push_files",
23
+ "Bash(git push *)",
24
+ "mcp__github__create_pull_request",
25
+ "mcp__github__search_pull_requests",
26
+ "Bash(git commit *)",
27
+ "Bash(git add *)"
28
+ ]
29
+ },
30
+ "model": "sonnet",
31
+ "enabledMcpjsonServers": [
32
+ "atlassian",
33
+ "figma",
34
+ "google-workspace",
35
+ "snowflake",
36
+ "aws",
37
+ "hex",
38
+ "sanity",
39
+ "mysql",
40
+ "slack",
41
+ "langfuse",
42
+ "chrome-devtools",
43
+ "railway",
44
+ "github",
45
+ "asana"
46
+ ],
47
+ "disabledMcpjsonServers": [
48
+ "nightwatch",
49
+ "1password"
50
+ ],
51
+ "effortLevel": "medium"
52
+ }
package/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
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.165.5](https://github.com/railroadmedia/musora-content-services/compare/v2.165.4...v2.165.5) (2026-06-25)
6
+
7
+
8
+ ### Bug Fixes
9
+
10
+ * **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))
11
+
5
12
  ### [2.165.4](https://github.com/railroadmedia/musora-content-services/compare/v2.165.3...v2.165.4) (2026-06-19)
6
13
 
7
14
  ### [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.165.5",
4
4
  "description": "A package for Musoras content services ",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -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) {
@@ -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, '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,6 +1,7 @@
1
1
  import { db } from '../sync'
2
2
  import { COLLECTION_TYPE, CollectionParameter } from '../sync/models/ContentProgress'
3
3
  import { getById, getByIds, getByRecordIds } from './internal/queries'
4
+ import type { ProgressSnapshot } from './types'
4
5
 
5
6
  export const state = async (contentId: number, collection?: CollectionParameter) =>
6
7
  getById(contentId, 'state', '', collection)
@@ -48,3 +49,49 @@ export const incompleteLesson = (
48
49
 
49
50
  return ids[0]
50
51
  }
52
+
53
+ export const snapshotByIds = async (
54
+ contentIds: number[],
55
+ collection?: CollectionParameter
56
+ ): Promise<Map<number, ProgressSnapshot>> => {
57
+ const result = new Map<number, ProgressSnapshot>(
58
+ contentIds.map((id) => [id, { last_update: 0, progress: 0, status: '' }])
59
+ )
60
+
61
+ await db.contentProgress.getSomeProgressByContentIds(contentIds, collection).then((r) => {
62
+ r.data.forEach((p) => {
63
+ result.set(p.content_id, {
64
+ last_update: p.last_interacted_a_la_carte,
65
+ progress: p.progress_percent,
66
+ status: p.state,
67
+ })
68
+ })
69
+ })
70
+
71
+ return result
72
+ }
73
+
74
+ export const snapshotByRecordIds = async (
75
+ ids: string[]
76
+ ): Promise<Record<string, ProgressSnapshot>> => {
77
+ const result = Object.fromEntries(
78
+ ids.map((id) => [id, { last_update: 0, progress: 0, status: '' }])
79
+ ) as Record<string, ProgressSnapshot>
80
+
81
+ await db.contentProgress.getSomeProgressByRecordIds(ids).then((r) => {
82
+ r.data.forEach((p) => {
83
+ result[p.id] = {
84
+ last_update: p.updated_at,
85
+ progress: p.progress_percent,
86
+ status: p.state,
87
+ }
88
+ })
89
+ })
90
+
91
+ return result
92
+ }
93
+
94
+ export const methodAccessedIds = async (contentIds: number[]): Promise<number[]> =>
95
+ db.contentProgress
96
+ .getSomeProgressWhereLastAccessedFromMethod(contentIds)
97
+ .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
  }
@@ -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
+ })