## Summary - add tenant triage review-state persistence, fingerprinting, resolver logic, service layer, and migration for current affected-set tracking - surface review-state and affected-set progress across tenant registry, tenant dashboard arrival continuity, and workspace overview - extend RBAC, audit/badge support, specs, and test coverage for portfolio triage review-state workflows - suppress expected hidden-page background transport failures in the global unhandled rejection logger while keeping visible-page failures logged ## Validation - targeted Pest coverage added for tenant registry, workspace overview, arrival context, RBAC authorization, badges, fingerprinting, resolver behavior, and logger asset behavior - code formatted with `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` ## Notes - full suite was not re-run in this final step - branch includes the spec artifacts under `specs/189-portfolio-triage-review-state/` Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #220
177 lines
8.2 KiB
PHP
177 lines
8.2 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Filament\Resources\TenantResource\Pages\ListTenants;
|
|
use App\Services\Auth\CapabilityResolver;
|
|
use App\Support\Auth\Capabilities;
|
|
use App\Models\Tenant;
|
|
use App\Models\TenantTriageReview;
|
|
use App\Support\BackupHealth\TenantBackupHealthAssessment;
|
|
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
|
|
use App\Support\Tenants\TenantRecoveryTriagePresentation;
|
|
use Carbon\CarbonImmutable;
|
|
use Filament\Actions\Action;
|
|
use Tests\Feature\Concerns\BuildsPortfolioTriageFixtures;
|
|
|
|
uses(BuildsPortfolioTriageFixtures::class);
|
|
|
|
afterEach(function (): void {
|
|
CarbonImmutable::setTestNow();
|
|
});
|
|
|
|
it('renders review-state badges and all four review-state filters for the current backup slice', function (): void {
|
|
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 10, 8, 0, 0, 'UTC'));
|
|
|
|
[$user, $anchorTenant] = $this->makePortfolioTriageActor('Anchor Backup Tenant');
|
|
|
|
$reviewedTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Reviewed Backup Tenant');
|
|
$followUpTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Follow-up Backup Tenant');
|
|
$changedTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Changed Backup Tenant');
|
|
$notReviewedTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Not Reviewed Backup Tenant');
|
|
$calmTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Calm Backup Tenant');
|
|
|
|
foreach ([$reviewedTenant, $followUpTenant, $changedTenant, $notReviewedTenant] as $tenant) {
|
|
$this->seedPortfolioBackupConcern($tenant, TenantBackupHealthAssessment::POSTURE_STALE);
|
|
}
|
|
|
|
$this->seedPortfolioBackupConcern($calmTenant, TenantBackupHealthAssessment::POSTURE_HEALTHY);
|
|
|
|
$this->seedPortfolioTriageReview($reviewedTenant, PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH, TenantTriageReview::STATE_REVIEWED, $user);
|
|
$this->seedPortfolioTriageReview($followUpTenant, PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH, TenantTriageReview::STATE_FOLLOW_UP_NEEDED, $user);
|
|
$this->seedPortfolioTriageReview($changedTenant, PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH, TenantTriageReview::STATE_REVIEWED, $user, changedFingerprint: true);
|
|
|
|
$this->portfolioTriageRegistryList($user, $anchorTenant, [
|
|
'backup_posture' => [TenantBackupHealthAssessment::POSTURE_STALE],
|
|
])
|
|
->assertTableColumnExists('review_state')
|
|
->assertTableColumnFormattedStateSet('review_state', 'Reviewed', $reviewedTenant)
|
|
->assertTableColumnFormattedStateSet('review_state', 'Follow-up needed', $followUpTenant)
|
|
->assertTableColumnFormattedStateSet('review_state', 'Changed since review', $changedTenant)
|
|
->assertTableColumnFormattedStateSet('review_state', 'Not reviewed', $notReviewedTenant)
|
|
->assertDontSee('Calm Backup Tenant');
|
|
|
|
$reviewedNames = $this->portfolioTriageRegistryList($user, $anchorTenant, [
|
|
'backup_posture' => [TenantBackupHealthAssessment::POSTURE_STALE],
|
|
])
|
|
->filterTable('review_state', [TenantTriageReview::STATE_REVIEWED])
|
|
->instance()
|
|
->getFilteredTableQuery()
|
|
?->pluck('tenants.name')
|
|
->all();
|
|
|
|
$followUpNames = $this->portfolioTriageRegistryList($user, $anchorTenant, [
|
|
'backup_posture' => [TenantBackupHealthAssessment::POSTURE_STALE],
|
|
])
|
|
->filterTable('review_state', [TenantTriageReview::STATE_FOLLOW_UP_NEEDED])
|
|
->instance()
|
|
->getFilteredTableQuery()
|
|
?->pluck('tenants.name')
|
|
->all();
|
|
|
|
$changedNames = $this->portfolioTriageRegistryList($user, $anchorTenant, [
|
|
'backup_posture' => [TenantBackupHealthAssessment::POSTURE_STALE],
|
|
])
|
|
->filterTable('review_state', [TenantTriageReview::DERIVED_STATE_CHANGED_SINCE_REVIEW])
|
|
->instance()
|
|
->getFilteredTableQuery()
|
|
?->pluck('tenants.name')
|
|
->all();
|
|
|
|
$notReviewedNames = $this->portfolioTriageRegistryList($user, $anchorTenant, [
|
|
'backup_posture' => [TenantBackupHealthAssessment::POSTURE_STALE],
|
|
])
|
|
->filterTable('review_state', [TenantTriageReview::DERIVED_STATE_NOT_REVIEWED])
|
|
->instance()
|
|
->getFilteredTableQuery()
|
|
?->pluck('tenants.name')
|
|
->all();
|
|
|
|
expect($reviewedNames)->toBe(['Reviewed Backup Tenant'])
|
|
->and($followUpNames)->toBe(['Follow-up Backup Tenant'])
|
|
->and($changedNames)->toBe(['Changed Backup Tenant'])
|
|
->and($notReviewedNames)->toBe(['Not Reviewed Backup Tenant']);
|
|
});
|
|
|
|
it('uses the highest-priority current concern family when the registry slice is mixed', function (): void {
|
|
[$user, $anchorTenant] = $this->makePortfolioTriageActor('Anchor Mixed Tenant');
|
|
$mixedTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Mixed Concern Tenant');
|
|
|
|
$backupSet = $this->seedPortfolioBackupConcern($mixedTenant, TenantBackupHealthAssessment::POSTURE_STALE);
|
|
$this->seedPortfolioRecoveryConcern($mixedTenant, 'failed', $backupSet);
|
|
$this->seedPortfolioTriageReview(
|
|
$mixedTenant,
|
|
PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE,
|
|
TenantTriageReview::STATE_FOLLOW_UP_NEEDED,
|
|
$user,
|
|
);
|
|
|
|
$this->portfolioTriageRegistryList($user, $anchorTenant, [
|
|
'triage_sort' => TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
|
|
])
|
|
->assertTableColumnFormattedStateSet('review_state', 'Follow-up needed', $mixedTenant)
|
|
->assertSee('Recovery evidence');
|
|
});
|
|
|
|
it('keeps review-state mutations in overflow with a preview-confirmed write path', function (): void {
|
|
[$user, $anchorTenant] = $this->makePortfolioTriageActor('Anchor Action Tenant');
|
|
$actionTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Action Backup Tenant');
|
|
$this->seedPortfolioBackupConcern($actionTenant, TenantBackupHealthAssessment::POSTURE_STALE);
|
|
|
|
$component = $this->portfolioTriageRegistryList($user, $anchorTenant, [
|
|
'backup_posture' => [TenantBackupHealthAssessment::POSTURE_STALE],
|
|
])
|
|
->assertTableActionVisible('openTenant', $actionTenant)
|
|
->assertTableActionEnabled('markReviewed', $actionTenant)
|
|
->assertTableActionExists('markReviewed', fn (Action $action): bool => $action->isConfirmationRequired()
|
|
&& str_contains((string) $action->getModalDescription(), 'Concern family: Backup health')
|
|
&& str_contains((string) $action->getModalDescription(), 'Target state: Reviewed')
|
|
&& str_contains((string) $action->getModalDescription(), 'TenantPilot only'), $actionTenant)
|
|
->assertTableActionExists('markFollowUpNeeded', fn (Action $action): bool => $action->isConfirmationRequired(), $actionTenant);
|
|
|
|
$action = $component->instance()->getAction([
|
|
[
|
|
'name' => 'markReviewed',
|
|
'context' => [
|
|
'table' => true,
|
|
'recordKey' => (string) $actionTenant->getKey(),
|
|
],
|
|
],
|
|
]);
|
|
|
|
expect(app(CapabilityResolver::class)->can($user, $actionTenant, Capabilities::TENANT_TRIAGE_REVIEW_MANAGE))->toBeTrue();
|
|
|
|
expect($action)->not->toBeNull()
|
|
->and($action?->getRecord())->toBeInstanceOf(Tenant::class)
|
|
->and((int) $action?->getRecord()?->getKey())->toBe((int) $actionTenant->getKey())
|
|
->and($action?->isDisabled())->toBeFalse()
|
|
->and($component->instance()->mountedActionShouldOpenModal($action))->toBeTrue();
|
|
|
|
$component->mountAction([
|
|
[
|
|
'name' => 'markReviewed',
|
|
'context' => [
|
|
'table' => true,
|
|
'recordKey' => (string) $actionTenant->getKey(),
|
|
],
|
|
],
|
|
]);
|
|
|
|
$mountedAction = $component->instance()->getMountedAction();
|
|
|
|
expect($mountedAction)->not->toBeNull()
|
|
->and($mountedAction?->getRecord())->toBeInstanceOf(Tenant::class)
|
|
->and((int) $mountedAction?->getRecord()?->getKey())->toBe((int) $actionTenant->getKey());
|
|
|
|
$component
|
|
->callMountedAction();
|
|
|
|
expect(TenantTriageReview::query()
|
|
->where('tenant_id', (int) $actionTenant->getKey())
|
|
->where('concern_family', PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH)
|
|
->where('current_state', TenantTriageReview::STATE_REVIEWED)
|
|
->whereNull('resolved_at')
|
|
->exists())->toBeTrue()
|
|
->and($component->instance())->toBeInstanceOf(ListTenants::class);
|
|
});
|