<# .SYNOPSIS BakNRet 端到端验收:真实备份 -> 校验排除 -> 删源 -> 恢复 -> 逐字节对拍。 .DESCRIPTION 单元测试只验证函数行为,这个脚本验证整条链路真的能用: 1. 造一个含可排除内容的源目录(目录名故意带空格,顺带验证命令行引用); 2. 跑 Backup.ps1,断言退出码为 0、归档生成、manifest 记录正确; 3. 解压归档,断言被排除的内容确实不在里面; 4. 删掉源目录,跑 Restore.ps1,断言文件逐字节还原、被排除的内容没有被还原; 5. 断言 Backup -DryRun 与 Restore -DryRun 都不写盘; 6. 源路径不存在时记为 missing-source,而不是静默忽略。 全程只在临时目录里操作,不会碰到真实备份。 .EXAMPLE pwsh -File .\tests\Run-E2E.ps1 #> [CmdletBinding()] param( [string]$WorkRoot, [switch]$KeepWorkRoot ) $ErrorActionPreference = 'Stop' Import-Module (Join-Path $PSScriptRoot 'TestHelpers.psm1') -Force $projectRoot = Split-Path -Parent $PSScriptRoot $backupScript = Join-Path $projectRoot 'Backup.ps1' $restoreScript = Join-Path $projectRoot 'Restore.ps1' Import-Module (Join-Path $projectRoot 'Common.psm1') -Force Reset-TestResult if (-not $WorkRoot) { $WorkRoot = Join-Path $env:TEMP ('bnr-' + [guid]::NewGuid().ToString('N').Substring(0, 6)) } $sourceParent = Join-Path $WorkRoot 'src' $source = Join-Path $sourceParent 'My Code Space' # 名字带空格,专门压一下命令行引用 $backupDir = Join-Path $WorkRoot 'Backups' $listPath = Join-Path $WorkRoot 'list.txt' $dryBackupDir = Join-Path $WorkRoot 'Backups-dry' $verifyDir = Join-Path $WorkRoot 'verify' Write-Host "" Write-Host '== 端到端:备份 -> 排除 -> 删源 -> 恢复 -> 对拍 ==' -ForegroundColor Cyan Write-Host " 工作目录:$WorkRoot" # ============================================================================ # 1. 造数据 # ============================================================================ foreach ($dir in 'logs', 'sub', 'Cache') { New-Item -ItemType Directory -Path (Join-Path $source $dir) -Force | Out-Null } Set-Content -LiteralPath (Join-Path $source 'keep.txt') -Value 'keep-me' -Encoding UTF8 Set-Content -LiteralPath (Join-Path $source 'sub\b.txt') -Value 'keep-me-too' -Encoding UTF8 Set-Content -LiteralPath (Join-Path $source 'logs\a.log') -Value 'must-be-excluded' -Encoding UTF8 Set-Content -LiteralPath (Join-Path $source 'Cache\c.bin') -Value 'must-be-excluded-too' -Encoding UTF8 # 一个确定性随机二进制文件,用于逐字节对拍 $blob = New-Object byte[] 8192 (New-Object System.Random 42).NextBytes($blob) [System.IO.File]::WriteAllBytes((Join-Path $source 'blob.bin'), $blob) [System.IO.File]::WriteAllText($listPath, "# e2e`n$source :: logs\,!*Cache`n", [System.Text.UTF8Encoding]::new($false)) $expectedHashes = @{} foreach ($relative in 'keep.txt', 'sub\b.txt', 'blob.bin') { $expectedHashes[$relative] = (Get-FileHash -LiteralPath (Join-Path $source $relative) -Algorithm SHA256).Hash } # ============================================================================ # 2. 备份 # ============================================================================ & $backupScript -BackupListPath $listPath -BackupDir $backupDir -Force -QuietTool $backupExitCode = $LASTEXITCODE Test-Case '备份退出码为 0(旧实现会把成功的压缩判成失败)' { Assert-Equal 0 $backupExitCode } $archives = @(Get-ChildItem -LiteralPath $backupDir -File -Filter *.7z -ErrorAction SilentlyContinue) Test-Case '归档已生成' { Assert-Equal 1 $archives.Count Assert-True ($archives[0].Length -gt 0) '归档不应为空' } $manifestPath = Join-Path $backupDir 'manifest.json' Test-Case 'manifest 记录了条目、动作与校验结果' { Assert-FileExists $manifestPath $manifest = Read-BaknretManifest -Path $manifestPath Assert-Equal 1 $manifest.items.Count $record = $manifest.items[$archives[0].BaseName] Assert-True ($null -ne $record) 'manifest 里应有对应条目' Assert-Equal 'backed-up' $record.action Assert-Equal $true $record.verified Assert-Equal 0 $record.exitCode Assert-Equal $source $record.source Assert-True ($record.sourceFiles -ge 4) '源文件数应不少于 4' } Test-Case '备份过程写了日志文件' { $logDir = Join-Path $projectRoot 'logs' $logs = @(Get-ChildItem -LiteralPath $logDir -File -Filter 'backup-*.log' -ErrorAction SilentlyContinue) Assert-True ($logs.Count -gt 0) '应生成 backup-*.log' } # ============================================================================ # 3. 解压归档,验证排除真的生效 # ============================================================================ New-Item -ItemType Directory -Path $verifyDir -Force | Out-Null $sevenZip = Get-Command 7z -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source if ($sevenZip) { $null = Invoke-ExternalCommand -FilePath $sevenZip ` -ArgumentList @('x', '-bso0', '-bsp0', '-y', "-o$verifyDir", $archives[0].FullName) } Test-Case '归档里保留了应当保留的内容' { Assert-FileExists (Join-Path $verifyDir 'My Code Space\keep.txt') Assert-FileExists (Join-Path $verifyDir 'My Code Space\sub\b.txt') Assert-FileExists (Join-Path $verifyDir 'My Code Space\blob.bin') } Test-Case '归档里不含被排除的 logs\ 与 !*Cache 命中项' { Assert-FileMissing (Join-Path $verifyDir 'My Code Space\logs\a.log') 'logs\ 应被排除' Assert-FileMissing (Join-Path $verifyDir 'My Code Space\Cache\c.bin') '!*Cache 应命中 Cache 目录' } # ============================================================================ # 4. 删源后恢复,逐字节对拍 # ============================================================================ Remove-Item -LiteralPath $source -Recurse -Force Test-Case '源目录确实已被删除(保证下面的恢复不是空操作)' { Assert-FileMissing $source } & $restoreScript -BackupListPath $listPath -BackupDir $backupDir -Force $restoreExitCode = $LASTEXITCODE Test-Case '恢复退出码为 0' { Assert-Equal 0 $restoreExitCode } Test-Case '恢复出的文件与源逐字节一致' { foreach ($relative in $expectedHashes.Keys) { $restored = Join-Path $source $relative Assert-FileExists $restored Assert-Equal $expectedHashes[$relative] (Get-FileHash -LiteralPath $restored -Algorithm SHA256).Hash "对拍 $relative" } } Test-Case '被排除的内容没有被恢复出来' { Assert-FileMissing (Join-Path $source 'logs\a.log') Assert-FileMissing (Join-Path $source 'Cache\c.bin') } # ============================================================================ # 4.5 保护规则:有警告时不拿不完整的归档覆盖完整归档 # ============================================================================ $lockSource = Join-Path $sourceParent 'Locked Case' $lockBackupDir = Join-Path $WorkRoot 'Backups-lock' $lockList = Join-Path $WorkRoot 'lock.txt' New-Item -ItemType Directory -Path $lockSource -Force | Out-Null Set-Content -LiteralPath (Join-Path $lockSource 'a.txt') -Value 'aaa' -Encoding UTF8 [System.IO.File]::WriteAllText($lockList, "$lockSource`n", [System.Text.UTF8Encoding]::new($false)) # 第一轮:没有占用,归档是"干净"的 & $backupScript -BackupListPath $lockList -BackupDir $lockBackupDir -Force -QuietTool $cleanExitCode = $LASTEXITCODE $cleanArchive = Get-ChildItem -LiteralPath $lockBackupDir -File -Filter *.7z | Select-Object -First 1 $cleanSize = $cleanArchive.Length $lockManifestPath = Join-Path $lockBackupDir 'manifest.json' $cleanRecord = (Read-BaknretManifest -Path $lockManifestPath).items[$cleanArchive.BaseName] Test-Case '干净归档:退出码 0,manifest 记 warnings=false' { Assert-Equal 0 $cleanExitCode Assert-Equal 'backed-up' $cleanRecord.action Assert-Equal $false $cleanRecord.warnings } # 第二轮:独占锁住一个文件,真实制造"有文件读不到"的 7z 警告 Set-Content -LiteralPath (Join-Path $lockSource 'b.txt') -Value 'bbb' -Encoding UTF8 $lockedPath = Join-Path $lockSource 'locked.bin' Set-Content -LiteralPath $lockedPath -Value 'locked' -Encoding UTF8 $lockStream = [System.IO.File]::Open($lockedPath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::None) try { & $backupScript -BackupListPath $lockList -BackupDir $lockBackupDir -Force -QuietTool $warnExitCode = $LASTEXITCODE $afterArchive = Get-ChildItem -LiteralPath $lockBackupDir -File -Filter *.7z | Select-Object -First 1 $afterRecord = (Read-BaknretManifest -Path $lockManifestPath).items[$afterArchive.BaseName] Test-Case '有警告时拒绝覆盖完整归档:退出码 1,旧归档原样保留' { Assert-Equal 1 $warnExitCode Assert-Equal $cleanSize $afterArchive.Length '在位归档应与被保护的那份完全一致' Assert-Equal 'failed' $afterRecord.action Assert-Equal $false $afterRecord.warnings '在位归档仍然是完整的' Assert-Equal $true $afterRecord.attemptWarnings '这次尝试确实报了警告' } # 明确接受之后才允许覆盖 & $backupScript -BackupListPath $lockList -BackupDir $lockBackupDir -Force -QuietTool -AcceptWarnings $acceptExitCode = $LASTEXITCODE $acceptedRecord = (Read-BaknretManifest -Path $lockManifestPath).items[$afterArchive.BaseName] Test-Case '-AcceptWarnings 允许覆盖,并在 manifest 记 warnings=true' { Assert-Equal 0 $acceptExitCode Assert-Equal 'backed-up' $acceptedRecord.action Assert-Equal $true $acceptedRecord.warnings } } finally { $lockStream.Close() $lockStream.Dispose() } # ============================================================================ # 5. DryRun 不写盘 # ============================================================================ if (Test-Path -LiteralPath $source) { Remove-Item -LiteralPath $source -Recurse -Force } & $restoreScript -BackupListPath $listPath -BackupDir $backupDir -DryRun $dryRestoreExitCode = $LASTEXITCODE Test-Case 'Restore -DryRun:退出码 0、不创建目标目录' { Assert-Equal 0 $dryRestoreExitCode Assert-FileMissing $source } & $backupScript -BackupListPath $listPath -BackupDir $dryBackupDir -Force -QuietTool -DryRun $dryBackupExitCode = $LASTEXITCODE Test-Case 'Backup -DryRun:退出码 0、不写归档也不写 manifest' { Assert-Equal 0 $dryBackupExitCode if (Test-Path -LiteralPath $dryBackupDir) { $written = @(Get-ChildItem -LiteralPath $dryBackupDir -File -ErrorAction SilentlyContinue) Assert-Equal 0 $written.Count '试运行不应写入任何文件' } } # ============================================================================ # 6. 失败路径:源不存在时必须留下可核对的记录 # ============================================================================ $missingList = Join-Path $WorkRoot 'missing.txt' [System.IO.File]::WriteAllText($missingList, "Z:\definitely-not-here-12345`n", [System.Text.UTF8Encoding]::new($false)) & $backupScript -BackupListPath $missingList -BackupDir $backupDir -Force -QuietTool $missingExitCode = $LASTEXITCODE Test-Case '源路径不存在被记为 missing-source,退出码仍为 0(跳过不算失败)' { Assert-Equal 0 $missingExitCode $after = Read-BaknretManifest -Path $manifestPath $record = $after.items['definitely-not-here-12345_from_Z_'] Assert-True ($null -ne $record) 'manifest 里应有该条目的记录' Assert-Equal 'missing-source' $record.action } # ============================================================================ if ($KeepWorkRoot) { Write-Host "`n工作目录保留在:$WorkRoot" -ForegroundColor Yellow } else { Remove-Item -LiteralPath $WorkRoot -Recurse -Force -ErrorAction SilentlyContinue } $failed = Write-TestSummary -Title '端到端验收' if ($failed -gt 0) { exit 1 } exit 0