fix: review pack generation UX + notifications
This commit is contained in:
parent
58f5519e94
commit
dc112664df
@ -10,6 +10,7 @@
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\ReviewPackStatus;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
@ -329,8 +330,23 @@ public static function executeGeneration(array $data): void
|
||||
'include_operations' => (bool) ($data['include_operations'] ?? true),
|
||||
];
|
||||
|
||||
$service->generate($tenant, $user, $options);
|
||||
$reviewPack = $service->generate($tenant, $user, $options);
|
||||
|
||||
Notification::make()->success()->title('Review pack generation started.')->send();
|
||||
if (! $reviewPack->wasRecentlyCreated) {
|
||||
Notification::make()
|
||||
->success()
|
||||
->title('Review pack already available')
|
||||
->body('A matching review pack is already ready. No new run was started.')
|
||||
->actions([
|
||||
Actions\Action::make('view_pack')
|
||||
->label('View pack')
|
||||
->url(static::getUrl('view', ['record' => $reviewPack], tenant: $tenant)),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
OperationUxPresenter::queuedToast('tenant.review_pack.generate')->send();
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\StoredReport;
|
||||
use App\Models\Tenant;
|
||||
use App\Notifications\ReviewPackStatusNotification;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\ReviewPackService;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
@ -30,7 +30,7 @@ public function __construct(
|
||||
public int $operationRunId,
|
||||
) {}
|
||||
|
||||
public function handle(): void
|
||||
public function handle(OperationRunService $operationRunService): void
|
||||
{
|
||||
$reviewPack = ReviewPack::query()->find($this->reviewPackId);
|
||||
$operationRun = OperationRun::query()->find($this->operationRunId);
|
||||
@ -47,28 +47,25 @@ public function handle(): void
|
||||
$tenant = $reviewPack->tenant;
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
$this->markFailed($reviewPack, $operationRun, 'tenant_not_found', 'Tenant not found');
|
||||
$this->markFailed($reviewPack, $operationRun, $operationRunService, 'tenant_not_found', 'Tenant not found');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark running
|
||||
$operationRun->update([
|
||||
'status' => OperationRunStatus::Running->value,
|
||||
'started_at' => now(),
|
||||
]);
|
||||
// Mark running via OperationRunService (auto-sets started_at)
|
||||
$operationRunService->updateRun($operationRun, OperationRunStatus::Running->value);
|
||||
$reviewPack->update(['status' => ReviewPackStatus::Generating->value]);
|
||||
|
||||
try {
|
||||
$this->executeGeneration($reviewPack, $operationRun, $tenant);
|
||||
$this->executeGeneration($reviewPack, $operationRun, $tenant, $operationRunService);
|
||||
} catch (Throwable $e) {
|
||||
$this->markFailed($reviewPack, $operationRun, 'generation_error', $e->getMessage());
|
||||
$this->markFailed($reviewPack, $operationRun, $operationRunService, 'generation_error', $e->getMessage());
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
private function executeGeneration(ReviewPack $reviewPack, OperationRun $operationRun, Tenant $tenant): void
|
||||
private function executeGeneration(ReviewPack $reviewPack, OperationRun $operationRun, Tenant $tenant, OperationRunService $operationRunService): void
|
||||
{
|
||||
$options = $reviewPack->options ?? [];
|
||||
$includePii = (bool) ($options['include_pii'] ?? true);
|
||||
@ -175,16 +172,13 @@ private function executeGeneration(ReviewPack $reviewPack, OperationRun $operati
|
||||
'summary' => $summary,
|
||||
]);
|
||||
|
||||
// 13. Mark OperationRun completed
|
||||
$operationRun->update([
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
'completed_at' => now(),
|
||||
'summary_counts' => $summary,
|
||||
]);
|
||||
|
||||
// 14. Notify initiator
|
||||
$this->notifyInitiator($reviewPack, 'ready');
|
||||
// 13. Mark OperationRun completed (auto-sends OperationRunCompleted notification)
|
||||
$operationRunService->updateRun(
|
||||
$operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Succeeded->value,
|
||||
summaryCounts: $summary,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -382,38 +376,17 @@ private function assembleZip(string $tempFile, array $fileMap): void
|
||||
$zip->close();
|
||||
}
|
||||
|
||||
private function markFailed(ReviewPack $reviewPack, OperationRun $operationRun, string $reasonCode, string $errorMessage): void
|
||||
private function markFailed(ReviewPack $reviewPack, OperationRun $operationRun, OperationRunService $operationRunService, string $reasonCode, string $errorMessage): void
|
||||
{
|
||||
$reviewPack->update(['status' => ReviewPackStatus::Failed->value]);
|
||||
|
||||
$operationRun->update([
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Failed->value,
|
||||
'completed_at' => now(),
|
||||
'context' => array_merge($operationRun->context ?? [], [
|
||||
'reason_code' => $reasonCode,
|
||||
'error_message' => mb_substr($errorMessage, 0, 500),
|
||||
]),
|
||||
]);
|
||||
|
||||
$this->notifyInitiator($reviewPack, 'failed', $reasonCode);
|
||||
}
|
||||
|
||||
private function notifyInitiator(ReviewPack $reviewPack, string $status, ?string $reasonCode = null): void
|
||||
{
|
||||
$initiator = $reviewPack->initiator;
|
||||
|
||||
if (! $initiator) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$initiator->notify(new ReviewPackStatusNotification($reviewPack, $status, $reasonCode));
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('Failed to send ReviewPack notification', [
|
||||
'review_pack_id' => $reviewPack->getKey(),
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
$operationRunService->updateRun(
|
||||
$operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Failed->value,
|
||||
failures: [
|
||||
['code' => $reasonCode, 'message' => mb_substr($errorMessage, 0, 500)],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,91 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\Tenant;
|
||||
use Filament\Actions\Action;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class ReviewPackStatusNotification extends Notification
|
||||
{
|
||||
public function __construct(
|
||||
public ReviewPack $reviewPack,
|
||||
public string $status,
|
||||
public ?string $reasonCode = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['database'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toDatabase(object $notifiable): array
|
||||
{
|
||||
$title = match ($this->status) {
|
||||
'ready' => 'Review Pack ready',
|
||||
'failed' => 'Review Pack generation failed',
|
||||
default => 'Review Pack status updated',
|
||||
};
|
||||
|
||||
$body = match ($this->status) {
|
||||
'ready' => 'Your tenant review pack has been generated and is ready for download.',
|
||||
'failed' => sprintf(
|
||||
'Review pack generation failed%s.',
|
||||
$this->reasonCode ? ": {$this->reasonCode}" : '',
|
||||
),
|
||||
default => 'Review pack status changed.',
|
||||
};
|
||||
|
||||
$color = match ($this->status) {
|
||||
'ready' => 'success',
|
||||
'failed' => 'danger',
|
||||
default => 'gray',
|
||||
};
|
||||
|
||||
$icon = match ($this->status) {
|
||||
'ready' => 'heroicon-o-document-arrow-down',
|
||||
'failed' => 'heroicon-o-exclamation-triangle',
|
||||
default => 'heroicon-o-document-text',
|
||||
};
|
||||
|
||||
$actions = [];
|
||||
$tenant = $this->reviewPack->tenant;
|
||||
|
||||
if ($tenant instanceof Tenant && $this->status === 'ready') {
|
||||
$actions[] = Action::make('view_pack')
|
||||
->label('View pack')
|
||||
->url(route('filament.admin.resources.review-packs.view', [
|
||||
'tenant' => $tenant->external_id,
|
||||
'record' => $this->reviewPack->getKey(),
|
||||
]))
|
||||
->toArray();
|
||||
}
|
||||
|
||||
return [
|
||||
'format' => 'filament',
|
||||
'title' => $title,
|
||||
'body' => $body,
|
||||
'color' => $color,
|
||||
'duration' => 'persistent',
|
||||
'actions' => $actions,
|
||||
'icon' => $icon,
|
||||
'iconColor' => $color,
|
||||
'status' => null,
|
||||
'view' => null,
|
||||
'viewData' => [
|
||||
'review_pack_id' => $this->reviewPack->getKey(),
|
||||
'status' => $this->status,
|
||||
'reason_code' => $this->reasonCode,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -56,10 +56,12 @@ public function generate(Tenant $tenant, User $user, array $options = []): Revie
|
||||
'summary' => [],
|
||||
]);
|
||||
|
||||
GenerateReviewPackJob::dispatch(
|
||||
reviewPackId: (int) $reviewPack->getKey(),
|
||||
operationRunId: (int) $operationRun->getKey(),
|
||||
);
|
||||
$this->operationRunService->dispatchOrFail($operationRun, function () use ($reviewPack, $operationRun): void {
|
||||
GenerateReviewPackJob::dispatch(
|
||||
reviewPackId: (int) $reviewPack->getKey(),
|
||||
operationRunId: (int) $operationRun->getKey(),
|
||||
);
|
||||
});
|
||||
|
||||
return $reviewPack;
|
||||
}
|
||||
|
||||
@ -36,6 +36,9 @@ public static function all(): array
|
||||
'report_created',
|
||||
'report_deduped',
|
||||
'alert_events_produced',
|
||||
'finding_count',
|
||||
'report_count',
|
||||
'operation_count',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,66 +6,93 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const Livewire = window.Livewire;
|
||||
const applyShim = () => {
|
||||
const Livewire = window.Livewire;
|
||||
|
||||
if (!Livewire || typeof Livewire.interceptMessage !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Livewire.__tenantpilotInterceptMessageShimApplied) {
|
||||
return;
|
||||
}
|
||||
|
||||
const original = Livewire.interceptMessage.bind(Livewire);
|
||||
|
||||
Livewire.interceptMessage = (handler) => {
|
||||
if (typeof handler !== 'function') {
|
||||
return original(handler);
|
||||
if (!Livewire || typeof Livewire.interceptMessage !== 'function') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return original((context) => {
|
||||
if (!context || typeof context !== 'object') {
|
||||
return handler(context);
|
||||
if (Livewire.__tenantpilotInterceptMessageShimApplied) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const original = Livewire.interceptMessage.bind(Livewire);
|
||||
|
||||
Livewire.interceptMessage = (handler) => {
|
||||
if (typeof handler !== 'function') {
|
||||
return original(handler);
|
||||
}
|
||||
|
||||
const originalOnFinish = context.onFinish;
|
||||
const originalOnSuccess = context.onSuccess;
|
||||
|
||||
if (typeof originalOnFinish !== 'function' || typeof originalOnSuccess !== 'function') {
|
||||
return handler(context);
|
||||
}
|
||||
|
||||
const finishCallbacks = [];
|
||||
|
||||
const onFinish = (callback) => {
|
||||
if (typeof callback === 'function') {
|
||||
finishCallbacks.push(callback);
|
||||
return original((context) => {
|
||||
if (!context || typeof context !== 'object') {
|
||||
return handler(context);
|
||||
}
|
||||
|
||||
return originalOnFinish(callback);
|
||||
};
|
||||
const originalOnFinish = context.onFinish;
|
||||
const originalOnSuccess = context.onSuccess;
|
||||
|
||||
const onSuccess = (callback) => {
|
||||
return originalOnSuccess((...args) => {
|
||||
// Ensure any registered finish callbacks are run before success callbacks.
|
||||
// We don't swallow errors; we just stabilize ordering.
|
||||
for (const finishCallback of finishCallbacks) {
|
||||
finishCallback(...args);
|
||||
}
|
||||
if (typeof originalOnFinish !== 'function' || typeof originalOnSuccess !== 'function') {
|
||||
return handler(context);
|
||||
}
|
||||
|
||||
const finishCallbacks = [];
|
||||
|
||||
const onFinish = (callback) => {
|
||||
if (typeof callback === 'function') {
|
||||
return callback(...args);
|
||||
finishCallbacks.push(callback);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return handler({
|
||||
...context,
|
||||
onFinish,
|
||||
onSuccess,
|
||||
return originalOnFinish(callback);
|
||||
};
|
||||
|
||||
const onSuccess = (callback) => {
|
||||
return originalOnSuccess((...args) => {
|
||||
// Ensure any registered finish callbacks are run before success callbacks.
|
||||
// We don't swallow errors; we just stabilize ordering.
|
||||
for (const finishCallback of finishCallbacks) {
|
||||
finishCallback(...args);
|
||||
}
|
||||
|
||||
if (typeof callback === 'function') {
|
||||
return callback(...args);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return handler({
|
||||
...context,
|
||||
onFinish,
|
||||
onSuccess,
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
Livewire.__tenantpilotInterceptMessageShimApplied = true;
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
Livewire.__tenantpilotInterceptMessageShimApplied = true;
|
||||
if (applyShim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Livewire may not be initialized yet when this script runs (depending on
|
||||
// script tag order). Try again on `livewire:init` and with a short fallback poll.
|
||||
const onInit = () => {
|
||||
applyShim();
|
||||
};
|
||||
|
||||
window.addEventListener('livewire:init', onInit, { once: true });
|
||||
document.addEventListener('livewire:init', onInit, { once: true });
|
||||
|
||||
let tries = 0;
|
||||
const maxTries = 50;
|
||||
const timer = setInterval(() => {
|
||||
tries += 1;
|
||||
|
||||
if (applyShim() || tries >= maxTries) {
|
||||
clearInterval(timer);
|
||||
}
|
||||
}, 100);
|
||||
})();
|
||||
|
||||
@ -184,7 +184,7 @@ ### Functional Requirements
|
||||
### Canonical allowed summary keys (single source of truth)
|
||||
|
||||
The following keys are the ONLY allowed summary keys for Ops-UX rendering:
|
||||
`total, processed, succeeded, failed, skipped, compliant, noncompliant, unknown, created, updated, deleted, items, tenants, high, medium, low, findings_created, findings_resolved, findings_reopened, findings_unchanged, errors_recorded, posture_score, report_created, report_deduped, alert_events_produced`
|
||||
`total, processed, succeeded, failed, skipped, compliant, noncompliant, unknown, created, updated, deleted, items, tenants, high, medium, low, findings_created, findings_resolved, findings_reopened, findings_unchanged, errors_recorded, posture_score, report_created, report_deduped, alert_events_produced, finding_count, report_count, operation_count`
|
||||
|
||||
All normalizers/renderers MUST reference this canonical list (no duplicated lists in multiple places).
|
||||
|
||||
|
||||
16
tests/Feature/LivewireInterceptShimTest.php
Normal file
16
tests/Feature/LivewireInterceptShimTest.php
Normal file
@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
it('injects the Livewire intercept shim into Filament pages', function (): void {
|
||||
$this->get('/admin/login')
|
||||
->assertSuccessful()
|
||||
->assertSee('js/tenantpilot/livewire-intercept-shim.js', escape: false);
|
||||
});
|
||||
|
||||
it('ships a shim that waits for Livewire initialization', function (): void {
|
||||
$js = file_get_contents(public_path('js/tenantpilot/livewire-intercept-shim.js'));
|
||||
|
||||
expect($js)->toBeString();
|
||||
expect($js)->toContain('livewire:init');
|
||||
});
|
||||
@ -8,7 +8,8 @@
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\StoredReport;
|
||||
use App\Models\Tenant;
|
||||
use App\Notifications\ReviewPackStatusNotification;
|
||||
use App\Notifications\OperationRunCompleted;
|
||||
use App\Notifications\OperationRunQueued;
|
||||
use App\Services\ReviewPackService;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
@ -84,7 +85,7 @@ function seedTenantWithData(Tenant $tenant): void
|
||||
reviewPackId: (int) $pack->getKey(),
|
||||
operationRunId: (int) $pack->operation_run_id,
|
||||
);
|
||||
$job->handle();
|
||||
app()->call([$job, 'handle']);
|
||||
|
||||
$pack->refresh();
|
||||
|
||||
@ -108,8 +109,8 @@ function seedTenantWithData(Tenant $tenant): void
|
||||
expect($opRun->status)->toBe(OperationRunStatus::Completed->value);
|
||||
expect($opRun->outcome)->toBe(OperationRunOutcome::Succeeded->value);
|
||||
|
||||
// Notification sent
|
||||
Notification::assertSentTo($user, ReviewPackStatusNotification::class);
|
||||
// Notification sent (standard OperationRunCompleted via OperationRunService)
|
||||
Notification::assertSentTo($user, OperationRunCompleted::class);
|
||||
});
|
||||
|
||||
// ─── Failure Path ──────────────────────────────────────────────
|
||||
@ -138,7 +139,7 @@ function seedTenantWithData(Tenant $tenant): void
|
||||
);
|
||||
|
||||
try {
|
||||
$job->handle();
|
||||
app()->call([$job, 'handle']);
|
||||
} catch (\RuntimeException) {
|
||||
// Expected — the job re-throws after marking failed
|
||||
}
|
||||
@ -150,11 +151,10 @@ function seedTenantWithData(Tenant $tenant): void
|
||||
$opRun = OperationRun::query()->find($pack->operation_run_id);
|
||||
expect($opRun->status)->toBe(OperationRunStatus::Completed->value);
|
||||
expect($opRun->outcome)->toBe(OperationRunOutcome::Failed->value);
|
||||
expect($opRun->context['reason_code'])->toBe('generation_error');
|
||||
expect($opRun->failure_summary)->toBeArray();
|
||||
expect($opRun->failure_summary[0]['code'])->toBe('generation_error');
|
||||
|
||||
Notification::assertSentTo($user, ReviewPackStatusNotification::class, function ($notification) {
|
||||
return $notification->status === 'failed';
|
||||
});
|
||||
Notification::assertSentTo($user, OperationRunCompleted::class);
|
||||
});
|
||||
|
||||
// ─── Empty Reports ──────────────────────────────────────────────
|
||||
@ -172,7 +172,7 @@ function seedTenantWithData(Tenant $tenant): void
|
||||
reviewPackId: (int) $pack->getKey(),
|
||||
operationRunId: (int) $pack->operation_run_id,
|
||||
);
|
||||
$job->handle();
|
||||
app()->call([$job, 'handle']);
|
||||
|
||||
$pack->refresh();
|
||||
|
||||
@ -198,7 +198,7 @@ function seedTenantWithData(Tenant $tenant): void
|
||||
reviewPackId: (int) $pack->getKey(),
|
||||
operationRunId: (int) $pack->operation_run_id,
|
||||
);
|
||||
$job->handle();
|
||||
app()->call([$job, 'handle']);
|
||||
|
||||
$pack->refresh();
|
||||
expect($pack->status)->toBe(ReviewPackStatus::Ready->value);
|
||||
@ -254,7 +254,7 @@ function seedTenantWithData(Tenant $tenant): void
|
||||
reviewPackId: (int) $pack->getKey(),
|
||||
operationRunId: (int) $pack->operation_run_id,
|
||||
);
|
||||
$job->handle();
|
||||
app()->call([$job, 'handle']);
|
||||
|
||||
$pack->refresh();
|
||||
|
||||
@ -303,6 +303,19 @@ function seedTenantWithData(Tenant $tenant): void
|
||||
});
|
||||
});
|
||||
|
||||
it('sends queued database notification when review pack generation is requested', function (): void {
|
||||
Queue::fake();
|
||||
Notification::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
/** @var ReviewPackService $service */
|
||||
$service = app(ReviewPackService::class);
|
||||
$service->generate($tenant, $user);
|
||||
|
||||
Notification::assertSentTo($user, OperationRunQueued::class);
|
||||
});
|
||||
|
||||
// ─── OperationRun Type ──────────────────────────────────────────
|
||||
|
||||
it('creates an OperationRun of type review_pack_generate', function (): void {
|
||||
|
||||
@ -5,12 +5,15 @@
|
||||
use App\Filament\Resources\ReviewPackResource;
|
||||
use App\Filament\Resources\ReviewPackResource\Pages\ListReviewPacks;
|
||||
use App\Filament\Resources\ReviewPackResource\Pages\ViewReviewPack;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\ReviewPackService;
|
||||
use App\Support\Auth\UiTooltips;
|
||||
use App\Support\ReviewPackStatus;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Livewire;
|
||||
|
||||
@ -95,6 +98,45 @@
|
||||
->assertActionExists('generate_pack', fn ($action): bool => $action->getTooltip() === UiTooltips::insufficientPermission());
|
||||
});
|
||||
|
||||
it('reuses an existing ready pack instead of starting a new run', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$fingerprint = app(ReviewPackService::class)->computeFingerprint($tenant, [
|
||||
'include_pii' => true,
|
||||
'include_operations' => true,
|
||||
]);
|
||||
|
||||
ReviewPack::factory()->ready()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
'fingerprint' => $fingerprint,
|
||||
'expires_at' => now()->addDay(),
|
||||
]);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$operationRunsBefore = OperationRun::query()->count();
|
||||
$reviewPacksBefore = ReviewPack::query()->count();
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ListReviewPacks::class)
|
||||
->callAction('generate_pack', [
|
||||
'include_pii' => true,
|
||||
'include_operations' => true,
|
||||
])
|
||||
->assertNotified();
|
||||
|
||||
expect(OperationRun::query()->count())->toBe($operationRunsBefore);
|
||||
expect(ReviewPack::query()->count())->toBe($reviewPacksBefore);
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
});
|
||||
|
||||
// ─── Table Row Actions ───────────────────────────────────────
|
||||
|
||||
it('shows the download action for a ready pack', function (): void {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user