add suggestion management
PHP Tests / php-tests (push) Waiting to run

This commit is contained in:
Nyan Lin Paing
2026-09-01 00:16:17 +07:00
parent b8d31e3dc4
commit 31ed52500a
12 changed files with 1226 additions and 5 deletions
@@ -0,0 +1,3 @@
<x-filament-panels::page>
{{ $this->table }}
</x-filament-panels::page>
@@ -0,0 +1,15 @@
<x-filament-panels::page>
<x-filament::section heading="Sync Status">
@if ($syncJobId)
<div wire:poll.2s="pollSyncStatus" class="text-sm">
Job {{ $syncJobId }}: {{ $syncStatus }}...
</div>
@elseif ($syncResult)
<pre class="whitespace-pre-wrap text-sm">{{ json_encode($syncResult, JSON_PRETTY_PRINT) }}</pre>
@else
<p class="text-sm text-gray-500">No sync running.</p>
@endif
</x-filament::section>
{{ $this->table }}
</x-filament-panels::page>
@@ -25,4 +25,21 @@ trait HandlesBnfexpressErrors
Notification::make()->title($failureTitle)->body($exception->getMessage())->danger()->send();
}
}
/**
* Same shape as callBnfexpress(), but for calls whose success notification
* needs the response (e.g. a "{created} created, {skipped} skipped" body)
* $onSuccess builds/sends its own Notification from $callback()'s return value.
*
* @param callable(): mixed $callback
* @param callable(mixed): void $onSuccess
*/
protected function callBnfexpressForResult(callable $callback, callable $onSuccess, string $failureTitle): void
{
try {
$onSuccess($callback());
} catch (BnfexpressApiException $exception) {
Notification::make()->title($failureTitle)->body($exception->getMessage())->danger()->send();
}
}
}
@@ -0,0 +1,33 @@
<?php
namespace Modules\AiAgent\Filament\Concerns;
use Illuminate\Pagination\LengthAwarePaginator;
/**
* Bridges a bnfexpress list response (a bare JSON array confirmed live via
* `php artisan bnfexpress:smoke-test` for FAQs/instructions, and the same
* shape for suggestions/misses per their `response_model=list[...]`) into a
* LengthAwarePaginator for Table::records(). bnfexpress reports no total
* count, so this falls back to a "there might be one more page" heuristic
* also tolerates a {total, <$itemsKey>} envelope in case that ever changes.
*/
trait PaginatesBnfexpressLists
{
/**
* @param array<string, mixed> $result
*/
private function paginateBareList(array $result, string $itemsKey, string $recordKey, 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[$recordKey] => $item]),
total: $total,
perPage: $recordsPerPage,
currentPage: $page,
);
}
}
@@ -0,0 +1,150 @@
<?php
namespace Modules\AiAgent\Filament\Pages;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Actions\BulkAction;
use Filament\Forms\Components\TextInput;
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\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Modules\AiAgent\Filament\Concerns\HandlesBnfexpressErrors;
use Modules\AiAgent\Filament\Concerns\PaginatesBnfexpressLists;
use Modules\Shared\Bnfexpress\BnfexpressAdminClient;
use UnitEnum;
/**
* Browse bnfexpress's "suggestion misses" queries typed by real users that
* no suggestion tier answered and either dismiss them (noise) or promote
* a batch straight into the suggestions bank. Read-mostly: no create/edit,
* these rows are only ever produced by bnfexpress's own suggest pipeline.
*/
class ManageSuggestionMisses extends Page implements HasTable
{
use HandlesBnfexpressErrors;
use InteractsWithTable;
use PaginatesBnfexpressLists;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedMagnifyingGlassCircle;
protected static string|UnitEnum|null $navigationGroup = 'AI Agent';
protected static ?string $navigationLabel = 'Suggestion Misses';
protected static ?string $title = 'Suggestion Misses';
protected string $view = 'ai-agent::filament.pages.manage-suggestion-misses';
public static function canAccess(): bool
{
return auth()->user()?->can('manage_ai_agent') ?? false;
}
public function table(Table $table): Table
{
return $table
->records(function (array $filters, int $page, int $recordsPerPage): LengthAwarePaginator {
$wasUsed = $filters['was_used']['value'] ?? null;
$result = app(BnfexpressAdminClient::class)->listSuggestionMisses(
wasUsed: $wasUsed === null || $wasUsed === '' ? null : (bool) $wasUsed,
limit: $recordsPerPage,
offset: ($page - 1) * $recordsPerPage,
);
return $this->paginateBareList($result, 'misses', 'id', $page, $recordsPerPage);
})
->columns([
TextColumn::make('id'),
TextColumn::make('text_norm')
->limit(80)
->wrap(),
TextColumn::make('lang')
->placeholder('—'),
TextColumn::make('syllables')
->placeholder('—'),
IconColumn::make('was_used')
->boolean(),
TextColumn::make('created_at')
->dateTime()
->sortable(),
])
->defaultSort('created_at', 'desc')
// SelectFilter (not TernaryFilter) so the value lands in
// $filters['was_used']['value'] predictably — TernaryFilter's
// internal field key isn't documented for the custom-data path.
->filters([
SelectFilter::make('was_used')
->label('Used?')
->options(['1' => 'Used', '0' => 'Not used']),
])
->recordActions([
$this->dismissAction(),
])
->toolbarActions([
$this->promoteBulkAction(),
]);
}
protected function dismissAction(): Action
{
return Action::make('dismiss')
->color('danger')
->icon(Heroicon::OutlinedTrash)
->requiresConfirmation()
->action(function (array $record): void {
$this->callBnfexpress(
fn () => app(BnfexpressAdminClient::class)->dismissSuggestionMiss($record['id']),
successTitle: 'Miss dismissed',
failureTitle: 'Failed to dismiss miss',
);
$this->resetTable();
});
}
protected function promoteBulkAction(): BulkAction
{
return BulkAction::make('promote')
->label('Promote Selected')
->icon(Heroicon::OutlinedArrowUp)
->fetchSelectedRecords(false)
->schema([
TextInput::make('lang')
->label('Language override')
->helperText("Applied to every selected miss; leave blank to keep each one's own language.")
->maxLength(10),
TextInput::make('intent'),
])
->deselectRecordsAfterCompletion()
->action(function (array $data, Collection $records): void {
// Same caveat as ManageSuggestions' deleteSelected — the collection
// holds full row arrays, not just keys, for a custom-data table.
$this->callBnfexpressForResult(
fn () => app(BnfexpressAdminClient::class)->promoteSuggestionMisses(
$records->keys()->all(),
$data['lang'] ?: null,
$data['intent'] ?: null,
),
function (array $result): void {
Notification::make()
->title("{$result['created']} promoted, {$result['skipped']} skipped")
->success()
->send();
$this->resetTable();
},
failureTitle: 'Failed to promote suggestion misses',
);
});
}
}
@@ -0,0 +1,414 @@
<?php
namespace Modules\AiAgent\Filament\Pages;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Actions\BulkAction;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\Component;
use Filament\Schemas\Components\Text;
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\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Modules\AiAgent\Filament\Concerns\HandlesBnfexpressErrors;
use Modules\AiAgent\Filament\Concerns\PaginatesBnfexpressLists;
use Modules\Shared\Bnfexpress\BnfexpressAdminClient;
use Modules\Shared\Bnfexpress\Exceptions\BnfexpressApiException;
use UnitEnum;
/**
* Manage bnfexpress's autocomplete "suggestions" phrase bank: CRUD, batch
* import, batch delete, and syncing embeddings folded into one page per
* the sync-alongside-CRUD layout (rather than a separate sync-only page).
* Data isn't Eloquent-backed, so the table is fed via Table::records() and
* mutating actions use plain Filament\Actions\Action, same as ManageFaqs.
*/
class ManageSuggestions extends Page implements HasTable
{
use HandlesBnfexpressErrors;
use InteractsWithTable;
use PaginatesBnfexpressLists;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedSparkles;
protected static string|UnitEnum|null $navigationGroup = 'AI Agent';
protected static ?string $navigationLabel = 'Suggestions';
protected static ?string $title = 'Suggestions';
protected string $view = 'ai-agent::filament.pages.manage-suggestions';
public ?string $syncJobId = null;
public ?string $syncStatus = null;
/**
* @var mixed
*/
public $syncResult = null;
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, int $page, int $recordsPerPage): LengthAwarePaginator {
$result = app(BnfexpressAdminClient::class)->listSuggestions(
q: $search,
limit: $recordsPerPage,
offset: ($page - 1) * $recordsPerPage,
);
return $this->paginateBareList($result, 'suggestions', 'id', $page, $recordsPerPage);
})
->columns([
TextColumn::make('id'),
TextColumn::make('text_display')
->limit(80)
->wrap(),
TextColumn::make('lang'),
TextColumn::make('intent')
->placeholder('—'),
TextColumn::make('weight')
->sortable(false),
TextColumn::make('source'),
TextColumn::make('synced_at')
->dateTime()
->placeholder('Never')
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('updated_at')
->dateTime(),
])
->searchable()
->recordActions([
$this->editAction(),
$this->deleteAction(),
$this->syncEmbeddingAction(),
$this->deleteEmbeddingAction(),
])
->toolbarActions([
$this->deleteSelectedBulkAction(),
])
->headerActions([
$this->createAction(),
$this->createManyAction(),
$this->syncAction(),
$this->reloadIndexAction(),
]);
}
protected function createAction(): Action
{
return Action::make('create')
->label('New Suggestion')
->icon(Heroicon::OutlinedPlus)
->schema($this->formSchema())
->action(function (array $data): void {
$this->callBnfexpress(
fn () => app(BnfexpressAdminClient::class)->createSuggestion(
$data['text_display'],
$data['lang'],
$data['intent'] ?: null,
(int) ($data['weight'] ?? 0),
$data['source'] ?: 'admin',
),
successTitle: 'Suggestion created',
failureTitle: 'Failed to create suggestion',
);
$this->resetTable();
});
}
protected function createManyAction(): Action
{
return Action::make('createMany')
->label('Create Many')
->icon(Heroicon::OutlinedQueueList)
->schema([
// No source field here — bnfexpress's batch endpoint always tags
// these rows source: "mined" server-side (it's a thin wrapper
// around the same promote() misses-promotion uses), regardless
// of what's sent, so exposing a picker would be misleading.
Text::make('Created rows are tagged source: mined by bnfexpress.')
->color('gray'),
Textarea::make('items_raw')
->label('Phrases (one per line)')
->required()
->rows(8),
TextInput::make('lang')
->required()
->maxLength(10),
TextInput::make('intent'),
])
->action(function (array $data): void {
$items = collect(preg_split('/\r\n|\r|\n/', (string) $data['items_raw']))
->map(fn (string $line): string => trim($line))
->filter()
->map(fn (string $text): array => array_filter([
'text' => $text,
'lang' => $data['lang'],
'intent' => $data['intent'] ?: null,
], fn (mixed $value): bool => $value !== null))
->values()
->all();
$this->callBnfexpressForResult(
fn () => app(BnfexpressAdminClient::class)->batchCreateSuggestions($items),
function (array $result): void {
Notification::make()
->title("{$result['created']} created, {$result['skipped']} skipped")
->success()
->send();
$this->resetTable();
},
failureTitle: 'Failed to create suggestions',
);
});
}
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)->updateSuggestion(
$record['id'],
$data['text_display'],
$data['lang'],
$data['intent'] ?: null,
(int) ($data['weight'] ?? 0),
$data['source'] ?: null,
),
successTitle: 'Suggestion updated',
failureTitle: 'Failed to update suggestion',
);
$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)->deleteSuggestion($record['id']),
successTitle: 'Suggestion deleted',
failureTitle: 'Failed to delete suggestion',
);
$this->resetTable();
});
}
protected function deleteSelectedBulkAction(): BulkAction
{
return BulkAction::make('deleteSelected')
->label('Delete Selected')
->color('danger')
->icon(Heroicon::OutlinedTrash)
->requiresConfirmation()
->fetchSelectedRecords(false)
->deselectRecordsAfterCompletion()
->action(function (Collection $records): void {
// fetchSelectedRecords(false) still resolves full row arrays for a
// custom-data table (there's no cheap ID-only path like an Eloquent
// query) — the record keys (our suggestion ids) are what's wanted here.
$this->callBnfexpressForResult(
fn () => app(BnfexpressAdminClient::class)->batchDeleteSuggestions($records->keys()->all()),
function (array $result): void {
Notification::make()
->title("{$result['deleted']} deleted, {$result['skipped']} skipped")
->success()
->send();
$this->resetTable();
},
failureTitle: 'Failed to delete suggestions',
);
});
}
protected function syncEmbeddingAction(): Action
{
return Action::make('syncEmbedding')
->label('Sync Embedding')
->icon(Heroicon::OutlinedArrowPath)
->action(function (array $record): void {
$this->callBnfexpress(
fn () => app(BnfexpressAdminClient::class)->syncOneSuggestion($record['id']),
successTitle: 'Embedding synced',
failureTitle: 'Failed to sync embedding',
);
$this->resetTable();
});
}
protected function deleteEmbeddingAction(): Action
{
return Action::make('deleteEmbedding')
->label('Delete Embedding')
->color('danger')
->icon(Heroicon::OutlinedXCircle)
->requiresConfirmation()
->action(function (array $record): void {
$this->callBnfexpress(
fn () => app(BnfexpressAdminClient::class)->deleteSuggestionEmbedding($record['id']),
successTitle: 'Embedding deleted',
failureTitle: 'Failed to delete embedding',
);
$this->resetTable();
});
}
/**
* Kicks off a full re-embed job and starts polling for it (pollSyncStatus(),
* driven by wire:poll in the view) rather than notifying immediately
* the real outcome only lands once the job finishes.
*/
protected function syncAction(): Action
{
return Action::make('sync')
->label('Sync to Chroma')
->icon(Heroicon::OutlinedArrowPath)
->action(function (): void {
try {
$result = app(BnfexpressAdminClient::class)->syncSuggestions();
$this->syncJobId = $result['job_id'] ?? null;
$this->syncStatus = 'queued';
$this->syncResult = null;
} catch (BnfexpressApiException $exception) {
Notification::make()
->title('Failed to start sync')
->body($exception->getMessage())
->danger()
->send();
}
});
}
protected function reloadIndexAction(): Action
{
return Action::make('reloadIndex')
->label('Reload Index')
->icon(Heroicon::OutlinedArrowPath)
->requiresConfirmation()
->action(function (): void {
$this->callBnfexpress(
fn () => app(BnfexpressAdminClient::class)->reloadSuggestionIndex(),
successTitle: 'Index reloaded',
failureTitle: 'Failed to reload index',
);
});
}
/**
* Polls a sync job's status (wire:poll.2s, see the view). On "finished",
* chains a reload-index call same two-step flow shweai_backend's admin
* JS does (POST sync-chroma poll POST reload-index) and refreshes
* the table. On "failed", notifies and stops polling.
*/
public function pollSyncStatus(): void
{
if ($this->syncJobId === null) {
return;
}
try {
$status = app(BnfexpressAdminClient::class)->getSuggestionSyncStatus($this->syncJobId);
} catch (BnfexpressApiException $exception) {
$this->syncJobId = null;
Notification::make()
->title('Failed to check sync status')
->body($exception->getMessage())
->danger()
->send();
return;
}
$this->syncStatus = $status['status'] ?? null;
if ($this->syncStatus === 'finished') {
$this->syncResult = $status['result'] ?? null;
$this->syncJobId = null;
$this->callBnfexpress(
fn () => app(BnfexpressAdminClient::class)->reloadSuggestionIndex(),
successTitle: 'Sync completed and index reloaded',
failureTitle: 'Sync finished but reloading the index failed',
);
$this->resetTable();
} elseif ($this->syncStatus === 'failed') {
$this->syncJobId = null;
Notification::make()
->title('Sync failed')
->body(is_string($status['result'] ?? null) ? $status['result'] : 'Unknown error.')
->danger()
->send();
}
// else: still queued/running — the view keeps polling.
}
/**
* @return array<int, Component>
*/
protected function formSchema(): array
{
return [
Textarea::make('text_display')
->required()
->rows(3),
TextInput::make('lang')
->required()
->maxLength(10),
TextInput::make('intent'),
TextInput::make('weight')
->numeric()
->default(0),
// Fixed choices rather than free text (matching shweai_backend's
// seed/mined dropdown) — 'admin' is bnfexpress's own default for
// a CRUD-created row (SuggestionAdminCreate.source), 'mined' is
// what promote() tags a row with. 'seed' isn't used by any
// bnfexpress code path today (unlike the other two, which are
// hardcoded/defaulted server-side) — source is just a free string
// there, so this is offered for admins bootstrapping initial
// phrases who want that distinct from an ad-hoc manual entry,
// same convention as shweai_backend's own seed/mined dropdown.
Select::make('source')
->options([
'admin' => 'Admin (manual entry)',
'seed' => 'Seed (initial/bootstrap data)',
'mined' => 'Mined (promoted from a miss)',
])
->default('admin')
->native(false)
->required(),
];
}
}
@@ -0,0 +1,89 @@
<?php
use App\Models\User;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
use Modules\AiAgent\Filament\Pages\ManageSuggestionMisses;
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(ManageSuggestionMisses::canAccess())->toBeFalse();
});
test('it renders and lists misses from the client', function () {
Http::fake(['bnfexpress.test/*' => Http::response([
['id' => 1, 'text_norm' => 'ev charging cost', 'lang' => 'en', 'syllables' => 3, 'was_used' => false, 'created_at' => now()->toIso8601String()],
])]);
Livewire::test(ManageSuggestionMisses::class)
->assertOk()
->loadTable()
->assertSee('ev charging cost');
Http::assertSent(fn (Request $request) => str_contains($request->url(), '/admin/suggestion-misses') && $request->method() === 'GET');
});
test('dismiss calls dismissSuggestionMiss and shows a success notification', function () {
Http::fake(['bnfexpress.test/*' => fn (Request $request) => match ($request->method()) {
'DELETE' => Http::response([]),
default => Http::response([
['id' => 1, 'text_norm' => 'to dismiss', 'lang' => 'en', 'syllables' => 2, 'was_used' => false, 'created_at' => now()->toIso8601String()],
]),
}]);
Livewire::test(ManageSuggestionMisses::class)
->loadTable()
->callTableAction('dismiss', 1)
->assertNotified('Miss dismissed');
Http::assertSent(fn (Request $request) => $request->method() === 'DELETE' && str_contains($request->url(), '/admin/suggestion-misses/1'));
});
test('a failed dismiss surfaces the gateway detail message', function () {
Http::fake(['bnfexpress.test/*' => fn (Request $request) => match ($request->method()) {
'DELETE' => Http::response(['detail' => 'Suggestion miss not found.'], 404),
default => Http::response([
['id' => 1, 'text_norm' => 'to dismiss', 'lang' => 'en', 'syllables' => 2, 'was_used' => false, 'created_at' => now()->toIso8601String()],
]),
}]);
Livewire::test(ManageSuggestionMisses::class)
->loadTable()
->callTableAction('dismiss', 1)
->assertNotified('Failed to dismiss miss');
});
test('promote bulk action calls promoteSuggestionMisses with the selected ids and shows the result', function () {
Http::fake(['bnfexpress.test/*' => fn (Request $request) => match (true) {
str_contains($request->url(), '/admin/suggestion-misses/promote') => Http::response(['created' => 2, 'skipped' => 0, 'trie_rebuilt' => true]),
default => Http::response([
['id' => 1, 'text_norm' => 'a', 'lang' => 'en', 'syllables' => 1, 'was_used' => false, 'created_at' => now()->toIso8601String()],
['id' => 2, 'text_norm' => 'b', 'lang' => 'en', 'syllables' => 1, 'was_used' => false, 'created_at' => now()->toIso8601String()],
]),
}]);
Livewire::test(ManageSuggestionMisses::class)
->loadTable()
->callTableBulkAction('promote', [1, 2], data: ['lang' => 'my', 'intent' => null])
->assertNotified('2 promoted, 0 skipped');
Http::assertSent(fn (Request $request) => str_contains($request->url(), '/admin/suggestion-misses/promote')
&& $request['miss_ids'] === [1, 2]
&& $request['lang'] === 'my');
});
@@ -0,0 +1,187 @@
<?php
use App\Models\User;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
use Modules\AiAgent\Filament\Pages\ManageSuggestions;
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(ManageSuggestions::canAccess())->toBeFalse();
});
test('it renders and lists suggestions from the client', function () {
Http::fake(['bnfexpress.test/*' => Http::response([
['id' => 1, 'text_display' => 'How do I charge my EV?', 'lang' => 'en', 'intent' => null, 'weight' => 0, 'source' => 'manual', 'updated_at' => now()->toIso8601String()],
])]);
Livewire::test(ManageSuggestions::class)
->assertOk()
->loadTable()
->assertSee('How do I charge my EV?');
Http::assertSent(fn (Request $request) => str_contains($request->url(), '/admin/suggestions') && $request->method() === 'GET');
});
test('creating a suggestion calls createSuggestion and shows a success notification', function () {
Http::fake(['bnfexpress.test/*' => fn (Request $request) => match ($request->method()) {
'POST' => Http::response(['id' => 1], 201),
default => Http::response([]),
}]);
Livewire::test(ManageSuggestions::class)
->loadTable()
->callTableAction('create', data: ['text_display' => 'New phrase', 'lang' => 'en', 'intent' => null, 'weight' => 0, 'source' => 'admin'])
->assertNotified('Suggestion created');
Http::assertSent(fn (Request $request) => $request->method() === 'POST'
&& $request->url() === 'https://bnfexpress.test/admin/suggestions'
&& $request['text_display'] === 'New phrase');
});
test('a failed create surfaces the gateway detail message', function () {
Http::fake(['bnfexpress.test/*' => fn (Request $request) => match ($request->method()) {
'POST' => Http::response(['detail' => 'text_display is required.'], 422),
default => Http::response([]),
}]);
Livewire::test(ManageSuggestions::class)
->loadTable()
->callTableAction('create', data: ['text_display' => 'New phrase', 'lang' => 'en', 'intent' => null, 'weight' => 0, 'source' => 'admin'])
->assertNotified('Failed to create suggestion');
});
test('createMany splits pasted lines into batch items and shows the created/skipped result', function () {
Http::fake(['bnfexpress.test/*' => fn (Request $request) => match (true) {
str_contains($request->url(), '/admin/suggestions/batch') => Http::response(['created' => 2, 'skipped' => 0, 'trie_rebuilt' => true]),
default => Http::response([]),
}]);
Livewire::test(ManageSuggestions::class)
->loadTable()
->callTableAction('createMany', data: ['items_raw' => "First phrase\nSecond phrase\n\n", 'lang' => 'en', 'intent' => null])
->assertNotified('2 created, 0 skipped');
Http::assertSent(fn (Request $request) => str_contains($request->url(), '/admin/suggestions/batch')
&& $request['items'] === [
['text' => 'First phrase', 'lang' => 'en'],
['text' => 'Second phrase', 'lang' => 'en'],
]);
});
test('deleting a suggestion calls deleteSuggestion and shows a success notification', function () {
Http::fake(['bnfexpress.test/*' => fn (Request $request) => match ($request->method()) {
'DELETE' => Http::response([]),
default => Http::response([
['id' => 1, 'text_display' => 'To delete', 'lang' => 'en', 'intent' => null, 'weight' => 0, 'source' => 'manual', 'updated_at' => now()->toIso8601String()],
]),
}]);
Livewire::test(ManageSuggestions::class)
->loadTable()
->callTableAction('delete', 1)
->assertNotified('Suggestion deleted');
Http::assertSent(fn (Request $request) => $request->method() === 'DELETE' && str_contains($request->url(), '/admin/suggestions/1'));
});
test('deleteSelected bulk action calls batchDeleteSuggestions with just the selected ids', function () {
Http::fake(['bnfexpress.test/*' => fn (Request $request) => match (true) {
$request->method() === 'DELETE' && str_contains($request->url(), '/admin/suggestions/batch') => Http::response(['deleted' => 2, 'skipped' => 0]),
default => Http::response([
['id' => 1, 'text_display' => 'A', 'lang' => 'en', 'intent' => null, 'weight' => 0, 'source' => 'manual', 'updated_at' => now()->toIso8601String()],
['id' => 2, 'text_display' => 'B', 'lang' => 'en', 'intent' => null, 'weight' => 0, 'source' => 'manual', 'updated_at' => now()->toIso8601String()],
]),
}]);
Livewire::test(ManageSuggestions::class)
->loadTable()
->callTableBulkAction('deleteSelected', [1, 2])
->assertNotified('2 deleted, 0 skipped');
Http::assertSent(fn (Request $request) => $request->method() === 'DELETE'
&& str_contains($request->url(), '/admin/suggestions/batch')
&& $request['ids'] === [1, 2]);
});
test('sync sets job state and polling chains a reload-index call on finished', function () {
Http::fake(['bnfexpress.test/*' => fn (Request $request) => match (true) {
str_contains($request->url(), '/sync-chroma/job-1') => Http::response(['status' => 'finished', 'result' => ['synced' => 3]]),
str_contains($request->url(), '/sync-chroma') => Http::response(['job_id' => 'job-1'], 202),
str_contains($request->url(), '/reload-index') => Http::response(['trie_rebuilt' => true]),
default => Http::response([]),
}]);
$test = Livewire::test(ManageSuggestions::class)
->loadTable()
->callTableAction('sync')
->assertSet('syncJobId', 'job-1')
->assertSet('syncStatus', 'queued');
$test->call('pollSyncStatus')
->assertSet('syncJobId', null)
->assertSet('syncStatus', 'finished')
->assertNotified('Sync completed and index reloaded');
Http::assertSent(fn (Request $request) => str_contains($request->url(), '/admin/suggestions/reload-index'));
});
test('a failed sync job notifies danger and stops polling', function () {
Http::fake(['bnfexpress.test/*' => fn (Request $request) => match (true) {
str_contains($request->url(), '/sync-chroma/job-1') => Http::response(['status' => 'failed', 'result' => null]),
str_contains($request->url(), '/sync-chroma') => Http::response(['job_id' => 'job-1'], 202),
default => Http::response([]),
}]);
Livewire::test(ManageSuggestions::class)
->loadTable()
->callTableAction('sync')
->call('pollSyncStatus')
->assertSet('syncJobId', null)
->assertNotified('Sync failed');
});
test('reloadIndex calls reloadSuggestionIndex directly', function () {
Http::fake(['bnfexpress.test/*' => Http::response(['trie_rebuilt' => true])]);
Livewire::test(ManageSuggestions::class)
->loadTable()
->callTableAction('reloadIndex')
->assertNotified('Index reloaded');
Http::assertSent(fn (Request $request) => str_contains($request->url(), '/admin/suggestions/reload-index'));
});
test('syncEmbedding and deleteEmbedding row actions call the right per-id endpoint', function () {
Http::fake(['bnfexpress.test/*' => fn (Request $request) => match (true) {
str_contains($request->url(), '/1/sync-chroma') => Http::response(['synced' => true]),
str_contains($request->url(), '/1/chroma') => Http::response([]),
default => Http::response([
['id' => 1, 'text_display' => 'A', 'lang' => 'en', 'intent' => null, 'weight' => 0, 'source' => 'manual', 'updated_at' => now()->toIso8601String()],
]),
}]);
$test = Livewire::test(ManageSuggestions::class)->loadTable();
$test->callTableAction('syncEmbedding', 1)->assertNotified('Embedding synced');
$test->callTableAction('deleteEmbedding', 1)->assertNotified('Embedding deleted');
Http::assertSent(fn (Request $request) => str_contains($request->url(), '/admin/suggestions/1/sync-chroma') && $request->method() === 'POST');
Http::assertSent(fn (Request $request) => str_contains($request->url(), '/admin/suggestions/1/chroma') && $request->method() === 'DELETE');
});