@@ -13,13 +13,15 @@ const mocks = vi.hoisted(() => ({
1313 requireOrganizationSearch : vi . fn ( ) ,
1414 resolvePermission : vi . fn ( ) ,
1515 getKnowledgeBase : vi . fn ( ) ,
16+ getKnowledgeBases : vi . fn ( ) ,
1617 resolveBilling : vi . fn ( ) ,
1718 checkUsage : vi . fn ( ) ,
1819 checkActorUsage : vi . fn ( ) ,
1920 generateEmbedding : vi . fn ( ) ,
2021 executeSearch : vi . fn ( ) ,
2122 getDocumentMetadata : vi . fn ( ) ,
2223 getTagDefinitions : vi . fn ( ) ,
24+ getTagDefinitionsBatch : vi . fn ( ) ,
2325 recordEmbeddingUsage : vi . fn ( ) ,
2426 importProvenance : vi . fn ( ) ,
2527 rerank : vi . fn ( ) ,
@@ -72,7 +74,7 @@ vi.mock('@/lib/permission-groups/resolve.server', () => ({
7274} ) )
7375
7476vi . mock ( '@/lib/knowledge/service' , ( ) => ( {
75- getActiveKnowledgeBaseReference : mocks . getKnowledgeBase ,
77+ getActiveKnowledgeBaseReferences : mocks . getKnowledgeBases ,
7678} ) )
7779
7880vi . mock ( '@/lib/knowledge/embeddings' , ( ) => ( {
@@ -87,7 +89,7 @@ vi.mock('@/lib/knowledge/search/queries', () => ({
8789} ) )
8890
8991vi . mock ( '@/lib/knowledge/tags/service' , ( ) => ( {
90- getDocumentTagDefinitions : mocks . getTagDefinitions ,
92+ getDocumentTagDefinitionsByKnowledgeBaseIds : mocks . getTagDefinitionsBatch ,
9193} ) )
9294
9395vi . mock ( '@/lib/knowledge/tags/utils' , ( ) => ( {
@@ -130,6 +132,13 @@ describe('knowledge search application use case', () => {
130132 mocks . resolveWorkspace . mockResolvedValue ( workspace )
131133 mocks . resolvePermission . mockResolvedValue ( 'read' )
132134 mocks . getKnowledgeBase . mockResolvedValue ( knowledgeBase )
135+ mocks . getKnowledgeBases . mockImplementation ( ( ids : string [ ] ) =>
136+ Promise . all ( ids . map ( ( id ) => mocks . getKnowledgeBase ( id ) ) )
137+ )
138+ mocks . getTagDefinitionsBatch . mockImplementation (
139+ async ( ids : string [ ] ) =>
140+ new Map ( await Promise . all ( ids . map ( async ( id ) => [ id , await mocks . getTagDefinitions ( id ) ] ) ) )
141+ )
133142 mocks . resolveBilling . mockResolvedValue ( {
134143 actorUserId : 'user-1' ,
135144 workspaceId : 'workspace-1' ,
@@ -554,6 +563,118 @@ describe('knowledge search application use case', () => {
554563
555564 expect ( mocks . resolveWorkspace ) . not . toHaveBeenCalled ( )
556565 expect ( mocks . getKnowledgeBase ) . not . toHaveBeenCalled ( )
566+ expect ( mocks . getKnowledgeBases ) . not . toHaveBeenCalled ( )
567+ expect ( mocks . getTagDefinitionsBatch ) . not . toHaveBeenCalled ( )
568+ } )
569+
570+ it ( 'loads references and tags once for twenty bases while preserving requested order' , async ( ) => {
571+ const ids = Array . from ( { length : 20 } , ( _ , index ) => `knowledge-${ 20 - index } ` )
572+ mocks . getKnowledgeBases . mockResolvedValue ( ids . map ( ( id ) => ( { ...knowledgeBase , id } ) ) )
573+ mocks . getTagDefinitionsBatch . mockResolvedValue ( new Map ( ids . map ( ( id ) => [ id , [ ] ] ) ) )
574+
575+ const result = await searchKnowledge . execute ( {
576+ principal : { kind : 'session' , userId : 'user-1' , sessionId : 'session-1' } ,
577+ input : { knowledgeBaseIds : ids , query : 'answer' , topK : 5 } ,
578+ } )
579+
580+ expect ( mocks . getKnowledgeBases ) . toHaveBeenCalledExactlyOnceWith ( ids )
581+ expect ( mocks . getTagDefinitionsBatch ) . toHaveBeenCalledExactlyOnceWith ( ids )
582+ expect ( result . knowledgeBaseIds ) . toEqual ( ids )
583+ expect ( result . knowledgeBaseId ) . toBe ( ids [ 0 ] )
584+ expect ( result . knowledgeBases . map ( ( base ) => base . id ) ) . toEqual ( ids )
585+ expect ( mocks . executeSearch ) . toHaveBeenCalledWith (
586+ expect . objectContaining ( { knowledgeBaseIds : ids } )
587+ )
588+ } )
589+
590+ it ( 'preserves duplicate requested bases in retrieval and the response' , async ( ) => {
591+ const ids = [ 'knowledge-2' , 'knowledge-1' , 'knowledge-2' ]
592+ mocks . getKnowledgeBases . mockResolvedValue ( ids . map ( ( id ) => ( { ...knowledgeBase , id } ) ) )
593+
594+ const result = await searchKnowledge . execute ( {
595+ principal : { kind : 'session' , userId : 'user-1' , sessionId : 'session-1' } ,
596+ input : { knowledgeBaseIds : ids , query : 'answer' , topK : 5 } ,
597+ } )
598+
599+ expect ( result . knowledgeBaseIds ) . toEqual ( ids )
600+ expect ( mocks . executeSearch ) . toHaveBeenCalledWith (
601+ expect . objectContaining ( { knowledgeBaseIds : ids } )
602+ )
603+ } )
604+
605+ it ( 'preserves missing-id order and duplicates in the concealed error before authorization or billing' , async ( ) => {
606+ mocks . getKnowledgeBases . mockResolvedValue ( [ null , knowledgeBase , null , null ] )
607+
608+ await expect (
609+ searchKnowledge . execute ( {
610+ principal : { kind : 'session' , userId : 'user-1' , sessionId : 'session-1' } ,
611+ input : {
612+ knowledgeBaseIds : [ 'missing-2' , 'knowledge-1' , 'missing-1' , 'missing-2' ] ,
613+ query : 'answer' ,
614+ topK : 5 ,
615+ } ,
616+ } )
617+ ) . rejects . toMatchObject ( {
618+ code : 'not_found' ,
619+ message : 'Knowledge bases not found or access denied: missing-2, missing-1, missing-2' ,
620+ } )
621+ expect ( mocks . resolvePermission ) . not . toHaveBeenCalled ( )
622+ expect ( mocks . resolveBilling ) . not . toHaveBeenCalled ( )
623+ expect ( mocks . executeSearch ) . not . toHaveBeenCalled ( )
624+ } )
625+
626+ it ( 'rejects a batch spanning different canonical workspaces before billing' , async ( ) => {
627+ mocks . getKnowledgeBases . mockResolvedValue ( [
628+ knowledgeBase ,
629+ { ...knowledgeBase , id : 'knowledge-2' , workspaceId : 'workspace-2' } ,
630+ ] )
631+
632+ await expect (
633+ searchKnowledge . execute ( {
634+ principal : { kind : 'session' , userId : 'user-1' , sessionId : 'session-1' } ,
635+ input : { knowledgeBaseIds : [ 'knowledge-1' , 'knowledge-2' ] , query : 'answer' , topK : 5 } ,
636+ } )
637+ ) . rejects . toMatchObject ( {
638+ code : 'validation' ,
639+ message : 'Selected knowledge bases must belong to the same workspace' ,
640+ } )
641+ expect ( mocks . resolveBilling ) . not . toHaveBeenCalled ( )
642+ expect ( mocks . executeSearch ) . not . toHaveBeenCalled ( )
643+ } )
644+
645+ it ( 'reuses the tag filter batch when naming result metadata' , async ( ) => {
646+ const ids = [ 'knowledge-1' , 'knowledge-2' ]
647+ mocks . getKnowledgeBases . mockResolvedValue ( ids . map ( ( id ) => ( { ...knowledgeBase , id } ) ) )
648+ mocks . getTagDefinitionsBatch . mockResolvedValue (
649+ new Map (
650+ ids . map ( ( id ) => [
651+ id ,
652+ [ { knowledgeBaseId : id , tagSlot : 'tag1' , displayName : 'team' , fieldType : 'text' } ] ,
653+ ] )
654+ )
655+ )
656+ mocks . executeSearch . mockResolvedValue ( [
657+ {
658+ id : 'chunk-1' ,
659+ documentId : 'document-1' ,
660+ knowledgeBaseId : ids [ 0 ] ,
661+ content : 'answer' ,
662+ tag1 : 'docs' ,
663+ } ,
664+ ] )
665+
666+ const result = await searchKnowledge . execute ( {
667+ principal : { kind : 'session' , userId : 'user-1' , sessionId : 'session-1' } ,
668+ input : {
669+ knowledgeBaseIds : ids ,
670+ topK : 5 ,
671+ tagFilters : [ { tagName : 'team' , operator : 'eq' , value : 'docs' } ] ,
672+ } ,
673+ } )
674+
675+ expect ( mocks . getTagDefinitionsBatch ) . toHaveBeenCalledExactlyOnceWith ( ids )
676+ expect ( result . results [ 0 ] . metadata ) . toEqual ( { team : 'docs' } )
677+ expect ( mocks . generateEmbedding ) . not . toHaveBeenCalled ( )
557678 } )
558679
559680 it ( 'rejects multi-knowledge-base tag filters without embedding spend' , async ( ) => {
0 commit comments