create(array_merge([ 'tenant_id' => (int) $tenant->getKey(), 'name' => 'Backup health schedule', 'is_enabled' => true, 'timezone' => 'UTC', 'frequency' => 'daily', 'time_of_day' => '01:00:00', 'days_of_week' => null, 'policy_types' => ['deviceConfiguration'], 'include_foundations' => true, 'retention_keep_last' => 30, 'next_run_at' => now()->addHour(), ], $attributes)); } function resolveTenantBackupHealth(Tenant $tenant): TenantBackupHealthAssessment { return app(TenantBackupHealthResolver::class)->assess($tenant); } it('marks the tenant absent when no usable completed backup basis exists', function (): void { [, $tenant] = createUserWithTenant(ensureDefaultMicrosoftProviderConnection: false); $assessment = resolveTenantBackupHealth($tenant); expect($assessment->posture)->toBe(TenantBackupHealthAssessment::POSTURE_ABSENT) ->and($assessment->primaryReason)->toBe(TenantBackupHealthAssessment::REASON_NO_BACKUP_BASIS) ->and($assessment->latestRelevantBackupSetId)->toBeNull() ->and($assessment->healthyClaimAllowed)->toBeFalse() ->and($assessment->primaryActionTarget?->surface)->toBe(BackupHealthActionTarget::SURFACE_BACKUP_SETS_INDEX); }); it('lets the latest stale backup basis outrank older healthier history and preserves degradation as supporting detail', function (): void { [, $tenant] = createUserWithTenant(ensureDefaultMicrosoftProviderConnection: false); $olderHealthy = BackupSet::factory()->for($tenant)->create([ 'name' => 'Older healthy backup', 'item_count' => 1, 'completed_at' => now()->subDays(3), ]); BackupItem::factory()->for($tenant)->for($olderHealthy)->create([ 'payload' => ['id' => 'healthy-policy'], 'metadata' => [], 'assignments' => [], ]); $latestStale = BackupSet::factory()->for($tenant)->create([ 'name' => 'Latest stale backup', 'item_count' => 1, 'completed_at' => now()->subDays(2), ]); BackupItem::factory()->for($tenant)->for($latestStale)->create([ 'payload' => [], 'metadata' => [ 'source' => 'metadata_only', 'assignments_fetch_failed' => true, ], 'assignments' => [], ]); $assessment = resolveTenantBackupHealth($tenant); expect($assessment->latestRelevantBackupSetId)->toBe((int) $latestStale->getKey()) ->and($assessment->posture)->toBe(TenantBackupHealthAssessment::POSTURE_STALE) ->and($assessment->primaryReason)->toBe(TenantBackupHealthAssessment::REASON_LATEST_BACKUP_STALE) ->and($assessment->qualitySummary?->hasDegradations())->toBeTrue() ->and($assessment->supportingMessage)->toContain('The latest completed backup was 2 days ago.') ->and($assessment->supportingMessage)->toContain('also degraded'); }); it('reuses the backup quality resolver when the latest recent backup is degraded', function (): void { [, $tenant] = createUserWithTenant(ensureDefaultMicrosoftProviderConnection: false); $latestDegraded = BackupSet::factory()->for($tenant)->create([ 'name' => 'Latest degraded backup', 'item_count' => 2, 'completed_at' => now()->subHour(), ]); BackupItem::factory()->for($tenant)->for($latestDegraded)->create([ 'payload' => [], 'metadata' => [ 'source' => 'metadata_only', 'assignments_fetch_failed' => true, ], 'assignments' => [], ]); BackupItem::factory()->for($tenant)->for($latestDegraded)->create([ 'payload' => ['id' => 'warning-policy'], 'metadata' => [ 'integrity_warning' => 'Protected values are intentionally hidden.', ], 'assignments' => [], ]); $assessment = resolveTenantBackupHealth($tenant); expect($assessment->posture)->toBe(TenantBackupHealthAssessment::POSTURE_DEGRADED) ->and($assessment->primaryReason)->toBe(TenantBackupHealthAssessment::REASON_LATEST_BACKUP_DEGRADED) ->and($assessment->qualitySummary?->degradedItemCount)->toBe(2) ->and($assessment->qualitySummary?->compactSummary)->toContain('2 degraded items') ->and($assessment->supportingMessage)->toContain('The latest completed backup was 1 hour ago.') ->and($assessment->primaryActionTarget?->surface)->toBe(BackupHealthActionTarget::SURFACE_BACKUP_SET_VIEW) ->and($assessment->primaryActionTarget?->recordId)->toBe((int) $latestDegraded->getKey()); }); it('allows a healthy claim only when the latest backup is recent and free of material degradation', function (): void { [, $tenant] = createUserWithTenant(ensureDefaultMicrosoftProviderConnection: false); $healthyBackup = BackupSet::factory()->for($tenant)->create([ 'name' => 'Healthy backup', 'item_count' => 1, 'completed_at' => now()->subMinutes(30), ]); BackupItem::factory()->for($tenant)->for($healthyBackup)->create([ 'payload' => ['id' => 'healthy-policy'], 'metadata' => [], 'assignments' => [], ]); $assessment = resolveTenantBackupHealth($tenant); expect($assessment->posture)->toBe(TenantBackupHealthAssessment::POSTURE_HEALTHY) ->and($assessment->primaryReason)->toBeNull() ->and($assessment->healthyClaimAllowed)->toBeTrue() ->and($assessment->headline)->toBe('Backups are recent and healthy.') ->and($assessment->supportingMessage)->toBe('The latest completed backup was 30 minutes ago.') ->and($assessment->primaryActionTarget)->toBeNull(); }); it('keeps the posture healthy but suppresses the healthy claim when schedules need follow-up', function (): void { [, $tenant] = createUserWithTenant(ensureDefaultMicrosoftProviderConnection: false); $healthyBackup = BackupSet::factory()->for($tenant)->create([ 'name' => 'Healthy backup with overdue schedule', 'item_count' => 1, 'completed_at' => now()->subMinutes(15), ]); BackupItem::factory()->for($tenant)->for($healthyBackup)->create([ 'payload' => ['id' => 'healthy-policy'], 'metadata' => [], 'assignments' => [], ]); makeBackupHealthSchedule($tenant, [ 'name' => 'Overdue schedule', 'next_run_at' => now()->subHours(2), 'last_run_at' => null, 'last_run_status' => null, ]); $assessment = resolveTenantBackupHealth($tenant); expect($assessment->posture)->toBe(TenantBackupHealthAssessment::POSTURE_HEALTHY) ->and($assessment->primaryReason)->toBe(TenantBackupHealthAssessment::REASON_SCHEDULE_FOLLOW_UP) ->and($assessment->healthyClaimAllowed)->toBeFalse() ->and($assessment->scheduleFollowUp->needsFollowUp)->toBeTrue() ->and($assessment->scheduleFollowUp->neverSuccessfulCount)->toBe(1) ->and($assessment->primaryActionTarget?->surface)->toBe(BackupHealthActionTarget::SURFACE_BACKUP_SCHEDULES_INDEX); }); it('treats failed recent schedule runs as backup follow-up even when the next run is not overdue yet', function (): void { [, $tenant] = createUserWithTenant(ensureDefaultMicrosoftProviderConnection: false); $healthyBackup = BackupSet::factory()->for($tenant)->create([ 'name' => 'Healthy backup with failed schedule', 'item_count' => 1, 'completed_at' => now()->subMinutes(20), ]); BackupItem::factory()->for($tenant)->for($healthyBackup)->create([ 'payload' => ['id' => 'healthy-policy'], 'metadata' => [], 'assignments' => [], ]); makeBackupHealthSchedule($tenant, [ 'name' => 'Failed schedule', 'last_run_at' => now()->subHour(), 'last_run_status' => 'failed', 'next_run_at' => now()->addHour(), ]); $assessment = resolveTenantBackupHealth($tenant); expect($assessment->posture)->toBe(TenantBackupHealthAssessment::POSTURE_HEALTHY) ->and($assessment->primaryReason)->toBe(TenantBackupHealthAssessment::REASON_SCHEDULE_FOLLOW_UP) ->and($assessment->scheduleFollowUp->failedRecentRunCount)->toBe(1) ->and($assessment->scheduleFollowUp->summaryMessage)->toContain('needs follow-up after the last run'); });