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
227 lines
9.6 KiB
PHP
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');
|
|
});
|