TenantAtlas/apps/platform/tests/Feature/Notifications/FindingNotificationLinkTest.php
ahmido 742d65f0d9
Some checks failed
Main Confidence / confidence (push) Failing after 51s
feat: converge findings notification presentation (#265)
## 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
2026-04-22 20:26:18 +00:00

154 lines
6.4 KiB
PHP

<?php
declare(strict_types=1);
use App\Filament\Resources\FindingResource;
use App\Models\AlertRule;
use App\Models\Finding;
use App\Models\TenantMembership;
use App\Models\User;
use App\Notifications\Findings\FindingEventNotification;
use App\Services\Auth\CapabilityResolver;
use App\Services\Findings\FindingNotificationService;
use App\Support\Auth\Capabilities;
use Filament\Facades\Filament;
use Filament\Notifications\Notification as FilamentNotification;
use Illuminate\Support\Facades\Gate;
if (! function_exists('spec230ExpectedNotificationIcon')) {
function spec230ExpectedNotificationIcon(string $status): string
{
return (string) data_get(
FilamentNotification::make()->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();
});