@@ -12,33 +12,38 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
1212vi . mock ( '@/lib/core/security/input-validation.server' , ( ) => inputValidationMock )
1313
1414import { POST } from '@/app/api/tools/agiloft/create_record/route'
15+ import { POST as READ } from '@/app/api/tools/agiloft/read_record/route'
1516import { POST as SEARCH } from '@/app/api/tools/agiloft/search_records/route'
16- import { POST as SELECT } from '@/app/api/tools/agiloft/select_records/route'
1717
1818const PINNED_IP = '93.184.216.34'
1919
2020const baseBody = {
2121 instanceUrl : 'https://example.agiloft.com' ,
22- knowledgeBase : 'Demo' ,
23- login : 'admin' ,
24- password : 'secret' ,
25- table : 'contacts.employees' ,
26- data : JSON . stringify ( { first_name : 'John' , last_name : 'Doe' } ) ,
22+ knowledgeBase : 'Russell Investments' ,
23+ login : 'svc.user' ,
24+ password : 's3cr3t' ,
25+ table : 'contract' ,
2726}
2827
29- function mockSecureFetchResponse ( body : { ok ?: boolean ; json ?: unknown ; text ?: string } ) {
28+ function res ( body : { ok ?: boolean ; status ?: number ; json ?: unknown ; text ?: string } ) {
29+ const text = body . text ?? JSON . stringify ( body . json ?? { } )
3030 return {
3131 ok : body . ok ?? true ,
32- status : body . ok === false ? 400 : 200 ,
32+ status : body . status ?? 200 ,
3333 statusText : '' ,
3434 headers : new Headers ( ) ,
3535 body : null ,
36- text : async ( ) => body . text ?? '' ,
37- json : async ( ) => body . json ?? { } ,
36+ text : async ( ) => text ,
37+ json : async ( ) => JSON . parse ( text ) ,
3838 arrayBuffer : async ( ) => new ArrayBuffer ( 0 ) ,
3939 }
4040}
4141
42+ /** Login envelope Agiloft returns — note the trailing space on the scheme. */
43+ const LOGIN_OK = res ( {
44+ json : { access_token : 'tok-123' , authentication_scheme : 'Bearer ' } ,
45+ } )
46+
4247beforeEach ( ( ) => {
4348 vi . clearAllMocks ( )
4449 hybridAuthMockFns . mockCheckInternalAuth . mockResolvedValue ( {
@@ -53,124 +58,149 @@ beforeEach(() => {
5358 } )
5459} )
5560
56- describe ( 'POST /api/tools/agiloft/create_record' , ( ) => {
57- it ( "reads the record ID out of EWCreate's EWREST_id assignment" , async ( ) => {
58- inputValidationMockFns . mockSecureFetchWithPinnedIP
59- . mockResolvedValueOnce ( mockSecureFetchResponse ( { json : { access_token : 'tok-c' } } ) )
60- . mockResolvedValueOnce ( mockSecureFetchResponse ( { text : "EWREST_id='353';" } ) )
61- . mockResolvedValueOnce ( mockSecureFetchResponse ( { } ) )
62-
63- const response = await POST ( createMockRequest ( 'POST' , baseBody ) )
64- const data = ( await response . json ( ) ) as {
65- success : boolean
66- output : { id : string | null }
67- }
68-
69- expect ( data . success ) . toBe ( true )
70- expect ( data . output . id ) . toBe ( '353' )
61+ function arrange ( operationResponse : ReturnType < typeof res > ) {
62+ inputValidationMockFns . mockSecureFetchWithPinnedIP
63+ . mockResolvedValueOnce ( LOGIN_OK )
64+ . mockResolvedValueOnce ( operationResponse )
65+ . mockResolvedValueOnce ( res ( { } ) )
66+ }
7167
72- const operationCall = inputValidationMockFns . mockSecureFetchWithPinnedIP . mock . calls [ 1 ]
73- expect ( operationCall [ 0 ] ) . toContain ( '/ewws/EWCreate?' )
74- expect ( operationCall [ 0 ] ) . toContain ( '&first_name=John' )
75- expect ( operationCall [ 2 ] ) . toMatchObject ( { method : 'POST' } )
68+ describe ( 'EWLogin' , ( ) => {
69+ it ( 'sends $table and $lang alongside $KB in a form body, which the live server requires' , async ( ) => {
70+ arrange ( res ( { json : { success : true , result : { id : 6342 } } } ) )
71+
72+ await POST ( createMockRequest ( 'POST' , { ...baseBody , data : '{"a":"b"}' } ) )
73+
74+ const [ url , ip , init ] = inputValidationMockFns . mockSecureFetchWithPinnedIP . mock . calls [ 0 ]
75+ expect ( url ) . toBe ( 'https://example.agiloft.com/ewws/EWLogin' )
76+ expect ( ip ) . toBe ( PINNED_IP )
77+ expect ( init . method ) . toBe ( 'POST' )
78+ expect ( init . headers [ 'Content-Type' ] ) . toBe ( 'application/x-www-form-urlencoded' )
79+
80+ const sent = new URLSearchParams ( init . body as string )
81+ expect ( sent . get ( '$KB' ) ) . toBe ( 'Russell Investments' )
82+ expect ( sent . get ( '$table' ) ) . toBe ( 'contract' )
83+ expect ( sent . get ( '$lang' ) ) . toBe ( 'en' )
84+ expect ( sent . get ( '$login' ) ) . toBe ( 'svc.user' )
85+ expect ( sent . get ( '$password' ) ) . toBe ( 's3cr3t' )
86+ // Credentials must not leak into the URL.
87+ expect ( url ) . not . toContain ( 's3cr3t' )
7688 } )
7789
78- it ( 'fails loudly when Agiloft answers 200 with something that is not an EWREST body' , async ( ) => {
79- inputValidationMockFns . mockSecureFetchWithPinnedIP
80- . mockResolvedValueOnce ( mockSecureFetchResponse ( { json : { access_token : 'tok-c' } } ) )
81- . mockResolvedValueOnce (
82- mockSecureFetchResponse ( { text : 'Error executing query, please consult logs' } )
83- )
84- . mockResolvedValueOnce ( mockSecureFetchResponse ( { } ) )
90+ it ( 'trims the trailing space Agiloft puts on authentication_scheme' , async ( ) => {
91+ arrange ( res ( { json : { success : true , result : { id : 6342 } } } ) )
8592
86- const response = await POST ( createMockRequest ( 'POST' , baseBody ) )
87- const data = ( await response . json ( ) ) as { success : boolean ; error ?: string }
93+ await POST ( createMockRequest ( 'POST' , { ...baseBody , data : '{"a":"b"}' } ) )
8894
89- expect ( data . success ) . toBe ( false )
90- expect ( data . error ) . toContain ( 'did not return a record ID ')
95+ const [ , , init ] = inputValidationMockFns . mockSecureFetchWithPinnedIP . mock . calls [ 1 ]
96+ expect ( init . headers . Authorization ) . toBe ( 'Bearer tok-123 ')
9197 } )
9298
93- it ( 'rejects a data payload that is not a JSON object' , async ( ) => {
94- const response = await POST (
95- createMockRequest ( 'POST' , { ...baseBody , data : '["not", "an", "object"]' } )
99+ it ( 'surfaces the live "One has to specify" refusal instead of a generic failure' , async ( ) => {
100+ inputValidationMockFns . mockSecureFetchWithPinnedIP . mockResolvedValueOnce (
101+ res ( {
102+ ok : false ,
103+ status : 400 ,
104+ text : '<html><body>EWWrongDataException has occurred: One has to specify $table, $KB, $lang parameters</body></html>' ,
105+ } )
96106 )
107+
108+ const response = await POST ( createMockRequest ( 'POST' , { ...baseBody , data : '{"a":"b"}' } ) )
97109 const data = ( await response . json ( ) ) as { success : boolean ; error ?: string }
98110
99111 expect ( data . success ) . toBe ( false )
100- expect ( data . error ) . toContain ( 'must be a JSON object' )
101- expect ( inputValidationMockFns . mockSecureFetchWithPinnedIP ) . not . toHaveBeenCalled ( )
112+ expect ( data . error ) . toContain ( 'One has to specify $table, $KB, $lang' )
102113 } )
103114} )
104115
105- describe ( 'empty EWREST bodies on search and select' , ( ) => {
106- const listBase = {
107- instanceUrl : 'https://example.agiloft.com' ,
108- knowledgeBase : 'Demo' ,
109- login : 'admin' ,
110- password : 'secret' ,
111- table : 'helpdesk_case' ,
112- }
116+ describe ( 'alrest envelope handling' , ( ) => {
117+ it ( 'targets /ewws/alrest/{KB} for record creation' , async ( ) => {
118+ arrange ( res ( { json : { success : true , result : { id : 6342 , contract_title1 : 'X' } } } ) )
113119
114- function arrange ( text : string ) {
115- inputValidationMockFns . mockSecureFetchWithPinnedIP
116- . mockResolvedValueOnce ( mockSecureFetchResponse ( { json : { access_token : 'tok' } } ) )
117- . mockResolvedValueOnce ( mockSecureFetchResponse ( { text } ) )
118- . mockResolvedValueOnce ( mockSecureFetchResponse ( { } ) )
119- }
120+ const response = await POST (
121+ createMockRequest ( 'POST' , { ...baseBody , data : '{"contract_title1":"X"}' } )
122+ )
123+ const data = ( await response . json ( ) ) as { success : boolean ; output : { id : string | null } }
120124
121- it ( 'treats a plain-text refusal from EWSearch as a failure, not an empty result' , async ( ) => {
122- arrange ( 'Error executing query, please consult logs' )
125+ const [ url ] = inputValidationMockFns . mockSecureFetchWithPinnedIP . mock . calls [ 1 ]
126+ expect ( url ) . toBe (
127+ 'https://example.agiloft.com/ewws/alrest/Russell%20Investments/contract?lang=en'
128+ )
129+ expect ( data . output . id ) . toBe ( '6342' )
130+ } )
123131
124- const response = await SEARCH (
125- createMockRequest ( 'POST' , { ...listBase , query : "priority='High'" } )
132+ it ( 'treats HTTP 200 with success:false as a failure, not a successful create' , async ( ) => {
133+ arrange (
134+ res ( {
135+ json : { success : false , errors : [ { message : 'Field contract_title1 is required' } ] } ,
136+ } )
126137 )
138+
139+ const response = await POST ( createMockRequest ( 'POST' , { ...baseBody , data : '{"a":"b"}' } ) )
127140 const data = ( await response . json ( ) ) as { success : boolean ; error ?: string }
128141
129142 expect ( data . success ) . toBe ( false )
130- expect ( data . error ) . toContain ( 'did not return search results ' )
143+ expect ( data . error ) . toContain ( 'Field contract_title1 is required ' )
131144 } )
145+ } )
132146
133- it ( 'still reports a genuinely empty EWSearch result as a success' , async ( ) => {
134- arrange ( "EWREST_id_length = '0';" )
147+ describe ( 'field projection' , ( ) => {
148+ it ( 'reads through search when fields are named, so a 184KB record is not pulled whole' , async ( ) => {
149+ arrange ( res ( { json : { success : true , result : [ { id : 6342 , contract_title1 : 'X' } ] } } ) )
135150
136- const response = await SEARCH (
137- createMockRequest ( 'POST' , { ...listBase , query : "priority='High'" } )
151+ await READ (
152+ createMockRequest ( 'POST' , {
153+ ...baseBody ,
154+ recordId : '6342' ,
155+ fields : 'contract_title1, company_name' ,
156+ } )
138157 )
139- const data = ( await response . json ( ) ) as {
140- success : boolean
141- output : { records : unknown [ ] ; totalCount : number }
142- }
143158
144- expect ( data . success ) . toBe ( true )
145- expect ( data . output . records ) . toEqual ( [ ] )
146- expect ( data . output . totalCount ) . toBe ( 0 )
159+ const [ url , , init ] = inputValidationMockFns . mockSecureFetchWithPinnedIP . mock . calls [ 1 ]
160+ expect ( url ) . toContain ( '/contract/search?lang=en' )
161+ expect ( JSON . parse ( init . body as string ) ) . toEqual ( {
162+ field : [ 'id' , 'contract_title1' , 'company_name' ] ,
163+ query : 'id=6342' ,
164+ } )
147165 } )
148166
149- it ( 'treats a plain-text refusal from EWSelect as a failure, not an empty result ' , async ( ) => {
150- arrange ( 'Error executing query, please consult logs' )
167+ it ( 'fetches the record directly when no projection was asked for ' , async ( ) => {
168+ arrange ( res ( { json : { success : true , result : { id : 6342 } } } ) )
151169
152- const response = await SELECT (
153- createMockRequest ( 'POST' , { ...listBase , where : "summary like '%new%'" } )
154- )
155- const data = ( await response . json ( ) ) as { success : boolean ; error ?: string }
170+ await READ ( createMockRequest ( 'POST' , { ...baseBody , recordId : '6342' } ) )
156171
157- expect ( data . success ) . toBe ( false )
158- expect ( data . error ) . toContain ( 'did not return a result set' )
172+ const [ url , , init ] = inputValidationMockFns . mockSecureFetchWithPinnedIP . mock . calls [ 1 ]
173+ expect ( url ) . toContain ( '/contract/6342?lang=en' )
174+ expect ( init . method ) . toBe ( 'GET' )
159175 } )
176+ } )
160177
161- it ( 'still reports a genuinely empty EWSelect result as a success' , async ( ) => {
162- arrange ( "EWREST_id_length = '0';" )
178+ describe ( 'optional inputs arriving as null' , ( ) => {
179+ it ( 'accepts a null page instead of rejecting the call before it is made' , async ( ) => {
180+ arrange ( res ( { json : { success : true , result : [ ] } } ) )
163181
164- const response = await SELECT (
165- createMockRequest ( 'POST' , { ...listBase , where : "summary like '%new%'" } )
182+ const response = await SEARCH (
183+ createMockRequest ( 'POST' , {
184+ ...baseBody ,
185+ query : "status='Active'" ,
186+ page : null ,
187+ limit : null ,
188+ fields : null ,
189+ search : null ,
190+ } )
166191 )
167- const data = ( await response . json ( ) ) as {
168- success : boolean
169- output : { recordIds : string [ ] ; totalCount : number }
170- }
192+ const data = ( await response . json ( ) ) as { success : boolean ; error ?: string }
171193
172194 expect ( data . success ) . toBe ( true )
173- expect ( data . output . recordIds ) . toEqual ( [ ] )
174- expect ( data . output . totalCount ) . toBe ( 0 )
195+ expect ( data . error ) . toBeUndefined ( )
196+ } )
197+
198+ it ( 'omits unset optional fields from the search body rather than sending null' , async ( ) => {
199+ arrange ( res ( { json : { success : true , result : [ ] } } ) )
200+
201+ await SEARCH ( createMockRequest ( 'POST' , { ...baseBody , query : "status='Active'" , page : null } ) )
202+
203+ const [ , , init ] = inputValidationMockFns . mockSecureFetchWithPinnedIP . mock . calls [ 1 ]
204+ expect ( JSON . parse ( init . body as string ) ) . toEqual ( { query : "status='Active'" } )
175205 } )
176206} )
0 commit comments