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:
Nyan Lin Paing
2026-08-30 23:52:21 +07:00
parent 95b369174d
commit b8d31e3dc4
30 changed files with 1362 additions and 1 deletions
+24
View File
@@ -0,0 +1,24 @@
{
"name": "modules/ai-agent",
"description": "",
"type": "library",
"version": "1.0",
"license": "proprietary",
"require": {},
"autoload": {
"psr-4": {
"Modules\\AiAgent\\": "src/",
"Modules\\AiAgent\\Tests\\": "tests/",
"Modules\\AiAgent\\Database\\Factories\\": "database/factories/",
"Modules\\AiAgent\\Database\\Seeders\\": "database/seeders/"
}
},
"minimum-stability": "stable",
"extra": {
"laravel": {
"providers": [
"Modules\\AiAgent\\Providers\\AiAgentServiceProvider"
]
}
}
}
@@ -0,0 +1,13 @@
<x-filament-panels::page>
<x-filament::section heading="Active Instruction">
@if ($active)
<pre class="whitespace-pre-wrap text-sm">{{ $active['content'] ?? '' }}</pre>
@elseif ($activeError)
<p class="text-sm text-danger-600">Could not load the active instruction: {{ $activeError }}</p>
@else
<p class="text-sm text-gray-500">No active instruction.</p>
@endif
</x-filament::section>
{{ $this->table }}
</x-filament-panels::page>
@@ -0,0 +1,3 @@
<x-filament-panels::page>
{{ $this->table }}
</x-filament-panels::page>
@@ -0,0 +1 @@
<p class="text-sm text-danger-600">Could not load this transcript: {{ $message }}</p>
@@ -0,0 +1,34 @@
@php
$labels = [
'user' => 'User',
'bnfexpress_ev_agent' => 'Assistant',
];
@endphp
<div class="max-h-[32rem] space-y-3 overflow-y-auto">
@forelse (($transcript['messages'] ?? []) as $message)
@php
$author = $message['author'] ?? 'unknown';
$isUser = $author === 'user';
@endphp
<div @class([
'max-w-[85%] rounded-lg border p-3',
'ms-auto border-primary-200 bg-primary-50 dark:border-primary-800 dark:bg-primary-950' => $isUser,
'border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-800' => ! $isUser,
])>
<div class="mb-1 flex items-center justify-between gap-3">
<span class="text-xs font-medium uppercase text-gray-500 dark:text-gray-400">
{{ $labels[$author] ?? $author }}
</span>
@if (isset($message['timestamp']))
<span class="text-xs text-gray-400 dark:text-gray-500">
{{ \Illuminate\Support\Carbon::createFromTimestamp($message['timestamp'])->format('M j, Y g:i A') }}
</span>
@endif
</div>
<p class="whitespace-pre-wrap text-sm">{{ $message['text'] ?? '' }}</p>
</div>
@empty
<p class="text-sm text-gray-500">No messages in this session.</p>
@endforelse
</div>
@@ -0,0 +1,3 @@
<x-filament-panels::page>
{{ $this->table }}
</x-filament-panels::page>
@@ -0,0 +1,29 @@
<?php
namespace Modules\AiAgent;
use Filament\Contracts\Plugin;
use Filament\Panel;
class AiAgentPlugin implements Plugin
{
public function getId(): string
{
return 'ai-agent';
}
public function register(Panel $panel): void
{
$panel->discoverPages(
in: __DIR__.'/Filament/Pages',
for: 'Modules\AiAgent\Filament\Pages',
);
}
public function boot(Panel $panel): void {}
public static function make(): static
{
return app(static::class);
}
}
@@ -0,0 +1,28 @@
<?php
namespace Modules\AiAgent\Filament\Concerns;
use Filament\Notifications\Notification;
use Modules\Shared\Bnfexpress\Exceptions\BnfexpressApiException;
/**
* Shared try/call/notify wrapper for bnfexpress admin API calls triggered
* from a Filament action every mutating action across the AI Agent pages
* (create/update/delete/publish/activate) follows this same shape.
*/
trait HandlesBnfexpressErrors
{
/**
* @param callable(): void $callback
*/
protected function callBnfexpress(callable $callback, string $successTitle, string $failureTitle): void
{
try {
$callback();
Notification::make()->title($successTitle)->success()->send();
} catch (BnfexpressApiException $exception) {
Notification::make()->title($failureTitle)->body($exception->getMessage())->danger()->send();
}
}
}
@@ -0,0 +1,145 @@
<?php
namespace Modules\AiAgent\Filament\Pages;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Illuminate\Pagination\LengthAwarePaginator;
use Modules\AiAgent\Filament\Concerns\HandlesBnfexpressErrors;
use Modules\Shared\Bnfexpress\BnfexpressAdminClient;
use Modules\Shared\Bnfexpress\Exceptions\BnfexpressApiException;
use UnitEnum;
/**
* Version history + publish/rollback for the EV agent's system prompt.
* Needs more than a bare table (an "active version" banner above the
* history), so like ManageAppSettings it renders through a custom view
* rather than relying purely on Filament's generated table layout.
*/
class ManageAgentInstructions extends Page implements HasTable
{
use HandlesBnfexpressErrors;
use InteractsWithTable;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedDocumentText;
protected static string|UnitEnum|null $navigationGroup = 'AI Agent';
protected static ?string $navigationLabel = 'Agent Instructions';
protected static ?string $title = 'Agent Instructions';
protected string $view = 'ai-agent::filament.pages.manage-agent-instructions';
/**
* @var array<string, mixed>|null
*/
public ?array $active = null;
public ?string $activeError = null;
public static function canAccess(): bool
{
return auth()->user()?->can('manage_ai_agent') ?? false;
}
public function mount(): void
{
$this->refreshActive();
}
public function table(Table $table): Table
{
return $table
->records(function (int $page, int $recordsPerPage): LengthAwarePaginator {
$result = app(BnfexpressAdminClient::class)->listInstructions(
limit: $recordsPerPage,
offset: ($page - 1) * $recordsPerPage,
);
$items = $result['instructions'] ?? (array_is_list($result) ? $result : []);
$total = $result['total'] ?? (($page - 1) * $recordsPerPage) + count($items) + (count($items) === $recordsPerPage ? 1 : 0);
return new LengthAwarePaginator(
items: collect($items)->mapWithKeys(fn (array $item): array => [$item['id'] => $item]),
total: $total,
perPage: $recordsPerPage,
currentPage: $page,
);
})
->columns([
TextColumn::make('id'),
IconColumn::make('is_active')->boolean(),
TextColumn::make('content')->limit(80)->wrap(),
TextColumn::make('created_at')->dateTime(),
])
->recordActions([
Action::make('activate')
->label('Activate')
->icon(Heroicon::OutlinedArrowUturnLeft)
->visible(fn (array $record): bool => ! ($record['is_active'] ?? false))
->requiresConfirmation()
->action(function (array $record): void {
$this->callBnfexpress(
fn () => app(BnfexpressAdminClient::class)->activateInstruction($record['id']),
successTitle: 'Instruction activated',
failureTitle: 'Failed to activate instruction',
);
$this->resetTable();
$this->refreshActive();
}),
])
->headerActions([
Action::make('publish')
->label('Publish New Version')
->icon(Heroicon::OutlinedPlusCircle)
->schema([
Textarea::make('content')
->required()
->rows(10),
Toggle::make('activate')
->label('Activate immediately')
->default(true)
->helperText('Deactivates the current active version automatically.'),
])
->action(function (array $data): void {
$this->callBnfexpress(
fn () => app(BnfexpressAdminClient::class)->publishInstruction($data['content'], $data['activate']),
successTitle: 'New instruction version published',
failureTitle: 'Failed to publish instruction',
);
$this->resetTable();
$this->refreshActive();
}),
]);
}
private function refreshActive(): void
{
try {
$this->active = app(BnfexpressAdminClient::class)->getActiveInstruction();
$this->activeError = null;
} catch (BnfexpressApiException $exception) {
$this->active = null;
$this->activeError = $exception->getMessage();
Notification::make()
->title('Could not load the active instruction')
->body($exception->getMessage())
->danger()
->send();
}
}
}
@@ -0,0 +1,177 @@
<?php
namespace Modules\AiAgent\Filament\Pages;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Forms\Components\KeyValue;
use Filament\Forms\Components\Textarea;
use Filament\Pages\Page;
use Filament\Schemas\Components\Component;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Pagination\LengthAwarePaginator;
use Modules\AiAgent\Filament\Concerns\HandlesBnfexpressErrors;
use Modules\Shared\Bnfexpress\BnfexpressAdminClient;
use UnitEnum;
/**
* Manage EV FAQs stored by bnfexpress data isn't Eloquent-backed, so the
* table is fed via Table::records() (Filament's documented "custom data"
* mechanism) rather than a query, and row/header actions use plain
* Filament\Actions\Action instead of EditAction/DeleteAction (which assume
* a Model).
*/
class ManageFaqs extends Page implements HasTable
{
use HandlesBnfexpressErrors;
use InteractsWithTable;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedQuestionMarkCircle;
protected static string|UnitEnum|null $navigationGroup = 'AI Agent';
protected static ?string $navigationLabel = 'EV FAQs';
protected static ?string $title = 'EV FAQs';
protected string $view = 'ai-agent::filament.pages.manage-faqs';
public static function canAccess(): bool
{
return auth()->user()?->can('manage_ai_agent') ?? false;
}
public function table(Table $table): Table
{
return $table
->records(function (?string $search, array $filters, int $page, int $recordsPerPage): LengthAwarePaginator {
$result = app(BnfexpressAdminClient::class)->listFaqs(
q: $search,
search: $filters['search_mode']['value'] ?? 'normal',
limit: $recordsPerPage,
offset: ($page - 1) * $recordsPerPage,
);
return $this->paginate($result, 'faqs', $page, $recordsPerPage);
})
->columns([
TextColumn::make('id'),
TextColumn::make('content')
->limit(80)
->wrap(),
TextColumn::make('metadata')
->formatStateUsing(fn (mixed $state): string => json_encode($state ?? [], JSON_THROW_ON_ERROR))
->toggleable(isToggledHiddenByDefault: true),
])
->searchable()
->filters([
SelectFilter::make('search_mode')
->label('Search mode')
->options([
'normal' => 'Normal (substring)',
'semantic' => 'Semantic (meaning-based)',
])
->default('normal'),
])
->recordActions([
$this->editAction(),
$this->deleteAction(),
])
->headerActions([
$this->createAction(),
]);
}
protected function createAction(): Action
{
return Action::make('create')
->label('New FAQ')
->icon(Heroicon::OutlinedPlus)
->schema($this->formSchema())
->action(function (array $data): void {
$this->callBnfexpress(
fn () => app(BnfexpressAdminClient::class)->createFaq($data['content'], $data['metadata'] ?? []),
successTitle: 'FAQ created',
failureTitle: 'Failed to create FAQ',
);
$this->resetTable();
});
}
protected function editAction(): Action
{
return Action::make('edit')
->icon(Heroicon::OutlinedPencilSquare)
->fillForm(fn (array $record): array => $record)
->schema($this->formSchema())
->action(function (array $data, array $record): void {
$this->callBnfexpress(
fn () => app(BnfexpressAdminClient::class)->updateFaq($record['id'], $data['content'], $data['metadata'] ?? []),
successTitle: 'FAQ updated',
failureTitle: 'Failed to update FAQ',
);
$this->resetTable();
});
}
protected function deleteAction(): Action
{
return Action::make('delete')
->color('danger')
->icon(Heroicon::OutlinedTrash)
->requiresConfirmation()
->action(function (array $record): void {
$this->callBnfexpress(
fn () => app(BnfexpressAdminClient::class)->deleteFaq($record['id']),
successTitle: 'FAQ deleted',
failureTitle: 'Failed to delete FAQ',
);
$this->resetTable();
});
}
/**
* @return array<int, Component>
*/
protected function formSchema(): array
{
return [
Textarea::make('content')
->required()
->rows(4),
KeyValue::make('metadata'),
];
}
/**
* bnfexpress's list/faqs and list/agent-instructions endpoints return a
* bare JSON array (confirmed live via `php artisan bnfexpress:smoke-test`),
* with no total/limit/offset envelope so there's no real total to
* report, and this falls back to a "there might be one more page"
* heuristic (also tolerates a {total, <$itemsKey>} envelope, in case
* that ever changes).
*
* @param array<string, mixed> $result
*/
private function paginate(array $result, string $itemsKey, int $page, int $recordsPerPage): LengthAwarePaginator
{
$items = $result[$itemsKey] ?? (array_is_list($result) ? $result : []);
$total = $result['total'] ?? (($page - 1) * $recordsPerPage) + count($items) + (count($items) === $recordsPerPage ? 1 : 0);
return new LengthAwarePaginator(
items: collect($items)->mapWithKeys(fn (array $item): array => [$item['id'] => $item]),
total: $total,
perPage: $recordsPerPage,
currentPage: $page,
);
}
}
@@ -0,0 +1,109 @@
<?php
namespace Modules\AiAgent\Filament\Pages;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Illuminate\Contracts\View\View;
use Illuminate\Pagination\LengthAwarePaginator;
use Modules\Shared\Bnfexpress\BnfexpressAdminClient;
use Modules\Shared\Bnfexpress\Exceptions\BnfexpressApiException;
use UnitEnum;
/**
* Read-only browse of all users' EV chat sessions. No create/edit/delete
* this is a support/QA tool, not a data-management screen.
*/
class ViewEvChatHistory extends Page implements HasTable
{
use InteractsWithTable;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedChatBubbleLeftRight;
protected static string|UnitEnum|null $navigationGroup = 'AI Agent';
protected static ?string $navigationLabel = 'EV Chat History';
protected static ?string $title = 'EV Chat History';
protected string $view = 'ai-agent::filament.pages.view-ev-chat-history';
public static function canAccess(): bool
{
return auth()->user()?->can('manage_ai_agent') ?? false;
}
public function table(Table $table): Table
{
return $table
->records(function (int $page, int $recordsPerPage): LengthAwarePaginator {
$result = app(BnfexpressAdminClient::class)->listSessions(
limit: $recordsPerPage,
offset: ($page - 1) * $recordsPerPage,
);
$sessions = $result['sessions'] ?? [];
return new LengthAwarePaginator(
items: collect($sessions)->mapWithKeys(
fn (array $session): array => ["{$session['user_id']}:{$session['session_id']}" => $session]
),
total: $result['total'] ?? count($sessions),
perPage: $result['limit'] ?? $recordsPerPage,
currentPage: $page,
);
})
->columns([
TextColumn::make('session_id')
->limit(20)
->tooltip(fn (TextColumn $column): ?string => $this->tooltipIfTruncated($column)),
TextColumn::make('user_id')
->limit(20)
->tooltip(fn (TextColumn $column): ?string => $this->tooltipIfTruncated($column)),
TextColumn::make('title')->limit(50),
TextColumn::make('last_message')->limit(80)->wrap(),
TextColumn::make('updated_at')->dateTime(),
])
->recordActions([
Action::make('view')
->label('View Transcript')
->icon(Heroicon::OutlinedEye)
->modalHeading(fn (array $record): string => "Transcript — {$record['session_id']}")
->modalContent(fn (array $record): View => $this->transcriptView($record))
->modalWidth('2xl')
->modalSubmitAction(false)
->modalCancelActionLabel('Close'),
]);
}
private function tooltipIfTruncated(TextColumn $column): ?string
{
$state = (string) $column->getState();
return strlen($state) > $column->getCharacterLimit() ? $state : null;
}
private function transcriptView(array $record): View
{
try {
$transcript = app(BnfexpressAdminClient::class)->getSessionTranscript($record['user_id'], $record['session_id']);
return view('ai-agent::filament.pages.partials.transcript', ['transcript' => $transcript]);
} catch (BnfexpressApiException $exception) {
Notification::make()
->title('Could not load transcript')
->body($exception->getMessage())
->danger()
->send();
return view('ai-agent::filament.pages.partials.transcript-error', ['message' => $exception->getMessage()]);
}
}
}
@@ -0,0 +1,12 @@
<?php
namespace Modules\AiAgent\Providers;
use Illuminate\Support\ServiceProvider;
class AiAgentServiceProvider extends ServiceProvider
{
public function register(): void {}
public function boot(): void {}
}
@@ -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');
});