status($status)->getDatabaseMessage(), 'icon', '', ); } } if (! function_exists('spec230AssertSharedNotificationPayload')) { /** * @param array $payload * @param array{ * title: string, * status: string, * actionLabel: string, * actionTarget: string, * supportingLines: list, * 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'); });