@@ -191,8 +191,8 @@ test('fetchPermissionOptions deduplicates results from both columns', async () =
191191 expect ( result . totalCount ) . toBe ( 3 ) ;
192192} ) ;
193193
194- test ( 'fetchPermissionOptions clears cache when search is empty' , async ( ) => {
195- // First, populate cache with a search
194+ test ( 'fetchPermissionOptions preserves cache across empty searches ' , async ( ) => {
195+ // Populate cache with a search
196196 getMock . mockResolvedValue ( {
197197 json : {
198198 count : 1 ,
@@ -204,10 +204,16 @@ test('fetchPermissionOptions clears cache when search is empty', async () => {
204204 expect ( getMock ) . toHaveBeenCalledTimes ( 2 ) ;
205205 getMock . mockReset ( ) ;
206206
207- // Empty search should clear cache and make a fresh request
207+ // Empty search makes a fresh request but does NOT clear search cache
208208 getMock . mockResolvedValue ( { json : { count : 0 , result : [ ] } } as any ) ;
209209 await fetchPermissionOptions ( '' , 0 , 50 , addDangerToast ) ;
210210 expect ( getMock ) . toHaveBeenCalledTimes ( 1 ) ;
211+ getMock . mockReset ( ) ;
212+
213+ // Previous search term should still be cached — zero API calls
214+ const cached = await fetchPermissionOptions ( 'test' , 0 , 50 , addDangerToast ) ;
215+ expect ( getMock ) . not . toHaveBeenCalled ( ) ;
216+ expect ( cached . totalCount ) . toBe ( 1 ) ;
211217} ) ;
212218
213219test ( 'fetchGroupOptions sends filters array with search term' , async ( ) => {
@@ -350,7 +356,7 @@ test('fetchPermissionOptions handles backend capping page_size below requested',
350356 expect ( result . data ) . toHaveLength ( 50 ) ; // first page of client-side pagination
351357} ) ;
352358
353- test ( 'fetchPermissionOptions treats different-case queries as distinct cache keys ' , async ( ) => {
359+ test ( 'fetchPermissionOptions shares cache across case variants ' , async ( ) => {
354360 getMock . mockResolvedValue ( {
355361 json : {
356362 count : 1 ,
@@ -368,9 +374,9 @@ test('fetchPermissionOptions treats different-case queries as distinct cache key
368374 await fetchPermissionOptions ( 'Dataset' , 0 , 50 , addDangerToast ) ;
369375 expect ( getMock ) . toHaveBeenCalledTimes ( 2 ) ;
370376
371- // Same letters, different case should be a cache miss (separate key)
377+ // Same letters, different case should be a cache hit (normalized key)
372378 const result = await fetchPermissionOptions ( 'dataset' , 0 , 50 , addDangerToast ) ;
373- expect ( getMock ) . toHaveBeenCalledTimes ( 4 ) ;
379+ expect ( getMock ) . toHaveBeenCalledTimes ( 2 ) ; // no new calls
374380 expect ( result ) . toEqual ( {
375381 data : [ { value : 10 , label : 'can access dataset one' } ] ,
376382 totalCount : 1 ,
@@ -418,3 +424,124 @@ test('fetchPermissionOptions evicts oldest cache entry when MAX_CACHE_ENTRIES is
418424 await fetchPermissionOptions ( 'term2' , 0 , 50 , addDangerToast ) ;
419425 expect ( getMock ) . not . toHaveBeenCalled ( ) ;
420426} ) ;
427+
428+ test ( 'fetchPermissionOptions handles variable page sizes from backend' , async ( ) => {
429+ const totalCount = 1200 ;
430+ const pageSizes = [ 500 , 300 , 400 ] ;
431+
432+ getMock . mockImplementation ( ( { endpoint } : { endpoint : string } ) => {
433+ const query = rison . decode ( endpoint . split ( '?q=' ) [ 1 ] ) as Record <
434+ string ,
435+ unknown
436+ > ;
437+ const page = query . page as number ;
438+ const size = page < pageSizes . length ? pageSizes [ page ] : 0 ;
439+ const start = pageSizes . slice ( 0 , page ) . reduce ( ( a , b ) => a + b , 0 ) ;
440+ const items = Array . from ( { length : size } , ( _ , i ) => ( {
441+ id : start + i + 1 ,
442+ permission : { name : `perm_${ start + i } ` } ,
443+ view_menu : { name : `view_${ start + i } ` } ,
444+ } ) ) ;
445+ return Promise . resolve ( {
446+ json : { count : totalCount , result : items } ,
447+ } as any ) ;
448+ } ) ;
449+
450+ const addDangerToast = jest . fn ( ) ;
451+ const result = await fetchPermissionOptions ( 'var' , 0 , 50 , addDangerToast ) ;
452+
453+ // Both branches return identical IDs so deduplicated total is 1200
454+ expect ( result . totalCount ) . toBe ( totalCount ) ;
455+ expect ( result . data ) . toHaveLength ( 50 ) ;
456+ } ) ;
457+
458+ test ( 'fetchPermissionOptions respects concurrency limit for parallel page fetches' , async ( ) => {
459+ const totalCount = 5000 ;
460+ const CONCURRENCY_LIMIT = 3 ;
461+ let maxConcurrent = 0 ;
462+ let inflight = 0 ;
463+
464+ const deferreds : Array < {
465+ resolve : ( ) => void ;
466+ } > = [ ] ;
467+
468+ getMock . mockImplementation ( ( { endpoint } : { endpoint : string } ) => {
469+ const query = rison . decode ( endpoint . split ( '?q=' ) [ 1 ] ) as Record <
470+ string ,
471+ unknown
472+ > ;
473+ const page = query . page as number ;
474+
475+ return new Promise ( resolve => {
476+ inflight += 1 ;
477+ maxConcurrent = Math . max ( maxConcurrent , inflight ) ;
478+ deferreds . push ( {
479+ resolve : ( ) => {
480+ inflight -= 1 ;
481+ const items =
482+ page < 5
483+ ? Array . from ( { length : 1000 } , ( _ , i ) => ( {
484+ id : page * 1000 + i + 1 ,
485+ permission : { name : `p${ page * 1000 + i } ` } ,
486+ view_menu : { name : `v${ page * 1000 + i } ` } ,
487+ } ) )
488+ : [ ] ;
489+ resolve ( { json : { count : totalCount , result : items } } as any ) ;
490+ } ,
491+ } ) ;
492+ } ) ;
493+ } ) ;
494+
495+ const addDangerToast = jest . fn ( ) ;
496+ const fetchPromise = fetchPermissionOptions ( 'conc' , 0 , 50 , addDangerToast ) ;
497+
498+ // Resolve page 0 for both branches (2 calls)
499+ await new Promise ( r => setTimeout ( r , 10 ) ) ;
500+ while ( deferreds . length > 0 ) {
501+ // Resolve all pending, then check concurrency on next batch
502+ const batch = deferreds . splice ( 0 ) ;
503+ batch . forEach ( d => d . resolve ( ) ) ;
504+ await new Promise ( r => setTimeout ( r , 10 ) ) ;
505+ }
506+
507+ await fetchPromise ;
508+
509+ // Page 0 fires 2 requests simultaneously (one per branch).
510+ // Remaining pages fire in batches of CONCURRENCY_LIMIT per branch.
511+ // Max concurrent should not exceed 2 * CONCURRENCY_LIMIT
512+ // (both branches may be fetching their next batch simultaneously).
513+ expect ( maxConcurrent ) . toBeLessThanOrEqual ( 2 * CONCURRENCY_LIMIT ) ;
514+ } ) ;
515+
516+ test ( 'fetchPermissionOptions normalizes whitespace and case for cache keys' , async ( ) => {
517+ getMock . mockResolvedValue ( {
518+ json : {
519+ count : 1 ,
520+ result : [
521+ {
522+ id : 10 ,
523+ permission : { name : 'can_access' } ,
524+ view_menu : { name : 'dataset_one' } ,
525+ } ,
526+ ] ,
527+ } ,
528+ } as any ) ;
529+ const addDangerToast = jest . fn ( ) ;
530+
531+ // Seed cache with "Dataset"
532+ await fetchPermissionOptions ( 'Dataset' , 0 , 50 , addDangerToast ) ;
533+ expect ( getMock ) . toHaveBeenCalledTimes ( 2 ) ;
534+
535+ // "dataset" — same normalized key, cache hit
536+ getMock . mockClear ( ) ;
537+ await fetchPermissionOptions ( 'dataset' , 0 , 50 , addDangerToast ) ;
538+ expect ( getMock ) . not . toHaveBeenCalled ( ) ;
539+
540+ // "dataset " (trailing space) — same normalized key, cache hit
541+ await fetchPermissionOptions ( 'dataset ' , 0 , 50 , addDangerToast ) ;
542+ expect ( getMock ) . not . toHaveBeenCalled ( ) ;
543+
544+ // " Dataset " (leading + trailing space) — same normalized key, cache hit
545+ await fetchPermissionOptions ( ' Dataset ' , 0 , 50 , addDangerToast ) ;
546+ expect ( getMock ) . not . toHaveBeenCalled ( ) ;
547+ } ) ;
0 commit comments