TenantAtlas/apps/platform/tests/Feature/Filament/TenantRegistryTriageReviewStateTest.php
2026-04-10 23:34:02 +02:00

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);
});