TenantAtlas/apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php
Ahmed Darrazi a2e855bd81
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 49s
feat: add findings intake queue and stabilize follow-up regressions
2026-04-22 00:51:18 +02:00

348 lines
12 KiB
PHP

<?php
declare(strict_types=1);
use App\Filament\Pages\Findings\FindingsIntakeQueue;
use App\Filament\Resources\FindingResource;
use App\Models\Finding;
use App\Models\Tenant;
use App\Models\User;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Livewire\Livewire;
function findingsIntakeActingUser(string $role = 'owner', string $workspaceRole = 'readonly'): array
{
$tenant = Tenant::factory()->create(['status' => 'active']);
[$user, $tenant] = createUserWithTenant($tenant, role: $role, workspaceRole: $workspaceRole);
test()->actingAs($user);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
return [$user, $tenant];
}
function findingsIntakePage(?User $user = null, array $query = [])
{
if ($user instanceof User) {
test()->actingAs($user);
}
setAdminPanelContext();
$factory = $query === []
? Livewire::actingAs(auth()->user())
: Livewire::withQueryParams($query)->actingAs(auth()->user());
return $factory->test(FindingsIntakeQueue::class);
}
function makeIntakeFinding(Tenant $tenant, array $attributes = []): Finding
{
return Finding::factory()->for($tenant)->create(array_merge([
'workspace_id' => (int) $tenant->workspace_id,
'status' => Finding::STATUS_TRIAGED,
'assignee_user_id' => null,
'owner_user_id' => null,
'subject_external_id' => fake()->uuid(),
], $attributes));
}
it('shows only visible unassigned open findings and exposes fixed queue view counts', function (): void {
[$user, $tenantA] = findingsIntakeActingUser();
$tenantA->forceFill(['name' => 'Alpha Tenant'])->save();
$tenantB = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $tenantA->workspace_id,
'name' => 'Tenant Bravo',
]);
createUserWithTenant($tenantB, $user, role: 'readonly', workspaceRole: 'readonly');
$hiddenTenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $tenantA->workspace_id,
'name' => 'Hidden Tenant',
]);
$otherAssignee = User::factory()->create();
createUserWithTenant($tenantA, $otherAssignee, role: 'operator', workspaceRole: 'readonly');
$otherOwner = User::factory()->create();
createUserWithTenant($tenantB, $otherOwner, role: 'owner', workspaceRole: 'readonly');
$visibleNew = makeIntakeFinding($tenantA, [
'subject_external_id' => 'visible-new',
'severity' => Finding::SEVERITY_HIGH,
'status' => Finding::STATUS_NEW,
]);
$visibleReopened = makeIntakeFinding($tenantB, [
'subject_external_id' => 'visible-reopened',
'status' => Finding::STATUS_REOPENED,
'reopened_at' => now()->subHours(6),
'owner_user_id' => (int) $otherOwner->getKey(),
]);
$visibleTriaged = makeIntakeFinding($tenantA, [
'subject_external_id' => 'visible-triaged',
'status' => Finding::STATUS_TRIAGED,
]);
$visibleInProgress = makeIntakeFinding($tenantB, [
'subject_external_id' => 'visible-progress',
'status' => Finding::STATUS_IN_PROGRESS,
'due_at' => now()->subDay(),
]);
$assignedOpen = makeIntakeFinding($tenantA, [
'subject_external_id' => 'assigned-open',
'assignee_user_id' => (int) $otherAssignee->getKey(),
]);
$acknowledged = Finding::factory()->for($tenantA)->acknowledged()->create([
'workspace_id' => (int) $tenantA->workspace_id,
'assignee_user_id' => null,
'subject_external_id' => 'acknowledged',
]);
$terminal = Finding::factory()->for($tenantA)->resolved()->create([
'workspace_id' => (int) $tenantA->workspace_id,
'assignee_user_id' => null,
'subject_external_id' => 'terminal',
]);
$hidden = makeIntakeFinding($hiddenTenant, [
'subject_external_id' => 'hidden-intake',
]);
$component = findingsIntakePage($user)
->assertCanSeeTableRecords([$visibleNew, $visibleReopened, $visibleTriaged, $visibleInProgress])
->assertCanNotSeeTableRecords([$assignedOpen, $acknowledged, $terminal, $hidden])
->assertSee('Owner: '.$otherOwner->name)
->assertSee('Needs triage')
->assertSee('Unassigned');
expect($component->instance()->summaryCounts())->toBe([
'visible_unassigned' => 4,
'visible_needs_triage' => 2,
'visible_overdue' => 1,
]);
$queueViews = collect($component->instance()->queueViews())->keyBy('key');
expect($queueViews['unassigned']['badge_count'])->toBe(4)
->and($queueViews['unassigned']['active'])->toBeTrue()
->and($queueViews['needs_triage']['badge_count'])->toBe(2)
->and($queueViews['needs_triage']['active'])->toBeFalse();
});
it('defaults to the active tenant prefilter and lets the operator clear it without dropping intake scope', function (): void {
[$user, $tenantA] = findingsIntakeActingUser();
$tenantB = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $tenantA->workspace_id,
'name' => 'Beta Tenant',
]);
createUserWithTenant($tenantB, $user, role: 'readonly', workspaceRole: 'readonly');
$findingA = makeIntakeFinding($tenantA, [
'subject_external_id' => 'tenant-a',
'status' => Finding::STATUS_NEW,
]);
$findingB = makeIntakeFinding($tenantB, [
'subject_external_id' => 'tenant-b',
'status' => Finding::STATUS_TRIAGED,
]);
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
(string) $tenantA->workspace_id => (int) $tenantB->getKey(),
]);
$component = findingsIntakePage($user)
->assertSet('tableFilters.tenant_id.value', (string) $tenantB->getKey())
->assertCanSeeTableRecords([$findingB])
->assertCanNotSeeTableRecords([$findingA])
->assertActionVisible('clear_tenant_filter');
expect($component->instance()->appliedScope())->toBe([
'workspace_scoped' => true,
'fixed_scope' => 'visible_unassigned_open_findings_only',
'queue_view' => 'unassigned',
'queue_view_label' => 'Unassigned',
'tenant_prefilter_source' => 'active_tenant_context',
'tenant_label' => $tenantB->name,
]);
$component->callAction('clear_tenant_filter')
->assertCanSeeTableRecords([$findingA, $findingB]);
expect($component->instance()->appliedScope())->toBe([
'workspace_scoped' => true,
'fixed_scope' => 'visible_unassigned_open_findings_only',
'queue_view' => 'unassigned',
'queue_view_label' => 'Unassigned',
'tenant_prefilter_source' => 'none',
'tenant_label' => null,
]);
});
it('keeps the needs triage view active when clearing the tenant prefilter', function (): void {
[$user, $tenantA] = findingsIntakeActingUser();
$tenantB = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $tenantA->workspace_id,
'name' => 'Beta Tenant',
]);
createUserWithTenant($tenantB, $user, role: 'readonly', workspaceRole: 'readonly');
$tenantATriage = makeIntakeFinding($tenantA, [
'subject_external_id' => 'tenant-a-triage',
'status' => Finding::STATUS_NEW,
]);
$tenantBTriage = makeIntakeFinding($tenantB, [
'subject_external_id' => 'tenant-b-triage',
'status' => Finding::STATUS_REOPENED,
'reopened_at' => now()->subHour(),
]);
$tenantBBacklog = makeIntakeFinding($tenantB, [
'subject_external_id' => 'tenant-b-backlog',
'status' => Finding::STATUS_TRIAGED,
]);
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
(string) $tenantA->workspace_id => (int) $tenantB->getKey(),
]);
$component = findingsIntakePage($user, ['view' => 'needs_triage'])
->assertSet('tableFilters.tenant_id.value', (string) $tenantB->getKey())
->assertCanSeeTableRecords([$tenantBTriage])
->assertCanNotSeeTableRecords([$tenantATriage, $tenantBBacklog]);
expect($component->instance()->appliedScope())->toBe([
'workspace_scoped' => true,
'fixed_scope' => 'visible_unassigned_open_findings_only',
'queue_view' => 'needs_triage',
'queue_view_label' => 'Needs triage',
'tenant_prefilter_source' => 'active_tenant_context',
'tenant_label' => $tenantB->name,
]);
$component->callAction('clear_tenant_filter')
->assertCanSeeTableRecords([$tenantBTriage, $tenantATriage], inOrder: true)
->assertCanNotSeeTableRecords([$tenantBBacklog]);
expect($component->instance()->appliedScope())->toBe([
'workspace_scoped' => true,
'fixed_scope' => 'visible_unassigned_open_findings_only',
'queue_view' => 'needs_triage',
'queue_view_label' => 'Needs triage',
'tenant_prefilter_source' => 'none',
'tenant_label' => null,
]);
$queueViews = collect($component->instance()->queueViews())->keyBy('key');
expect($queueViews['unassigned']['active'])->toBeFalse()
->and($queueViews['needs_triage']['active'])->toBeTrue();
});
it('separates needs triage from the remaining backlog and keeps deterministic urgency ordering', function (): void {
[$user, $tenant] = findingsIntakeActingUser();
$overdue = makeIntakeFinding($tenant, [
'subject_external_id' => 'overdue',
'status' => Finding::STATUS_TRIAGED,
'due_at' => now()->subDay(),
]);
$reopened = makeIntakeFinding($tenant, [
'subject_external_id' => 'reopened',
'status' => Finding::STATUS_REOPENED,
'reopened_at' => now()->subHours(2),
'due_at' => now()->addDay(),
]);
$newFinding = makeIntakeFinding($tenant, [
'subject_external_id' => 'new-finding',
'status' => Finding::STATUS_NEW,
'due_at' => now()->addDays(2),
]);
$remainingBacklog = makeIntakeFinding($tenant, [
'subject_external_id' => 'remaining-backlog',
'status' => Finding::STATUS_TRIAGED,
'due_at' => now()->addHours(12),
]);
$undatedBacklog = makeIntakeFinding($tenant, [
'subject_external_id' => 'undated-backlog',
'status' => Finding::STATUS_IN_PROGRESS,
'due_at' => null,
]);
findingsIntakePage($user)
->assertCanSeeTableRecords([$overdue, $reopened, $newFinding, $remainingBacklog, $undatedBacklog], inOrder: true);
findingsIntakePage($user, ['view' => 'needs_triage'])
->assertCanSeeTableRecords([$reopened, $newFinding], inOrder: true)
->assertCanNotSeeTableRecords([$overdue, $remainingBacklog, $undatedBacklog]);
});
it('builds tenant detail drilldowns with intake continuity', function (): void {
[$user, $tenant] = findingsIntakeActingUser();
$finding = makeIntakeFinding($tenant, [
'subject_external_id' => 'continuity',
'status' => Finding::STATUS_NEW,
]);
$component = findingsIntakePage($user, [
'tenant' => (string) $tenant->external_id,
'view' => 'needs_triage',
]);
$detailUrl = $component->instance()->getTable()->getRecordUrl($finding);
expect($detailUrl)->toContain(FindingResource::getUrl('view', ['record' => $finding], panel: 'tenant', tenant: $tenant))
->and($detailUrl)->toContain('nav%5Bback_label%5D=Back+to+findings+intake');
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get($detailUrl)
->assertOk()
->assertSee('Back to findings intake');
});
it('renders both intake empty-state branches with the correct single recovery action', function (): void {
[$user, $tenantA] = findingsIntakeActingUser();
$tenantB = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $tenantA->workspace_id,
'name' => 'Work Tenant',
]);
createUserWithTenant($tenantB, $user, role: 'readonly', workspaceRole: 'readonly');
makeIntakeFinding($tenantB, [
'subject_external_id' => 'available-elsewhere',
]);
findingsIntakePage($user, [
'tenant' => (string) $tenantA->external_id,
])
->assertSee('No intake findings match this tenant scope')
->assertTableEmptyStateActionsExistInOrder(['clear_tenant_filter_empty']);
Finding::query()->delete();
session()->forget(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY);
Filament::setTenant(null, true);
findingsIntakePage($user)
->assertSee('Shared intake is clear')
->assertTableEmptyStateActionsExistInOrder(['open_my_findings_empty']);
});