TenantAtlas/apps/platform/tests/Feature/Findings/MyWorkInboxTest.php
ahmido 81bb5f42c7
Some checks failed
Main Confidence / confidence (push) Failing after 55s
feat: add findings operator inbox (#258)
## Summary
- add the canonical admin-plane `My Findings` inbox at `/admin/findings/my-work`
- add the workspace overview `Assigned to me` signal and inbox-to-detail continuity
- add focused Pest coverage plus the full Spec 221 artifact bundle

## Validation
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/MyWorkInboxTest.php tests/Feature/Authorization/MyWorkInboxAuthorizationTest.php tests/Feature/Dashboard/MyFindingsSignalTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewNavigationTest.php`
- integrated-browser smoke completed against the browser-facing `tenantatlas` runtime, including seeded positive-path and negative-path checks plus fixture cleanup

## Filament v5 Guardrails
- Livewire v4.0+ compliant
- panel provider registration remains in `apps/platform/bootstrap/providers.php`
- global search behavior is unchanged; `FindingResource` already has a View page and the new inbox is a custom page, not a searchable resource
- no destructive actions were introduced on the inbox or overview signal
- no new assets were added; the existing deploy step for `cd apps/platform && php artisan filament:assets` remains unchanged
- coverage includes the new inbox page, authorization boundaries, the workspace overview signal, and the overview CTA regression

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #258
2026-04-21 09:19:54 +00:00

347 lines
12 KiB
PHP

<?php
declare(strict_types=1);
use App\Filament\Pages\Findings\MyFindingsInbox;
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 myWorkInboxActingUser(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 myWorkInboxPage(?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(MyFindingsInbox::class);
}
function makeAssignedFindingForInbox(Tenant $tenant, User $assignee, array $attributes = []): Finding
{
return Finding::factory()->for($tenant)->create(array_merge([
'workspace_id' => (int) $tenant->workspace_id,
'owner_user_id' => (int) $assignee->getKey(),
'assignee_user_id' => (int) $assignee->getKey(),
'status' => Finding::STATUS_TRIAGED,
'subject_external_id' => fake()->uuid(),
], $attributes));
}
it('shows only visible assigned open findings and exposes the fixed filter contract', function (): void {
[$user, $tenantA] = myWorkInboxActingUser();
$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');
$assignedVisible = makeAssignedFindingForInbox($tenantA, $user, [
'subject_external_id' => 'visible-a',
'severity' => Finding::SEVERITY_HIGH,
'status' => Finding::STATUS_NEW,
]);
$assignedOwnerSplit = makeAssignedFindingForInbox($tenantB, $user, [
'subject_external_id' => 'visible-b',
'owner_user_id' => (int) $otherOwner->getKey(),
'status' => Finding::STATUS_IN_PROGRESS,
'due_at' => now()->subDay(),
]);
$ownerOnly = makeAssignedFindingForInbox($tenantA, $otherAssignee, [
'subject_external_id' => 'owner-only',
'owner_user_id' => (int) $user->getKey(),
]);
$assignedTerminal = makeAssignedFindingForInbox($tenantA, $user, [
'subject_external_id' => 'terminal',
'status' => Finding::STATUS_RESOLVED,
'resolved_at' => now(),
]);
$assignedToOther = makeAssignedFindingForInbox($tenantA, $otherAssignee, [
'subject_external_id' => 'other-assignee',
]);
$hiddenAssigned = makeAssignedFindingForInbox($hiddenTenant, $user, [
'subject_external_id' => 'hidden',
]);
$component = myWorkInboxPage($user)
->assertCanSeeTableRecords([$assignedVisible, $assignedOwnerSplit])
->assertCanNotSeeTableRecords([$ownerOnly, $assignedTerminal, $assignedToOther, $hiddenAssigned])
->assertSee('Owner: '.$otherOwner->name)
->assertSee('Assigned to me only');
expect($component->instance()->summaryCounts())->toBe([
'open_assigned' => 2,
'overdue_assigned' => 1,
]);
expect($component->instance()->availableFilters())->toBe([
[
'key' => 'assignee_scope',
'label' => 'Assigned to me',
'fixed' => true,
'options' => [],
],
[
'key' => 'tenant',
'label' => 'Tenant',
'fixed' => false,
'options' => [
['value' => (string) $tenantA->getKey(), 'label' => 'Alpha Tenant'],
['value' => (string) $tenantB->getKey(), 'label' => $tenantB->name],
],
],
[
'key' => 'overdue',
'label' => 'Overdue',
'fixed' => false,
'options' => [],
],
[
'key' => 'reopened',
'label' => 'Reopened',
'fixed' => false,
'options' => [],
],
[
'key' => 'high_severity',
'label' => 'High severity',
'fixed' => false,
'options' => [],
],
]);
});
it('defaults to the active tenant prefilter and lets the operator clear it without dropping personal scope', function (): void {
[$user, $tenantA] = myWorkInboxActingUser();
$tenantB = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $tenantA->workspace_id,
'name' => 'Beta Tenant',
]);
createUserWithTenant($tenantB, $user, role: 'readonly', workspaceRole: 'readonly');
$findingA = makeAssignedFindingForInbox($tenantA, $user, [
'subject_external_id' => 'tenant-a',
'status' => Finding::STATUS_NEW,
]);
$findingB = makeAssignedFindingForInbox($tenantB, $user, [
'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 = myWorkInboxPage($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,
'assignee_scope' => 'current_user_only',
'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,
'assignee_scope' => 'current_user_only',
'tenant_prefilter_source' => 'none',
'tenant_label' => null,
]);
});
it('orders overdue work before reopened work and keeps deterministic due-date and id tie breaks', function (): void {
[$user, $tenant] = myWorkInboxActingUser();
$overdue = makeAssignedFindingForInbox($tenant, $user, [
'subject_external_id' => 'overdue',
'status' => Finding::STATUS_IN_PROGRESS,
'due_at' => now()->subDay(),
]);
$reopened = makeAssignedFindingForInbox($tenant, $user, [
'subject_external_id' => 'reopened',
'status' => Finding::STATUS_REOPENED,
'reopened_at' => now()->subHours(6),
'due_at' => now()->addDay(),
]);
$ordinarySooner = makeAssignedFindingForInbox($tenant, $user, [
'subject_external_id' => 'ordinary-sooner',
'status' => Finding::STATUS_TRIAGED,
'due_at' => now()->addDays(2),
]);
$ordinaryLater = makeAssignedFindingForInbox($tenant, $user, [
'subject_external_id' => 'ordinary-later',
'status' => Finding::STATUS_NEW,
'due_at' => now()->addDays(4),
]);
$ordinaryNoDue = makeAssignedFindingForInbox($tenant, $user, [
'subject_external_id' => 'ordinary-no-due',
'status' => Finding::STATUS_TRIAGED,
'due_at' => null,
]);
myWorkInboxPage($user)
->assertCanSeeTableRecords([$overdue, $reopened, $ordinarySooner, $ordinaryLater, $ordinaryNoDue], inOrder: true)
->assertSee('Reopened');
});
it('applies reopened and high-severity filters without widening beyond assigned work', function (): void {
[$user, $tenant] = myWorkInboxActingUser();
$reopenedHigh = makeAssignedFindingForInbox($tenant, $user, [
'subject_external_id' => 'reopened-high',
'status' => Finding::STATUS_REOPENED,
'reopened_at' => now()->subHour(),
'severity' => Finding::SEVERITY_CRITICAL,
]);
$highOnly = makeAssignedFindingForInbox($tenant, $user, [
'subject_external_id' => 'high-only',
'status' => Finding::STATUS_TRIAGED,
'severity' => Finding::SEVERITY_HIGH,
]);
$ordinary = makeAssignedFindingForInbox($tenant, $user, [
'subject_external_id' => 'ordinary',
'status' => Finding::STATUS_NEW,
'severity' => Finding::SEVERITY_MEDIUM,
]);
myWorkInboxPage($user)
->set('tableFilters.reopened.isActive', true)
->assertCanSeeTableRecords([$reopenedHigh])
->assertCanNotSeeTableRecords([$highOnly, $ordinary])
->set('tableFilters.reopened.isActive', false)
->set('tableFilters.high_severity.isActive', true)
->assertCanSeeTableRecords([$reopenedHigh, $highOnly])
->assertCanNotSeeTableRecords([$ordinary]);
});
it('renders the tenant-prefilter empty-state branch and offers only a clear-filter recovery action', function (): void {
[$user, $tenantA] = myWorkInboxActingUser();
$tenantB = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $tenantA->workspace_id,
'name' => 'Work Tenant',
]);
createUserWithTenant($tenantB, $user, role: 'readonly', workspaceRole: 'readonly');
makeAssignedFindingForInbox($tenantB, $user, [
'subject_external_id' => 'available-elsewhere',
]);
$component = myWorkInboxPage($user, [
'tenant' => (string) $tenantA->external_id,
])
->assertCanNotSeeTableRecords([])
->assertSee('No assigned findings match this tenant scope')
->assertTableEmptyStateActionsExistInOrder(['clear_tenant_filter_empty']);
expect($component->instance()->summaryCounts())->toBe([
'open_assigned' => 0,
'overdue_assigned' => 0,
]);
});
it('renders the calm zero-work branch and points back to tenant selection when no active tenant context exists', function (): void {
[$user, $tenant] = myWorkInboxActingUser();
session()->forget(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY);
Filament::setTenant(null, true);
myWorkInboxPage($user)
->assertSee('No visible assigned findings right now')
->assertTableEmptyStateActionsExistInOrder(['choose_tenant_empty']);
});
it('uses the active visible tenant for the calm empty-state drillback when tenant context exists', function (): void {
[$user, $tenant] = myWorkInboxActingUser();
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
(string) $tenant->workspace_id => (int) $tenant->getKey(),
]);
$component = myWorkInboxPage($user)
->assertSee('No visible assigned findings right now')
->assertTableEmptyStateActionsExistInOrder(['open_tenant_findings_empty']);
expect($component->instance()->emptyState())->toMatchArray([
'action_name' => 'open_tenant_findings_empty',
'action_label' => 'Open tenant findings',
'action_kind' => 'url',
'action_url' => FindingResource::getUrl('index', panel: 'tenant', tenant: $tenant),
]);
});
it('builds tenant detail drilldowns with inbox continuity', function (): void {
[$user, $tenant] = myWorkInboxActingUser();
$finding = makeAssignedFindingForInbox($tenant, $user, [
'subject_external_id' => 'continuity',
]);
$component = myWorkInboxPage($user);
$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+my+findings');
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get($detailUrl)
->assertOk()
->assertSee('Back to my findings');
});