347 lines
12 KiB
PHP
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');
|
|
});
|