@@ -700,8 +700,8 @@ describe('Knowledge Search API Route', () => {
700700
701701 describe ( 'Optional Query Search' , ( ) => {
702702 const mockTagDefinitions = [
703- { tagSlot : 'tag1' , displayName : 'category' , fieldType : 'text' } ,
704- { tagSlot : 'tag2' , displayName : 'priority' , fieldType : 'text' } ,
703+ { id : 'tag-category' , tagSlot : 'tag1' , displayName : 'category' , fieldType : 'text' } ,
704+ { id : 'tag-priority' , tagSlot : 'tag2' , displayName : 'priority' , fieldType : 'text' } ,
705705 ]
706706
707707 const mockTaggedResults = [
@@ -772,6 +772,268 @@ describe('Knowledge Search API Route', () => {
772772 } )
773773 } )
774774
775+ it ( 'should resolve tag IDs and derive each filter type and slot server-side' , async ( ) => {
776+ const tagOnlyData = {
777+ knowledgeBaseIds : 'kb-123' ,
778+ tagFilters : [
779+ { tagId : 'tag-text' , value : 'api' , fieldType : 'boolean' , operator : 'contains' } ,
780+ {
781+ tagId : 'tag-number' ,
782+ value : 10 ,
783+ valueTo : 20 ,
784+ fieldType : 'text' ,
785+ operator : 'between' ,
786+ } ,
787+ { tagId : 'tag-date' , value : '2026-07-27' , fieldType : 'text' , operator : 'lte' } ,
788+ { tagId : 'tag-boolean' , value : true , fieldType : 'number' , operator : 'eq' } ,
789+ ] ,
790+ topK : 10 ,
791+ }
792+ const tagDefinitions = [
793+ { id : 'tag-text' , tagSlot : 'tag1' , displayName : 'category' , fieldType : 'text' } ,
794+ { id : 'tag-number' , tagSlot : 'number1' , displayName : 'score' , fieldType : 'number' } ,
795+ { id : 'tag-date' , tagSlot : 'date1' , displayName : 'published' , fieldType : 'date' } ,
796+ { id : 'tag-boolean' , tagSlot : 'boolean1' , displayName : 'verified' , fieldType : 'boolean' } ,
797+ ]
798+
799+ mockCheckKnowledgeBaseAccess . mockResolvedValue ( {
800+ hasAccess : true ,
801+ knowledgeBase : {
802+ id : 'kb-123' ,
803+ userId : 'user-123' ,
804+ name : 'Test KB' ,
805+ deletedAt : null ,
806+ embeddingModel : 'text-embedding-3-small' ,
807+ } ,
808+ } )
809+ mockGetDocumentTagDefinitions . mockResolvedValue ( tagDefinitions )
810+ mockHandleTagOnlySearch . mockResolvedValue ( [ ] )
811+
812+ const req = createMockRequest ( 'POST' , tagOnlyData )
813+ const response = await POST ( req )
814+
815+ expect ( response . status ) . toBe ( 200 )
816+ expect ( mockHandleTagOnlySearch ) . toHaveBeenCalledWith ( {
817+ knowledgeBaseIds : [ 'kb-123' ] ,
818+ topK : 10 ,
819+ structuredFilters : [
820+ {
821+ tagSlot : 'tag1' ,
822+ fieldType : 'text' ,
823+ operator : 'contains' ,
824+ value : 'api' ,
825+ valueTo : undefined ,
826+ } ,
827+ {
828+ tagSlot : 'number1' ,
829+ fieldType : 'number' ,
830+ operator : 'between' ,
831+ value : 10 ,
832+ valueTo : 20 ,
833+ } ,
834+ {
835+ tagSlot : 'date1' ,
836+ fieldType : 'date' ,
837+ operator : 'lte' ,
838+ value : '2026-07-27' ,
839+ valueTo : undefined ,
840+ } ,
841+ {
842+ tagSlot : 'boolean1' ,
843+ fieldType : 'boolean' ,
844+ operator : 'eq' ,
845+ value : true ,
846+ valueTo : undefined ,
847+ } ,
848+ ] ,
849+ } )
850+ } )
851+
852+ it ( 'should reject a tag ID that does not belong to the selected knowledge base' , async ( ) => {
853+ mockCheckKnowledgeBaseAccess . mockResolvedValue ( {
854+ hasAccess : true ,
855+ knowledgeBase : {
856+ id : 'kb-123' ,
857+ userId : 'user-123' ,
858+ name : 'Test KB' ,
859+ deletedAt : null ,
860+ embeddingModel : 'text-embedding-3-small' ,
861+ } ,
862+ } )
863+ mockGetDocumentTagDefinitions . mockResolvedValue ( mockTagDefinitions )
864+
865+ const req = createMockRequest ( 'POST' , {
866+ knowledgeBaseIds : 'kb-123' ,
867+ tagFilters : [ { tagId : 'tag-from-another-kb' , value : 'api' , operator : 'eq' } ] ,
868+ } )
869+ const response = await POST ( req )
870+ const data = await response . json ( )
871+
872+ expect ( response . status ) . toBe ( 400 )
873+ expect ( data . error ) . toContain (
874+ 'Tag IDs not found in the selected knowledge base: tag-from-another-kb'
875+ )
876+ expect ( mockHandleTagOnlySearch ) . not . toHaveBeenCalled ( )
877+ } )
878+
879+ it ( 'should reject ambiguous filters containing both tag name and tag ID' , async ( ) => {
880+ const req = createMockRequest ( 'POST' , {
881+ knowledgeBaseIds : 'kb-123' ,
882+ tagFilters : [
883+ {
884+ tagName : 'category' ,
885+ tagId : 'tag-category' ,
886+ value : 'api' ,
887+ operator : 'eq' ,
888+ } ,
889+ ] ,
890+ } )
891+ const response = await POST ( req )
892+ const data = await response . json ( )
893+
894+ expect ( response . status ) . toBe ( 400 )
895+ expect ( data . error ) . toBe ( 'Validation error' )
896+ expect ( data . details ) . toEqual (
897+ expect . arrayContaining ( [
898+ expect . objectContaining ( {
899+ message : 'Each tag filter must include exactly one of tagName or tagId' ,
900+ } ) ,
901+ ] )
902+ )
903+ } )
904+
905+ it ( 'should reject a configured filter whose dynamic tag ID resolved empty' , async ( ) => {
906+ const req = createMockRequest ( 'POST' , {
907+ knowledgeBaseIds : 'kb-123' ,
908+ query : 'api' ,
909+ tagFilters : [ { value : 'api' , operator : 'eq' } ] ,
910+ } )
911+ const response = await POST ( req )
912+ const data = await response . json ( )
913+
914+ expect ( response . status ) . toBe ( 400 )
915+ expect ( data . error ) . toBe ( 'Validation error' )
916+ expect ( data . details ) . toEqual (
917+ expect . arrayContaining ( [
918+ expect . objectContaining ( {
919+ message : 'Each tag filter must include exactly one of tagName or tagId' ,
920+ } ) ,
921+ ] )
922+ )
923+ } )
924+
925+ it ( 'should reject tag ID filters across multiple knowledge bases' , async ( ) => {
926+ mockCheckKnowledgeBaseAccess . mockImplementation ( async ( knowledgeBaseId : string ) => ( {
927+ hasAccess : true ,
928+ knowledgeBase : {
929+ id : knowledgeBaseId ,
930+ userId : 'user-123' ,
931+ name : 'Test KB' ,
932+ deletedAt : null ,
933+ embeddingModel : 'text-embedding-3-small' ,
934+ } ,
935+ } ) )
936+
937+ const req = createMockRequest ( 'POST' , {
938+ knowledgeBaseIds : [ 'kb-123' , 'kb-456' ] ,
939+ tagFilters : [ { tagId : 'tag-category' , value : 'api' , operator : 'eq' } ] ,
940+ } )
941+ const response = await POST ( req )
942+ const data = await response . json ( )
943+
944+ expect ( response . status ) . toBe ( 400 )
945+ expect ( data . error ) . toContain ( 'Tag ID filters can only search one knowledge base at a time' )
946+ expect ( mockGetDocumentTagDefinitions ) . not . toHaveBeenCalled ( )
947+ } )
948+
949+ it ( 'should validate the operator against the tag type resolved from the ID' , async ( ) => {
950+ mockCheckKnowledgeBaseAccess . mockResolvedValue ( {
951+ hasAccess : true ,
952+ knowledgeBase : {
953+ id : 'kb-123' ,
954+ userId : 'user-123' ,
955+ name : 'Test KB' ,
956+ deletedAt : null ,
957+ embeddingModel : 'text-embedding-3-small' ,
958+ } ,
959+ } )
960+ mockGetDocumentTagDefinitions . mockResolvedValue ( [
961+ { id : 'tag-enabled' , tagSlot : 'tag7' , displayName : 'enabled' , fieldType : 'boolean' } ,
962+ ] )
963+
964+ const req = createMockRequest ( 'POST' , {
965+ knowledgeBaseIds : 'kb-123' ,
966+ tagFilters : [ { tagId : 'tag-enabled' , value : true , operator : 'contains' } ] ,
967+ } )
968+ const response = await POST ( req )
969+ const data = await response . json ( )
970+
971+ expect ( response . status ) . toBe ( 400 )
972+ expect ( data . error ) . toContain ( 'Operator "contains" is not valid for boolean tag "enabled"' )
973+ expect ( mockHandleTagOnlySearch ) . not . toHaveBeenCalled ( )
974+ } )
975+
976+ it . each ( [
977+ {
978+ label : 'missing second value' ,
979+ fieldType : 'number' ,
980+ valueTo : undefined ,
981+ expectedError : 'requires a second value for the "between" operator' ,
982+ } ,
983+ {
984+ label : 'invalid number' ,
985+ fieldType : 'number' ,
986+ valueTo : 'not-a-number' ,
987+ expectedError : 'Invalid second value for "between"' ,
988+ } ,
989+ {
990+ label : 'invalid date' ,
991+ fieldType : 'date' ,
992+ valueTo : '2026-02-31' ,
993+ expectedError : 'Invalid second value for "between"' ,
994+ } ,
995+ ] ) (
996+ 'should reject $label in an ID-resolved between filter' ,
997+ async ( { fieldType, valueTo, expectedError } ) => {
998+ mockCheckKnowledgeBaseAccess . mockResolvedValue ( {
999+ hasAccess : true ,
1000+ knowledgeBase : {
1001+ id : 'kb-123' ,
1002+ userId : 'user-123' ,
1003+ name : 'Test KB' ,
1004+ deletedAt : null ,
1005+ embeddingModel : 'text-embedding-3-small' ,
1006+ } ,
1007+ } )
1008+ mockGetDocumentTagDefinitions . mockResolvedValue ( [
1009+ {
1010+ id : 'tag-range' ,
1011+ tagSlot : fieldType === 'date' ? 'date1' : 'number1' ,
1012+ displayName : 'range' ,
1013+ fieldType,
1014+ } ,
1015+ ] )
1016+
1017+ const req = createMockRequest ( 'POST' , {
1018+ knowledgeBaseIds : 'kb-123' ,
1019+ tagFilters : [
1020+ {
1021+ tagId : 'tag-range' ,
1022+ value : fieldType === 'date' ? '2026-01-01' : 1 ,
1023+ valueTo,
1024+ operator : 'between' ,
1025+ } ,
1026+ ] ,
1027+ } )
1028+ const response = await POST ( req )
1029+ const data = await response . json ( )
1030+
1031+ expect ( response . status ) . toBe ( 400 )
1032+ expect ( data . error ) . toContain ( expectedError )
1033+ expect ( mockHandleTagOnlySearch ) . not . toHaveBeenCalled ( )
1034+ }
1035+ )
1036+
7751037 it ( 'should perform query + tag combination search' , async ( ) => {
7761038 const combinedData = {
7771039 knowledgeBaseIds : 'kb-123' ,
0 commit comments