2026-08-30 23:52:21 +07:00
<? php
use Illuminate\Support\Facades\Http ;
use Modules\Shared\Bnfexpress\BnfexpressAdminClient ;
use Modules\Shared\Bnfexpress\Exceptions\BnfexpressApiException ;
$config = [
'ai_api_url' => 'https://bnfexpress.test' ,
'client_id' => 'ev_admin' ,
'client_secret' => 'shared-secret' ,
];
function assertSignedCorrectly ( $request , string $method , string $path , string $rawBody , string $secret ) : bool
{
$timestamp = $request -> header ( 'X-Timestamp' )[ 0 ] ?? null ;
$expected = hash_hmac ( 'sha256' , strtoupper ( $method ) . " \n " . $path . " \n " . $timestamp . " \n " . $rawBody , $secret );
return $request -> hasHeader ( 'X-Client-Id' , 'ev_admin' )
&& $timestamp !== null
&& abs ( time () - ( int ) $timestamp ) < 5
&& $request -> header ( 'X-Signature' )[ 0 ] === $expected ;
}
test ( 'listFaqs signs a GET request and excludes the query string from the signed path' , function () use ( $config ) {
Http :: fake ([ 'bnfexpress.test/*' => Http :: response ([ 'faqs' => []])]);
( new BnfexpressAdminClient ( $config )) -> listFaqs ( q : 'range' , search : 'semantic' , limit : 10 , offset : 0 );
Http :: assertSent ( function ( $request ) use ( $config ) {
return $request -> url () === 'https://bnfexpress.test/admin/faqs?agent=ev&q=range&search=semantic&limit=10&offset=0'
&& assertSignedCorrectly ( $request , 'GET' , '/admin/faqs' , '' , $config [ 'client_secret' ]);
});
});
test ( 'listFaqs omits search when q is empty' , function () use ( $config ) {
Http :: fake ([ 'bnfexpress.test/*' => Http :: response ([ 'faqs' => []])]);
( new BnfexpressAdminClient ( $config )) -> listFaqs ();
Http :: assertSent ( fn ( $request ) => $request -> url () === 'https://bnfexpress.test/admin/faqs?agent=ev' );
});
test ( 'createFaq signs the exact raw JSON body being sent' , function () use ( $config ) {
Http :: fake ([ 'bnfexpress.test/*' => Http :: response ([ 'id' => 1 ], 201 )]);
( new BnfexpressAdminClient ( $config )) -> createFaq ( 'How do I charge?' , [ 'source' => 'admin' ]);
$rawBody = json_encode ([
'content' => 'How do I charge?' ,
'agent' => 'ev' ,
'metadata' => [ 'source' => 'admin' ],
]);
Http :: assertSent ( function ( $request ) use ( $config , $rawBody ) {
return $request -> url () === 'https://bnfexpress.test/admin/faqs'
&& $request -> method () === 'POST'
&& $request -> body () === $rawBody
&& assertSignedCorrectly ( $request , 'POST' , '/admin/faqs' , $rawBody , $config [ 'client_secret' ]);
});
});
test ( 'getActiveInstruction requests the active instruction for the ev agent' , function () use ( $config ) {
Http :: fake ([ 'bnfexpress.test/*' => Http :: response ([ 'content' => 'You are the EV assistant.' ])]);
$result = ( new BnfexpressAdminClient ( $config )) -> getActiveInstruction ();
expect ( $result ) -> toBe ([ 'content' => 'You are the EV assistant.' ]);
Http :: assertSent ( fn ( $request ) => $request -> url () === 'https://bnfexpress.test/admin/agent-instructions/active?agent=ev' );
});
test ( 'a non-2xx response surfaces the gateway detail message' , function () use ( $config ) {
Http :: fake ([ 'bnfexpress.test/*' => Http :: response ([ 'detail' => 'FAQ not found.' ], 404 )]);
( new BnfexpressAdminClient ( $config )) -> getFaq ( 999 );
}) -> throws ( BnfexpressApiException :: class , 'FAQ not found.' );
2026-09-01 00:16:17 +07:00
test ( 'a FastAPI validation error (detail as a list of objects) is flattened into a readable message' , function () use ( $config ) {
Http :: fake ([ 'bnfexpress.test/*' => Http :: response ([
'detail' => [
[ 'type' => 'int_parsing' , 'loc' => [ 'path' , 'suggestion_id' ], 'msg' => 'Input should be a valid integer, unable to parse string as an integer' , 'input' => 'batch' ],
],
], 422 )]);
( new BnfexpressAdminClient ( $config )) -> getSuggestion ( 'batch' );
}) -> throws ( BnfexpressApiException :: class , 'Input should be a valid integer, unable to parse string as an integer' );
2026-08-30 23:52:21 +07:00
test ( 'deleteFaq signs a bodyless DELETE request' , function () use ( $config ) {
Http :: fake ([ 'bnfexpress.test/*' => Http :: response ([ 'deleted' => true ])]);
( new BnfexpressAdminClient ( $config )) -> deleteFaq ( 5 );
Http :: assertSent ( function ( $request ) use ( $config ) {
return $request -> url () === 'https://bnfexpress.test/admin/faqs/5'
&& $request -> method () === 'DELETE'
&& assertSignedCorrectly ( $request , 'DELETE' , '/admin/faqs/5' , '' , $config [ 'client_secret' ]);
});
});
2026-09-01 00:16:17 +07:00
test ( 'createSuggestion sends text_display/lang/intent/weight/source' , function () use ( $config ) {
Http :: fake ([ 'bnfexpress.test/*' => Http :: response ([ 'id' => 1 ], 201 )]);
( new BnfexpressAdminClient ( $config )) -> createSuggestion ( 'How do I charge?' , 'en' , 'charging' , 5 , 'manual' );
$rawBody = json_encode ([
'text_display' => 'How do I charge?' ,
'lang' => 'en' ,
'intent' => 'charging' ,
'weight' => 5 ,
'source' => 'manual' ,
]);
Http :: assertSent ( fn ( $request ) => $request -> url () === 'https://bnfexpress.test/admin/suggestions'
&& $request -> method () === 'POST'
&& $request -> body () === $rawBody );
});
test ( 'updateSuggestion only sends the given fields' , function () use ( $config ) {
Http :: fake ([ 'bnfexpress.test/*' => Http :: response ([ 'id' => 1 ])]);
( new BnfexpressAdminClient ( $config )) -> updateSuggestion ( 1 , weight : 10 );
Http :: assertSent ( fn ( $request ) => $request -> body () === json_encode ([ 'weight' => 10 ]));
});
test ( 'batchCreateSuggestions posts items and returns the created/skipped/trie_rebuilt result' , function () use ( $config ) {
Http :: fake ([ 'bnfexpress.test/*' => Http :: response ([ 'created' => 2 , 'skipped' => 1 , 'trie_rebuilt' => true ])]);
$result = ( new BnfexpressAdminClient ( $config )) -> batchCreateSuggestions ([
[ 'text' => 'a' , 'lang' => 'en' ],
[ 'text' => 'b' , 'lang' => 'en' , 'intent' => 'x' ],
]);
expect ( $result ) -> toBe ([ 'created' => 2 , 'skipped' => 1 , 'trie_rebuilt' => true ]);
Http :: assertSent ( fn ( $request ) => $request -> url () === 'https://bnfexpress.test/admin/suggestions/batch'
&& $request -> method () === 'POST'
&& $request [ 'items' ] === [
[ 'text' => 'a' , 'lang' => 'en' ],
[ 'text' => 'b' , 'lang' => 'en' , 'intent' => 'x' ],
]);
});
test ( 'batchDeleteSuggestions sends a JSON body on DELETE and signs it' , function () use ( $config ) {
Http :: fake ([ 'bnfexpress.test/*' => Http :: response ([ 'deleted' => 2 , 'skipped' => 0 ])]);
$result = ( new BnfexpressAdminClient ( $config )) -> batchDeleteSuggestions ([ 1 , 2 ]);
$rawBody = json_encode ([ 'ids' => [ 1 , 2 ]]);
expect ( $result ) -> toBe ([ 'deleted' => 2 , 'skipped' => 0 ]);
Http :: assertSent ( function ( $request ) use ( $config , $rawBody ) {
return $request -> url () === 'https://bnfexpress.test/admin/suggestions/batch'
&& $request -> method () === 'DELETE'
&& $request -> body () === $rawBody
&& assertSignedCorrectly ( $request , 'DELETE' , '/admin/suggestions/batch' , $rawBody , $config [ 'client_secret' ]);
});
});
test ( 'listSuggestionMisses filters by was_used' , function () use ( $config ) {
Http :: fake ([ 'bnfexpress.test/*' => Http :: response ([])]);
( new BnfexpressAdminClient ( $config )) -> listSuggestionMisses ( wasUsed : false , limit : 25 );
Http :: assertSent ( fn ( $request ) => $request -> url () === 'https://bnfexpress.test/admin/suggestion-misses?was_used=0&limit=25'
|| $request -> url () === 'https://bnfexpress.test/admin/suggestion-misses?was_used=&limit=25' );
});
test ( 'promoteSuggestionMisses posts miss_ids with an optional lang/intent override' , function () use ( $config ) {
Http :: fake ([ 'bnfexpress.test/*' => Http :: response ([ 'created' => 1 , 'skipped' => 0 , 'trie_rebuilt' => true ])]);
$result = ( new BnfexpressAdminClient ( $config )) -> promoteSuggestionMisses ([ 1 , 2 ], lang : 'my' );
expect ( $result ) -> toBe ([ 'created' => 1 , 'skipped' => 0 , 'trie_rebuilt' => true ]);
Http :: assertSent ( fn ( $request ) => $request -> url () === 'https://bnfexpress.test/admin/suggestion-misses/promote'
&& $request -> method () === 'POST'
&& $request -> body () === json_encode ([ 'miss_ids' => [ 1 , 2 ], 'lang' => 'my' ]));
});
test ( 'syncSuggestions triggers a sync-chroma job and returns the job id' , function () use ( $config ) {
Http :: fake ([ 'bnfexpress.test/*' => Http :: response ([ 'job_id' => 'abc-123' ], 202 )]);
$result = ( new BnfexpressAdminClient ( $config )) -> syncSuggestions ();
expect ( $result ) -> toBe ([ 'job_id' => 'abc-123' ]);
Http :: assertSent ( fn ( $request ) => $request -> url () === 'https://bnfexpress.test/admin/suggestions/sync-chroma'
&& $request -> method () === 'POST' );
});
test ( 'getSuggestionSyncStatus polls the job status endpoint' , function () use ( $config ) {
Http :: fake ([ 'bnfexpress.test/*' => Http :: response ([ 'status' => 'finished' , 'result' => [ 'synced' => 3 ]])]);
$result = ( new BnfexpressAdminClient ( $config )) -> getSuggestionSyncStatus ( 'abc-123' );
expect ( $result ) -> toBe ([ 'status' => 'finished' , 'result' => [ 'synced' => 3 ]]);
Http :: assertSent ( fn ( $request ) => $request -> url () === 'https://bnfexpress.test/admin/suggestions/sync-chroma/abc-123'
&& $request -> method () === 'GET' );
});
test ( 'deleteSuggestionEmbedding signs a bodyless DELETE request' , function () use ( $config ) {
Http :: fake ([ 'bnfexpress.test/*' => Http :: response ([])]);
( new BnfexpressAdminClient ( $config )) -> deleteSuggestionEmbedding ( 5 );
Http :: assertSent ( function ( $request ) use ( $config ) {
return $request -> url () === 'https://bnfexpress.test/admin/suggestions/5/chroma'
&& $request -> method () === 'DELETE'
&& assertSignedCorrectly ( $request , 'DELETE' , '/admin/suggestions/5/chroma' , '' , $config [ 'client_secret' ]);
});
});