musora-content-services 2.158.2 → 2.159.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.
Files changed (54) hide show
  1. package/.claude/settings.local.json +12 -0
  2. package/.github/workflows/automated-testing.yml +21 -1
  3. package/CHANGELOG.md +15 -0
  4. package/README.md +21 -2
  5. package/jest.config.js +1 -4
  6. package/jest.integration.config.js +6 -0
  7. package/jest.live.config.js +1 -5
  8. package/package.json +5 -2
  9. package/src/contentTypeConfig.js +8 -5
  10. package/src/index.d.ts +2 -6
  11. package/src/index.js +2 -6
  12. package/src/services/content-org/learning-paths.ts +44 -39
  13. package/src/services/contentAggregator.js +1 -1
  14. package/src/services/contentProgress.js +216 -207
  15. package/src/services/offline/progress.ts +107 -27
  16. package/src/services/sanity.js +55 -64
  17. package/src/services/sync/models/ContentProgress.ts +50 -34
  18. package/src/services/sync/repositories/content-progress.ts +105 -92
  19. package/test/{unit → integration}/awards/award-exclusion-handling.test.ts +2 -2
  20. package/test/integration/content-progress/__mocks__/mocks.ts +104 -0
  21. package/test/integration/content-progress/contentProgress.test.ts +335 -0
  22. package/test/integration/content-progress/e2eOfflineProgress.test.ts +352 -0
  23. package/test/integration/content-progress/e2eProgress.test.ts +612 -0
  24. package/test/integration/content-progress/getters.test.ts +334 -0
  25. package/test/integration/content-progress/helpers.test.ts +263 -0
  26. package/test/integration/content-progress/offlineContentProgress.test.ts +226 -0
  27. package/test/integration/forums.test.ts +209 -0
  28. package/test/integration/initializeTestDB.ts +80 -0
  29. package/test/{unit → integration}/sync/fetch.test.ts +1 -1
  30. package/test/{unit → integration}/sync/repositories/content-likes.test.ts +1 -1
  31. package/test/{unit → integration}/sync/repositories/practices.test.ts +1 -1
  32. package/test/{unit → integration}/sync/repositories/progress.test.ts +1 -1
  33. package/test/{unit → integration}/sync/repositories/user-award-progress.test.ts +1 -1
  34. package/test/{unit → integration}/sync/store/cross-user-protection.test.ts +2 -2
  35. package/test/{unit → integration}/sync/store/store-idb.test.ts +2 -2
  36. package/test/{unit → integration}/sync/store/store.test.ts +2 -2
  37. package/test/unit/content-progress/bubbleTrickle.test.ts +322 -0
  38. package/test/unit/content-progress/helpers.test.ts +329 -0
  39. package/test/unit/content-progress/navigateTo.test.ts +381 -0
  40. package/test/unit/contentMetaData.test.ts +58 -0
  41. package/tools/generate-index.cjs +6 -3
  42. package/test/SKIPPED_TESTS.md +0 -151
  43. package/test/integration/content.test.js +0 -107
  44. package/test/integration/contentProgress.test.js +0 -73
  45. package/test/integration/forum.test.js +0 -16
  46. package/test/integration/sanityQueryService.test.js +0 -681
  47. package/test/unit/contentProgress.test.ts +0 -81
  48. /package/test/{unit → integration}/awards/internal/image-utils.test.ts +0 -0
  49. /package/test/{unit → integration}/infrastructure/FetchRequestExecutor.test.ts +0 -0
  50. /package/test/{unit → integration}/notifications.test.ts +0 -0
  51. /package/test/{unit → integration}/sync/adapters/idb-errors.test.ts +0 -0
  52. /package/test/{unit → integration}/sync/adapters/sqlite-errors.test.ts +0 -0
  53. /package/test/{unit → integration}/sync/repositories/user-award-progress.static.test.ts +0 -0
  54. /package/test/{unit → integration}/userActivity.test.ts +0 -0
@@ -1,20 +1,13 @@
1
1
  import BaseModel from './Base'
2
2
  import { SYNC_TABLES } from '../schema'
