[CmdletBinding()] param() $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest $repositoryRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) $smokeArtifactRoot = [System.IO.Path]::GetFullPath((Join-Path $repositoryRoot 'artifacts\smoke')) $testDirectory = Join-Path $smokeArtifactRoot ("eis-migration-smoke-{0}" -f [guid]::NewGuid().ToString('N')) $nodeProcess = $null $dotnetProcess = $null $nativeAuthProcess = $null function Get-AvailableTcpPort { $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0) try { $listener.Start() return ([System.Net.IPEndPoint] $listener.LocalEndpoint).Port } finally { $listener.Stop() } } function Start-TestProcess { param( [Parameter(Mandatory)] [string] $FileName, [Parameter(Mandatory)] [string[]] $ArgumentList, [Parameter(Mandatory)] [hashtable] $Environment ) $startInfo = [System.Diagnostics.ProcessStartInfo]::new() $startInfo.FileName = $FileName $startInfo.WorkingDirectory = $repositoryRoot $startInfo.UseShellExecute = $false $startInfo.CreateNoWindow = $true $startInfo.RedirectStandardOutput = $true $startInfo.RedirectStandardError = $true foreach ($argument in $ArgumentList) { $startInfo.ArgumentList.Add($argument) } foreach ($entry in $Environment.GetEnumerator()) { $startInfo.Environment[$entry.Key] = [string] $entry.Value } return [System.Diagnostics.Process]::Start($startInfo) } function Wait-ForUrl { param( [Parameter(Mandatory)] [uri] $Uri, [Parameter(Mandatory)] [System.Diagnostics.Process[]] $Processes ) $deadline = [DateTimeOffset]::UtcNow.AddSeconds(25) while ([DateTimeOffset]::UtcNow -lt $deadline) { foreach ($process in $Processes) { if ($process.HasExited) { $standardOutput = $process.StandardOutput.ReadToEnd() $standardError = $process.StandardError.ReadToEnd() throw "Process $($process.Id) exited with code $($process.ExitCode) before $Uri became ready`n$standardOutput`n$standardError" } } try { $response = Invoke-WebRequest -Uri $Uri -TimeoutSec 2 if ($response.StatusCode -eq 200) { return } } catch { Start-Sleep -Milliseconds 250 } } throw "Timed out waiting for $Uri" } function Assert-JsonEquivalent { param( [Parameter(Mandatory)] [string] $Expected, [Parameter(Mandatory)] [string] $Actual, [Parameter(Mandatory)] [string] $Label ) $expectedNode = [System.Text.Json.Nodes.JsonNode]::Parse($Expected) $actualNode = [System.Text.Json.Nodes.JsonNode]::Parse($Actual) if (-not [System.Text.Json.Nodes.JsonNode]::DeepEquals($expectedNode, $actualNode)) { $difference = Find-JsonDifference -Expected $expectedNode -Actual $actualNode -Path '$' throw "$Label JSON payload differs from the legacy API at $difference" } } function Assert-CandidateResultsEquivalent { param( [Parameter(Mandatory)] [string] $Expected, [Parameter(Mandatory)] [string] $Actual ) $expectedNode = [System.Text.Json.Nodes.JsonNode]::Parse($Expected) $actualNode = [System.Text.Json.Nodes.JsonNode]::Parse($Actual) foreach ($node in @($expectedNode, $actualNode)) { foreach ($summary in $node['summaries'].AsArray()) { $summary.AsObject().Remove('verificationQr') | Out-Null } } if (-not [System.Text.Json.Nodes.JsonNode]::DeepEquals($expectedNode, $actualNode)) { $difference = Find-JsonDifference -Expected $expectedNode -Actual $actualNode -Path '$' throw "Candidate route 'results' JSON payload differs from the legacy API at $difference" } } function Assert-CandidateAdmissionsEquivalent { param( [Parameter(Mandatory)] [string] $Expected, [Parameter(Mandatory)] [string] $Actual ) $expectedNode = [System.Text.Json.Nodes.JsonNode]::Parse($Expected) $actualNode = [System.Text.Json.Nodes.JsonNode]::Parse($Actual) foreach ($node in @($expectedNode, $actualNode)) { foreach ($admission in $node['admissions'].AsArray()) { $admission.AsObject().Remove('noticeVerificationQr') | Out-Null } } if (-not [System.Text.Json.Nodes.JsonNode]::DeepEquals($expectedNode, $actualNode)) { $difference = Find-JsonDifference -Expected $expectedNode -Actual $actualNode -Path '$' throw "Candidate route 'admissions' JSON payload differs from the legacy API at $difference" } } function Assert-ArrangementPreviewEquivalent { param( [Parameter(Mandatory)] [string] $Expected, [Parameter(Mandatory)] [string] $Actual ) $expectedNode = [System.Text.Json.Nodes.JsonNode]::Parse($Expected) $actualNode = [System.Text.Json.Nodes.JsonNode]::Parse($Actual) foreach ($node in @($expectedNode, $actualNode)) { foreach ($sample in $node['samples'].AsArray()) { $sample.AsObject().Remove('generatedAt') | Out-Null } } if (-not [System.Text.Json.Nodes.JsonNode]::DeepEquals($expectedNode, $actualNode)) { $difference = Find-JsonDifference -Expected $expectedNode -Actual $actualNode -Path '$' throw "Arrangement preview JSON payload differs from the legacy API at $difference" } } function Find-JsonDifference { param( [AllowNull()] [System.Text.Json.Nodes.JsonNode] $Expected, [AllowNull()] [System.Text.Json.Nodes.JsonNode] $Actual, [Parameter(Mandatory)] [string] $Path ) if ($null -eq $Expected -or $null -eq $Actual) { return "$Path (expected=$Expected, actual=$Actual)" } if ($Expected -is [System.Text.Json.Nodes.JsonObject] -and $Actual -is [System.Text.Json.Nodes.JsonObject]) { foreach ($entry in $Expected) { if (-not $Actual.ContainsKey($entry.Key)) { return "$Path.$($entry.Key) (missing from actual)" } if (-not [System.Text.Json.Nodes.JsonNode]::DeepEquals($entry.Value, $Actual[$entry.Key])) { return Find-JsonDifference -Expected $entry.Value -Actual $Actual[$entry.Key] -Path "$Path.$($entry.Key)" } } foreach ($entry in $Actual) { if (-not $Expected.ContainsKey($entry.Key)) { return "$Path.$($entry.Key) (unexpected in actual)" } } return "$Path (object values differ)" } if ($Expected -is [System.Text.Json.Nodes.JsonArray] -and $Actual -is [System.Text.Json.Nodes.JsonArray]) { if ($Expected.Count -ne $Actual.Count) { return "$Path.Count (expected=$($Expected.Count), actual=$($Actual.Count))" } for ($index = 0; $index -lt $Expected.Count; $index++) { if (-not [System.Text.Json.Nodes.JsonNode]::DeepEquals($Expected[$index], $Actual[$index])) { return Find-JsonDifference -Expected $Expected[$index] -Actual $Actual[$index] -Path "$Path[$index]" } } return "$Path (array values differ)" } return "$Path (expected=$($Expected.ToJsonString()), actual=$($Actual.ToJsonString()))" } try { New-Item -ItemType Directory -Path $testDirectory | Out-Null $legacyPort = Get-AvailableTcpPort $webPort = Get-AvailableTcpPort $nativeAuthPort = Get-AvailableTcpPort $legacyBaseUrl = "http://127.0.0.1:$legacyPort" $webBaseUrl = "http://127.0.0.1:$webPort" $nativeAuthBaseUrl = "http://127.0.0.1:$nativeAuthPort" $nodeExecutable = (Get-Command node.exe -ErrorAction Stop).Source $dotnetExecutable = (Get-Command dotnet.exe -ErrorAction Stop).Source $smokeDatabasePath = Join-Path $testDirectory 'smoke.sqlite' $nodeEnvironment = @{ NODE_ENV = 'test' HOST = '127.0.0.1' PORT = [string] $legacyPort DATABASE_CLIENT = 'sqlite' SQLITE_PATH = $smokeDatabasePath INITIAL_ADMIN_USERNAME = 'migration_admin' INITIAL_ADMIN_PASSWORD = 'Migration123!' INITIAL_ADMIN_DISPLAY_NAME = '迁移测试管理员' REDIS_URL = '' REDIS_SESSION_URL = '' TOTP_ENCRYPTION_KEY = 'migration-smoke-totp-key-32-characters-minimum' DOCUMENT_VERIFICATION_SECRET = 'migration-smoke-document-key-32-characters-minimum' } $seedProcess = Start-TestProcess -FileName $dotnetExecutable -ArgumentList @( 'run', '--project', 'src/Eis.Tools', '--', 'database', 'seed', '--sqlite', '--path', $smokeDatabasePath, '--confirm-target', $smokeDatabasePath ) -Environment $nodeEnvironment if (-not $seedProcess.WaitForExit(30000)) { $seedProcess.Kill($true) throw 'Timed out while preparing the migration smoke-test database' } $seedOutput = $seedProcess.StandardOutput.ReadToEnd() $seedError = $seedProcess.StandardError.ReadToEnd() if ($seedProcess.ExitCode -ne 0) { throw "Failed to prepare the migration smoke-test database`n$seedOutput`n$seedError" } $seedProcess.Dispose() $registrationSwitchProcess = Start-TestProcess -FileName $nodeExecutable -ArgumentList @( 'tests/helpers/enable-self-registration.mjs', $smokeDatabasePath ) -Environment $nodeEnvironment if (-not $registrationSwitchProcess.WaitForExit(10000)) { $registrationSwitchProcess.Kill($true) throw 'Timed out while enabling self-registration in the smoke-test database' } $registrationSwitchOutput = $registrationSwitchProcess.StandardOutput.ReadToEnd() $registrationSwitchError = $registrationSwitchProcess.StandardError.ReadToEnd() if ($registrationSwitchProcess.ExitCode -ne 0) { throw "Could not enable self-registration in the smoke-test database`n$registrationSwitchOutput`n$registrationSwitchError" } $registrationSwitchProcess.Dispose() $arrangementCapacityProcess = Start-TestProcess -FileName $nodeExecutable -ArgumentList @( 'tests/helpers/prepare-admin-arrangement-smoke.mjs', $smokeDatabasePath ) -Environment $nodeEnvironment if (-not $arrangementCapacityProcess.WaitForExit(10000)) { $arrangementCapacityProcess.Kill($true) throw 'Timed out while preparing arrangement capacity in the smoke-test database' } $arrangementCapacityOutput = $arrangementCapacityProcess.StandardOutput.ReadToEnd() $arrangementCapacityError = $arrangementCapacityProcess.StandardError.ReadToEnd() if ($arrangementCapacityProcess.ExitCode -ne 0) { throw "Could not prepare arrangement capacity in the smoke-test database`n$arrangementCapacityOutput`n$arrangementCapacityError" } $arrangementCapacityProcess.Dispose() $projectPath = Join-Path $repositoryRoot 'src\Eis.Web\Eis.Web.csproj' $buildProcess = Start-TestProcess -FileName $dotnetExecutable -ArgumentList @( 'build', $projectPath, '--configuration', 'Release' ) -Environment @{} if (-not $buildProcess.WaitForExit(60000)) { $buildProcess.Kill($true) throw 'Timed out while building the ASP.NET Core migration host' } $buildOutput = $buildProcess.StandardOutput.ReadToEnd() $buildError = $buildProcess.StandardError.ReadToEnd() if ($buildProcess.ExitCode -ne 0) { throw "Failed to build the ASP.NET Core migration host`n$buildOutput`n$buildError" } $buildProcess.Dispose() $nodeProcess = Start-TestProcess -FileName $nodeExecutable -ArgumentList @('server.mjs') -Environment $nodeEnvironment $dotnetProcess = Start-TestProcess -FileName $dotnetExecutable -ArgumentList @( 'run', '--project', $projectPath, '--configuration', 'Release', '--no-build', '--no-launch-profile', '--', '--urls', $webBaseUrl ) -Environment @{ ASPNETCORE_ENVIRONMENT = 'Development' LegacyNode__Enabled = 'true' LegacyNode__BaseUrl = $legacyBaseUrl DATABASE_CLIENT = 'sqlite' SQLITE_PATH = $smokeDatabasePath DOCUMENT_VERIFICATION_SECRET = 'migration-smoke-document-key-32-characters-minimum' } Wait-ForUrl -Uri "$webBaseUrl/health/live" -Processes @($nodeProcess, $dotnetProcess) Wait-ForUrl -Uri "$webBaseUrl/health/migration" -Processes @($nodeProcess, $dotnetProcess) $index = Invoke-WebRequest -Uri "$webBaseUrl/" if ($index.Content -notmatch '衡准') { throw 'ASP.NET Core did not serve the existing index.html' } $clientModule = Invoke-WebRequest -Uri "$webBaseUrl/src/client/api.mjs" if ($clientModule.Content -notmatch 'export function api') { throw 'ASP.NET Core did not serve the existing client module' } $homeResponse = Invoke-WebRequest -Uri "$webBaseUrl/api/public/home" if ($homeResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core') { throw 'Public home request did not use the native ASP.NET Core endpoint' } $homePayload = $homeResponse.Content | ConvertFrom-Json if ($homePayload.ok -ne $true) { throw 'Native public API did not return a valid JSON response' } $legacyHomePayload = Invoke-RestMethod -Uri "$legacyBaseUrl/api/public/home" if ($homePayload.stats.candidates -ne $legacyHomePayload.stats.candidates -or $homePayload.stats.exams -ne $legacyHomePayload.stats.exams -or $homePayload.stats.registrations -ne $legacyHomePayload.stats.registrations -or $homePayload.schools.Count -ne $legacyHomePayload.schools.Count -or $homePayload.classes.Count -ne $legacyHomePayload.classes.Count -or $homePayload.notices.Count -ne $legacyHomePayload.notices.Count) { throw 'Native public home aggregates do not match the legacy API' } foreach ($legacyExam in $legacyHomePayload.exams) { $nativeExam = $homePayload.exams | Where-Object id -eq $legacyExam.id | Select-Object -First 1 if ($null -eq $nativeExam -or $nativeExam.subjects.Count -ne $legacyExam.subjects.Count -or $nativeExam.totalScore -ne $legacyExam.totalScore -or $nativeExam.registrationCount -ne $legacyExam.registrationCount) { throw "Native public exam projection does not match the legacy API for $($legacyExam.id)" } } $announcementResponse = Invoke-WebRequest -Uri "$webBaseUrl/api/public/announcements" if ($announcementResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core') { throw 'Public announcements request did not use the native ASP.NET Core endpoint' } $nativeAnnouncements = $announcementResponse.Content | ConvertFrom-Json $legacyAnnouncements = Invoke-RestMethod -Uri "$legacyBaseUrl/api/public/announcements" foreach ($section in @('plans', 'qualifications', 'admissions', 'cutoffs', 'reports')) { $nativeIds = @($nativeAnnouncements.$section | ForEach-Object id) $legacyIds = @($legacyAnnouncements.$section | ForEach-Object id) if (($nativeIds -join "`0") -ne ($legacyIds -join "`0")) { throw "Native public announcement section '$section' does not match the legacy API" } $nativeSectionJson = $nativeAnnouncements.$section | ConvertTo-Json -Depth 100 -Compress $legacySectionJson = $legacyAnnouncements.$section | ConvertTo-Json -Depth 100 -Compress if ($nativeSectionJson -ne $legacySectionJson) { throw "Native public announcement payload '$section' differs from the legacy API" } } $session = [Microsoft.PowerShell.Commands.WebRequestSession]::new() $loginBody = @{ username = 'admin'; password = '12345678' } | ConvertTo-Json -Compress $login = Invoke-RestMethod -Uri "$webBaseUrl/api/auth/login" -Method Post -ContentType 'application/json' -Body $loginBody -WebSession $session if ($login.ok -ne $true -or $login.user.username -ne 'admin') { throw 'Legacy login API proxy did not preserve the response' } $currentUser = Invoke-RestMethod -Uri "$webBaseUrl/api/auth/me" -WebSession $session if ($currentUser.user.username -ne 'admin') { throw 'Legacy API proxy did not preserve the session cookie' } $noticeBody = @{ title = 'ASP.NET Core 迁移冒烟通知' content = '
原生公开读取验证
' category = '系统测试' pinned = $true status = 'published' } | ConvertTo-Json -Compress $createdNotice = Invoke-RestMethod -Uri "$webBaseUrl/api/admin/notices" -Method Post -ContentType 'application/json' -Body $noticeBody -WebSession $session $noticeResponse = Invoke-WebRequest -Uri "$webBaseUrl/api/public/notices/$($createdNotice.notice.id)" if ($noticeResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core') { throw 'Public notice request did not use the native ASP.NET Core endpoint' } $noticePayload = $noticeResponse.Content | ConvertFrom-Json if ($noticePayload.notice.title -ne 'ASP.NET Core 迁移冒烟通知' -or $noticePayload.notice.content -match '