Files
famous-ly4-ev/app-modules/shared/tests/Unit/BnfexpressAdminClientTest.php
T
Nyan Lin Paing 31ed52500a
PHP Tests / php-tests (push) Waiting to run
add suggestion management
2026-09-01 00:16:17 +07:00

209 lines
9.4 KiB
PHP

<?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.');
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');
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']);
});
});
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']);
});
});