musora-content-services 2.160.4 → 2.161.2
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/.agent/decisions/2026-05-20-live-event-fetch-permissions-id.md +23 -0
- package/CHANGELOG.md +19 -0
- package/package.json +1 -1
- package/src/contentTypeConfig.js +13 -15
- package/src/index.d.ts +6 -2
- package/src/index.js +6 -2
- package/src/infrastructure/sanity/README.md +230 -0
- package/src/infrastructure/sanity/SanityClient.ts +105 -0
- package/src/infrastructure/sanity/clients/ContentClient.ts +164 -0
- package/src/infrastructure/sanity/examples/usage.ts +101 -0
- package/src/infrastructure/sanity/executors/FetchQueryExecutor.ts +110 -0
- package/src/infrastructure/sanity/index.ts +19 -0
- package/src/infrastructure/sanity/interfaces/ConfigProvider.ts +6 -0
- package/src/infrastructure/sanity/interfaces/FetchByIdOptions.ts +7 -0
- package/src/infrastructure/sanity/interfaces/QueryExecutor.ts +8 -0
- package/src/infrastructure/sanity/interfaces/SanityConfig.ts +10 -0
- package/src/infrastructure/sanity/interfaces/SanityError.ts +7 -0
- package/src/infrastructure/sanity/interfaces/SanityQuery.ts +5 -0
- package/src/infrastructure/sanity/interfaces/SanityResponse.ts +6 -0
- package/src/infrastructure/sanity/providers/DefaultConfigProvider.ts +38 -0
- package/src/lib/sanity/decorators/base.ts +142 -0
- package/src/lib/sanity/decorators/examples.ts +229 -0
- package/src/lib/sanity/decorators/navigate-to.ts +139 -0
- package/src/lib/sanity/decorators/need-access.ts +40 -0
- package/src/lib/sanity/decorators/page-type.ts +35 -0
- package/src/services/awards/award-query.js +71 -0
- package/src/services/contentAggregator.js +1 -1
- package/src/services/multi-user-accounts/multi-user-accounts.ts +11 -7
- package/src/services/user/memberships.ts +46 -34
- package/src/services/user/profile.ts +66 -0
- package/test/unit/infrastructure/sanity/ContentClient.test.ts +168 -0
- package/test/unit/infrastructure/sanity/DefaultConfigProvider.test.ts +93 -0
- package/test/unit/infrastructure/sanity/FetchQueryExecutor.test.ts +174 -0
- package/test/unit/infrastructure/sanity/SanityClient.test.ts +140 -0
- package/test/unit/lib/sanity/decorators/base.test.ts +368 -0
- package/test/unit/lib/sanity/decorators/navigate-to.test.ts +266 -0
- package/test/unit/lib/sanity/decorators/need-access.test.ts +89 -0
- package/test/unit/lib/sanity/decorators/page-type.test.ts +81 -0
- package/.claude/settings.local.json +0 -23
- package/src/services/user/profile.js +0 -43
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
let mockProgressRecords: any[] = []
|
|
2
|
+
let mockLastInteracted: number | null = null
|
|
3
|
+
|
|
4
|
+
jest.mock('../../../../../src/services/sync/repository-proxy', () => {
|
|
5
|
+
const mockFns = {
|
|
6
|
+
contentProgress: {
|
|
7
|
+
getOneProgressByContentId: jest.fn().mockImplementation((contentId) => {
|
|
8
|
+
const record = mockProgressRecords.find((r) => r.content_id === contentId)
|
|
9
|
+
return Promise.resolve({ data: record || null })
|
|
10
|
+
}),
|
|
11
|
+
getSomeProgressByContentIds: jest.fn().mockImplementation((contentIds) => {
|
|
12
|
+
const records = mockProgressRecords.filter((r) => contentIds.includes(r.content_id))
|
|
13
|
+
return Promise.resolve({ data: records })
|
|
14
|
+
}),
|
|
15
|
+
mostRecentlyUpdatedId: jest.fn().mockImplementation(() => {
|
|
16
|
+
return Promise.resolve({ data: mockLastInteracted })
|
|
17
|
+
}),
|
|
18
|
+
},
|
|
19
|
+
practices: {
|
|
20
|
+
queryAll: jest.fn().mockResolvedValue({ data: [] }),
|
|
21
|
+
getAll: jest.fn().mockResolvedValue({ data: [] }),
|
|
22
|
+
},
|
|
23
|
+
}
|
|
24
|
+
return { default: mockFns, ...mockFns }
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
jest.mock('../../../../../src/services/content-org/learning-paths', () => ({
|
|
28
|
+
getDailySession: jest.fn().mockResolvedValue(null),
|
|
29
|
+
onLearningPathCompletedActions: jest.fn().mockResolvedValue(undefined),
|
|
30
|
+
}))
|
|
31
|
+
|
|
32
|
+
jest.mock('../../../../../src/services/sanity.js', () => ({
|
|
33
|
+
getHierarchy: jest.fn().mockResolvedValue({ metadata: {}, parents: {}, children: {} }),
|
|
34
|
+
getHierarchies: jest.fn().mockResolvedValue({ metadata: {}, parents: {}, children: {} }),
|
|
35
|
+
getSanityDate: jest.fn((date: Date) => date.toISOString()),
|
|
36
|
+
}))
|
|
37
|
+
|
|
38
|
+
import { initializeTestService } from '../../../../initializeTests.js'
|
|
39
|
+
import {
|
|
40
|
+
NAVIGATE_TO_FIELD,
|
|
41
|
+
decorateNavigateTo,
|
|
42
|
+
navigateToDecorator,
|
|
43
|
+
type NavigateToDecoratable,
|
|
44
|
+
} from '../../../../../src/lib/sanity/decorators/navigate-to'
|
|
45
|
+
import { COLLECTION_TYPE } from '../../../../../src/services/sync/models/ContentProgress'
|
|
46
|
+
|
|
47
|
+
const child = (id: number, type = 'course-lesson'): NavigateToDecoratable => ({
|
|
48
|
+
id,
|
|
49
|
+
type,
|
|
50
|
+
brand: 'drumeo',
|
|
51
|
+
thumbnail: '',
|
|
52
|
+
published_on: null,
|
|
53
|
+
status: 'published',
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
const parent = (
|
|
57
|
+
id: number,
|
|
58
|
+
type: string,
|
|
59
|
+
children: NavigateToDecoratable[]
|
|
60
|
+
): NavigateToDecoratable => ({
|
|
61
|
+
id,
|
|
62
|
+
type,
|
|
63
|
+
brand: 'drumeo',
|
|
64
|
+
thumbnail: '',
|
|
65
|
+
published_on: null,
|
|
66
|
+
status: 'published',
|
|
67
|
+
children,
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
beforeEach(() => {
|
|
71
|
+
jest.clearAllMocks()
|
|
72
|
+
initializeTestService()
|
|
73
|
+
mockProgressRecords = []
|
|
74
|
+
mockLastInteracted = null
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe('navigate-to decorator', () => {
|
|
78
|
+
describe('navigateToDecorator (const)', () => {
|
|
79
|
+
test('field is navigateTo', () => {
|
|
80
|
+
expect(navigateToDecorator.field).toBe('navigateTo')
|
|
81
|
+
expect(NAVIGATE_TO_FIELD).toBe('navigateTo')
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
describe('decorateNavigateTo', () => {
|
|
86
|
+
test('non-navigable type → null', async () => {
|
|
87
|
+
const item = parent(1, 'lesson', [child(101)])
|
|
88
|
+
const result = await decorateNavigateTo(item)
|
|
89
|
+
expect(result.navigateTo).toBeNull()
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('empty children → null', async () => {
|
|
93
|
+
const item = parent(1, 'course', [])
|
|
94
|
+
const result = await decorateNavigateTo(item)
|
|
95
|
+
expect(result.navigateTo).toBeNull()
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('not-started course → first child', async () => {
|
|
99
|
+
const item = parent(1, 'course', [child(101), child(102)])
|
|
100
|
+
const result = await decorateNavigateTo(item)
|
|
101
|
+
expect(result.navigateTo).toMatchObject({ id: 101, child: null })
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('course started, lastInteracted started → lastInteracted', async () => {
|
|
105
|
+
mockProgressRecords = [
|
|
106
|
+
{ content_id: 1, state: 'started', progress_percent: 50, updated_at: 1000 },
|
|
107
|
+
{ content_id: 101, state: 'started', progress_percent: 30, updated_at: 900 },
|
|
108
|
+
{ content_id: 102, state: 'started', progress_percent: 10, updated_at: 1000 },
|
|
109
|
+
]
|
|
110
|
+
mockLastInteracted = 101
|
|
111
|
+
const item = parent(1, 'course', [child(101), child(102)])
|
|
112
|
+
const result = await decorateNavigateTo(item)
|
|
113
|
+
expect(result.navigateTo).toMatchObject({ id: 101 })
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('course started, lastInteracted completed → first incomplete after', async () => {
|
|
117
|
+
mockProgressRecords = [
|
|
118
|
+
{ content_id: 1, state: 'started', progress_percent: 60, updated_at: 1000 },
|
|
119
|
+
{ content_id: 101, state: 'completed', progress_percent: 100, updated_at: 900 },
|
|
120
|
+
{ content_id: 102, state: 'completed', progress_percent: 100, updated_at: 1000 },
|
|
121
|
+
{ content_id: 103, state: 'started', progress_percent: 20, updated_at: 800 },
|
|
122
|
+
]
|
|
123
|
+
mockLastInteracted = 101
|
|
124
|
+
const item = parent(1, 'course', [child(101), child(102), child(103)])
|
|
125
|
+
const result = await decorateNavigateTo(item)
|
|
126
|
+
expect(result.navigateTo).toMatchObject({ id: 103 })
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('guided-course started → first incomplete regardless of lastInteracted', async () => {
|
|
130
|
+
mockProgressRecords = [
|
|
131
|
+
{ content_id: 1, state: 'started', progress_percent: 50, updated_at: 1000 },
|
|
132
|
+
{ content_id: 101, state: '', progress_percent: 0, updated_at: 0 },
|
|
133
|
+
{ content_id: 102, state: 'completed', progress_percent: 100, updated_at: 1000 },
|
|
134
|
+
{ content_id: 103, state: '', progress_percent: 0, updated_at: 0 },
|
|
135
|
+
]
|
|
136
|
+
mockLastInteracted = 102
|
|
137
|
+
const item = parent(1, 'guided-course', [child(101), child(102), child(103)])
|
|
138
|
+
const result = await decorateNavigateTo(item)
|
|
139
|
+
expect(result.navigateTo).toMatchObject({ id: 101 })
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test('learning-path-v2 started → first incomplete regardless of lastInteracted', async () => {
|
|
143
|
+
mockProgressRecords = [
|
|
144
|
+
{ content_id: 1, state: 'started', progress_percent: 50, updated_at: 1000 },
|
|
145
|
+
{ content_id: 101, state: '', progress_percent: 0, updated_at: 0 },
|
|
146
|
+
{ content_id: 102, state: 'completed', progress_percent: 100, updated_at: 1000 },
|
|
147
|
+
{ content_id: 103, state: '', progress_percent: 0, updated_at: 0 },
|
|
148
|
+
]
|
|
149
|
+
mockLastInteracted = 102
|
|
150
|
+
const item = parent(1, COLLECTION_TYPE.LEARNING_PATH, [child(101), child(102), child(103)])
|
|
151
|
+
const result = await decorateNavigateTo(item)
|
|
152
|
+
expect(result.navigateTo).toMatchObject({ id: 101 })
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
test('two-depth: not-started course-collection nests first child nav', async () => {
|
|
156
|
+
const courseChild = parent(101, 'course', [child(201), child(202)])
|
|
157
|
+
const collection = parent(1, 'course-collection', [courseChild, parent(102, 'course', [])])
|
|
158
|
+
const result = await decorateNavigateTo(collection)
|
|
159
|
+
expect(result.navigateTo).toMatchObject({
|
|
160
|
+
id: 101,
|
|
161
|
+
child: { id: 201 },
|
|
162
|
+
})
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
test('two-depth: started course-collection nests lastInteracted child nav', async () => {
|
|
166
|
+
mockProgressRecords = [
|
|
167
|
+
{ content_id: 1, state: 'started', progress_percent: 50, updated_at: 1000 },
|
|
168
|
+
{ content_id: 101, state: 'started', progress_percent: 50, updated_at: 1000 },
|
|
169
|
+
{ content_id: 102, state: '', progress_percent: 0, updated_at: 0 },
|
|
170
|
+
]
|
|
171
|
+
mockLastInteracted = 102
|
|
172
|
+
const courseA = parent(101, 'course', [child(201), child(202)])
|
|
173
|
+
const courseB = parent(102, 'course', [child(301), child(302)])
|
|
174
|
+
const collection = parent(1, 'course-collection', [courseA, courseB])
|
|
175
|
+
const result = await decorateNavigateTo(collection)
|
|
176
|
+
expect(result.navigateTo).toMatchObject({
|
|
177
|
+
id: 102,
|
|
178
|
+
child: { id: 301 },
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test('decorates every item in an array', async () => {
|
|
183
|
+
const items = [parent(1, 'course', [child(101)]), parent(2, 'lesson', [child(201)])]
|
|
184
|
+
const result = await decorateNavigateTo(items)
|
|
185
|
+
expect(result[0].navigateTo).toMatchObject({ id: 101 })
|
|
186
|
+
expect(result[1].navigateTo).toBeNull()
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
test('returns the same reference it was given', async () => {
|
|
190
|
+
const items = [parent(1, 'course', [child(101)])]
|
|
191
|
+
const result = await decorateNavigateTo(items)
|
|
192
|
+
expect(result).toBe(items)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
test('navigateToDecorator.compute fires once per top-level item, never on descendants', async () => {
|
|
196
|
+
const spy = jest.spyOn(navigateToDecorator, 'compute')
|
|
197
|
+
const courseChild = parent(101, 'course', [child(201), child(202)])
|
|
198
|
+
const collection = parent(1, 'course-collection', [courseChild, parent(102, 'course', [])])
|
|
199
|
+
const standalone = parent(2, 'course', [child(301)])
|
|
200
|
+
await decorateNavigateTo([collection, standalone])
|
|
201
|
+
expect(spy).toHaveBeenCalledTimes(2)
|
|
202
|
+
expect(spy).toHaveBeenCalledWith(collection)
|
|
203
|
+
expect(spy).toHaveBeenCalledWith(standalone)
|
|
204
|
+
spy.mockRestore()
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
test('descendants of decorated items do not receive navigateTo field', async () => {
|
|
208
|
+
const item = parent(1, 'course', [child(101), child(102)])
|
|
209
|
+
const result = await decorateNavigateTo(item)
|
|
210
|
+
expect(result.navigateTo).not.toBeNull()
|
|
211
|
+
const children = result.children as NavigateToDecoratable[]
|
|
212
|
+
expect((children[0] as Record<string, unknown>).navigateTo).toBeUndefined()
|
|
213
|
+
expect((children[1] as Record<string, unknown>).navigateTo).toBeUndefined()
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
test('skill-pack uses course flow', async () => {
|
|
217
|
+
mockProgressRecords = [
|
|
218
|
+
{ content_id: 1, state: 'started', progress_percent: 60, updated_at: 1000 },
|
|
219
|
+
{ content_id: 101, state: 'completed', progress_percent: 100, updated_at: 900 },
|
|
220
|
+
{ content_id: 102, state: 'started', progress_percent: 20, updated_at: 1000 },
|
|
221
|
+
]
|
|
222
|
+
mockLastInteracted = 102
|
|
223
|
+
const item = parent(1, 'skill-pack', [child(101), child(102)])
|
|
224
|
+
const result = await decorateNavigateTo(item)
|
|
225
|
+
expect(result.navigateTo).toMatchObject({ id: 102 })
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
test('song-tutorial uses course flow', async () => {
|
|
229
|
+
mockProgressRecords = [
|
|
230
|
+
{ content_id: 1, state: 'started', progress_percent: 50, updated_at: 1000 },
|
|
231
|
+
{ content_id: 101, state: 'completed', progress_percent: 100, updated_at: 900 },
|
|
232
|
+
{ content_id: 102, state: '', progress_percent: 0, updated_at: 0 },
|
|
233
|
+
]
|
|
234
|
+
mockLastInteracted = 101
|
|
235
|
+
const item = parent(1, 'song-tutorial', [child(101), child(102)])
|
|
236
|
+
const result = await decorateNavigateTo(item)
|
|
237
|
+
expect(result.navigateTo).toMatchObject({ id: 102 })
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
test('two-depth started but lastInteracted child not in collection → null', async () => {
|
|
241
|
+
mockProgressRecords = [
|
|
242
|
+
{ content_id: 1, state: 'started', progress_percent: 50, updated_at: 1000 },
|
|
243
|
+
]
|
|
244
|
+
mockLastInteracted = 999
|
|
245
|
+
const courseA = parent(101, 'course', [child(201)])
|
|
246
|
+
const collection = parent(1, 'course-collection', [courseA])
|
|
247
|
+
const result = await decorateNavigateTo(collection)
|
|
248
|
+
expect(result.navigateTo).toBeNull()
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
test('output shape matches NavigateTo interface', async () => {
|
|
252
|
+
const item = parent(1, 'course', [child(101)])
|
|
253
|
+
const result = await decorateNavigateTo(item)
|
|
254
|
+
expect(result.navigateTo).toEqual({
|
|
255
|
+
id: 101,
|
|
256
|
+
type: 'course-lesson',
|
|
257
|
+
brand: 'drumeo',
|
|
258
|
+
thumbnail: '',
|
|
259
|
+
published_on: null,
|
|
260
|
+
status: 'published',
|
|
261
|
+
child: null,
|
|
262
|
+
collection: null,
|
|
263
|
+
})
|
|
264
|
+
})
|
|
265
|
+
})
|
|
266
|
+
})
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
const mockDoesUserNeedAccess = jest.fn()
|
|
2
|
+
|
|
3
|
+
jest.mock('../../../../../src/services/permissions', () => ({
|
|
4
|
+
getPermissionsAdapter: () => ({
|
|
5
|
+
doesUserNeedAccess: mockDoesUserNeedAccess,
|
|
6
|
+
}),
|
|
7
|
+
}))
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
NEED_ACCESS_FIELD,
|
|
11
|
+
accessDecorator,
|
|
12
|
+
decorateAccess,
|
|
13
|
+
type AccessDecoratable,
|
|
14
|
+
} from '../../../../../src/lib/sanity/decorators/need-access'
|
|
15
|
+
|
|
16
|
+
const perms = {
|
|
17
|
+
permissions: [78, 91],
|
|
18
|
+
isAdmin: false,
|
|
19
|
+
isModerator: false,
|
|
20
|
+
isABasicMember: false,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('need-access decorator', () => {
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
mockDoesUserNeedAccess.mockReset()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe('accessDecorator (factory)', () => {
|
|
29
|
+
test('field is need_access', () => {
|
|
30
|
+
const dec = accessDecorator(perms)
|
|
31
|
+
expect(dec.field).toBe('need_access')
|
|
32
|
+
expect(NEED_ACCESS_FIELD).toBe('need_access')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('compute delegates to adapter with item + perms', () => {
|
|
36
|
+
mockDoesUserNeedAccess.mockReturnValue(true)
|
|
37
|
+
const dec = accessDecorator(perms)
|
|
38
|
+
const item: AccessDecoratable = { permission_id: [78] }
|
|
39
|
+
|
|
40
|
+
const result = dec.compute(item)
|
|
41
|
+
|
|
42
|
+
expect(result).toBe(true)
|
|
43
|
+
expect(mockDoesUserNeedAccess).toHaveBeenCalledWith(item, perms)
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe('decorateAccess', () => {
|
|
48
|
+
test('writes adapter result onto each item', () => {
|
|
49
|
+
mockDoesUserNeedAccess.mockImplementation((item) =>
|
|
50
|
+
item.permission_id?.includes(78) ? false : true
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
const items: AccessDecoratable[] = [
|
|
54
|
+
{ permission_id: [78] },
|
|
55
|
+
{ permission_id: [999] },
|
|
56
|
+
{},
|
|
57
|
+
]
|
|
58
|
+
const decorated = decorateAccess(items, perms)
|
|
59
|
+
|
|
60
|
+
expect(decorated.map((i) => i.need_access)).toEqual([false, true, true])
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('decorates children recursively', () => {
|
|
64
|
+
mockDoesUserNeedAccess.mockReturnValue(true)
|
|
65
|
+
const tree: AccessDecoratable = {
|
|
66
|
+
permission_id: [],
|
|
67
|
+
children: [
|
|
68
|
+
{
|
|
69
|
+
permission_id: [],
|
|
70
|
+
children: [{ permission_id: [] }],
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const decorated = decorateAccess(tree, perms)
|
|
76
|
+
|
|
77
|
+
expect(decorated.need_access).toBe(true)
|
|
78
|
+
expect(decorated.children![0].need_access).toBe(true)
|
|
79
|
+
expect(decorated.children![0].children![0].need_access).toBe(true)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('returns the same reference it was given', () => {
|
|
83
|
+
mockDoesUserNeedAccess.mockReturnValue(false)
|
|
84
|
+
const items: AccessDecoratable[] = [{ permission_id: [78] }]
|
|
85
|
+
const decorated = decorateAccess(items, perms)
|
|
86
|
+
expect(decorated).toBe(items)
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
})
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import {
|
|
2
|
+
PAGE_TYPE_FIELD,
|
|
3
|
+
decoratePageType,
|
|
4
|
+
pageTypeDecorator,
|
|
5
|
+
type PageTypeDecoratable,
|
|
6
|
+
} from '../../../../../src/lib/sanity/decorators/page-type'
|
|
7
|
+
|
|
8
|
+
describe('page-type decorator', () => {
|
|
9
|
+
describe('pageTypeDecorator (const)', () => {
|
|
10
|
+
test('field is page_type', () => {
|
|
11
|
+
expect(pageTypeDecorator.field).toBe('page_type')
|
|
12
|
+
expect(PAGE_TYPE_FIELD).toBe('page_type')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
test.each([
|
|
16
|
+
['song', 'song'],
|
|
17
|
+
['play-along', 'song'],
|
|
18
|
+
['jam-track', 'song'],
|
|
19
|
+
['song-tutorial', 'song'],
|
|
20
|
+
['song-tutorial-lesson', 'song'],
|
|
21
|
+
['course', 'lesson'],
|
|
22
|
+
['workout', 'lesson'],
|
|
23
|
+
['', 'lesson'],
|
|
24
|
+
])('type=%j -> %s', (type, expected) => {
|
|
25
|
+
expect(pageTypeDecorator.compute({ type })).toBe(expected)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('missing type falls through to lesson', () => {
|
|
29
|
+
expect(pageTypeDecorator.compute({})).toBe('lesson')
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
describe('decoratePageType', () => {
|
|
34
|
+
test('decorates a single object', () => {
|
|
35
|
+
const item: PageTypeDecoratable = { type: 'song' }
|
|
36
|
+
const decorated = decoratePageType(item)
|
|
37
|
+
expect(decorated.page_type).toBe('song')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('decorates every item in an array', () => {
|
|
41
|
+
const items: PageTypeDecoratable[] = [
|
|
42
|
+
{ type: 'song' },
|
|
43
|
+
{ type: 'course' },
|
|
44
|
+
{ type: 'play-along' },
|
|
45
|
+
]
|
|
46
|
+
const decorated = decoratePageType(items)
|
|
47
|
+
expect(decorated.map((i) => i.page_type)).toEqual(['song', 'lesson', 'song'])
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('decorates nested children up to depth 3', () => {
|
|
51
|
+
const tree: PageTypeDecoratable = {
|
|
52
|
+
type: 'course',
|
|
53
|
+
children: [
|
|
54
|
+
{
|
|
55
|
+
type: 'song',
|
|
56
|
+
children: [
|
|
57
|
+
{
|
|
58
|
+
type: 'play-along',
|
|
59
|
+
children: [{ type: 'song' }],
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
}
|
|
65
|
+
const decorated = decoratePageType(tree)
|
|
66
|
+
expect(decorated.page_type).toBe('lesson')
|
|
67
|
+
const lvl1 = decorated.children![0]
|
|
68
|
+
const lvl2 = lvl1.children![0]
|
|
69
|
+
const lvl3 = lvl2.children![0]
|
|
70
|
+
expect(lvl1.page_type).toBe('song')
|
|
71
|
+
expect(lvl2.page_type).toBe('song')
|
|
72
|
+
expect(lvl3.page_type).toBeUndefined()
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('preserves the input reference', () => {
|
|
76
|
+
const items: PageTypeDecoratable[] = [{ type: 'song' }]
|
|
77
|
+
const decorated = decoratePageType(items)
|
|
78
|
+
expect(decorated).toBe(items)
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
})
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"permissions": {
|
|
3
|
-
"allow": [
|
|
4
|
-
"Bash(npx jest *)",
|
|
5
|
-
"Bash(npx tsc *)",
|
|
6
|
-
"Skill(counselors)",
|
|
7
|
-
"Bash(counselors ls *)",
|
|
8
|
-
"Bash(counselors groups *)",
|
|
9
|
-
"Bash(counselors run *)",
|
|
10
|
-
"Bash(npm test *)",
|
|
11
|
-
"Bash(gh pr *)",
|
|
12
|
-
"Bash(gh api *)",
|
|
13
|
-
"Bash(mkdir -p /tmp/pr-review-v2)",
|
|
14
|
-
"Read(//tmp/pr-review-v2/**)",
|
|
15
|
-
"Bash(cat /home/alesevero/railenvironment/applications/musora-content-services/AGENTS.md)",
|
|
16
|
-
"Bash(echo \"no AGENTS.md\")",
|
|
17
|
-
"Bash(echo \"exit=$?\")",
|
|
18
|
-
"Bash(git checkout *)",
|
|
19
|
-
"Skill(pr)",
|
|
20
|
-
"Skill(create-decision)"
|
|
21
|
-
]
|
|
22
|
-
}
|
|
23
|
-
}
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @module UserProfile
|
|
3
|
-
*/
|
|
4
|
-
import { globalConfig } from '../config.js'
|
|
5
|
-
import { GET, DELETE } from '../../infrastructure/http/HttpClient.ts'
|
|
6
|
-
import { calculateLongestStreaks } from '../userActivity.js'
|
|
7
|
-
import './types.js'
|
|
8
|
-
|
|
9
|
-
const baseUrl = `/api/user-management-system`
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* @param {number|null} userId - The user ID to reset permissions for.
|
|
13
|
-
* @returns {Promise<OtherStatsDTO>}
|
|
14
|
-
*/
|
|
15
|
-
export async function otherStats(userId = globalConfig.sessionConfig.userId) {
|
|
16
|
-
const [stats, longestStreaks] = await Promise.all([
|
|
17
|
-
GET(`${baseUrl}/v1/users/${userId}/statistics`),
|
|
18
|
-
calculateLongestStreaks(userId),
|
|
19
|
-
])
|
|
20
|
-
|
|
21
|
-
return {
|
|
22
|
-
...stats,
|
|
23
|
-
longest_day_streak: {
|
|
24
|
-
type: 'day',
|
|
25
|
-
length: longestStreaks.longestDailyStreak,
|
|
26
|
-
},
|
|
27
|
-
longest_week_streak: {
|
|
28
|
-
type: 'week',
|
|
29
|
-
length: longestStreaks.longestWeeklyStreak,
|
|
30
|
-
},
|
|
31
|
-
total_practice_time: longestStreaks.totalPracticeSeconds + (stats.v1_practice_time ?? 0),
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Delete profile picture for the authenticated user
|
|
37
|
-
*
|
|
38
|
-
* @returns {Promise<void>}
|
|
39
|
-
*/
|
|
40
|
-
export async function deleteProfilePicture() {
|
|
41
|
-
const url = `${baseUrl}/v1/users/profile_picture`
|
|
42
|
-
await DELETE(url)
|
|
43
|
-
}
|