status($status)->getDatabaseMessage(), 'icon', '', ); } } it('stores a filament payload with one tenant finding deep link and recipient reason copy', function (): void { [$owner, $tenant] = createUserWithTenant(role: 'owner'); $assignee = User::factory()->create(['name' => 'Assigned Operator']); createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator'); $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); $notification = $assignee->notifications() ->where('type', FindingEventNotification::class) ->latest('id') ->first(); expect($notification)->not->toBeNull() ->and(data_get($notification?->data, 'format'))->toBe('filament') ->and(data_get($notification?->data, 'title'))->toBe('Finding assigned') ->and(data_get($notification?->data, 'status'))->toBe('info') ->and(data_get($notification?->data, 'icon'))->toBe(spec230ExpectedNotificationIcon('info')) ->and(data_get($notification?->data, 'actions.0.label'))->toBe('Open finding') ->and(data_get($notification?->data, 'actions.0.url')) ->toBe(FindingResource::getUrl('view', ['record' => $finding], panel: 'tenant', tenant: $tenant)) ->and(data_get($notification?->data, 'actions.0.target'))->toBe('finding_detail') ->and(data_get($notification?->data, 'supporting_lines'))->toBe(['You are the new assignee.']) ->and(data_get($notification?->data, 'finding_event.event_type'))->toBe(AlertRule::EVENT_FINDINGS_ASSIGNED) ->and(data_get($notification?->data, 'finding_event.recipient_reason'))->toBe('new_assignee'); $this->actingAs($assignee); Filament::setTenant($tenant, true); $this->get(data_get($notification?->data, 'actions.0.url'))->assertSuccessful(); }); it('maps due soon and overdue finding notifications onto the shared status and icon treatment', function ( string $eventType, string $recipient, string $expectedStatus, string $findingStatus, string $relativeDueAt, ): void { [$owner, $tenant] = createUserWithTenant(role: 'owner'); $assignee = User::factory()->create(['name' => 'Urgency Operator']); createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator'); $finding = Finding::factory()->for($tenant)->create([ 'workspace_id' => (int) $tenant->workspace_id, 'status' => $findingStatus, 'owner_user_id' => (int) $owner->getKey(), 'assignee_user_id' => (int) $assignee->getKey(), 'due_at' => now()->modify($relativeDueAt), ]); app(FindingNotificationService::class)->dispatch($finding, $eventType); $notifiable = $recipient === 'owner' ? $owner : $assignee; $notification = $notifiable->notifications() ->where('type', FindingEventNotification::class) ->latest('id') ->first(); expect($notification)->not->toBeNull() ->and(data_get($notification?->data, 'status'))->toBe($expectedStatus) ->and(data_get($notification?->data, 'icon'))->toBe(spec230ExpectedNotificationIcon($expectedStatus)) ->and(data_get($notification?->data, 'actions.0.target'))->toBe('finding_detail'); })->with([ 'due soon' => [AlertRule::EVENT_FINDINGS_DUE_SOON, 'assignee', 'warning', Finding::STATUS_TRIAGED, '+6 hours'], 'overdue' => [AlertRule::EVENT_FINDINGS_OVERDUE, 'owner', 'danger', Finding::STATUS_IN_PROGRESS, '-2 hours'], ]); it('returns 404 when a finding notification link is opened after tenant access is removed', function (): void { [$owner, $tenant] = createUserWithTenant(role: 'owner'); $assignee = User::factory()->create(['name' => 'Removed Operator']); createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator'); $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); $url = data_get($assignee->notifications()->latest('id')->first(), 'data.actions.0.url'); TenantMembership::query() ->where('tenant_id', (int) $tenant->getKey()) ->where('user_id', (int) $assignee->getKey()) ->delete(); app(CapabilityResolver::class)->clearCache(); $this->actingAs($assignee) ->get($url) ->assertNotFound(); }); it('returns 403 when a finding notification link is opened by an in-scope member without findings view capability', function (): void { [$owner, $tenant] = createUserWithTenant(role: 'owner'); $assignee = User::factory()->create(['name' => 'Scoped Operator']); createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator'); $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); $url = data_get($assignee->notifications()->latest('id')->first(), 'data.actions.0.url'); Gate::define(Capabilities::TENANT_FINDINGS_VIEW, fn (): bool => false); $this->actingAs($assignee); Filament::setTenant($tenant, true); $this->get($url)->assertForbidden(); });