musora-content-services 2.100.3 → 2.101.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 CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
4
4
 
5
+ ## [2.101.0](https://github.com/railroadmedia/musora-content-services/compare/v2.100.3...v2.101.0) (2025-12-10)
6
+
7
+
8
+ ### Features
9
+
10
+ * **auth:** authenticate via auth key ([#644](https://github.com/railroadmedia/musora-content-services/issues/644)) ([122ba63](https://github.com/railroadmedia/musora-content-services/commit/122ba63572e95faf49944a3e819b768cb0a9ab85))
11
+
5
12
  ### [2.100.3](https://github.com/railroadmedia/musora-content-services/compare/v2.100.2...v2.100.3) (2025-12-10)
6
13
 
7
14
  ### [2.100.2](https://github.com/railroadmedia/musora-content-services/compare/v2.100.1...v2.100.2) (2025-12-10)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "musora-content-services",
3
- "version": "2.100.3",
3
+ "version": "2.101.0",
4
4
  "description": "A package for Musoras content services ",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -32,7 +32,6 @@ export async function login(email, password, deviceName, deviceToken, platform)
32
32
  return fetch(`${baseUrl}/v1/sessions`, {
33
33
  method: 'POST',
34
34
  headers: {
35
- 'X-Client-Platform': 'mobile',
36
35
  'Content-Type': 'application/json',
37
36
  Authorization: null,
38
37
  },
@@ -96,3 +95,20 @@ export async function logout() {
96
95
  },
97
96
  })
98
97
  }
98
+
99
+ export async function loginWithAuthKey(authKey, deviceName, deviceToken, platform) {
100
+ const baseUrl = `${globalConfig.baseUrl}/api/user-management-system`
101
+ return fetch(`${baseUrl}/v1/sessions/auth-key`, {
102
+ method: 'POST',
103
+ headers: {
104
+ 'Content-Type': 'application/json',
105
+ Authorization: null,
106
+ },
107
+ body: JSON.stringify({
108
+ auth_key: authKey,
109
+ device_name: deviceName,
110
+ device_token: deviceToken,
111
+ platform: platform,
112
+ }),
113
+ })
114
+ }
@@ -0,0 +1,528 @@
1
+ import { query } from '../../src/lib/sanity/query'
2
+
3
+ describe('Sanity Query Builder', () => {
4
+ describe('Basic Query Building', () => {
5
+ test('builds empty query', () => {
6
+ const result = query().build()
7
+ expect(result).toBe('*[]')
8
+ })
9
+
10
+ test('builds query with single filter', () => {
11
+ const result = query().and('_type == "course"').build()
12
+ expect(result).toBe('*[_type == "course"]')
13
+ })
14
+
15
+ test('builds query with multiple and filters', () => {
16
+ const result = query().and('_type == "course"').and('brand == "drumeo"').build()
17
+ expect(result).toBe('*[_type == "course" && brand == "drumeo"]')
18
+ })
19
+
20
+ test('builds query with three and filters', () => {
21
+ const result = query()
22
+ .and('_type == "course"')
23
+ .and('brand == "drumeo"')
24
+ .and('published == true')
25
+ .build()
26
+ expect(result).toBe('*[_type == "course" && brand == "drumeo" && published == true]')
27
+ })
28
+ })
29
+
30
+ describe('OR Filters', () => {
31
+ test('builds query with single or expression', () => {
32
+ const result = query().or('brand == "drumeo"', 'brand == "pianote"').build()
33
+ expect(result).toBe('*[(brand == "drumeo" || brand == "pianote")]')
34
+ })
35
+
36
+ test('builds query with multiple or expressions', () => {
37
+ const result = query()
38
+ .or('brand == "drumeo"', 'brand == "pianote"', 'brand == "guitareo"')
39
+ .build()
40
+ expect(result).toBe('*[((brand == "drumeo" || brand == "pianote") || brand == "guitareo")]')
41
+ })
42
+
43
+ test('combines and with or filters', () => {
44
+ const result = query()
45
+ .and('_type == "course"')
46
+ .or('brand == "drumeo"', 'brand == "pianote"')
47
+ .build()
48
+ expect(result).toBe('*[_type == "course" && (brand == "drumeo" || brand == "pianote")]')
49
+ })
50
+
51
+ test('combines multiple and with or filters', () => {
52
+ const result = query()
53
+ .and('_type == "course"')
54
+ .and('published == true')
55
+ .or('brand == "drumeo"', 'brand == "pianote"')
56
+ .build()
57
+ expect(result).toBe(
58
+ '*[_type == "course" && published == true && (brand == "drumeo" || brand == "pianote")]'
59
+ )
60
+ })
61
+
62
+ test('handles single or expression', () => {
63
+ const result = query().or('brand == "drumeo"').build()
64
+ expect(result).toBe('*[brand == "drumeo"]')
65
+ })
66
+
67
+ test('handles empty or expressions', () => {
68
+ const result = query().or().build()
69
+ expect(result).toBe('*[]')
70
+ })
71
+ })
72
+
73
+ describe('Ordering', () => {
74
+ test('builds query with order', () => {
75
+ const result = query().and('_type == "course"').order('publishedOn desc').build()
76
+ expect(result).toContain('*[_type == "course"]')
77
+ expect(result).toContain('| order(publishedOn desc)')
78
+ })
79
+
80
+ test('builds query with order only', () => {
81
+ const result = query().order('title asc').build()
82
+ expect(result).toContain('*[]')
83
+ expect(result).toContain('| order(title asc)')
84
+ })
85
+
86
+ test('overrides previous order when called multiple times', () => {
87
+ const result = query()
88
+ .and('_type == "course"')
89
+ .order('publishedOn desc')
90
+ .order('title asc')
91
+ .build()
92
+ expect(result).toContain('*[_type == "course"]')
93
+ expect(result).toContain('| order(title asc)')
94
+ expect(result).not.toContain('publishedOn desc')
95
+ })
96
+ })
97
+
98
+ describe('Slicing and Pagination', () => {
99
+ test('builds query with range slice', () => {
100
+ const result = query().and('_type == "course"').slice(0, 10).build()
101
+ expect(result).toContain('*[_type == "course"]')
102
+ expect(result).toContain('[0...10]')
103
+ })
104
+
105
+ test('builds query with single index slice', () => {
106
+ const result = query().and('_type == "course"').slice(5).build()
107
+ expect(result).toContain('*[_type == "course"]')
108
+ expect(result).toContain('[5]')
109
+ })
110
+
111
+ test('builds query with first() helper', () => {
112
+ const result = query().and('_type == "course"').first().build()
113
+ expect(result).toContain('*[_type == "course"]')
114
+ expect(result).toContain('[0]')
115
+ })
116
+
117
+ test('overrides previous slice when called multiple times', () => {
118
+ const result = query().and('_type == "course"').slice(0, 10).slice(10, 20).build()
119
+ expect(result).toContain('*[_type == "course"]')
120
+ expect(result).toContain('[10...20]')
121
+ expect(result).not.toContain('[0...10]')
122
+ })
123
+
124
+ test('handles slice with zero start', () => {
125
+ const result = query().slice(0, 5).build()
126
+ expect(result).toContain('[0...5]')
127
+ })
128
+ })
129
+
130
+ describe('Projection (Select)', () => {
131
+ test('builds query with single field selection', () => {
132
+ const result = query().and('_type == "course"').select('_id').build()
133
+ expect(result).toContain('*[_type == "course"]')
134
+ expect(result).toContain('{ _id }')
135
+ })
136
+
137
+ test('builds query with multiple field selections', () => {
138
+ const result = query().and('_type == "course"').select('_id', 'title', 'brand').build()
139
+ expect(result).toContain('*[_type == "course"]')
140
+ expect(result).toContain('{ _id, title, brand }')
141
+ })
142
+
143
+ test('builds query with select called multiple times', () => {
144
+ const result = query()
145
+ .and('_type == "course"')
146
+ .select('_id')
147
+ .select('title')
148
+ .select('brand')
149
+ .build()
150
+ expect(result).toContain('*[_type == "course"]')
151
+ expect(result).toContain('{ _id, title, brand }')
152
+ })
153
+
154
+ test('builds query with select only', () => {
155
+ const result = query().select('_id', 'title').build()
156
+ expect(result).toContain('*[]')
157
+ expect(result).toContain('{ _id, title }')
158
+ })
159
+
160
+ test('handles empty select', () => {
161
+ const result = query().and('_type == "course"').select().build()
162
+ expect(result).toBe('*[_type == "course"]')
163
+ })
164
+
165
+ test('handles complex projection with nested fields', () => {
166
+ const result = query()
167
+ .and('_type == "course"')
168
+ .select('_id', '"instructor": instructor->name', '"lessons": lessons[]->title')
169
+ .build()
170
+ expect(result).toContain('*[_type == "course"]')
171
+ expect(result).toContain(
172
+ '{ _id, "instructor": instructor->name, "lessons": lessons[]->title }'
173
+ )
174
+ })
175
+ })
176
+
177
+ describe('Post Filters', () => {
178
+ test('builds query with post filter', () => {
179
+ const result = query()
180
+ .and('_type == "course"')
181
+ .select('_id', 'title', '"lessonCount": count(lessons)')
182
+ .postFilter('lessonCount > 5')
183
+ .build()
184
+ expect(result).toContain('*[_type == "course"]')
185
+ expect(result).toContain('{ _id, title, "lessonCount": count(lessons) }')
186
+ expect(result).toContain('[lessonCount > 5]')
187
+ })
188
+
189
+ test('builds query with multiple post filters', () => {
190
+ const result = query()
191
+ .and('_type == "course"')
192
+ .select('_id', 'title', '"lessonCount": count(lessons)')
193
+ .postFilter('lessonCount > 5')
194
+ .postFilter('lessonCount < 20')
195
+ .build()
196
+ expect(result).toContain('*[_type == "course"]')
197
+ expect(result).toContain('{ _id, title, "lessonCount": count(lessons) }')
198
+ expect(result).toContain('[lessonCount > 5 && lessonCount < 20]')
199
+ })
200
+
201
+ test('builds query with post filter only', () => {
202
+ const result = query()
203
+ .select('_id', '"lessonCount": count(lessons)')
204
+ .postFilter('lessonCount > 5')
205
+ .build()
206
+ expect(result).toContain('*[]')
207
+ expect(result).toContain('{ _id, "lessonCount": count(lessons) }')
208
+ expect(result).toContain('[lessonCount > 5]')
209
+ })
210
+ })
211
+
212
+ describe('Complex Query Combinations', () => {
213
+ test('builds query with all features combined', () => {
214
+ const result = query()
215
+ .and('_type == "course"')
216
+ .and('published == true')
217
+ .or('brand == "drumeo"', 'brand == "pianote"')
218
+ .select('_id', 'title', '"lessonCount": count(lessons)')
219
+ .postFilter('lessonCount > 5')
220
+ .order('publishedOn desc')
221
+ .slice(0, 10)
222
+ .build()
223
+ expect(result).toContain(
224
+ '*[_type == "course" && published == true && (brand == "drumeo" || brand == "pianote")]'
225
+ )
226
+ expect(result).toContain('{ _id, title, "lessonCount": count(lessons) }')
227
+ expect(result).toContain('[lessonCount > 5]')
228
+ expect(result).toContain('| order(publishedOn desc)')
229
+ expect(result).toContain('[0...10]')
230
+ })
231
+
232
+ test('builds query with order and slice only', () => {
233
+ const result = query().order('publishedOn desc').slice(0, 10).build()
234
+ expect(result).toContain('*[]')
235
+ expect(result).toContain('| order(publishedOn desc)')
236
+ expect(result).toContain('[0...10]')
237
+ })
238
+
239
+ test('builds query with filter, order, and first', () => {
240
+ const result = query()
241
+ .and('_type == "course"')
242
+ .and('brand == "drumeo"')
243
+ .order('publishedOn desc')
244
+ .first()
245
+ .build()
246
+ expect(result).toContain('*[_type == "course" && brand == "drumeo"]')
247
+ expect(result).toContain('| order(publishedOn desc)')
248
+ expect(result).toContain('[0]')
249
+ })
250
+
251
+ test('builds realistic content query', () => {
252
+ const result = query()
253
+ .and('_type in ["course", "play-along", "song"]')
254
+ .and('!(_id in path("drafts.**"))')
255
+ .and('published == true')
256
+ .or('difficulty == "beginner"', 'difficulty == "intermediate"')
257
+ .select('_id', 'title', 'brand', 'difficulty', 'publishedOn')
258
+ .order('publishedOn desc')
259
+ .slice(0, 20)
260
+ .build()
261
+ expect(result).toContain('*[_type in ["course", "play-along", "song"]')
262
+ expect(result).toContain('!(_id in path("drafts.**"))')
263
+ expect(result).toContain('published == true')
264
+ expect(result).toContain('(difficulty == "beginner" || difficulty == "intermediate")')
265
+ expect(result).toContain('{ _id, title, brand, difficulty, publishedOn }')
266
+ expect(result).toContain('| order(publishedOn desc)')
267
+ expect(result).toContain('[0...20]')
268
+ })
269
+ })
270
+
271
+ describe('Method Chaining', () => {
272
+ test('returns same builder instance for chaining', () => {
273
+ const builder = query()
274
+ const result1 = builder.and('_type == "course"')
275
+ const result2 = result1.order('title')
276
+ const result3 = result2.slice(0, 10)
277
+ expect(result1).toBe(builder)
278
+ expect(result2).toBe(builder)
279
+ expect(result3).toBe(builder)
280
+ })
281
+
282
+ test('maintains state across chained calls', () => {
283
+ const builder = query()
284
+ builder.and('_type == "course"')
285
+ builder.and('brand == "drumeo"')
286
+ builder.order('publishedOn desc')
287
+ const result = builder.build()
288
+ expect(result).toContain('*[_type == "course" && brand == "drumeo"]')
289
+ expect(result).toContain('| order(publishedOn desc)')
290
+ })
291
+ })
292
+
293
+ describe('State Management', () => {
294
+ test('exposes internal state via _state()', () => {
295
+ const builder = query().and('_type == "course"').order('title').slice(0, 10)
296
+ const state = builder._state()
297
+ expect(state.filter).toBe('_type == "course"')
298
+ expect(state.ordering).toBe('| order(title)')
299
+ expect(state.slice).toBe('[0...10]')
300
+ expect(state.projection).toBe('')
301
+ expect(state.postFilter).toBe('')
302
+ })
303
+
304
+ test('state reflects all builder operations', () => {
305
+ const builder = query()
306
+ .and('_type == "course"')
307
+ .or('brand == "drumeo"', 'brand == "pianote"')
308
+ .select('_id', 'title')
309
+ .postFilter('count > 5')
310
+ .order('publishedOn desc')
311
+ .slice(0, 10)
312
+ const state = builder._state()
313
+ expect(state.filter).toBe('_type == "course" && (brand == "drumeo" || brand == "pianote")')
314
+ expect(state.ordering).toBe('| order(publishedOn desc)')
315
+ expect(state.slice).toBe('[0...10]')
316
+ expect(state.projection).toBe('_id, title')
317
+ expect(state.postFilter).toBe('count > 5')
318
+ })
319
+
320
+ test('independent builder instances have separate state', () => {
321
+ const builder1 = query().and('_type == "course"')
322
+ const builder2 = query().and('_type == "song"')
323
+ expect(builder1._state().filter).toBe('_type == "course"')
324
+ expect(builder2._state().filter).toBe('_type == "song"')
325
+ })
326
+ })
327
+
328
+ describe('Edge Cases', () => {
329
+ test('handles empty string filters', () => {
330
+ const result = query().and('').build()
331
+ expect(result).toBe('*[]')
332
+ })
333
+
334
+ test('handles empty string in or', () => {
335
+ const result = query().or('', 'brand == "drumeo"').build()
336
+ expect(result).toBe('*[brand == "drumeo"]')
337
+ })
338
+
339
+ test('handles all empty strings in or', () => {
340
+ const result = query().or('', '', '').build()
341
+ expect(result).toBe('*[]')
342
+ })
343
+
344
+ test('handles empty string in select', () => {
345
+ const result = query().select('', '_id', '').build()
346
+ expect(result).toContain('{ _id }')
347
+ })
348
+
349
+ test('handles calling first after slice', () => {
350
+ const result = query().slice(0, 10).first().build()
351
+ expect(result).toContain('[0]')
352
+ expect(result).not.toContain('[0...10]')
353
+ })
354
+
355
+ test('builds valid query after multiple modifications', () => {
356
+ const builder = query()
357
+ builder.and('_type == "course"')
358
+ builder.order('title asc')
359
+ builder.order('publishedOn desc') // Override
360
+ builder.slice(0, 10)
361
+ builder.slice(5, 15) // Override
362
+ const result = builder.build()
363
+ expect(result).toContain('*[_type == "course"]')
364
+ expect(result).toContain('| order(publishedOn desc)')
365
+ expect(result).toContain('[5...15]')
366
+ expect(result).not.toContain('title asc')
367
+ expect(result).not.toContain('[0...10]')
368
+ })
369
+ })
370
+
371
+ describe('Whitespace and Formatting', () => {
372
+ test('trims whitespace from built query', () => {
373
+ const result = query().and('_type == "course"').build()
374
+ expect(result).toBe('*[_type == "course"]')
375
+ expect(result.startsWith(' ')).toBe(false)
376
+ expect(result.endsWith(' ')).toBe(false)
377
+ })
378
+
379
+ test('maintains proper structure in complex queries', () => {
380
+ const result = query()
381
+ .and('_type == "course"')
382
+ .select('_id')
383
+ .order('title')
384
+ .slice(0, 10)
385
+ .build()
386
+ // Query should contain all expected parts
387
+ expect(result).toContain('*[_type == "course"]')
388
+ expect(result).toContain('{ _id }')
389
+ expect(result).toContain('| order(title)')
390
+ expect(result).toContain('[0...10]')
391
+ })
392
+ })
393
+
394
+ describe('Real-World Query Patterns', () => {
395
+ test('builds instructor content query', () => {
396
+ const result = query()
397
+ .and('_type == "instructor"')
398
+ .and('!(_id in path("drafts.**"))')
399
+ .select(
400
+ '_id',
401
+ 'name',
402
+ 'biography',
403
+ '"courseCount": count(*[_type == "course" && references(^._id)])'
404
+ )
405
+ .order('name asc')
406
+ .build()
407
+ expect(result).toContain('*[_type == "instructor"')
408
+ expect(result).toContain('!(_id in path("drafts.**"))')
409
+ expect(result).toContain('{ _id, name, biography')
410
+ expect(result).toContain('| order(name asc)')
411
+ })
412
+
413
+ test('builds paginated search query', () => {
414
+ const searchTerm = 'jazz'
415
+ const result = query()
416
+ .and('_type in ["course", "song", "play-along"]')
417
+ .and(`title match "*${searchTerm}*"`)
418
+ .and('published == true')
419
+ .select('_id', 'title', '_type', 'brand')
420
+ .order('_score desc')
421
+ .slice(20, 40)
422
+ .build()
423
+ expect(result).toContain('title match "*jazz*"')
424
+ expect(result).toContain('[20...40]')
425
+ })
426
+
427
+ test('builds content with aggregations and post-filter', () => {
428
+ const result = query()
429
+ .and('_type == "learning-path"')
430
+ .select(
431
+ '_id',
432
+ 'title',
433
+ '"courses": courses[]->{ _id, title }',
434
+ '"totalLessons": count(courses[]->lessons[])'
435
+ )
436
+ .postFilter('totalLessons >= 10')
437
+ .postFilter('totalLessons <= 50')
438
+ .order('totalLessons desc')
439
+ .slice(0, 20)
440
+ .build()
441
+ expect(result).toContain('[totalLessons >= 10 && totalLessons <= 50]')
442
+ })
443
+ })
444
+
445
+ describe('Monoid Laws', () => {
446
+ test('and monoid - empty is identity (left)', () => {
447
+ const builder1 = query().and('').and('_type == "course"')
448
+ const builder2 = query().and('_type == "course"')
449
+ expect(builder1._state().filter).toBe(builder2._state().filter)
450
+ })
451
+
452
+ test('and monoid - empty is identity (right)', () => {
453
+ const builder1 = query().and('_type == "course"').and('')
454
+ const builder2 = query().and('_type == "course"')
455
+ expect(builder1._state().filter).toBe(builder2._state().filter)
456
+ })
457
+
458
+ test('and monoid - associativity', () => {
459
+ const builder1 = query().and('a').and('b').and('c')
460
+ expect(builder1._state().filter).toBe('a && b && c')
461
+ })
462
+
463
+ test('or monoid - empty is identity', () => {
464
+ const builder1 = query().or('', 'brand == "drumeo"')
465
+ const builder2 = query().or('brand == "drumeo"')
466
+ expect(builder1._state().filter).toBe(builder2._state().filter)
467
+ })
468
+
469
+ test('or monoid - combines multiple expressions', () => {
470
+ const builder = query().or('a', 'b', 'c')
471
+ expect(builder._state().filter).toBe('((a || b) || c)')
472
+ })
473
+
474
+ test('projection monoid - accumulates fields', () => {
475
+ const builder = query().select('a').select('b').select('c')
476
+ expect(builder._state().projection).toBe('a, b, c')
477
+ })
478
+
479
+ test('projection monoid - handles empty strings', () => {
480
+ const builder = query().select('', 'a', '', 'b')
481
+ expect(builder._state().projection).toBe('a, b')
482
+ })
483
+ })
484
+
485
+ describe('Query Correctness', () => {
486
+ test('filter comes before projection', () => {
487
+ const result = query().and('_type == "course"').select('_id').build()
488
+ const filterIndex = result.indexOf('*[_type')
489
+ const projectionIndex = result.indexOf('{ _id }')
490
+ expect(filterIndex).toBeLessThan(projectionIndex)
491
+ })
492
+
493
+ test('projection comes before post-filter', () => {
494
+ const result = query()
495
+ .and('_type == "course"')
496
+ .select('_id', '"count": count(lessons)')
497
+ .postFilter('count > 5')
498
+ .build()
499
+ const projectionIndex = result.indexOf('{ _id')
500
+ const postFilterIndex = result.indexOf('[count > 5]')
501
+ expect(projectionIndex).toBeLessThan(postFilterIndex)
502
+ })
503
+
504
+ test('ordering comes after post-filter', () => {
505
+ const result = query()
506
+ .and('_type == "course"')
507
+ .select('_id')
508
+ .postFilter('count > 5')
509
+ .order('title')
510
+ .build()
511
+ const postFilterIndex = result.indexOf('[count > 5]')
512
+ const orderIndex = result.indexOf('| order')
513
+ expect(postFilterIndex).toBeLessThan(orderIndex)
514
+ })
515
+
516
+ test('slice comes last', () => {
517
+ const result = query()
518
+ .and('_type == "course"')
519
+ .select('_id')
520
+ .order('title')
521
+ .slice(0, 10)
522
+ .build()
523
+ const orderIndex = result.indexOf('| order')
524
+ const sliceIndex = result.indexOf('[0...10]')
525
+ expect(orderIndex).toBeLessThan(sliceIndex)
526
+ })
527
+ })
528
+ })