feat: add preview diff step

This commit is contained in:
Ahmed Darrazi 2025-12-30 22:05:57 +01:00
parent cd76fa5dd7
commit a58db008f8
8 changed files with 687 additions and 8 deletions

View File

@ -13,6 +13,7 @@
use App\Services\BulkOperationService; use App\Services\BulkOperationService;
use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GroupResolver; use App\Services\Graph\GroupResolver;
use App\Services\Intune\RestoreDiffGenerator;
use App\Services\Intune\RestoreRiskChecker; use App\Services\Intune\RestoreRiskChecker;
use App\Services\Intune\RestoreService; use App\Services\Intune\RestoreService;
use BackedEnum; use BackedEnum;
@ -180,6 +181,9 @@ public static function getWizardSteps(): array
$set('check_summary', null); $set('check_summary', null);
$set('check_results', []); $set('check_results', []);
$set('checks_ran_at', null); $set('checks_ran_at', null);
$set('preview_summary', null);
$set('preview_diffs', []);
$set('preview_ran_at', null);
}) })
->required(), ->required(),
]), ]),
@ -199,6 +203,9 @@ public static function getWizardSteps(): array
$set('check_summary', null); $set('check_summary', null);
$set('check_results', []); $set('check_results', []);
$set('checks_ran_at', null); $set('checks_ran_at', null);
$set('preview_summary', null);
$set('preview_diffs', []);
$set('preview_ran_at', null);
if ($state === 'all') { if ($state === 'all') {
$set('backup_item_ids', null); $set('backup_item_ids', null);
@ -223,6 +230,9 @@ public static function getWizardSteps(): array
$set('check_summary', null); $set('check_summary', null);
$set('check_results', []); $set('check_results', []);
$set('checks_ran_at', null); $set('checks_ran_at', null);
$set('preview_summary', null);
$set('preview_diffs', []);
$set('preview_ran_at', null);
}) })
->visible(fn (Get $get): bool => $get('scope_mode') === 'selected') ->visible(fn (Get $get): bool => $get('scope_mode') === 'selected')
->required(fn (Get $get): bool => $get('scope_mode') === 'selected') ->required(fn (Get $get): bool => $get('scope_mode') === 'selected')
@ -292,6 +302,9 @@ public static function getWizardSteps(): array
$set('check_summary', null); $set('check_summary', null);
$set('check_results', []); $set('check_results', []);
$set('checks_ran_at', null); $set('checks_ran_at', null);
$set('preview_summary', null);
$set('preview_diffs', []);
$set('preview_ran_at', null);
}) })
->helperText('Choose a target group or select Skip.'); ->helperText('Choose a target group or select Skip.');
}, $unresolved); }, $unresolved);
@ -414,16 +427,99 @@ public static function getWizardSteps(): array
->helperText('Run checks after defining scope and mapping missing groups.'), ->helperText('Run checks after defining scope and mapping missing groups.'),
]), ]),
Step::make('Preview') Step::make('Preview')
->description('Dry-run preview (Phase 5)') ->description('Dry-run preview')
->schema([ ->schema([
Forms\Components\Toggle::make('is_dry_run') Forms\Components\Toggle::make('is_dry_run')
->label('Preview only (dry-run)') ->label('Preview only (dry-run)')
->default(true) ->default(true)
->disabled() ->disabled()
->helperText('Execution will be enabled once checks, preview, and confirmations are implemented (Phase 6).'), ->helperText('Execution will be enabled once checks, preview, and confirmations are implemented (Phase 6).'),
Forms\Components\Placeholder::make('preview_placeholder') Forms\Components\Hidden::make('preview_summary')
->default(null),
Forms\Components\Hidden::make('preview_ran_at')
->default(null)
->required(),
Forms\Components\ViewField::make('preview_diffs')
->label('Preview') ->label('Preview')
->content('Preview diff summary will be added in Phase 5.'), ->default([])
->view('filament.forms.components.restore-run-preview')
->viewData(fn (Get $get): array => [
'summary' => $get('preview_summary'),
'ranAt' => $get('preview_ran_at'),
])
->hintActions([
Actions\Action::make('run_restore_preview')
->label('Generate preview')
->icon('heroicon-o-eye')
->color('gray')
->visible(fn (Get $get): bool => filled($get('backup_set_id')))
->action(function (Get $get, Set $set): void {
$tenant = Tenant::current();
if (! $tenant) {
return;
}
$backupSetId = $get('backup_set_id');
if (! $backupSetId) {
return;
}
$backupSet = BackupSet::find($backupSetId);
if (! $backupSet || $backupSet->tenant_id !== $tenant->id) {
Notification::make()
->title('Unable to generate preview')
->body('Backup set is not available for the active tenant.')
->danger()
->send();
return;
}
$scopeMode = $get('scope_mode') ?? 'all';
$selectedItemIds = ($scopeMode === 'selected')
? ($get('backup_item_ids') ?? null)
: null;
$selectedItemIds = is_array($selectedItemIds) ? $selectedItemIds : null;
$generator = app(RestoreDiffGenerator::class);
$outcome = $generator->generate(
tenant: $tenant,
backupSet: $backupSet,
selectedItemIds: $selectedItemIds,
);
$summary = $outcome['summary'] ?? [];
$diffs = $outcome['diffs'] ?? [];
$set('preview_summary', $summary, shouldCallUpdatedHooks: true);
$set('preview_diffs', $diffs, shouldCallUpdatedHooks: true);
$set('preview_ran_at', $summary['generated_at'] ?? now()->toIso8601String(), shouldCallUpdatedHooks: true);
$policiesChanged = (int) ($summary['policies_changed'] ?? 0);
$policiesTotal = (int) ($summary['policies_total'] ?? 0);
Notification::make()
->title('Preview generated')
->body("Policies: {$policiesChanged}/{$policiesTotal} changed")
->status($policiesChanged > 0 ? 'warning' : 'success')
->send();
}),
Actions\Action::make('clear_restore_preview')
->label('Clear')
->icon('heroicon-o-x-mark')
->color('gray')
->visible(fn (Get $get): bool => filled($get('preview_diffs')) || filled($get('preview_summary')))
->action(function (Set $set): void {
$set('preview_summary', null, shouldCallUpdatedHooks: true);
$set('preview_diffs', [], shouldCallUpdatedHooks: true);
$set('preview_ran_at', null, shouldCallUpdatedHooks: true);
}),
])
->helperText('Generate a normalized diff preview before creating the dry-run restore.'),
]), ]),
Step::make('Confirm & Execute') Step::make('Confirm & Execute')
->description('Explicit confirmations (Phase 6)') ->description('Explicit confirmations (Phase 6)')
@ -976,8 +1072,18 @@ public static function createRestoreRun(array $data): RestoreRun
$checkSummary = $data['check_summary'] ?? null; $checkSummary = $data['check_summary'] ?? null;
$checkResults = $data['check_results'] ?? null; $checkResults = $data['check_results'] ?? null;
$checksRanAt = $data['checks_ran_at'] ?? null; $checksRanAt = $data['checks_ran_at'] ?? null;
$previewSummary = $data['preview_summary'] ?? null;
$previewDiffs = $data['preview_diffs'] ?? null;
$previewRanAt = $data['preview_ran_at'] ?? null;
if (is_array($checkSummary) || is_array($checkResults) || (is_string($checksRanAt) && $checksRanAt !== '')) { if (
is_array($checkSummary)
|| is_array($checkResults)
|| (is_string($checksRanAt) && $checksRanAt !== '')
|| is_array($previewSummary)
|| is_array($previewDiffs)
|| (is_string($previewRanAt) && $previewRanAt !== '')
) {
$metadata = $restoreRun->metadata ?? []; $metadata = $restoreRun->metadata ?? [];
if (is_array($checkSummary)) { if (is_array($checkSummary)) {
@ -992,6 +1098,18 @@ public static function createRestoreRun(array $data): RestoreRun
$metadata['checks_ran_at'] = $checksRanAt; $metadata['checks_ran_at'] = $checksRanAt;
} }
if (is_array($previewSummary)) {
$metadata['preview_summary'] = $previewSummary;
}
if (is_array($previewDiffs)) {
$metadata['preview_diffs'] = $previewDiffs;
}
if (is_string($previewRanAt) && $previewRanAt !== '') {
$metadata['preview_ran_at'] = $previewRanAt;
}
$restoreRun->update([ $restoreRun->update([
'metadata' => $metadata, 'metadata' => $metadata,
]); ]);

View File

@ -0,0 +1,248 @@
<?php
namespace App\Services\Intune;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use Carbon\CarbonImmutable;
use Illuminate\Support\Collection;
class RestoreDiffGenerator
{
public function __construct(
private readonly PolicyNormalizer $policyNormalizer,
private readonly VersionDiff $versionDiff,
) {}
/**
* @param array<int>|null $selectedItemIds
* @return array{summary: array<string, mixed>, diffs: array<int, array<string, mixed>>}
*/
public function generate(Tenant $tenant, BackupSet $backupSet, ?array $selectedItemIds = null): array
{
if ($backupSet->tenant_id !== $tenant->id) {
throw new \InvalidArgumentException('Backup set does not belong to the provided tenant.');
}
if ($selectedItemIds === []) {
$selectedItemIds = null;
}
$items = $this->loadItems($backupSet, $selectedItemIds);
$policyItems = $items
->reject(fn (BackupItem $item): bool => $item->isFoundation())
->values();
$policyIds = $policyItems
->pluck('policy_id')
->filter()
->unique()
->values()
->all();
$latestVersions = $this->latestVersionsByPolicyId($tenant, $policyIds);
$maxDetailedDiffs = 25;
$maxEntriesPerSection = 200;
$policiesChanged = 0;
$assignmentsChanged = 0;
$scopeTagsChanged = 0;
$diffs = [];
$diffsOmitted = 0;
foreach ($policyItems as $index => $item) {
$policyId = $item->policy_id ? (int) $item->policy_id : null;
$currentVersion = $policyId ? ($latestVersions[$policyId] ?? null) : null;
$currentSnapshot = is_array($currentVersion?->snapshot) ? $currentVersion->snapshot : [];
$backupSnapshot = is_array($item->payload) ? $item->payload : [];
$policyType = (string) ($item->policy_type ?? '');
$platform = $item->platform;
$from = $this->policyNormalizer->flattenForDiff($currentSnapshot, $policyType, $platform);
$to = $this->policyNormalizer->flattenForDiff($backupSnapshot, $policyType, $platform);
$diff = $this->versionDiff->compare($from, $to);
$summary = $diff['summary'] ?? ['added' => 0, 'removed' => 0, 'changed' => 0];
$hasPolicyChanges = ((int) ($summary['added'] ?? 0) + (int) ($summary['removed'] ?? 0) + (int) ($summary['changed'] ?? 0)) > 0;
if ($hasPolicyChanges) {
$policiesChanged++;
}
$assignmentDiff = $this->assignmentsChanged($item->assignments, $currentVersion?->assignments);
if ($assignmentDiff) {
$assignmentsChanged++;
}
$scopeTagDiff = $this->scopeTagsChanged($item, $currentVersion);
if ($scopeTagDiff) {
$scopeTagsChanged++;
}
$diffEntry = [
'backup_item_id' => $item->id,
'display_name' => $item->resolvedDisplayName(),
'policy_identifier' => $item->policy_identifier,
'policy_type' => $policyType,
'platform' => $platform,
'action' => $currentVersion ? 'update' : 'create',
'diff' => [
'summary' => $summary,
'added' => [],
'removed' => [],
'changed' => [],
],
'assignments_changed' => $assignmentDiff,
'scope_tags_changed' => $scopeTagDiff,
'diff_omitted' => false,
'diff_truncated' => false,
];
if ($index >= $maxDetailedDiffs) {
$diffEntry['diff_omitted'] = true;
$diffEntry['diff_truncated'] = true;
$diffEntry['diff'] = [
'summary' => $summary,
];
$diffsOmitted++;
$diffs[] = $diffEntry;
continue;
}
$added = is_array($diff['added'] ?? null) ? $diff['added'] : [];
$removed = is_array($diff['removed'] ?? null) ? $diff['removed'] : [];
$changed = is_array($diff['changed'] ?? null) ? $diff['changed'] : [];
$diffEntry['diff_truncated'] = count($added) > $maxEntriesPerSection
|| count($removed) > $maxEntriesPerSection
|| count($changed) > $maxEntriesPerSection;
$diffEntry['diff'] = [
'summary' => $summary,
'added' => array_slice($added, 0, $maxEntriesPerSection, true),
'removed' => array_slice($removed, 0, $maxEntriesPerSection, true),
'changed' => array_slice($changed, 0, $maxEntriesPerSection, true),
];
$diffs[] = $diffEntry;
}
return [
'summary' => [
'generated_at' => CarbonImmutable::now()->toIso8601String(),
'policies_total' => $policyItems->count(),
'policies_changed' => $policiesChanged,
'assignments_changed' => $assignmentsChanged,
'scope_tags_changed' => $scopeTagsChanged,
'diffs_detailed' => min($policyItems->count(), $maxDetailedDiffs),
'diffs_omitted' => $diffsOmitted,
'limits' => [
'max_detailed_diffs' => $maxDetailedDiffs,
'max_entries_per_section' => $maxEntriesPerSection,
],
],
'diffs' => $diffs,
];
}
/**
* @param array<int>|null $selectedItemIds
* @return Collection<int, BackupItem>
*/
private function loadItems(BackupSet $backupSet, ?array $selectedItemIds): Collection
{
$query = $backupSet->items()->getQuery();
if ($selectedItemIds !== null) {
$query->whereIn('id', $selectedItemIds);
}
return $query->orderBy('id')->get();
}
/**
* @param array<int, int> $policyIds
* @return array<int, PolicyVersion>
*/
private function latestVersionsByPolicyId(Tenant $tenant, array $policyIds): array
{
if ($policyIds === []) {
return [];
}
$latestVersionsQuery = PolicyVersion::query()
->where('tenant_id', $tenant->id)
->whereIn('policy_id', $policyIds)
->selectRaw('policy_id, max(version_number) as version_number')
->groupBy('policy_id');
return PolicyVersion::query()
->where('tenant_id', $tenant->id)
->joinSub($latestVersionsQuery, 'latest_versions', function ($join): void {
$join->on('policy_versions.policy_id', '=', 'latest_versions.policy_id')
->on('policy_versions.version_number', '=', 'latest_versions.version_number');
})
->get()
->keyBy('policy_id')
->all();
}
private function assignmentsChanged(?array $backupAssignments, ?array $currentAssignments): bool
{
$backup = $this->normalizeAssignments($backupAssignments);
$current = $this->normalizeAssignments($currentAssignments);
return $backup !== $current;
}
private function scopeTagsChanged(BackupItem $backupItem, ?PolicyVersion $currentVersion): bool
{
$backupIds = $backupItem->scope_tag_ids;
$backupIds = is_array($backupIds) ? $backupIds : [];
$backupIds = array_values(array_filter($backupIds, fn (mixed $id): bool => is_string($id) && $id !== '' && $id !== '0'));
sort($backupIds);
$scopeTags = $currentVersion?->scope_tags;
$currentIds = is_array($scopeTags) ? ($scopeTags['ids'] ?? []) : [];
$currentIds = is_array($currentIds) ? $currentIds : [];
$currentIds = array_values(array_filter($currentIds, fn (mixed $id): bool => is_string($id) && $id !== '' && $id !== '0'));
sort($currentIds);
return $backupIds !== $currentIds;
}
/**
* @return array<int, array<string, mixed>>
*/
private function normalizeAssignments(?array $assignments): array
{
$assignments = is_array($assignments) ? $assignments : [];
$normalized = [];
foreach ($assignments as $assignment) {
if (! is_array($assignment)) {
continue;
}
$normalized[] = $assignment;
}
usort($normalized, function (array $a, array $b): int {
$left = json_encode($a, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '';
$right = json_encode($b, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '';
return $left <=> $right;
});
return $normalized;
}
}

View File

@ -0,0 +1,177 @@
@php
$diffs = $getState() ?? [];
$diffs = is_array($diffs) ? $diffs : [];
$summary = $summary ?? [];
$summary = is_array($summary) ? $summary : [];
$ranAt = $ranAt ?? null;
$ranAtLabel = null;
if (is_string($ranAt) && $ranAt !== '') {
try {
$ranAtLabel = \Carbon\CarbonImmutable::parse($ranAt)->format('Y-m-d H:i');
} catch (\Throwable) {
$ranAtLabel = $ranAt;
}
}
$policiesTotal = (int) ($summary['policies_total'] ?? 0);
$policiesChanged = (int) ($summary['policies_changed'] ?? 0);
$assignmentsChanged = (int) ($summary['assignments_changed'] ?? 0);
$scopeTagsChanged = (int) ($summary['scope_tags_changed'] ?? 0);
$diffsOmitted = (int) ($summary['diffs_omitted'] ?? 0);
$limitedKeys = static function (array $items, int $limit = 8): array {
$keys = array_keys($items);
if (count($keys) <= $limit) {
return $keys;
}
return array_slice($keys, 0, $limit);
};
@endphp
<div class="space-y-4">
<x-filament::section
heading="Preview"
:description="$ranAtLabel ? ('Generated: ' . $ranAtLabel) : 'Generate a preview to see what would change.'"
>
<div class="flex flex-wrap gap-2">
<x-filament::badge :color="$policiesChanged > 0 ? 'warning' : 'success'">
{{ $policiesChanged }}/{{ $policiesTotal }} policies changed
</x-filament::badge>
<x-filament::badge :color="$assignmentsChanged > 0 ? 'warning' : 'gray'">
{{ $assignmentsChanged }} assignments changed
</x-filament::badge>
<x-filament::badge :color="$scopeTagsChanged > 0 ? 'warning' : 'gray'">
{{ $scopeTagsChanged }} scope tags changed
</x-filament::badge>
@if ($diffsOmitted > 0)
<x-filament::badge color="gray">
{{ $diffsOmitted }} diffs omitted (limit)
</x-filament::badge>
@endif
</div>
</x-filament::section>
@if ($diffs === [])
<x-filament::section>
<div class="text-sm text-gray-600 dark:text-gray-300">
No preview generated yet.
</div>
</x-filament::section>
@else
<div class="space-y-3">
@foreach ($diffs as $entry)
@php
$entry = is_array($entry) ? $entry : [];
$name = $entry['display_name'] ?? $entry['policy_identifier'] ?? 'Item';
$type = $entry['policy_type'] ?? 'type';
$platform = $entry['platform'] ?? 'platform';
$action = $entry['action'] ?? 'update';
$diff = is_array($entry['diff'] ?? null) ? $entry['diff'] : [];
$diffSummary = is_array($diff['summary'] ?? null) ? $diff['summary'] : [];
$added = (int) ($diffSummary['added'] ?? 0);
$removed = (int) ($diffSummary['removed'] ?? 0);
$changed = (int) ($diffSummary['changed'] ?? 0);
$assignmentsDelta = (bool) ($entry['assignments_changed'] ?? false);
$scopeTagsDelta = (bool) ($entry['scope_tags_changed'] ?? false);
$diffOmitted = (bool) ($entry['diff_omitted'] ?? false);
$diffTruncated = (bool) ($entry['diff_truncated'] ?? false);
$changedKeys = $limitedKeys(is_array($diff['changed'] ?? null) ? $diff['changed'] : []);
$addedKeys = $limitedKeys(is_array($diff['added'] ?? null) ? $diff['added'] : []);
$removedKeys = $limitedKeys(is_array($diff['removed'] ?? null) ? $diff['removed'] : []);
@endphp
<x-filament::section :heading="$name" :description="sprintf('%s • %s', $type, $platform)" collapsible :collapsed="true">
<div class="flex flex-wrap gap-2">
<x-filament::badge :color="$action === 'create' ? 'success' : 'gray'" size="sm">
{{ $action }}
</x-filament::badge>
<x-filament::badge color="success" size="sm">
{{ $added }} added
</x-filament::badge>
<x-filament::badge color="danger" size="sm">
{{ $removed }} removed
</x-filament::badge>
<x-filament::badge color="warning" size="sm">
{{ $changed }} changed
</x-filament::badge>
@if ($assignmentsDelta)
<x-filament::badge color="warning" size="sm">
assignments
</x-filament::badge>
@endif
@if ($scopeTagsDelta)
<x-filament::badge color="warning" size="sm">
scope tags
</x-filament::badge>
@endif
@if ($diffTruncated)
<x-filament::badge color="gray" size="sm">
truncated
</x-filament::badge>
@endif
</div>
@if ($diffOmitted)
<div class="mt-3 text-sm text-gray-600 dark:text-gray-300">
Diff details omitted due to preview limits. Narrow scope to see more items in detail.
</div>
@elseif ($changedKeys !== [] || $addedKeys !== [] || $removedKeys !== [])
<div class="mt-3 space-y-3 text-sm text-gray-700 dark:text-gray-200">
@if ($changedKeys !== [])
<div>
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Changed keys (sample)
</div>
<ul class="mt-1 space-y-1">
@foreach ($changedKeys as $key)
<li class="rounded bg-gray-50 px-2 py-1 text-xs text-gray-800 dark:bg-white/5 dark:text-gray-200">
{{ $key }}
</li>
@endforeach
</ul>
</div>
@endif
@if ($addedKeys !== [])
<div>
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Added keys (sample)
</div>
<ul class="mt-1 space-y-1">
@foreach ($addedKeys as $key)
<li class="rounded bg-gray-50 px-2 py-1 text-xs text-gray-800 dark:bg-white/5 dark:text-gray-200">
{{ $key }}
</li>
@endforeach
</ul>
</div>
@endif
@if ($removedKeys !== [])
<div>
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Removed keys (sample)
</div>
<ul class="mt-1 space-y-1">
@foreach ($removedKeys as $key)
<li class="rounded bg-gray-50 px-2 py-1 text-xs text-gray-800 dark:bg-white/5 dark:text-gray-200">
{{ $key }}
</li>
@endforeach
</ul>
</div>
@endif
</div>
@endif
</x-filament::section>
@endforeach
</div>
@endif
</div>

View File

@ -26,8 +26,8 @@ ## Phase 4 — Safety & Conflict Checks
- [x] T012 Render check results with severity (blocking/warning/safe) and block execute when blockers exist. - [x] T012 Render check results with severity (blocking/warning/safe) and block execute when blockers exist.
## Phase 5 — Preview (Diff) ## Phase 5 — Preview (Diff)
- [ ] T013 Implement `RestoreDiffGenerator` using `PolicyNormalizer` + `VersionDiff`. - [x] T013 Implement `RestoreDiffGenerator` using `PolicyNormalizer` + `VersionDiff`.
- [ ] T014 Persist preview summary (and per-item diffs with safe limits) and require preview completion before execute. - [x] T014 Persist preview summary (and per-item diffs with safe limits) and require preview completion before execute.
## Phase 6 — Confirm & Execute ## Phase 6 — Confirm & Execute
- [ ] T015 Implement Step 5 confirmations (ack checkbox + tenant hard-confirm). - [ ] T015 Implement Step 5 confirmations (ack checkbox + tenant hard-confirm).
@ -36,8 +36,8 @@ ## Phase 6 — Confirm & Execute
## Phase 7 — Tests + Formatting ## Phase 7 — Tests + Formatting
- [ ] T018 Add Pest tests for wizard gating rules and status transitions. - [ ] T018 Add Pest tests for wizard gating rules and status transitions.
- [ ] T019 Add Pest tests for safety checks persistence and blocking behavior. - [x] T019 Add Pest tests for safety checks persistence and blocking behavior.
- [ ] T020 Add Pest tests for preview summary generation. - [x] T020 Add Pest tests for preview summary generation.
- [x] T021 Run `./vendor/bin/pint --dirty`. - [x] T021 Run `./vendor/bin/pint --dirty`.
- [x] T022 Run targeted tests (e.g. `./vendor/bin/sail artisan test --filter=RestoreRunWizard` once tests exist). - [x] T022 Run targeted tests (e.g. `./vendor/bin/sail artisan test --filter=RestoreRunWizard` once tests exist).

View File

@ -168,6 +168,7 @@
->fillForm([ ->fillForm([
'is_dry_run' => true, 'is_dry_run' => true,
]) ])
->callFormComponentAction('preview_diffs', 'run_restore_preview')
->goToNextWizardStep() ->goToNextWizardStep()
->call('create') ->call('create')
->assertHasNoFormErrors(); ->assertHasNoFormErrors();

View File

@ -0,0 +1,132 @@
<?php
use App\Filament\Resources\RestoreRunResource\Pages\CreateRestoreRun;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\RestoreRun;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
putenv('INTUNE_TENANT_ID');
unset($_ENV['INTUNE_TENANT_ID'], $_SERVER['INTUNE_TENANT_ID']);
});
test('restore wizard generates a normalized preview diff summary and persists it', function () {
$tenant = Tenant::create([
'tenant_id' => 'tenant-1',
'name' => 'Tenant One',
'metadata' => [],
]);
$tenant->makeCurrent();
$policy = Policy::create([
'tenant_id' => $tenant->id,
'external_id' => 'policy-1',
'policy_type' => 'deviceConfiguration',
'display_name' => 'Device Config Policy',
'platform' => 'windows',
]);
PolicyVersion::create([
'tenant_id' => $tenant->id,
'policy_id' => $policy->id,
'version_number' => 1,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
'captured_at' => now()->subDay(),
'snapshot' => [
'foo' => 'current',
],
'metadata' => [],
'assignments' => [],
'scope_tags' => [
'ids' => ['tag-2'],
'names' => ['Tag Two'],
],
]);
$backupSet = BackupSet::create([
'tenant_id' => $tenant->id,
'name' => 'Backup',
'status' => 'completed',
'item_count' => 1,
]);
BackupItem::create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'policy_id' => $policy->id,
'policy_identifier' => $policy->external_id,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
'captured_at' => now(),
'payload' => [
'foo' => 'backup',
],
'assignments' => [[
'target' => [
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
'groupId' => 'group-1',
],
'intent' => 'apply',
]],
'metadata' => [
'scope_tag_ids' => ['tag-1'],
'scope_tag_names' => ['Tag One'],
],
]);
$user = User::factory()->create();
$this->actingAs($user);
$component = Livewire::test(CreateRestoreRun::class)
->fillForm([
'backup_set_id' => $backupSet->id,
])
->goToNextWizardStep()
->goToNextWizardStep()
->goToNextWizardStep()
->callFormComponentAction('preview_diffs', 'run_restore_preview');
$summary = $component->get('data.preview_summary');
$diffs = $component->get('data.preview_diffs');
expect($summary)->toBeArray();
expect($summary['policies_total'] ?? null)->toBe(1);
expect($summary['policies_changed'] ?? null)->toBe(1);
expect($summary['assignments_changed'] ?? null)->toBe(1);
expect($summary['scope_tags_changed'] ?? null)->toBe(1);
expect($diffs)->toBeArray();
expect($diffs)->not->toBeEmpty();
$first = $diffs[0] ?? [];
expect($first)->toBeArray();
expect($first['action'] ?? null)->toBe('update');
expect($first['assignments_changed'] ?? null)->toBeTrue();
expect($first['scope_tags_changed'] ?? null)->toBeTrue();
expect($first['diff']['summary']['changed'] ?? null)->toBe(1);
$component
->goToNextWizardStep()
->call('create')
->assertHasNoFormErrors();
$run = RestoreRun::query()->latest('id')->first();
expect($run)->not->toBeNull();
expect($run->metadata)->toHaveKeys([
'preview_summary',
'preview_diffs',
'preview_ran_at',
]);
expect($run->metadata['preview_summary']['policies_changed'] ?? null)->toBe(1);
});

View File

@ -114,6 +114,7 @@
$component $component
->goToNextWizardStep() ->goToNextWizardStep()
->callFormComponentAction('preview_diffs', 'run_restore_preview')
->goToNextWizardStep() ->goToNextWizardStep()
->call('create') ->call('create')
->assertHasNoFormErrors(); ->assertHasNoFormErrors();

View File

@ -65,6 +65,7 @@
->fillForm([ ->fillForm([
'is_dry_run' => true, 'is_dry_run' => true,
]) ])
->callFormComponentAction('preview_diffs', 'run_restore_preview')
->goToNextWizardStep() ->goToNextWizardStep()
->call('create') ->call('create')
->assertHasNoFormErrors(); ->assertHasNoFormErrors();
@ -127,6 +128,7 @@
->goToNextWizardStep() ->goToNextWizardStep()
->goToNextWizardStep() ->goToNextWizardStep()
->set('data.is_dry_run', false) ->set('data.is_dry_run', false)
->callFormComponentAction('preview_diffs', 'run_restore_preview')
->goToNextWizardStep() ->goToNextWizardStep()
->call('create') ->call('create')
->assertHasNoFormErrors(); ->assertHasNoFormErrors();