TenantAtlas/apps/platform/tests/Feature/Findings/FindingsNotificationRoutingTest.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

227 lines
9.6 KiB
PHP

<?php
declare(strict_types=1);
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\Services\Findings\FindingWorkflowService;
use Carbon\CarbonImmutable;
use Filament\Notifications\Notification as FilamentNotification;
afterEach(function (): void {
CarbonImmutable::setTestNow();
});
if (! function_exists('spec230ExpectedNotificationIcon')) {
function spec230ExpectedNotificationIcon(string $status): string
{
return (string) data_get(
FilamentNotification::make()->status($status)->getDatabaseMessage(),
'icon',
'',
);
}
}
function dispatchedFindingNotificationsFor(User $user): \Illuminate\Support\Collection
{
return $user->notifications()
->where('type', FindingEventNotification::class)
->orderBy('id')
->get();
}
it('uses the documented recipient precedence for assignment reopen due soon and overdue', function (): void {
[$owner, $tenant] = createUserWithTenant(role: 'owner');
$service = app(FindingNotificationService::class);
$assignee = User::factory()->create(['name' => 'Assignee']);
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(),
'due_at' => now()->addHours(6),
]);
$service->dispatch($finding, AlertRule::EVENT_FINDINGS_ASSIGNED);
$service->dispatch($finding, AlertRule::EVENT_FINDINGS_REOPENED);
$service->dispatch($finding, AlertRule::EVENT_FINDINGS_DUE_SOON);
$service->dispatch($finding, AlertRule::EVENT_FINDINGS_OVERDUE);
expect(dispatchedFindingNotificationsFor($assignee)
->pluck('data.finding_event.event_type')
->all())
->toContain(AlertRule::EVENT_FINDINGS_ASSIGNED, AlertRule::EVENT_FINDINGS_REOPENED, AlertRule::EVENT_FINDINGS_DUE_SOON);
expect(dispatchedFindingNotificationsFor($owner)
->pluck('data.finding_event.event_type')
->all())
->toContain(AlertRule::EVENT_FINDINGS_OVERDUE);
$assignedNotification = dispatchedFindingNotificationsFor($assignee)
->first(fn ($notification): bool => data_get($notification->data, 'finding_event.event_type') === AlertRule::EVENT_FINDINGS_ASSIGNED);
$overdueNotification = dispatchedFindingNotificationsFor($owner)
->first(fn ($notification): bool => data_get($notification->data, 'finding_event.event_type') === AlertRule::EVENT_FINDINGS_OVERDUE);
expect($assignedNotification)->not->toBeNull()
->and(data_get($assignedNotification?->data, 'status'))->toBe('info')
->and(data_get($assignedNotification?->data, 'actions.0.target'))->toBe('finding_detail');
expect($overdueNotification)->not->toBeNull()
->and(data_get($overdueNotification?->data, 'status'))->toBe('danger')
->and(data_get($overdueNotification?->data, 'icon'))->toBe(spec230ExpectedNotificationIcon('danger'))
->and(data_get($overdueNotification?->data, 'actions.0.target'))->toBe('finding_detail');
$fallbackFinding = Finding::factory()->for($tenant)->create([
'workspace_id' => (int) $tenant->workspace_id,
'status' => Finding::STATUS_REOPENED,
'owner_user_id' => (int) $owner->getKey(),
'assignee_user_id' => null,
'due_at' => now()->addHours(2),
]);
$service->dispatch($fallbackFinding, AlertRule::EVENT_FINDINGS_REOPENED);
$service->dispatch($fallbackFinding, AlertRule::EVENT_FINDINGS_DUE_SOON);
$ownerEventTypes = dispatchedFindingNotificationsFor($owner)
->pluck('data.finding_event.event_type')
->all();
expect($ownerEventTypes)->toContain(AlertRule::EVENT_FINDINGS_REOPENED, AlertRule::EVENT_FINDINGS_DUE_SOON);
});
it('suppresses direct delivery when the preferred recipient loses tenant access', function (): void {
[$owner, $tenant] = createUserWithTenant(role: 'owner');
$assignee = User::factory()->create(['name' => 'Removed Assignee']);
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
$finding = Finding::factory()->for($tenant)->create([
'workspace_id' => (int) $tenant->workspace_id,
'status' => Finding::STATUS_REOPENED,
'owner_user_id' => (int) $owner->getKey(),
'assignee_user_id' => (int) $assignee->getKey(),
]);
TenantMembership::query()
->where('tenant_id', (int) $tenant->getKey())
->where('user_id', (int) $assignee->getKey())
->delete();
app(CapabilityResolver::class)->clearCache();
$result = app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_REOPENED);
expect($result['direct_delivery_status'])->toBe('suppressed')
->and(dispatchedFindingNotificationsFor($assignee))->toHaveCount(0)
->and(dispatchedFindingNotificationsFor($owner))->toHaveCount(0);
});
it('does not broaden delivery to the owner when the assignee is present but no longer entitled', function (): void {
[$owner, $tenant] = createUserWithTenant(role: 'owner');
$assignee = User::factory()->create(['name' => 'Current Assignee']);
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(),
'due_at' => now()->addHours(3),
]);
$resolver = \Mockery::mock(CapabilityResolver::class);
$resolver->shouldReceive('isMember')->andReturnTrue();
$resolver->shouldReceive('can')
->andReturnUsing(function (User $user): bool {
return $user->name !== 'Current Assignee';
});
app()->instance(CapabilityResolver::class, $resolver);
$result = app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_DUE_SOON);
expect($result['direct_delivery_status'])->toBe('suppressed')
->and(dispatchedFindingNotificationsFor($assignee))->toHaveCount(0)
->and(dispatchedFindingNotificationsFor($owner))->toHaveCount(0);
});
it('suppresses owner-only assignment edits and assignee clears from creating direct notifications', function (): void {
[$owner, $tenant] = createUserWithTenant(role: 'owner');
$assignee = User::factory()->create(['name' => 'Assigned Operator']);
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
$replacementOwner = User::factory()->create(['name' => 'Replacement Owner']);
createUserWithTenant(tenant: $tenant, user: $replacementOwner, role: 'manager');
$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(),
]);
$workflow = app(FindingWorkflowService::class);
$workflow->assign(
finding: $finding,
tenant: $tenant,
actor: $owner,
assigneeUserId: (int) $assignee->getKey(),
ownerUserId: (int) $replacementOwner->getKey(),
);
$workflow->assign(
finding: $finding->fresh(),
tenant: $tenant,
actor: $owner,
assigneeUserId: null,
ownerUserId: (int) $replacementOwner->getKey(),
);
expect(dispatchedFindingNotificationsFor($assignee))->toHaveCount(0)
->and(dispatchedFindingNotificationsFor($owner))->toHaveCount(0)
->and(dispatchedFindingNotificationsFor($replacementOwner))->toHaveCount(0);
});
it('suppresses due notifications for terminal findings', function (): void {
[$owner, $tenant] = createUserWithTenant(role: 'owner');
$finding = Finding::factory()->for($tenant)->closed()->create([
'workspace_id' => (int) $tenant->workspace_id,
'owner_user_id' => (int) $owner->getKey(),
'assignee_user_id' => (int) $owner->getKey(),
'due_at' => now()->subHour(),
]);
$result = app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_OVERDUE);
expect($result['direct_delivery_status'])->toBe('suppressed')
->and(dispatchedFindingNotificationsFor($owner))->toHaveCount(0);
});
it('sends one direct notification when owner and assignee are the same entitled user', function (): void {
[$owner, $tenant] = createUserWithTenant(role: 'owner');
$finding = Finding::factory()->for($tenant)->create([
'workspace_id' => (int) $tenant->workspace_id,
'status' => Finding::STATUS_IN_PROGRESS,
'owner_user_id' => (int) $owner->getKey(),
'assignee_user_id' => (int) $owner->getKey(),
'due_at' => now()->subHour(),
]);
$result = app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_OVERDUE);
expect($result['direct_delivery_status'])->toBe('sent')
->and(dispatchedFindingNotificationsFor($owner))->toHaveCount(1)
->and(data_get(dispatchedFindingNotificationsFor($owner)->first(), 'data.finding_event.recipient_reason'))->toBe('current_owner')
->and(data_get(dispatchedFindingNotificationsFor($owner)->first(), 'data.status'))->toBe('danger')
->and(data_get(dispatchedFindingNotificationsFor($owner)->first(), 'data.actions.0.label'))->toBe('Open finding');
});