Some checks failed
Main Confidence / confidence (push) Failing after 51s
## Summary - converge finding, queued, and completed database notifications on one shared `OperationUxPresenter` presentation contract - preserve existing finding and operation deep-link authorities while standardizing title, body, status/icon treatment, and single primary action - add focused notification, findings, and guard coverage plus the full feature 230 spec artifacts ## Validation - `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Notifications/SharedDatabaseNotificationContractTest.php tests/Feature/Notifications/OperationRunNotificationTest.php tests/Feature/Notifications/FindingNotificationLinkTest.php` - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingsNotificationEventTest.php tests/Feature/Findings/FindingsNotificationRoutingTest.php tests/Feature/OpsUx/Constitution/LegacyNotificationGuardTest.php` ## Filament / Platform Notes - Livewire v4.0+ compliance preserved on Filament v5 primitives - provider registration remains unchanged in `apps/platform/bootstrap/providers.php` - no globally searchable resource behavior changed in this feature - no destructive actions were introduced - asset strategy is unchanged; the existing `cd apps/platform && php artisan filament:assets` deploy step remains sufficient Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #265
230 lines
9.0 KiB
PHP
230 lines
9.0 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\AlertRule;
|
|
use App\Models\Finding;
|
|
use App\Models\OperationRun;
|
|
use App\Notifications\Findings\FindingEventNotification;
|
|
use App\Notifications\OperationRunCompleted;
|
|
use App\Notifications\OperationRunQueued;
|
|
use App\Services\Findings\FindingNotificationService;
|
|
use App\Services\OperationRunService;
|
|
use Filament\Facades\Filament;
|
|
use Filament\Notifications\Notification as FilamentNotification;
|
|
|
|
if (! function_exists('spec230ExpectedNotificationIcon')) {
|
|
function spec230ExpectedNotificationIcon(string $status): string
|
|
{
|
|
return (string) data_get(
|
|
FilamentNotification::make()->status($status)->getDatabaseMessage(),
|
|
'icon',
|
|
'',
|
|
);
|
|
}
|
|
}
|
|
|
|
if (! function_exists('spec230AssertSharedNotificationPayload')) {
|
|
/**
|
|
* @param array<string, mixed> $payload
|
|
* @param array{
|
|
* title: string,
|
|
* status: string,
|
|
* actionLabel: string,
|
|
* actionTarget: string,
|
|
* supportingLines: list<string>,
|
|
* primaryBody: string
|
|
* } $expected
|
|
*/
|
|
function spec230AssertSharedNotificationPayload(array $payload, array $expected): void
|
|
{
|
|
expect(data_get($payload, 'format'))->toBe('filament')
|
|
->and((string) data_get($payload, 'title'))->toBe($expected['title'])
|
|
->and((string) data_get($payload, 'body'))->toStartWith($expected['primaryBody'])
|
|
->and(data_get($payload, 'status'))->toBe($expected['status'])
|
|
->and(data_get($payload, 'icon'))->toBe(spec230ExpectedNotificationIcon($expected['status']))
|
|
->and(data_get($payload, 'actions', []))->toHaveCount(1)
|
|
->and(data_get($payload, 'actions.0.label'))->toBe($expected['actionLabel'])
|
|
->and(data_get($payload, 'actions.0.target'))->toBe($expected['actionTarget'])
|
|
->and(array_values(data_get($payload, 'supporting_lines', [])))->toBe($expected['supportingLines']);
|
|
|
|
foreach ($expected['supportingLines'] as $line) {
|
|
expect((string) data_get($payload, 'body'))->toContain($line);
|
|
}
|
|
}
|
|
}
|
|
|
|
it('enforces the shared database notification contract across finding queued and completed consumers', function (): void {
|
|
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
|
$assignee = \App\Models\User::factory()->create(['name' => 'Shared Contract Assignee']);
|
|
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
|
|
|
|
$this->actingAs($owner);
|
|
$tenant->makeCurrent();
|
|
Filament::setTenant($tenant, true);
|
|
|
|
$finding = Finding::factory()->for($tenant)->create([
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'status' => Finding::STATUS_TRIAGED,
|
|
'severity' => 'high',
|
|
'owner_user_id' => (int) $owner->getKey(),
|
|
'assignee_user_id' => (int) $assignee->getKey(),
|
|
]);
|
|
|
|
app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_ASSIGNED);
|
|
|
|
$queuedRun = app(OperationRunService::class)->ensureRun(
|
|
tenant: $tenant,
|
|
type: 'policy.sync',
|
|
inputs: ['scope' => 'all'],
|
|
initiator: $owner,
|
|
);
|
|
|
|
app(OperationRunService::class)->dispatchOrFail($queuedRun, function (): void {
|
|
// no-op
|
|
}, emitQueuedNotification: true);
|
|
|
|
$completedRun = OperationRun::factory()->create([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'user_id' => (int) $owner->getKey(),
|
|
'initiator_name' => $owner->name,
|
|
'type' => 'inventory_sync',
|
|
'status' => 'queued',
|
|
'outcome' => 'pending',
|
|
]);
|
|
|
|
app(OperationRunService::class)->updateRun(
|
|
$completedRun,
|
|
status: 'completed',
|
|
outcome: 'succeeded',
|
|
summaryCounts: ['total' => 1],
|
|
failures: [],
|
|
);
|
|
|
|
$findingNotification = $assignee->notifications()
|
|
->where('type', FindingEventNotification::class)
|
|
->latest('id')
|
|
->first();
|
|
$queuedNotification = $owner->notifications()
|
|
->where('type', OperationRunQueued::class)
|
|
->latest('id')
|
|
->first();
|
|
$completedNotification = $owner->notifications()
|
|
->where('type', OperationRunCompleted::class)
|
|
->latest('id')
|
|
->first();
|
|
|
|
expect($findingNotification)->not->toBeNull();
|
|
expect($queuedNotification)->not->toBeNull();
|
|
expect($completedNotification)->not->toBeNull();
|
|
|
|
spec230AssertSharedNotificationPayload($findingNotification?->data ?? [], [
|
|
'title' => 'Finding assigned',
|
|
'primaryBody' => 'Finding #'.(int) $finding->getKey().' in '.$tenant->getFilamentName().' was assigned. High severity.',
|
|
'status' => 'info',
|
|
'actionLabel' => 'Open finding',
|
|
'actionTarget' => 'finding_detail',
|
|
'supportingLines' => ['You are the new assignee.'],
|
|
]);
|
|
|
|
spec230AssertSharedNotificationPayload($queuedNotification?->data ?? [], [
|
|
'title' => 'Policy sync queued',
|
|
'primaryBody' => 'Queued for execution. Open the operation for progress and next steps.',
|
|
'status' => 'info',
|
|
'actionLabel' => 'Open operation',
|
|
'actionTarget' => 'admin_operation_run',
|
|
'supportingLines' => [],
|
|
]);
|
|
|
|
spec230AssertSharedNotificationPayload($completedNotification?->data ?? [], [
|
|
'title' => 'Inventory sync completed successfully',
|
|
'primaryBody' => 'Completed successfully.',
|
|
'status' => 'success',
|
|
'actionLabel' => 'Open operation',
|
|
'actionTarget' => 'admin_operation_run',
|
|
'supportingLines' => ['No action needed.', 'Total: 1'],
|
|
]);
|
|
});
|
|
|
|
it('keeps exactly one primary action and preserves secondary metadata boundaries across in-scope consumers', function (): void {
|
|
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
|
$assignee = \App\Models\User::factory()->create(['name' => 'Boundary Assignee']);
|
|
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
|
|
|
|
$this->actingAs($owner);
|
|
$tenant->makeCurrent();
|
|
Filament::setTenant($tenant, true);
|
|
|
|
$finding = Finding::factory()->for($tenant)->create([
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'status' => Finding::STATUS_TRIAGED,
|
|
'owner_user_id' => (int) $owner->getKey(),
|
|
'assignee_user_id' => (int) $assignee->getKey(),
|
|
]);
|
|
|
|
app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_ASSIGNED);
|
|
|
|
$tenantlessQueuedRun = OperationRun::factory()->create([
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'tenant_id' => null,
|
|
'user_id' => (int) $owner->getKey(),
|
|
'initiator_name' => $owner->name,
|
|
'type' => 'provider.connection.check',
|
|
'status' => 'queued',
|
|
'outcome' => 'pending',
|
|
]);
|
|
|
|
$owner->notify(new OperationRunQueued($tenantlessQueuedRun));
|
|
|
|
$tenantlessCompletedRun = OperationRun::factory()->create([
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'tenant_id' => null,
|
|
'user_id' => (int) $owner->getKey(),
|
|
'initiator_name' => $owner->name,
|
|
'type' => 'provider.connection.check',
|
|
'status' => 'queued',
|
|
'outcome' => 'pending',
|
|
]);
|
|
|
|
app(OperationRunService::class)->updateRun(
|
|
$tenantlessCompletedRun,
|
|
status: 'completed',
|
|
outcome: 'blocked',
|
|
failures: [[
|
|
'code' => 'operation.blocked',
|
|
'reason_code' => 'execution_prerequisite_invalid',
|
|
'message' => 'Operation blocked because the queued execution prerequisites are no longer satisfied.',
|
|
]],
|
|
);
|
|
|
|
$findingPayload = data_get(
|
|
$assignee->notifications()->where('type', FindingEventNotification::class)->latest('id')->first(),
|
|
'data',
|
|
[],
|
|
);
|
|
$queuedPayload = data_get(
|
|
$owner->notifications()->where('type', OperationRunQueued::class)->latest('id')->first(),
|
|
'data',
|
|
[],
|
|
);
|
|
$completedPayload = data_get(
|
|
$owner->notifications()->where('type', OperationRunCompleted::class)->latest('id')->first(),
|
|
'data',
|
|
[],
|
|
);
|
|
|
|
expect(data_get($findingPayload, 'actions', []))->toHaveCount(1)
|
|
->and(data_get($queuedPayload, 'actions', []))->toHaveCount(1)
|
|
->and(data_get($completedPayload, 'actions', []))->toHaveCount(1)
|
|
->and(data_get($findingPayload, 'finding_event.event_type'))->toBe(AlertRule::EVENT_FINDINGS_ASSIGNED)
|
|
->and(data_get($findingPayload, 'reason_translation'))->toBeNull()
|
|
->and(data_get($queuedPayload, 'finding_event'))->toBeNull()
|
|
->and(data_get($queuedPayload, 'reason_translation'))->toBeNull()
|
|
->and(data_get($completedPayload, 'finding_event'))->toBeNull()
|
|
->and(data_get($completedPayload, 'reason_translation.operator_label'))->toBe('Execution prerequisite changed')
|
|
->and(data_get($completedPayload, 'actions.0.target'))->toBe('tenantless_operation_run')
|
|
->and(array_values(data_get($queuedPayload, 'supporting_lines', [])))->toBe([])
|
|
->and(array_values(data_get($completedPayload, 'supporting_lines', [])))->toContain('Execution prerequisite changed');
|
|
});
|