3
- import {
4
- positiveInt,
5
- nullableUint16,
6
- uint32,
7
- string,
8
- percent,
9
- mediumint,
10
- enumValue,
11
- } from '../errors/validators'
3
+ import { enumValue, mediumint, nullableUint16, percent, positiveInt, string, uint32 } from '../errors/validators'
12
4
 
13
5
  export enum COLLECTION_TYPE {
14
6
  SELF = 'self',
15
7
  LEARNING_PATH = 'learning-path-v2',
16
8
  PLAYLIST = 'playlist',
17
9
  }
10
+
18
11
  export const COLLECTION_ID_SELF = 0
19
12
 
20
13
  export const PARENT_ID_TOP_LEVEL = 0
@@ -49,68 +42,85 @@ export default class ContentProgress extends BaseModel {
49
42
  get content_id() {
50
43
  return this._getRaw('content_id') as number
51
44
  }
45
+
46
+ set content_id(value: number) {
47
+ this._setRaw('content_id', validators.content_id(value))
48
+ }
49
+
52
50
  get content_brand() {
53
51
  return this._getRaw('content_brand') as string
54
52
  }
53
+
54
+ set content_brand(value: string) {
55
+ this._setRaw('content_brand', validators.content_brand(value))
56
+ }
57
+
55
58
  get content_type() {
56
59
  return this._getRaw('content_type') as string
57
60
  }
61
+
62
+ set content_type(value: string) {
63
+ this._setRaw('content_type', validators.content_type(value))
64
+ }
65
+
58
66
  get content_parent_id() {
59
67
  return this._getRaw('content_parent_id') as number
60
68
  }
69
+
70
+ set content_parent_id(value: number) {
71
+ this._setRaw('content_parent_id', validators.content_parent_id(value))
72
+ }
73
+
61
74
  get state() {
62
75
  return this._getRaw('state') as STATE
63
76
  }
77
+
64
78
  get progress_percent() {
65
79
  return this._getRaw('progress_percent') as number
66
80
  }
67
- get collection_type() {
68
- return this._getRaw('collection_type') as COLLECTION_TYPE
69
- }
70
- get collection_id() {
71
- return this._getRaw('collection_id') as number
72
- }
73
- get resume_time_seconds() {
74
- return this._getRaw('resume_time_seconds') as number | null
75
- }
76
- get last_interacted_a_la_carte() {
77
- return this._getRaw('last_interacted_a_la_carte') as number
78
- }
79
81
 
80
- set content_id(value: number) {
81
- this._setRaw('content_id', validators.content_id(value))
82
- }
83
- set content_brand(value: string) {
84
- this._setRaw('content_brand', validators.content_brand(value))
85
- }
86
- set content_type(value: string) {
87
- this._setRaw('content_type', validators.content_type(value))
88
- }
89
- set content_parent_id(value: number) {
90
- this._setRaw('content_parent_id', validators.content_parent_id(value))
91
- }
92
82
  set progress_percent(value: number) {
93
83
  const percent = validators.progress_percent(value, this.progress_percent)
94
84
 
95
85
  this._setRaw('progress_percent', percent)
96
86
  this._setRaw('state', percent === 100 ? STATE.COMPLETED : STATE.STARTED)
97
87
  }
88
+
89
+ get collection_type() {
90
+ return this._getRaw('collection_type') as COLLECTION_TYPE
91
+ }
92
+
98
93
  set collection_type(value: COLLECTION_TYPE) {
99
94
  this._setRaw('collection_type', validators.collection_type(value))
100
95
  }
96
+
97
+ get collection_id() {
98
+ return this._getRaw('collection_id') as number
99
+ }
100
+
101
101
  set collection_id(value: number) {
102
102
  this._setRaw('collection_id', validators.collection_id(value))
103
103
  }
104
+
105
+ get resume_time_seconds() {
106
+ return this._getRaw('resume_time_seconds') as number | null
107
+ }
108
+
104
109
  set resume_time_seconds(value: number | null) {
105
110
  this._setRaw('resume_time_seconds', validators.resume_time_seconds(value))
106
111
  }
112
+
113
+ get last_interacted_a_la_carte() {
114
+ return this._getRaw('last_interacted_a_la_carte') as number
115
+ }
116
+
107
117
  set last_interacted_a_la_carte(value: number) {
108
118
  this._setRaw('last_interacted_a_la_carte', value)
109
119
  }
110
120
 
111
121
  static generateId(
112
122
  contentId: number,
113
- collection: CollectionParameter | null
123
+ collection: CollectionParameter | null,
114
124
  ) {
115
125
  validators.content_id(contentId)
116
126
 
@@ -121,4 +131,10 @@ export default class ContentProgress extends BaseModel {
121
131
 
122
132
  return `${contentId}:${collection?.type || COLLECTION_TYPE.SELF}:${collection?.id || COLLECTION_ID_SELF}`
123
133
  }
134
+
135
+ setProgressForceRegression(value: number) {
136
+ const validated = percent(value)
137
+ this._setRaw('progress_percent', validated)
138
+ this._setRaw('state', validated === 100 ? STATE.COMPLETED : STATE.STARTED)
139
+ }
124
140
  }
@@ -1,5 +1,10 @@
1
- import SyncRepository, {Q} from './base'
2
- import ContentProgress, {COLLECTION_ID_SELF, COLLECTION_TYPE, STATE, CollectionParameter} from '../models/ContentProgress'
1
+ import SyncRepository, { Q } from './base'
2
+ import ContentProgress, {
3
+ COLLECTION_ID_SELF,
4
+ COLLECTION_TYPE,
5
+ CollectionParameter,
6
+ STATE,
7
+ } from '../models/ContentProgress'
3
8
 
4
9
  interface MetadataParameter {
5
10
  brand: string
@@ -9,13 +14,43 @@ interface MetadataParameter {
9
14
 
10
15
  export default class ProgressRepository extends SyncRepository<ContentProgress> {
11
16
 
17
+ static collectionTypeFilter(
18
+ params: {
19
+ aLaCarte?: boolean;
20
+ learningPaths?: boolean
21
+ } = {}) {
22
+ let clauses: Q.Where[] = []
23
+
24
+ if (params.aLaCarte) {
25
+ clauses.push(
26
+ Q.and( // a-la-carte content that's been accessed directly
27
+ Q.where('collection_type', COLLECTION_TYPE.SELF),
28
+ Q.where('collection_id', COLLECTION_ID_SELF),
29
+ Q.where('last_interacted_a_la_carte', Q.notEq(null)),
30
+ ),
31
+ )
32
+ }
33
+
34
+ if (params.learningPaths) {
35
+ clauses.push(
36
+ Q.and( // just parents
37
+ Q.where('collection_type', COLLECTION_TYPE.LEARNING_PATH),
38
+ Q.where('content_id', Q.eq(Q.column('collection_id'))),
39
+ ),
40
+ )
41
+ }
42
+
43
+ if (clauses.length === 0) return
44
+ return Q.or(...clauses)
45
+ }
46
+
12
47
  async started(
13
48
  limit?: number,
14
49
  opts: {
15
50
  onlyIds?: boolean
16
51
  include?: { aLaCarte?: boolean, learningPaths?: boolean }
17
- } = {}
18
- ) {
52
+ } = {},
53
+ ) {
19
54
  const results = await this.queryAll(...[
20
55
  ProgressRepository.collectionTypeFilter(opts.include),
21
56
 
@@ -26,8 +61,8 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
26
61
  ].filter(Boolean) as Q.Clause[])
27
62
 
28
63
  return opts.onlyIds
29
- ? results.data.map((r) => r.content_id)
30
- : results.data
64
+ ? results.data.map((r) => r.content_id)
65
+ : results.data
31
66
  }
32
67
 
33
68
  async completed(
@@ -35,7 +70,7 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
35
70
  opts: {
36
71
  onlyIds?: boolean
37
72
  include?: { aLaCarte?: boolean, learningPaths?: boolean }
38
- } = {}
73
+ } = {},
39
74
  ) {
40
75
  const results = await this.queryAll(...[
41
76
  ProgressRepository.collectionTypeFilter(opts.include),
@@ -55,7 +90,7 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
55
90
  async completedByContentIds(contentIds: number[]) {
56
91
  return this.queryAll(
57
92
  Q.where('content_id', Q.oneOf(contentIds)),
58
- Q.where('state', STATE.COMPLETED)
93
+ Q.where('state', STATE.COMPLETED),
59
94
  )
60
95
  }
61
96
 
@@ -63,59 +98,19 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
63
98
  return this.queryAll(...this.startedOrCompletedClauses(opts))
64
99
  }
65
100
 
66
- private startedOrCompletedClauses(
67
- opts: {
68
- brand?: string | null
69
- contentTypes?: string[] | null
70
- parentId?: number | null
71
- include?: { aLaCarte?: boolean, learningPaths?: boolean }
72
- updatedAfter?: number
73
- limit?: number
74
- } = {}
75
- ) {
76
- const clauses: Q.Clause[] = [
77
- ProgressRepository.collectionTypeFilter(opts.include),
78
-
79
- Q.or(Q.where('state', STATE.STARTED), Q.where('state', STATE.COMPLETED)),
80
- Q.sortBy('updated_at', 'desc'),
81
- ].filter(Boolean) as Q.Clause[]
82
-
83
- if (opts.updatedAfter) {
84
- clauses.push(Q.where('updated_at', Q.gte(opts.updatedAfter)))
85
- }
86
-
87
- if (opts.brand) {
88
- clauses.push(Q.where('content_brand', opts.brand))
89
- }
90
-
91
- if (opts.contentTypes) {
92
- clauses.push(Q.where('content_type', Q.oneOf(opts.contentTypes)))
93
- }
94
-
95
- if (opts.parentId || opts.parentId === 0) {
96
- clauses.push(Q.where('content_parent_id', opts.parentId))
97
- }
98
-
99
- if (opts.limit) {
100
- clauses.push(Q.take(opts.limit))
101
- }
102
-
103
- return clauses
104
- }
105
-
106
101
  async mostRecentlyUpdatedId(contentIds: number[], collection: CollectionParameter | null = null) {
107
102
  return this.queryOneId(
108
103
  Q.where('content_id', Q.oneOf(contentIds)),
109
104
  Q.where('collection_type', collection?.type ?? COLLECTION_TYPE.SELF),
110
105
  Q.where('collection_id', collection?.id ?? COLLECTION_ID_SELF),
111
106
 
112
- Q.sortBy('updated_at', 'desc')
107
+ Q.sortBy('updated_at', 'desc'),
113
108
  )
114
109
  }
115
110
 
116
111
  async getOneProgressByContentId(
117
112
  contentId: number,
118
- collection: CollectionParameter | null = null
113
+ collection: CollectionParameter | null = null,
119
114
  ) {
120
115
  const clauses = [
121
116
  Q.where('content_id', contentId),
@@ -128,7 +123,7 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
128
123
 
129
124
  async getSomeProgressByContentIds(
130
125
  contentIds: number[],
131
- collection: CollectionParameter | null = null
126
+ collection: CollectionParameter | null = null,
132
127
  ) {
133
128
  const clauses = [
134
129
  Q.where('content_id', Q.oneOf(contentIds)),
@@ -147,16 +142,16 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
147
142
  Q.where('collection_type', COLLECTION_TYPE.SELF),
148
143
  Q.where('collection_id', COLLECTION_ID_SELF),
149
144
  Q.or(
150
- Q.and(
151
- Q.where('updated_at', Q.notEq(null)),
152
- Q.where('last_interacted_a_la_carte', null)
153
- ),
154
- Q.and(
155
- Q.where('updated_at', Q.notEq(null)),
156
- Q.where('last_interacted_a_la_carte', Q.notEq(null)),
157
- Q.where('updated_at', Q.gt(Q.column('last_interacted_a_la_carte')))
158
- )
159
- )
145
+ Q.and(
146
+ Q.where('updated_at', Q.notEq(null)),
147
+ Q.where('last_interacted_a_la_carte', null),
148
+ ),
149
+ Q.and(
150
+ Q.where('updated_at', Q.notEq(null)),
151
+ Q.where('last_interacted_a_la_carte', Q.notEq(null)),
152
+ Q.where('updated_at', Q.gt(Q.column('last_interacted_a_la_carte'))),
153
+ ),
154
+ ),
160
155
  ]
161
156
 
162
157
  return await this.queryAll(...clauses)
@@ -172,7 +167,7 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
172
167
  progressPct: number,
173
168
  metadata: MetadataParameter,
174
169
  resumeTime?: number,
175
- {skipPush = false, accessedDirectly = true} = {}) {
170
+ { skipPush = false, accessedDirectly = true } = {}) {
176
171
  const id = ContentProgress.generateId(contentId, collection)
177
172
 
178
173
  if (collection?.type === COLLECTION_TYPE.LEARNING_PATH) {
@@ -205,7 +200,7 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
205
200
  // Emit event AFTER database write completes (don't let emit failures affect the result)
206
201
  Promise.all([
207
202
  import('../../progress-events'),
208
- import('../../config')
203
+ import('../../config'),
209
204
  ]).then(([progressEventsModule, { globalConfig }]) => {
210
205
  progressEventsModule.emitProgressSaved({
211
206
  userId: Number(globalConfig.railcontentConfig?.userId) || 0,
@@ -216,7 +211,7 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
216
211
  collectionType: collection?.type ?? COLLECTION_TYPE.SELF,
217
212
  collectionId: collection?.id ?? COLLECTION_ID_SELF,
218
213
  resumeTimeSeconds: resumeTime ?? null,
219
- timestamp: Date.now()
214
+ timestamp: Date.now(),
220
215
  })
221
216
  }).catch(error => {
222
217
  console.error('Failed to emit progress saved event:', error)
@@ -229,7 +224,11 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
229
224
  contentProgresses: Record<string, number>, // Accept plain object
230
225
  collection: CollectionParameter | null,
231
226
  metadata: Record<string, MetadataParameter>,
232
- { skipPush = false, accessedDirectly = true }: { skipPush?: boolean; accessedDirectly?: boolean } = {}
227
+ { skipPush = false, accessedDirectly = true, allowRegression = false }: {
228
+ skipPush?: boolean;
229
+ accessedDirectly?: boolean,
230
+ allowRegression?: boolean
231
+ } = {},
233
232
  ) {
234
233
  if (collection?.type === COLLECTION_TYPE.LEARNING_PATH) {
235
234
  accessedDirectly = false
@@ -243,7 +242,11 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
243
242
  r.collection_type = collection?.type ?? COLLECTION_TYPE.SELF
244
243
  r.collection_id = collection?.id ?? COLLECTION_ID_SELF
245
244
 
246
- r.progress_percent = progressPct
245
+ if (allowRegression) {
246
+ r.setProgressForceRegression(progressPct)
247
+ } else {
248
+ r.progress_percent = progressPct
249
+ }
247
250
 
248
251
  r.content_brand = metadata[contentId].brand
249
252
  r.content_type = metadata[contentId].type
@@ -253,49 +256,59 @@ export default class ProgressRepository extends SyncRepository<ContentProgress>
253
256
  r.last_interacted_a_la_carte = r.updated_at
254
257
  }
255
258
  },
256
- ])
259
+ ]),
257
260
  )
258
261
  return await this.upsertSome(data, { skipPush })
259
262
 
260
263
  //todo add event emitting for bulk updates?
261
264
  }
262
265
 
263
- eraseProgress(contentId: number, collection: CollectionParameter | null, {skipPush = false} = {}) {
266
+ eraseProgress(contentId: number, collection: CollectionParameter | null, { skipPush = false } = {}) {
264
267
  return this.deleteOne(ContentProgress.generateId(contentId, collection), { skipPush })
265
268
  }
266
269
 
267
- eraseProgressMany(contentIds: number[], collection: CollectionParameter | null, {skipPush = false} = {}) {
270
+ eraseProgressMany(contentIds: number[], collection: CollectionParameter | null, { skipPush = false } = {}) {
268
271
  const ids = contentIds.map((id) => ContentProgress.generateId(id, collection))
269
272
  return this.deleteSome(ids, { skipPush })
270
273
  }
271
274
 
272
- static collectionTypeFilter(
273
- params: {
274
- aLaCarte?: boolean;
275
- learningPaths?: boolean
276
- } = {}) {
277
- let clauses: Q.Where[] = []
275
+ private startedOrCompletedClauses(
276
+ opts: {
277
+ brand?: string | null
278
+ contentTypes?: string[] | null
279
+ parentId?: number | null
280
+ include?: { aLaCarte?: boolean, learningPaths?: boolean }
281
+ updatedAfter?: number
282
+ limit?: number
283
+ } = {},
284
+ ) {
285
+ const clauses: Q.Clause[] = [
286
+ ProgressRepository.collectionTypeFilter(opts.include),
278
287
 
279
- if (params.aLaCarte) {
280
- clauses.push(
281
- Q.and( // a-la-carte content that's been accessed directly
282
- Q.where('collection_type', COLLECTION_TYPE.SELF),
283
- Q.where('collection_id', COLLECTION_ID_SELF),
284
- Q.where('last_interacted_a_la_carte', Q.notEq(null)),
285
- ),
286
- )
288
+ Q.or(Q.where('state', STATE.STARTED), Q.where('state', STATE.COMPLETED)),
289
+ Q.sortBy('updated_at', 'desc'),
290
+ ].filter(Boolean) as Q.Clause[]
291
+
292
+ if (opts.updatedAfter) {
293
+ clauses.push(Q.where('updated_at', Q.gte(opts.updatedAfter)))
287
294
  }
288
295
 
289
- if (params.learningPaths) {
290
- clauses.push(
291
- Q.and( // just parents
292
- Q.where('collection_type', COLLECTION_TYPE.LEARNING_PATH),
293
- Q.where('content_id', Q.eq(Q.column('collection_id')))
294
- )
295
- )
296
+ if (opts.brand) {
297
+ clauses.push(Q.where('content_brand', opts.brand))
296
298
  }
297
299
 
298
- if (clauses.length === 0) return
299
- return Q.or(...clauses)
300
+ if (opts.contentTypes) {
301
+ clauses.push(Q.where('content_type', Q.oneOf(opts.contentTypes)))
302
+ }
303
+
304
+ if (opts.parentId || opts.parentId === 0) {
305
+ clauses.push(Q.where('content_parent_id', opts.parentId))
306
+ }
307
+
308
+ if (opts.limit) {
309
+ clauses.push(Q.take(opts.limit))
310
+ }
311
+
312
+ return clauses
300
313
  }
301
314
  }
@@ -3,8 +3,8 @@ import { awardEvents } from '../../../src/services/awards/internal/award-events.
3
3
  import { mockAwardDefinitions, getAwardByContentId } from '../../mockData/award-definitions.js'
4
4
  import { globalConfig } from '../../../src/services/config.js'
5
5
  import { LocalStorageMock } from '../../localStorageMock.js'
6
- import { setupDefaultMocks, setupAwardEventListeners } from './helpers/index'
7
- import { mockCompletionStates, mockAllCompleted, mockNoneCompleted } from './helpers/completion-mock'
6
+ import { setupDefaultMocks, setupAwardEventListeners } from '../../unit/awards/helpers/index'
7
+ import { mockCompletionStates, mockAllCompleted, mockNoneCompleted } from '../../unit/awards/helpers/completion-mock'
8
8
 
9
9
  jest.mock('../../../src/services/sanity.js', () => ({
10
10
  ...jest.requireActual('../../../src/services/sanity'),
@@ -0,0 +1,104 @@
1
+ export const mockContentProgressObserver = () => ({
2
+ contentProgressObserver: {
3
+ start: jest.fn().mockResolvedValue(undefined),
4
+ stop: jest.fn(),
5
+ },
6
+ })
7
+
8
+ export const mockLearningPaths = () => ({
9
+ getDailySession: jest.fn().mockResolvedValue(null),
10
+ onLearningPathCompletedActions: jest.fn().mockResolvedValue(undefined),
11
+ })
12
+
13
+ export const mockProgressEvents = () => ({
14
+ emitProgressSaved: jest.fn(),
15
+ })
16
+
17
+ export type HierarchyTreeNode = {
18
+ id: number
19
+ type?: string
20
+ brand?: string
21
+ children?: HierarchyTreeNode[]
22
+ }
23
+
24
+ type HierarchyLookups = {
25
+ topLevelId: number
26
+ parents: Record<number, number>
27
+ children: Record<number, number[]>
28
+ metadata: Record<number, { type: string; brand: string; parent_id: number }>
29
+ }
30
+
31
+ const LP_TYPE = 'learning-path-v2'
32
+
33
+ const ALC_KEY = 'alc'
34
+ const LP_KEY = 'lp'
35
+
36
+ let hierarchiesByKey: Record<string, Record<number, HierarchyLookups>> = {}
37
+ let topByKey: Record<string, Record<number, number>> = {}
38
+
39
+ function keyFor(collection?: { type?: string } | null): string {
40
+ return collection?.type === LP_TYPE ? LP_KEY : ALC_KEY
41
+ }
42
+
43
+ export function setHierarchy(tree: HierarchyTreeNode, options?: { lp?: boolean }) {
44
+ const key = options?.lp ? LP_KEY : ALC_KEY
45
+ hierarchiesByKey[key] ??= {}
46
+ topByKey[key] ??= {}
47
+ hierarchiesByKey[key][tree.id] = treeToLookups(tree)
48
+ registerIds(tree, tree.id, topByKey[key])
49
+ }
50
+
51
+ export function clearHierarchies() {
52
+ hierarchiesByKey = {}
53
+ topByKey = {}
54
+ }
55
+
56
+ function treeToLookups(root: HierarchyTreeNode): HierarchyLookups {
57
+ const parents: Record<number, number> = {}
58
+ const children: Record<number, number[]> = {}
59
+ const metadata: Record<number, { type: string; brand: string; parent_id: number }> = {}
60
+
61
+ function walk(node: HierarchyTreeNode, parentId: number) {
62
+ const childIds = (node.children ?? []).map(c => c.id)
63
+ metadata[node.id] = {
64
+ type: node.type ?? 'lesson',
65
+ brand: node.brand ?? 'drumeo',
66
+ parent_id: parentId,
67
+ }
68
+ if (parentId) parents[node.id] = parentId
69
+ if (childIds.length > 0) children[node.id] = childIds
70
+ for (const c of node.children ?? []) walk(c, node.id)
71
+ }
72
+
73
+ walk(root, 0)
74
+
75
+ return { topLevelId: root.id, parents, children, metadata }
76
+ }
77
+
78
+ function registerIds(node: HierarchyTreeNode, topId: number, target: Record<number, number>) {
79
+ target[node.id] = topId
80
+ for (const c of node.children ?? []) registerIds(c, topId, target)
81
+ }
82
+
83
+ function lookupFor(contentId: number, collection?: any): HierarchyLookups {
84
+ const key = keyFor(collection)
85
+ const tops = topByKey[key] ?? {}
86
+ const hierarchies = hierarchiesByKey[key] ?? {}
87
+ const topId = tops[contentId] ?? (key === LP_KEY ? collection?.id : contentId)
88
+ return hierarchies[topId] ?? {
89
+ topLevelId: contentId,
90
+ parents: {},
91
+ children: {},
92
+ metadata: { [contentId]: { brand: 'drumeo', type: 'lesson', parent_id: 0 } },
93
+ }
94
+ }
95
+
96
+ export const mockSanity = () => ({
97
+ getHierarchy: jest.fn((contentId: number, collection?: any) =>
98
+ Promise.resolve(lookupFor(contentId, collection)),
99
+ ),
100
+ getHierarchies: jest.fn((contentIds: number[], collection?: any) =>
101
+ Promise.resolve(Object.fromEntries(contentIds.map(id => [id, lookupFor(id, collection)]))),
102
+ ),
103
+ getSanityDate: jest.fn((date: Date) => date.toISOString()),
104
+ })