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 +19 -0
- package/package.json +1 -1
- package/src/contentTypeConfig.js +8 -1
- package/src/infrastructure/http/HttpClient.ts +10 -7
- package/src/services/multi-user-accounts/multi-user-accounts.ts +3 -1
- package/src/services/progress/index.ts +9 -2
- package/src/services/progress/internal/bubble.ts +138 -0
- package/src/services/progress/internal/queries.ts +25 -7
- package/src/services/progress/state.ts +45 -13
- 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 -2
- 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
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
package/src/contentTypeConfig.js
CHANGED
|
@@ -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) =>
|
|
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
|
|
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) {
|
|
@@ -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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
33
|
+
.then((r) => (r.data ? selector(r.data) : defaultValue) ?? defaultValue)
|
|
31
34
|
}
|
|
32
35
|
|
|
33
|
-
export const getByRecordIds = async <V>(ids: string[],
|
|
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
|
|
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 {
|
|
3
|
-
import {
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
14
|
-
|
|
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
|
|
17
|
-
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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
|
+
})
|