musora-content-services 2.102.1 → 2.102.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.
@@ -0,0 +1,1148 @@
1
+ /**
2
+ * Pure unit tests for Filters class synchronous methods
3
+ * Tests GROQ filter generation without external dependencies
4
+ */
5
+
6
+ import Filters from '../../src/lib/sanity/filter'
7
+
8
+ describe('Filters - Pure Synchronous Functions', () => {
9
+ describe('Simple Filters', () => {
10
+ describe('brand', () => {
11
+ test('generates brand filter with double quotes', () => {
12
+ expect(Filters.brand('drumeo')).toBe('brand == "drumeo"')
13
+ })
14
+
15
+ test('handles different brand names', () => {
16
+ expect(Filters.brand('pianote')).toBe('brand == "pianote"')
17
+ expect(Filters.brand('guitareo')).toBe('brand == "guitareo"')
18
+ })
19
+ })
20
+
21
+ describe('type', () => {
22
+ test('generates type filter', () => {
23
+ expect(Filters.type('song')).toBe('_type == "song"')
24
+ })
25
+ })
26
+
27
+ describe('slug', () => {
28
+ test('generates slug filter with .current', () => {
29
+ expect(Filters.slug('guitar-basics')).toBe('slug.current == "guitar-basics"')
30
+ })
31
+ })
32
+
33
+ describe('railcontentId', () => {
34
+ test('generates railcontent_id filter', () => {
35
+ expect(Filters.railcontentId(12345)).toBe('railcontent_id == 12345')
36
+ })
37
+ })
38
+
39
+ describe('statusIn', () => {
40
+ test('generates status in array filter', () => {
41
+ const result = Filters.statusIn(['published', 'scheduled'])
42
+ expect(result).toBe("status in ['published','scheduled']")
43
+ })
44
+
45
+ test('handles empty array', () => {
46
+ expect(Filters.statusIn([])).toBe('status in []')
47
+ })
48
+ })
49
+
50
+ describe('idIn', () => {
51
+ test('generates railcontent_id in array filter', () => {
52
+ const result = Filters.idIn([123, 456, 789])
53
+ expect(result).toBe('railcontent_id in [123,456,789]')
54
+ })
55
+
56
+ test('handles empty array', () => {
57
+ expect(Filters.idIn([])).toBe('railcontent_id in []')
58
+ })
59
+ })
60
+
61
+ describe('references', () => {
62
+ test('generates references filter', () => {
63
+ expect(Filters.references('abc123')).toBe('references("abc123")')
64
+ })
65
+ })
66
+
67
+ describe('referencesIDWithFilter', () => {
68
+ test('generates references with subquery filter', () => {
69
+ const filter = 'brand == "drumeo"'
70
+ expect(Filters.referencesIDWithFilter(filter)).toBe('references(*[brand == "drumeo"]._id)')
71
+ })
72
+ })
73
+
74
+ describe('referencesParent', () => {
75
+ test('generates parent reference filter', () => {
76
+ expect(Filters.referencesParent()).toBe('references(^._id)')
77
+ })
78
+ })
79
+
80
+ describe('referencesField', () => {
81
+ test('generates field-based reference filter', () => {
82
+ const result = Filters.referencesField('slug.current', 'john-doe')
83
+ expect(result).toBe('references(*[slug.current == "john-doe"]._id)')
84
+ })
85
+ })
86
+
87
+ describe('titleMatch', () => {
88
+ test('generates title match filter with wildcard', () => {
89
+ expect(Filters.titleMatch('guitar')).toBe('title match "guitar*"')
90
+ })
91
+ })
92
+
93
+ describe('searchMatch', () => {
94
+ test('generates search match filter with term', () => {
95
+ const result = Filters.searchMatch('description', 'beginner')
96
+ expect(result).toBe('description match "beginner*"')
97
+ })
98
+
99
+ test('returns empty string without term', () => {
100
+ expect(Filters.searchMatch('description')).toBe('')
101
+ })
102
+ })
103
+
104
+ describe('publishedBefore', () => {
105
+ test('generates published_on <= filter', () => {
106
+ const date = '2024-01-01T00:00:00.000Z'
107
+ expect(Filters.publishedBefore(date)).toBe(`published_on <= "${date}"`)
108
+ })
109
+ })
110
+
111
+ describe('publishedAfter', () => {
112
+ test('generates published_on >= filter', () => {
113
+ const date = '2024-01-01T00:00:00.000Z'
114
+ expect(Filters.publishedAfter(date)).toBe(`published_on >= "${date}"`)
115
+ })
116
+ })
117
+
118
+ describe('defined', () => {
119
+ test('generates defined() filter', () => {
120
+ expect(Filters.defined('thumbnail')).toBe('defined(thumbnail)')
121
+ })
122
+ })
123
+
124
+ describe('notDefined', () => {
125
+ test('generates !defined() filter', () => {
126
+ expect(Filters.notDefined('thumbnail')).toBe('!defined(thumbnail)')
127
+ })
128
+ })
129
+ })
130
+
131
+ describe('Field Checking - notDeprecated', () => {
132
+ test('generates filter without prefix', () => {
133
+ expect(Filters.notDeprecated()).toBe('!defined(deprecated_railcontent_id)')
134
+ })
135
+
136
+ test('generates filter with empty string prefix', () => {
137
+ expect(Filters.notDeprecated('')).toBe('!defined(deprecated_railcontent_id)')
138
+ })
139
+
140
+ test('generates filter with child prefix', () => {
141
+ expect(Filters.notDeprecated('@->')).toBe('!defined(@->deprecated_railcontent_id)')
142
+ })
143
+
144
+ test('generates filter with parent prefix', () => {
145
+ expect(Filters.notDeprecated('^.')).toBe('!defined(^.deprecated_railcontent_id)')
146
+ })
147
+ })
148
+
149
+ describe('Prefix Modifiers', () => {
150
+ describe('withPrefix', () => {
151
+ test('applies child prefix to filter', () => {
152
+ const filter = Filters.brand('drumeo')
153
+ const result = Filters.withPrefix('@->', filter)
154
+ expect(result).toBe('@->brand == "drumeo"')
155
+ })
156
+
157
+ test('applies parent prefix to filter', () => {
158
+ const filter = Filters.type('song')
159
+ const result = Filters.withPrefix('^.', filter)
160
+ expect(result).toBe('^._type == "song"')
161
+ })
162
+
163
+ test('empty prefix returns original filter', () => {
164
+ const filter = Filters.brand('drumeo')
165
+ expect(Filters.withPrefix('', filter)).toBe(filter)
166
+ })
167
+ })
168
+
169
+ describe('asChild', () => {
170
+ test('applies child prefix to simple filter', () => {
171
+ const result = Filters.asChild(Filters.brand('drumeo'))
172
+ expect(result).toBe('@->brand == "drumeo"')
173
+ })
174
+
175
+ test('applies child prefix to statusIn filter', () => {
176
+ const result = Filters.asChild(Filters.statusIn(['published']))
177
+ expect(result).toBe("@->status in ['published']")
178
+ })
179
+ })
180
+
181
+ describe('asParent', () => {
182
+ test('applies parent prefix to filter', () => {
183
+ const result = Filters.asParent(Filters.type('song'))
184
+ expect(result).toBe('^._type == "song"')
185
+ })
186
+ })
187
+ })
188
+
189
+ describe('Composition Utilities', () => {
190
+ describe('combine', () => {
191
+ test('combines two filters with &&', () => {
192
+ const result = Filters.combine(Filters.brand('drumeo'), Filters.type('song'))
193
+ expect(result).toBe('brand == "drumeo" && _type == "song"')
194
+ })
195
+
196
+ test('combines multiple filters', () => {
197
+ const result = Filters.combine(
198
+ Filters.brand('drumeo'),
199
+ Filters.type('song'),
200
+ Filters.statusIn(['published'])
201
+ )
202
+ expect(result).toContain('brand == "drumeo"')
203
+ expect(result).toContain('_type == "song"')
204
+ expect(result).toContain("status in ['published']")
205
+ expect(result.split(' && ')).toHaveLength(3)
206
+ })
207
+
208
+ test('filters out undefined, null, and false values', () => {
209
+ const result = Filters.combine(
210
+ Filters.brand('drumeo'),
211
+ undefined,
212
+ null,
213
+ false,
214
+ Filters.type('song')
215
+ )
216
+ expect(result).toBe('brand == "drumeo" && _type == "song"')
217
+ })
218
+
219
+ test('returns single filter without &&', () => {
220
+ const result = Filters.combine(Filters.brand('drumeo'))
221
+ expect(result).toBe('brand == "drumeo"')
222
+ })
223
+
224
+ test('returns empty string for all falsy values', () => {
225
+ const result = Filters.combine(undefined, null, false)
226
+ expect(result).toBe('')
227
+ })
228
+ })
229
+
230
+ describe('combineOr', () => {
231
+ test('wraps multiple filters in parentheses with ||', () => {
232
+ const result = Filters.combineOr(Filters.type('song'), Filters.type('workout'))
233
+ expect(result).toBe('(_type == "song" || _type == "workout")')
234
+ })
235
+
236
+ test('single filter has no parentheses', () => {
237
+ const result = Filters.combineOr(Filters.type('song'))
238
+ expect(result).toBe('_type == "song"')
239
+ })
240
+
241
+ test('returns empty string for no filters', () => {
242
+ expect(Filters.combineOr()).toBe('')
243
+ })
244
+
245
+ test('filters out falsy values', () => {
246
+ const result = Filters.combineOr(
247
+ undefined,
248
+ Filters.type('song'),
249
+ null,
250
+ false,
251
+ Filters.type('workout')
252
+ )
253
+ expect(result).toBe('(_type == "song" || _type == "workout")')
254
+ })
255
+ })
256
+ })
257
+
258
+ describe('publishedDate', () => {
259
+ beforeEach(() => {
260
+ jest.useFakeTimers()
261
+ // Set to a specific date: Jan 15, 2024 at 14:30:45
262
+ jest.setSystemTime(new Date('2024-01-15T14:30:45.000Z'))
263
+ })
264
+
265
+ afterEach(() => {
266
+ jest.useRealTimers()
267
+ })
268
+
269
+ test('returns empty string when bypassPublishedDate is true', () => {
270
+ const result = Filters.publishedDate({ bypassPublishedDate: true })
271
+ expect(result).toBe('')
272
+ })
273
+
274
+ test('returns publishedAfter when getFutureContentOnly is true', () => {
275
+ const result = Filters.publishedDate({ getFutureContentOnly: true })
276
+ // Should be rounded to 14:01:00 (1 minute past the hour)
277
+ expect(result).toBe('published_on >= "2024-01-15T14:01:00.000Z"')
278
+ })
279
+
280
+ test('returns publishedBefore when pullFutureContent is false', () => {
281
+ const result = Filters.publishedDate({ pullFutureContent: false })
282
+ expect(result).toBe('published_on <= "2024-01-15T14:01:00.000Z"')
283
+ })
284
+
285
+ test('returns empty string when pullFutureContent is true', () => {
286
+ const result = Filters.publishedDate({ pullFutureContent: true })
287
+ expect(result).toBe('')
288
+ })
289
+
290
+ test('applies prefix when provided', () => {
291
+ const result = Filters.publishedDate({
292
+ pullFutureContent: false,
293
+ prefix: '@->',
294
+ })
295
+ expect(result).toBe('@->published_on <= "2024-01-15T14:01:00.000Z"')
296
+ })
297
+ })
298
+
299
+ describe('Misc Utility Filters', () => {
300
+ describe('includedFields', () => {
301
+ test('processes non-empty array through filtersToGroq', () => {
302
+ const fields = ['difficulty=easy', 'instructor=john']
303
+ const result = Filters.includedFields(fields)
304
+ // Should call filtersToGroq and return its output
305
+ expect(result).toBeTruthy()
306
+ expect(typeof result).toBe('string')
307
+ })
308
+
309
+ test('returns empty string for empty array', () => {
310
+ expect(Filters.includedFields([])).toBe('')
311
+ })
312
+ })
313
+
314
+ describe('count', () => {
315
+ test('wraps filter in count() syntax', () => {
316
+ const filter = 'brand == "drumeo"'
317
+ const result = Filters.count(filter)
318
+ expect(result).toBe('count(*[brand == "drumeo"])')
319
+ })
320
+
321
+ test('works with complex filters', () => {
322
+ const filter = Filters.combine(Filters.brand('drumeo'), Filters.type('song'))
323
+ const result = Filters.count(filter)
324
+ expect(result).toContain('count(*[')
325
+ expect(result).toContain('brand == "drumeo"')
326
+ expect(result).toContain('])')
327
+ })
328
+ })
329
+
330
+ describe('progressIds', () => {
331
+ test('uses idIn for non-empty array', () => {
332
+ const ids = [123, 456, 789]
333
+ const result = Filters.progressIds(ids)
334
+ expect(result).toBe('railcontent_id in [123,456,789]')
335
+ })
336
+
337
+ test('returns empty string for empty array', () => {
338
+ expect(Filters.progressIds([])).toBe('')
339
+ })
340
+ })
341
+ })
342
+
343
+ describe('Edge Cases', () => {
344
+ test('combining filters with different prefixes', () => {
345
+ const childFilter = Filters.asChild(Filters.brand('drumeo'))
346
+ const parentFilter = Filters.asParent(Filters.type('song'))
347
+ const regular = Filters.statusIn(['published'])
348
+
349
+ const result = Filters.combine(childFilter, parentFilter, regular)
350
+
351
+ expect(result).toContain('@->brand == "drumeo"')
352
+ expect(result).toContain('^._type == "song"')
353
+ expect(result).toContain("status in ['published']")
354
+ })
355
+
356
+ test('handles zero and negative IDs', () => {
357
+ expect(Filters.railcontentId(0)).toBe('railcontent_id == 0')
358
+ expect(Filters.railcontentId(-1)).toBe('railcontent_id == -1')
359
+ expect(Filters.idIn([0, -1, 5])).toBe('railcontent_id in [0,-1,5]')
360
+ })
361
+
362
+ test('handles large arrays efficiently', () => {
363
+ const largeArray = Array.from({ length: 100 }, (_, i) => i)
364
+ const result = Filters.idIn(largeArray)
365
+ expect(result).toContain('railcontent_id in [')
366
+ expect(result.split(',').length).toBe(100)
367
+ })
368
+
369
+ test('handles special characters in strings', () => {
370
+ expect(Filters.brand("drumeo's")).toBe('brand == "drumeo\'s"')
371
+ expect(Filters.titleMatch('Señor')).toBe('title match "Señor*"')
372
+ expect(Filters.searchMatch('field', '日本語')).toBe('field match "日本語*"')
373
+ })
374
+
375
+ test('combining all filter types together', () => {
376
+ const result = Filters.combine(
377
+ Filters.brand('drumeo'),
378
+ Filters.type('song'),
379
+ Filters.statusIn(['published']),
380
+ Filters.defined('thumbnail'),
381
+ Filters.notDeprecated()
382
+ )
383
+
384
+ expect(result).toContain('brand == "drumeo"')
385
+ expect(result).toContain('_type == "song"')
386
+ expect(result).toContain("status in ['published']")
387
+ expect(result).toContain('defined(thumbnail)')
388
+ expect(result).toContain('!defined(deprecated_railcontent_id)')
389
+ })
390
+
391
+ test('empty string handling in compose methods', () => {
392
+ const result = Filters.combine(Filters.brand('drumeo'), '', Filters.type('song'))
393
+ // Empty strings should be filtered out
394
+ expect(result.split(' && ').length).toBe(2)
395
+ })
396
+ })
397
+ })
398
+
399
+ // ============================================
400
+ // ASYNC TESTS - MOCK SETUP
401
+ // ============================================
402
+ import { getPermissionsAdapter } from '../../src/services/permissions/index'
403
+ import type { UserPermissions } from '../../src/services/permissions/PermissionsAdapter'
404
+
405
+ // Mock the permissions module
406
+ jest.mock('../../src/services/permissions/index')
407
+
408
+ // Predefined user profiles for V2 testing
409
+ const mockUsers = {
410
+ admin: {
411
+ isAdmin: true,
412
+ permissions: [],
413
+ isABasicMember: false,
414
+ },
415
+ free: {
416
+ isAdmin: false,
417
+ permissions: [],
418
+ isABasicMember: false,
419
+ },
420
+ basicMember: {
421
+ isAdmin: false,
422
+ permissions: ['78', '91', '92'],
423
+ isABasicMember: true,
424
+ },
425
+ plusMember: {
426
+ isAdmin: false,
427
+ permissions: ['78', '108', '91', '92'],
428
+ isABasicMember: true,
429
+ },
430
+ ownedOnly: {
431
+ isAdmin: false,
432
+ permissions: ['100000234', '100000567'], // Owned content IDs: 234, 567
433
+ isABasicMember: false,
434
+ },
435
+ }
436
+
437
+ // Helper to create mock adapter with V2 logic
438
+ const createMockAdapter = (userData: UserPermissions) => {
439
+ return {
440
+ fetchUserPermissions: jest.fn().mockResolvedValue(userData),
441
+ isAdmin: jest.fn((data) => data?.isAdmin ?? false),
442
+ generatePermissionsFilter: jest.fn((data, options = {}) => {
443
+ // V2 Adapter logic implementation
444
+ if (data.isAdmin) return null
445
+
446
+ const prefix = options.prefix || ''
447
+ const userPermissionIds = data?.permissions ?? []
448
+
449
+ // showOnlyOwnedContent: extract content IDs from permissions >= 100000000
450
+ if (options.showOnlyOwnedContent) {
451
+ const minContentPermissionId = 100000000
452
+ const ownedContentIds = userPermissionIds
453
+ .map((permId) => parseInt(permId) - minContentPermissionId)
454
+ .filter((contentId) => contentId > 0)
455
+
456
+ if (ownedContentIds.length === 0) {
457
+ return `railcontent_id == null`
458
+ }
459
+ return ` railcontent_id in [${ownedContentIds.join(',')}] `
460
+ }
461
+
462
+ // showMembershipRestrictedContent: use membership_tier
463
+ if (options.showMembershipRestrictedContent) {
464
+ return ` ${prefix}membership_tier in ['plus','basic'] `
465
+ }
466
+
467
+ // Standard filter: no permissions OR user has matching permissions
468
+ const clauses: string[] = []
469
+ clauses.push(`(!defined(${prefix}permission_v2) || count(${prefix}permission_v2) == 0)`)
470
+
471
+ if (userPermissionIds.length > 0) {
472
+ const isDereferenced = prefix === '@->'
473
+ if (isDereferenced) {
474
+ clauses.push(
475
+ `array::intersects(${prefix}permission_v2, [${userPermissionIds.join(',')}])`
476
+ )
477
+ } else {
478
+ clauses.push(`array::intersects(permission_v2, [${userPermissionIds.join(',')}])`)
479
+ }
480
+ }
481
+
482
+ return `(${clauses.join(' || ')})`
483
+ }),
484
+ getUserPermissionIds: jest.fn((data) => data?.permissions ?? []),
485
+ }
486
+ }
487
+
488
+ // Helper to setup mock adapter
489
+ const setupMockAdapter = (userData: UserPermissions) => {
490
+ const mockAdapter = createMockAdapter(userData)
491
+ ;(getPermissionsAdapter as jest.Mock).mockReturnValue(mockAdapter)
492
+ return mockAdapter
493
+ }
494
+
495
+ describe('Filters - Async Methods (Integration)', () => {
496
+ beforeEach(() => {
497
+ jest.clearAllMocks()
498
+ })
499
+
500
+ describe('Async Composition Utilities', () => {
501
+ describe('combineAsync', () => {
502
+ test('combines mix of sync strings and async promises', async () => {
503
+ const result = await Filters.combineAsync(
504
+ Filters.brand('drumeo'),
505
+ Promise.resolve(Filters.type('song'))
506
+ )
507
+ expect(result).toBe('brand == "drumeo" && _type == "song"')
508
+ })
509
+
510
+ test('handles all sync values', async () => {
511
+ const result = await Filters.combineAsync(Filters.brand('drumeo'), Filters.type('song'))
512
+ expect(result).toBe('brand == "drumeo" && _type == "song"')
513
+ })
514
+
515
+ test('handles all async values with Promise.all', async () => {
516
+ const result = await Filters.combineAsync(
517
+ Promise.resolve(Filters.brand('drumeo')),
518
+ Promise.resolve(Filters.type('song'))
519
+ )
520
+ expect(result).toBe('brand == "drumeo" && _type == "song"')
521
+ })
522
+
523
+ test('filters out falsy values (undefined, null, false, empty)', async () => {
524
+ const result = await Filters.combineAsync(
525
+ Filters.brand('drumeo'),
526
+ Promise.resolve(''),
527
+ undefined,
528
+ null,
529
+ false,
530
+ Filters.type('song')
531
+ )
532
+ expect(result).toBe('brand == "drumeo" && _type == "song"')
533
+ })
534
+ })
535
+
536
+ describe('combineAsyncOr', () => {
537
+ test('wraps multiple filters with OR and parentheses', async () => {
538
+ const result = await Filters.combineAsyncOr(
539
+ Promise.resolve(Filters.type('song')),
540
+ Promise.resolve(Filters.type('workout'))
541
+ )
542
+ expect(result).toBe('(_type == "song" || _type == "workout")')
543
+ })
544
+
545
+ test('single filter has no parentheses', async () => {
546
+ const result = await Filters.combineAsyncOr(Promise.resolve(Filters.type('song')))
547
+ expect(result).toBe('_type == "song"')
548
+ })
549
+
550
+ test('returns empty string for no filters', async () => {
551
+ const result = await Filters.combineAsyncOr()
552
+ expect(result).toBe('')
553
+ })
554
+
555
+ test('filters out falsy values', async () => {
556
+ const result = await Filters.combineAsyncOr(
557
+ undefined,
558
+ Promise.resolve(Filters.type('song')),
559
+ null,
560
+ Promise.resolve(''),
561
+ Filters.type('workout')
562
+ )
563
+ expect(result).toBe('(_type == "song" || _type == "workout")')
564
+ })
565
+ })
566
+ })
567
+
568
+ describe('permissions', () => {
569
+ test('admin user bypasses permission filter', async () => {
570
+ setupMockAdapter(mockUsers.admin)
571
+ const result = await Filters.permissions()
572
+ expect(result).toBe('')
573
+ })
574
+
575
+ test('free user with no permissions gets standard filter', async () => {
576
+ setupMockAdapter(mockUsers.free)
577
+ const result = await Filters.permissions()
578
+ expect(result).toContain('!defined(permission_v2)')
579
+ expect(result).toContain('count(permission_v2) == 0')
580
+ })
581
+
582
+ test('user with permissions generates array::intersects filter', async () => {
583
+ setupMockAdapter(mockUsers.basicMember)
584
+ const result = await Filters.permissions()
585
+ expect(result).toContain('array::intersects(permission_v2')
586
+ expect(result).toContain('[78,91,92]')
587
+ })
588
+
589
+ test('showMembershipRestrictedContent uses membership_tier', async () => {
590
+ setupMockAdapter(mockUsers.free)
591
+ const result = await Filters.permissions({
592
+ showMembershipRestrictedContent: true,
593
+ })
594
+ expect(result).toContain('membership_tier')
595
+ expect(result).toContain("['plus','basic']")
596
+ })
597
+
598
+ test('showOnlyOwnedContent extracts content IDs from permissions', async () => {
599
+ setupMockAdapter(mockUsers.ownedOnly)
600
+ const result = await Filters.permissions({
601
+ showOnlyOwnedContent: true,
602
+ })
603
+ expect(result).toContain('railcontent_id in')
604
+ expect(result).toContain('[234,567]')
605
+ })
606
+
607
+ test('showOnlyOwnedContent with no owned perms returns null filter', async () => {
608
+ setupMockAdapter(mockUsers.free)
609
+ const result = await Filters.permissions({
610
+ showOnlyOwnedContent: true,
611
+ })
612
+ expect(result).toBe('railcontent_id == null')
613
+ })
614
+
615
+ test('bypassPermissions returns empty string', async () => {
616
+ setupMockAdapter(mockUsers.basicMember)
617
+ const result = await Filters.permissions({ bypassPermissions: true })
618
+ expect(result).toBe('')
619
+ })
620
+
621
+ test('applies child prefix to permission_v2 field', async () => {
622
+ setupMockAdapter(mockUsers.basicMember)
623
+ const result = await Filters.permissions({ prefix: '@->' })
624
+ expect(result).toContain('@->permission_v2')
625
+ })
626
+
627
+ test('applies parent prefix to permission_v2 field', async () => {
628
+ setupMockAdapter(mockUsers.basicMember)
629
+ const result = await Filters.permissions({ prefix: '^.' })
630
+ expect(result).toContain('^.permission_v2')
631
+ })
632
+
633
+ test('uses provided userData instead of fetching', async () => {
634
+ const mockAdapter = setupMockAdapter(mockUsers.admin)
635
+ const result = await Filters.permissions({
636
+ userData: mockUsers.basicMember,
637
+ })
638
+ expect(mockAdapter.fetchUserPermissions).not.toHaveBeenCalled()
639
+ expect(result).toContain('[78,91,92]')
640
+ })
641
+
642
+ test('calls generatePermissionsFilter with correct options', async () => {
643
+ const mockAdapter = setupMockAdapter(mockUsers.basicMember)
644
+ await Filters.permissions({
645
+ prefix: '@->',
646
+ showMembershipRestrictedContent: true,
647
+ })
648
+ expect(mockAdapter.generatePermissionsFilter).toHaveBeenCalledWith(
649
+ mockUsers.basicMember,
650
+ expect.objectContaining({
651
+ prefix: '@->',
652
+ showMembershipRestrictedContent: true,
653
+ })
654
+ )
655
+ })
656
+
657
+ test('prefix with showMembershipRestrictedContent', async () => {
658
+ setupMockAdapter(mockUsers.free)
659
+ const result = await Filters.permissions({
660
+ prefix: '@->',
661
+ showMembershipRestrictedContent: true,
662
+ })
663
+ expect(result).toContain('@->membership_tier')
664
+ })
665
+
666
+ test('empty permissions array is handled', async () => {
667
+ setupMockAdapter({ ...mockUsers.free, permissions: [] })
668
+ const result = await Filters.permissions()
669
+ expect(result).toContain('!defined(permission_v2)')
670
+ })
671
+
672
+ test('adapter returns null for admin', async () => {
673
+ const mockAdapter = setupMockAdapter(mockUsers.admin)
674
+ const result = await Filters.permissions()
675
+ expect(mockAdapter.isAdmin).toHaveBeenCalledWith(mockUsers.admin)
676
+ expect(result).toBe('')
677
+ })
678
+
679
+ test('null or undefined options handled gracefully', async () => {
680
+ setupMockAdapter(mockUsers.basicMember)
681
+ const result = await Filters.permissions({})
682
+ expect(result).toBeDefined()
683
+ })
684
+ })
685
+
686
+ describe('status', () => {
687
+ test('admin user via fetchUserPermissions gets all statuses', async () => {
688
+ setupMockAdapter(mockUsers.admin)
689
+ const result = await Filters.status()
690
+ expect(result).toContain('draft')
691
+ expect(result).toContain('scheduled')
692
+ expect(result).toContain('published')
693
+ expect(result).toContain('archived')
694
+ expect(result).toContain('unlisted')
695
+ })
696
+
697
+ test('config.isAdmin: true overrides user data', async () => {
698
+ setupMockAdapter(mockUsers.free)
699
+ const result = await Filters.status({ isAdmin: true })
700
+ expect(result).toContain('draft')
701
+ expect(result).toContain('unlisted')
702
+ })
703
+
704
+ test('regular user gets published and scheduled only', async () => {
705
+ setupMockAdapter(mockUsers.basicMember)
706
+ const result = await Filters.status()
707
+ expect(result).toContain('scheduled')
708
+ expect(result).toContain('published')
709
+ expect(result).not.toContain('draft')
710
+ })
711
+
712
+ test('isSingle: true adds unlisted and archived', async () => {
713
+ setupMockAdapter(mockUsers.basicMember)
714
+ const result = await Filters.status({ isSingle: true })
715
+ expect(result).toContain('scheduled')
716
+ expect(result).toContain('published')
717
+ expect(result).toContain('unlisted')
718
+ expect(result).toContain('archived')
719
+ expect(result).not.toContain('draft')
720
+ })
721
+
722
+ test('explicit statuses override auto-determination', async () => {
723
+ setupMockAdapter(mockUsers.admin)
724
+ const result = await Filters.status({
725
+ statuses: ['published', 'draft'],
726
+ })
727
+ expect(result).toBe("status in ['published','draft']")
728
+ })
729
+
730
+ test('empty statuses array triggers auto-determination', async () => {
731
+ setupMockAdapter(mockUsers.free)
732
+ const result = await Filters.status({ statuses: [] })
733
+ expect(result).toContain('scheduled')
734
+ expect(result).toContain('published')
735
+ })
736
+
737
+ test('bypassStatuses returns empty string', async () => {
738
+ setupMockAdapter(mockUsers.admin)
739
+ const result = await Filters.status({ bypassStatuses: true })
740
+ expect(result).toBe('')
741
+ })
742
+
743
+ test('applies prefix to status filter', async () => {
744
+ setupMockAdapter(mockUsers.basicMember)
745
+ const result = await Filters.status({ prefix: '@->' })
746
+ expect(result).toContain('@->status')
747
+ })
748
+
749
+ test('prefix with explicit statuses', async () => {
750
+ setupMockAdapter(mockUsers.free)
751
+ const result = await Filters.status({
752
+ statuses: ['published'],
753
+ prefix: '^.',
754
+ })
755
+ expect(result).toContain('^.status')
756
+ })
757
+
758
+ test('fetches user permissions when not admin', async () => {
759
+ const mockAdapter = setupMockAdapter(mockUsers.basicMember)
760
+ await Filters.status()
761
+ expect(mockAdapter.fetchUserPermissions).toHaveBeenCalled()
762
+ })
763
+
764
+ test('admin detection via adapter.isAdmin()', async () => {
765
+ const mockAdapter = setupMockAdapter(mockUsers.admin)
766
+ const result = await Filters.status()
767
+ expect(mockAdapter.isAdmin).toHaveBeenCalled()
768
+ expect(result).toContain('draft')
769
+ })
770
+
771
+ test('isSingle with admin gets all statuses', async () => {
772
+ setupMockAdapter(mockUsers.admin)
773
+ const result = await Filters.status({ isSingle: true })
774
+ expect(result).toContain('draft')
775
+ })
776
+ })
777
+
778
+ describe('contentFilter', () => {
779
+ beforeEach(() => {
780
+ jest.useFakeTimers()
781
+ jest.setSystemTime(new Date('2024-01-15T14:30:45.000Z'))
782
+ })
783
+
784
+ afterEach(() => {
785
+ jest.useRealTimers()
786
+ })
787
+
788
+ test('combines status + permissions + date + deprecated', async () => {
789
+ setupMockAdapter(mockUsers.basicMember)
790
+ const result = await Filters.contentFilter({
791
+ pullFutureContent: false,
792
+ })
793
+
794
+ expect(result).toContain('status in')
795
+ expect(result).toContain('permission_v2')
796
+ expect(result).toContain('published_on <=')
797
+ expect(result).toContain('!defined(deprecated_railcontent_id)')
798
+ })
799
+
800
+ test('admin user gets minimal filter (status + date + deprecated)', async () => {
801
+ setupMockAdapter(mockUsers.admin)
802
+ const result = await Filters.contentFilter({
803
+ pullFutureContent: false,
804
+ })
805
+
806
+ expect(result).toContain('draft')
807
+ expect(result).toContain('published_on <=')
808
+ expect(result).toContain('!defined(deprecated_railcontent_id)')
809
+ })
810
+
811
+ test('bypassPermissions excludes permission filter', async () => {
812
+ setupMockAdapter(mockUsers.basicMember)
813
+ const result = await Filters.contentFilter({
814
+ bypassPermissions: true,
815
+ pullFutureContent: false,
816
+ })
817
+
818
+ expect(result).toContain('status in')
819
+ expect(result).toContain('published_on <=')
820
+ expect(result).toContain('!defined(deprecated_railcontent_id)')
821
+ })
822
+
823
+ test('pullFutureContent: true excludes date filter', async () => {
824
+ setupMockAdapter(mockUsers.basicMember)
825
+ const result = await Filters.contentFilter({
826
+ pullFutureContent: true,
827
+ })
828
+
829
+ expect(result).toContain('status in')
830
+ expect(result).toContain('permission_v2')
831
+ expect(result).not.toContain('published_on')
832
+ expect(result).toContain('!defined(deprecated_railcontent_id)')
833
+ })
834
+
835
+ test('bypassPublishedDate excludes date filter', async () => {
836
+ setupMockAdapter(mockUsers.basicMember)
837
+ const result = await Filters.contentFilter({
838
+ bypassPublishedDate: true,
839
+ })
840
+
841
+ expect(result).not.toContain('published_on')
842
+ expect(result).toContain('!defined(deprecated_railcontent_id)')
843
+ })
844
+
845
+ test('showMembershipRestrictedContent affects permission filter', async () => {
846
+ setupMockAdapter(mockUsers.free)
847
+ const result = await Filters.contentFilter({
848
+ showMembershipRestrictedContent: true,
849
+ pullFutureContent: true,
850
+ })
851
+
852
+ expect(result).toContain('membership_tier')
853
+ })
854
+
855
+ test('all bypasses returns only notDeprecated', async () => {
856
+ setupMockAdapter(mockUsers.basicMember)
857
+ const result = await Filters.contentFilter({
858
+ bypassStatuses: true,
859
+ bypassPermissions: true,
860
+ bypassPublishedDate: true,
861
+ })
862
+
863
+ expect(result).toBe('!defined(deprecated_railcontent_id)')
864
+ })
865
+
866
+ test('combines with && separator', async () => {
867
+ setupMockAdapter(mockUsers.basicMember)
868
+ const result = await Filters.contentFilter({
869
+ pullFutureContent: false,
870
+ })
871
+
872
+ const separatorCount = (result.match(/&&/g) || []).length
873
+ expect(separatorCount).toBeGreaterThanOrEqual(3)
874
+ })
875
+
876
+ test('prefix is passed to all components', async () => {
877
+ setupMockAdapter(mockUsers.basicMember)
878
+ const result = await Filters.contentFilter({
879
+ prefix: '@->',
880
+ pullFutureContent: false,
881
+ })
882
+
883
+ expect(result).toContain('@->status')
884
+ expect(result).toContain('@->permission_v2')
885
+ expect(result).toContain('@->published_on')
886
+ expect(result).toContain('@->deprecated_railcontent_id')
887
+ })
888
+
889
+ test('empty statuses with isSingle', async () => {
890
+ setupMockAdapter(mockUsers.basicMember)
891
+ const result = await Filters.contentFilter({
892
+ isSingle: true,
893
+ pullFutureContent: false,
894
+ })
895
+
896
+ expect(result).toContain('unlisted')
897
+ expect(result).toContain('archived')
898
+ })
899
+ })
900
+
901
+ describe('childFilter', () => {
902
+ beforeEach(() => {
903
+ jest.useFakeTimers()
904
+ jest.setSystemTime(new Date('2024-01-15T14:30:45.000Z'))
905
+ })
906
+
907
+ afterEach(() => {
908
+ jest.useRealTimers()
909
+ })
910
+
911
+ test('applies @-> prefix to all components', async () => {
912
+ setupMockAdapter(mockUsers.basicMember)
913
+ const result = await Filters.childFilter({
914
+ pullFutureContent: false,
915
+ })
916
+
917
+ expect(result).toContain('@->status')
918
+ expect(result).toContain('@->permission_v2')
919
+ expect(result).toContain('@->published_on')
920
+ expect(result).toContain('@->deprecated_railcontent_id')
921
+ })
922
+
923
+ test('passes through config options', async () => {
924
+ setupMockAdapter(mockUsers.basicMember)
925
+ const result = await Filters.childFilter({
926
+ bypassPermissions: true,
927
+ pullFutureContent: false,
928
+ })
929
+
930
+ expect(result).toContain('@->status')
931
+ expect(result).not.toContain('permission_v2')
932
+ })
933
+
934
+ test('works with admin user', async () => {
935
+ setupMockAdapter(mockUsers.admin)
936
+ const result = await Filters.childFilter()
937
+
938
+ expect(result).toContain('@->status')
939
+ expect(result).toContain('draft')
940
+ })
941
+
942
+ test('showMembershipRestrictedContent with child prefix', async () => {
943
+ setupMockAdapter(mockUsers.free)
944
+ const result = await Filters.childFilter({
945
+ showMembershipRestrictedContent: true,
946
+ })
947
+
948
+ expect(result).toContain('@->membership_tier')
949
+ })
950
+
951
+ test('isSingle with child prefix', async () => {
952
+ setupMockAdapter(mockUsers.basicMember)
953
+ const result = await Filters.childFilter({
954
+ isSingle: true,
955
+ })
956
+
957
+ expect(result).toContain('unlisted')
958
+ expect(result).toContain('@->status')
959
+ })
960
+
961
+ test('all components use child prefix', async () => {
962
+ setupMockAdapter(mockUsers.basicMember)
963
+ const result = await Filters.childFilter({
964
+ pullFutureContent: false,
965
+ })
966
+
967
+ const prefixedStatus = result.match(/@->status in/g)
968
+ expect(prefixedStatus).toBeTruthy()
969
+ })
970
+ })
971
+
972
+ describe('parentFilter', () => {
973
+ beforeEach(() => {
974
+ jest.useFakeTimers()
975
+ jest.setSystemTime(new Date('2024-01-15T14:30:45.000Z'))
976
+ })
977
+
978
+ afterEach(() => {
979
+ jest.useRealTimers()
980
+ })
981
+
982
+ test('applies ^. prefix to all components', async () => {
983
+ setupMockAdapter(mockUsers.basicMember)
984
+ const result = await Filters.parentFilter({
985
+ pullFutureContent: false,
986
+ })
987
+
988
+ expect(result).toContain('^.status')
989
+ expect(result).toContain('^.permission_v2')
990
+ expect(result).toContain('^.published_on')
991
+ expect(result).toContain('^.deprecated_railcontent_id')
992
+ })
993
+
994
+ test('passes through config options', async () => {
995
+ setupMockAdapter(mockUsers.basicMember)
996
+ const result = await Filters.parentFilter({
997
+ bypassPermissions: true,
998
+ pullFutureContent: false,
999
+ })
1000
+
1001
+ expect(result).toContain('^.status')
1002
+ expect(result).not.toContain('permission_v2')
1003
+ })
1004
+
1005
+ test('works with admin user', async () => {
1006
+ setupMockAdapter(mockUsers.admin)
1007
+ const result = await Filters.parentFilter()
1008
+
1009
+ expect(result).toContain('^.status')
1010
+ expect(result).toContain('draft')
1011
+ })
1012
+
1013
+ test('showMembershipRestrictedContent with parent prefix', async () => {
1014
+ setupMockAdapter(mockUsers.free)
1015
+ const result = await Filters.parentFilter({
1016
+ showMembershipRestrictedContent: true,
1017
+ })
1018
+
1019
+ expect(result).toContain('^.membership_tier')
1020
+ })
1021
+
1022
+ test('isSingle with parent prefix', async () => {
1023
+ setupMockAdapter(mockUsers.basicMember)
1024
+ const result = await Filters.parentFilter({
1025
+ isSingle: true,
1026
+ })
1027
+
1028
+ expect(result).toContain('unlisted')
1029
+ expect(result).toContain('^.status')
1030
+ })
1031
+
1032
+ test('all components use parent prefix', async () => {
1033
+ setupMockAdapter(mockUsers.basicMember)
1034
+ const result = await Filters.parentFilter({
1035
+ pullFutureContent: false,
1036
+ })
1037
+
1038
+ const prefixedStatus = result.match(/\^\.status in/g)
1039
+ expect(prefixedStatus).toBeTruthy()
1040
+ })
1041
+ })
1042
+
1043
+ describe('Integration & Edge Cases', () => {
1044
+ beforeEach(() => {
1045
+ jest.useFakeTimers()
1046
+ jest.setSystemTime(new Date('2024-01-15T14:30:45.000Z'))
1047
+ })
1048
+
1049
+ afterEach(() => {
1050
+ jest.useRealTimers()
1051
+ })
1052
+
1053
+ test('real-world: content list for free user', async () => {
1054
+ setupMockAdapter(mockUsers.free)
1055
+
1056
+ const result = await Filters.contentFilter({
1057
+ pullFutureContent: false,
1058
+ showMembershipRestrictedContent: false,
1059
+ })
1060
+
1061
+ expect(result).toContain('scheduled')
1062
+ expect(result).toContain('published')
1063
+ expect(result).toContain('!defined(permission_v2)')
1064
+ expect(result).toContain('published_on <=')
1065
+ expect(result).toContain('!defined(deprecated_railcontent_id)')
1066
+ })
1067
+
1068
+ test('real-world: single content for plus member', async () => {
1069
+ setupMockAdapter(mockUsers.plusMember)
1070
+
1071
+ const result = await Filters.contentFilter({
1072
+ isSingle: true,
1073
+ pullFutureContent: false,
1074
+ })
1075
+
1076
+ expect(result).toContain('unlisted')
1077
+ expect(result).toContain('archived')
1078
+ expect(result).toContain('[78,108,91,92]')
1079
+ })
1080
+
1081
+ test('real-world: admin panel with all content', async () => {
1082
+ setupMockAdapter(mockUsers.admin)
1083
+
1084
+ const result = await Filters.contentFilter({
1085
+ pullFutureContent: true,
1086
+ })
1087
+
1088
+ expect(result).toContain('draft')
1089
+ })
1090
+
1091
+ test('concurrent contentFilter calls', async () => {
1092
+ setupMockAdapter(mockUsers.basicMember)
1093
+
1094
+ const results = await Promise.all([
1095
+ Filters.contentFilter({ pullFutureContent: false }),
1096
+ Filters.contentFilter({ pullFutureContent: true }),
1097
+ Filters.contentFilter({ isSingle: true }),
1098
+ ])
1099
+
1100
+ expect(results).toHaveLength(3)
1101
+ expect(results[0]).toContain('published_on <=')
1102
+ expect(results[1]).not.toContain('published_on')
1103
+ expect(results[2]).toContain('unlisted')
1104
+ })
1105
+
1106
+ test('adapter throws error is handled', async () => {
1107
+ const mockAdapter = createMockAdapter(mockUsers.basicMember)
1108
+ mockAdapter.fetchUserPermissions.mockRejectedValue(new Error('Network error'))
1109
+ ;(getPermissionsAdapter as jest.Mock).mockReturnValue(mockAdapter)
1110
+
1111
+ await expect(Filters.permissions()).rejects.toThrow('Network error')
1112
+ })
1113
+
1114
+ test('snapshot: free user complete filter', async () => {
1115
+ setupMockAdapter(mockUsers.free)
1116
+
1117
+ const result = await Filters.contentFilter({
1118
+ pullFutureContent: false,
1119
+ showMembershipRestrictedContent: false,
1120
+ })
1121
+
1122
+ expect(result).toMatchSnapshot()
1123
+ })
1124
+
1125
+ test('snapshot: admin complete filter', async () => {
1126
+ setupMockAdapter(mockUsers.admin)
1127
+
1128
+ const result = await Filters.contentFilter({
1129
+ pullFutureContent: false,
1130
+ })
1131
+
1132
+ expect(result).toMatchSnapshot()
1133
+ })
1134
+
1135
+ test('empty user permissions handled gracefully', async () => {
1136
+ const emptyUser = {
1137
+ isAdmin: false,
1138
+ permissions: [],
1139
+ isABasicMember: false,
1140
+ }
1141
+ setupMockAdapter(emptyUser)
1142
+
1143
+ const result = await Filters.contentFilter()
1144
+ expect(result).toBeTruthy()
1145
+ expect(result).toContain('!defined(deprecated_railcontent_id)')
1146
+ })
1147
+ })
1148
+ })