musora-content-services 2.165.1 → 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.
- package/.claude/settings.local.json +6 -2
- package/CHANGELOG.md +13 -0
- package/package.json +1 -1
- package/src/infrastructure/http/HttpClient.ts +10 -7
- package/src/services/progress/index.ts +9 -2
- package/src/services/progress/internal/bubble.ts +138 -0
- package/src/services/progress/state.ts +47 -0
- package/src/services/progress/types.ts +26 -0
- package/src/services/progress/utils.ts +28 -0
- package/src/services/recommendations.js +22 -22
- package/src/services/user/memberships.ts +4 -0
- package/test/unit/HttpClient.test.ts +16 -0
- package/test/unit/recommendations.test.js +47 -0
- package/test/unit/services/progress-internal/bubble.test.ts +279 -0
- package/test/unit/services/progress.test.ts +97 -1
|
@@ -21,7 +21,10 @@
|
|
|
21
21
|
"mcp__github__pull_request_read",
|
|
22
22
|
"mcp__github__push_files",
|
|
23
23
|
"Bash(git push *)",
|
|
24
|
-
"mcp__github__create_pull_request"
|
|
24
|
+
"mcp__github__create_pull_request",
|
|
25
|
+
"mcp__github__search_pull_requests",
|
|
26
|
+
"Bash(git commit *)",
|
|
27
|
+
"Bash(git add *)"
|
|
25
28
|
]
|
|
26
29
|
},
|
|
27
30
|
"model": "sonnet",
|
|
@@ -42,7 +45,8 @@
|
|
|
42
45
|
"asana"
|
|
43
46
|
],
|
|
44
47
|
"disabledMcpjsonServers": [
|
|
45
|
-
"nightwatch"
|
|
48
|
+
"nightwatch",
|
|
49
|
+
"1password"
|
|
46
50
|
],
|
|
47
51
|
"effortLevel": "medium"
|
|
48
52
|
}
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
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
|
+
|
|
12
|
+
### [2.165.4](https://github.com/railroadmedia/musora-content-services/compare/v2.165.3...v2.165.4) (2026-06-19)
|
|
13
|
+
|
|
14
|
+
### [2.165.3](https://github.com/railroadmedia/musora-content-services/compare/v2.165.1...v2.165.3) (2026-06-19)
|
|
15
|
+
|
|
16
|
+
### [2.165.2](https://github.com/railroadmedia/musora-content-services/compare/v2.165.1...v2.165.2) (2026-06-19)
|
|
17
|
+
|
|
5
18
|
### [2.165.1](https://github.com/railroadmedia/musora-content-services/compare/v2.165.0...v2.165.1) (2026-06-19)
|
|
6
19
|
|
|
7
20
|
## [2.165.0](https://github.com/railroadmedia/musora-content-services/compare/v2.164.6...v2.165.0) (2026-06-18)
|
package/package.json
CHANGED
|
@@ -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
|
|
24
|
-
headerProvider
|
|
25
|
-
requestExecutor
|
|
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:
|
|
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 {
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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 ? `§ion=${section}` : ''
|
|
137
|
-
const contentTypesString =
|
|
138
|
-
|
|
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
|
+
})
|