Add bnfexpress signed admin client and AI Agent Filament UI
- Modules\Shared\Bnfexpress\BnfexpressAdminClient: HMAC-signed HTTP client for bnfexpress's admin API (EV FAQs, agent instructions, chat history), with a bnfexpress:smoke-test command and full unit coverage. - New ai-agent module: Filament pages to manage EV FAQs, publish/roll back agent instruction versions, and browse EV chat history + transcripts. - New manage_ai_agent permission (super_admin/admin). - Recorded .ai/rules for the client's auth scheme and non-Resource Filament page/table testing gotchas.
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Client\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Livewire;
|
||||
use Modules\AiAgent\Filament\Pages\ManageAgentInstructions;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
|
||||
beforeEach(function () {
|
||||
config([
|
||||
'services.bnfexpress.ai_api_url' => 'https://bnfexpress.test',
|
||||
'services.bnfexpress.client_id' => 'ev_admin',
|
||||
'services.bnfexpress.client_secret' => 'test-secret',
|
||||
]);
|
||||
|
||||
Permission::findOrCreate('manage_ai_agent', 'web');
|
||||
|
||||
$this->admin = User::factory()->create()->givePermissionTo(['manage_ai_agent']);
|
||||
$this->actingAs($this->admin);
|
||||
});
|
||||
|
||||
test('a user without manage_ai_agent cannot access it', function () {
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
expect(ManageAgentInstructions::canAccess())->toBeFalse();
|
||||
});
|
||||
|
||||
test('it renders the active instruction and version history', function () {
|
||||
Http::fake([
|
||||
'bnfexpress.test/admin/agent-instructions/active*' => Http::response(['id' => 2, 'content' => 'You are the EV assistant.']),
|
||||
'bnfexpress.test/admin/agent-instructions*' => Http::response([
|
||||
'total' => 1,
|
||||
'instructions' => [['id' => 2, 'is_active' => true, 'content' => 'You are the EV assistant.', 'created_at' => now()->toIso8601String()]],
|
||||
]),
|
||||
]);
|
||||
|
||||
Livewire::test(ManageAgentInstructions::class)
|
||||
->assertOk()
|
||||
->assertSee('You are the EV assistant.')
|
||||
->loadTable()
|
||||
->assertSee('You are the EV assistant.');
|
||||
});
|
||||
|
||||
test('publishing a new version calls publishInstruction and shows a success notification', function () {
|
||||
Http::fake([
|
||||
'bnfexpress.test/admin/agent-instructions/active*' => Http::response(['id' => 3, 'content' => 'New instructions.']),
|
||||
'bnfexpress.test/admin/agent-instructions*' => fn (Request $request) => match ($request->method()) {
|
||||
'POST' => Http::response(['id' => 3, 'content' => 'New instructions.', 'is_active' => true], 201),
|
||||
default => Http::response(['total' => 0, 'instructions' => []]),
|
||||
},
|
||||
]);
|
||||
|
||||
Livewire::test(ManageAgentInstructions::class)
|
||||
->loadTable()
|
||||
->callTableAction('publish', data: ['content' => 'New instructions.', 'activate' => true])
|
||||
->assertNotified('New instruction version published');
|
||||
|
||||
Http::assertSent(fn (Request $request) => $request->method() === 'POST'
|
||||
&& str_contains($request->url(), '/admin/agent-instructions')
|
||||
&& ! str_contains($request->url(), '/active')
|
||||
&& $request['content'] === 'New instructions.'
|
||||
&& $request['activate'] === true);
|
||||
});
|
||||
|
||||
test('a failed publish surfaces the gateway detail message', function () {
|
||||
Http::fake([
|
||||
'bnfexpress.test/admin/agent-instructions/active*' => Http::response(['id' => 1, 'content' => 'Old instructions.']),
|
||||
'bnfexpress.test/admin/agent-instructions*' => fn (Request $request) => match ($request->method()) {
|
||||
'POST' => Http::response(['detail' => 'Content is required.'], 422),
|
||||
default => Http::response(['total' => 0, 'instructions' => []]),
|
||||
},
|
||||
]);
|
||||
|
||||
Livewire::test(ManageAgentInstructions::class)
|
||||
->loadTable()
|
||||
->callTableAction('publish', data: ['content' => 'New instructions.', 'activate' => true])
|
||||
->assertNotified('Failed to publish instruction');
|
||||
});
|
||||
|
||||
test('activate is hidden on the already-active row and visible on others', function () {
|
||||
Http::fake([
|
||||
'bnfexpress.test/admin/agent-instructions/active*' => Http::response(['id' => 2, 'content' => 'Current.']),
|
||||
'bnfexpress.test/admin/agent-instructions*' => Http::response([
|
||||
'total' => 2,
|
||||
'instructions' => [
|
||||
['id' => 2, 'is_active' => true, 'content' => 'Current.', 'created_at' => now()->toIso8601String()],
|
||||
['id' => 1, 'is_active' => false, 'content' => 'Older.', 'created_at' => now()->toIso8601String()],
|
||||
],
|
||||
]),
|
||||
]);
|
||||
|
||||
Livewire::test(ManageAgentInstructions::class)
|
||||
->loadTable()
|
||||
->assertTableActionHidden('activate', 2)
|
||||
->assertTableActionVisible('activate', 1);
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Client\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Livewire;
|
||||
use Modules\AiAgent\Filament\Pages\ManageFaqs;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
|
||||
beforeEach(function () {
|
||||
config([
|
||||
'services.bnfexpress.ai_api_url' => 'https://bnfexpress.test',
|
||||
'services.bnfexpress.client_id' => 'ev_admin',
|
||||
'services.bnfexpress.client_secret' => 'test-secret',
|
||||
]);
|
||||
|
||||
Permission::findOrCreate('manage_ai_agent', 'web');
|
||||
|
||||
$this->admin = User::factory()->create()->givePermissionTo(['manage_ai_agent']);
|
||||
$this->actingAs($this->admin);
|
||||
});
|
||||
|
||||
test('a user without manage_ai_agent cannot access it', function () {
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
expect(ManageFaqs::canAccess())->toBeFalse();
|
||||
});
|
||||
|
||||
test('it renders and lists faqs from the client', function () {
|
||||
Http::fake(['bnfexpress.test/*' => Http::response([
|
||||
'total' => 1,
|
||||
'faqs' => [
|
||||
['id' => 1, 'content' => 'How do I charge my EV?', 'metadata' => []],
|
||||
],
|
||||
])]);
|
||||
|
||||
Livewire::test(ManageFaqs::class)
|
||||
->assertOk()
|
||||
->loadTable()
|
||||
->assertSee('How do I charge my EV?');
|
||||
|
||||
Http::assertSent(fn (Request $request) => str_contains($request->url(), '/admin/faqs') && $request->method() === 'GET');
|
||||
});
|
||||
|
||||
test('creating a faq calls createFaq and shows a success notification', function () {
|
||||
Http::fake(['bnfexpress.test/*' => fn (Request $request) => match ($request->method()) {
|
||||
'POST' => Http::response(['id' => 1, 'content' => 'New FAQ', 'metadata' => []], 201),
|
||||
default => Http::response(['total' => 0, 'faqs' => []]),
|
||||
}]);
|
||||
|
||||
Livewire::test(ManageFaqs::class)
|
||||
->loadTable()
|
||||
->callTableAction('create', data: ['content' => 'New FAQ', 'metadata' => []])
|
||||
->assertNotified('FAQ created');
|
||||
|
||||
Http::assertSent(fn (Request $request) => $request->method() === 'POST' && $request['content'] === 'New FAQ');
|
||||
});
|
||||
|
||||
test('a failed create surfaces the gateway detail message', function () {
|
||||
Http::fake(['bnfexpress.test/*' => fn (Request $request) => match ($request->method()) {
|
||||
'POST' => Http::response(['detail' => 'Content is required.'], 422),
|
||||
default => Http::response(['total' => 0, 'faqs' => []]),
|
||||
}]);
|
||||
|
||||
Livewire::test(ManageFaqs::class)
|
||||
->loadTable()
|
||||
->callTableAction('create', data: ['content' => 'New FAQ', 'metadata' => []])
|
||||
->assertNotified('Failed to create FAQ');
|
||||
});
|
||||
|
||||
test('deleting a faq calls deleteFaq and shows a success notification', function () {
|
||||
Http::fake(['bnfexpress.test/*' => fn (Request $request) => match ($request->method()) {
|
||||
'DELETE' => Http::response(['deleted' => true]),
|
||||
default => Http::response(['total' => 1, 'faqs' => [['id' => 1, 'content' => 'To delete', 'metadata' => []]]]),
|
||||
}]);
|
||||
|
||||
Livewire::test(ManageFaqs::class)
|
||||
->loadTable()
|
||||
->callTableAction('delete', 1)
|
||||
->assertNotified('FAQ deleted');
|
||||
|
||||
Http::assertSent(fn (Request $request) => $request->method() === 'DELETE' && str_contains($request->url(), '/admin/faqs/1'));
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Livewire;
|
||||
use Modules\AiAgent\Filament\Pages\ViewEvChatHistory;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
|
||||
beforeEach(function () {
|
||||
config([
|
||||
'services.bnfexpress.ai_api_url' => 'https://bnfexpress.test',
|
||||
'services.bnfexpress.client_id' => 'ev_admin',
|
||||
'services.bnfexpress.client_secret' => 'test-secret',
|
||||
]);
|
||||
|
||||
Permission::findOrCreate('manage_ai_agent', 'web');
|
||||
|
||||
$this->admin = User::factory()->create()->givePermissionTo(['manage_ai_agent']);
|
||||
$this->actingAs($this->admin);
|
||||
});
|
||||
|
||||
test('a user without manage_ai_agent cannot access it', function () {
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
expect(ViewEvChatHistory::canAccess())->toBeFalse();
|
||||
});
|
||||
|
||||
test('it lists sessions from the client', function () {
|
||||
Http::fake(['bnfexpress.test/admin/ev/history*' => Http::response([
|
||||
'total' => 1,
|
||||
'limit' => 25,
|
||||
'offset' => 0,
|
||||
'sessions' => [
|
||||
['session_id' => 'sess-1', 'user_id' => 'user-1', 'title' => 'Charging question', 'last_message' => 'Thanks!', 'updated_at' => now()->toIso8601String()],
|
||||
],
|
||||
])]);
|
||||
|
||||
Livewire::test(ViewEvChatHistory::class)
|
||||
->assertOk()
|
||||
->loadTable()
|
||||
->assertSee('Charging question');
|
||||
});
|
||||
|
||||
test('viewing a transcript shows its messages', function () {
|
||||
Http::fake([
|
||||
'bnfexpress.test/admin/ev/history/user-1/sess-1' => Http::response([
|
||||
'session_id' => 'sess-1',
|
||||
'user_id' => 'user-1',
|
||||
'messages' => [
|
||||
['author' => 'user', 'text' => 'How do I charge my EV?', 'timestamp' => now()->timestamp],
|
||||
['author' => 'bnfexpress_ev_agent', 'text' => 'Plug it in at a station.', 'timestamp' => now()->timestamp],
|
||||
],
|
||||
]),
|
||||
'bnfexpress.test/admin/ev/history*' => Http::response([
|
||||
'total' => 1,
|
||||
'limit' => 25,
|
||||
'offset' => 0,
|
||||
'sessions' => [
|
||||
['session_id' => 'sess-1', 'user_id' => 'user-1', 'title' => 'Charging question', 'last_message' => 'Thanks!', 'updated_at' => now()->toIso8601String()],
|
||||
],
|
||||
]),
|
||||
]);
|
||||
|
||||
Livewire::test(ViewEvChatHistory::class)
|
||||
->loadTable()
|
||||
->mountTableAction('view', 'user-1:sess-1')
|
||||
->assertMountedActionModalSee('Plug it in at a station.');
|
||||
});
|
||||
|
||||
test('a failed transcript fetch surfaces the gateway detail message', function () {
|
||||
Http::fake([
|
||||
'bnfexpress.test/admin/ev/history/user-1/sess-1' => Http::response(['detail' => 'Session not found.'], 404),
|
||||
'bnfexpress.test/admin/ev/history*' => Http::response([
|
||||
'total' => 1,
|
||||
'limit' => 25,
|
||||
'offset' => 0,
|
||||
'sessions' => [
|
||||
['session_id' => 'sess-1', 'user_id' => 'user-1', 'title' => 'Charging question', 'last_message' => 'Thanks!', 'updated_at' => now()->toIso8601String()],
|
||||
],
|
||||
]),
|
||||
]);
|
||||
|
||||
Livewire::test(ViewEvChatHistory::class)
|
||||
->loadTable()
|
||||
->mountTableAction('view', 'user-1:sess-1')
|
||||
->assertMountedActionModalSee('Session not found.')
|
||||
->assertNotified('Could not load transcript');
|
||||
});
|
||||
Reference in New Issue
Block a user