[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 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" } try { New-Item -ItemType Directory -Path $testDirectory | Out-Null $legacyPort = Get-AvailableTcpPort $webPort = Get-AvailableTcpPort $legacyBaseUrl = "http://127.0.0.1:$legacyPort" $webBaseUrl = "http://127.0.0.1:$webPort" $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 $nodeExecutable -ArgumentList @('scripts/import-test-data.mjs', '--sqlite') -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() $nodeProcess = Start-TestProcess -FileName $nodeExecutable -ArgumentList @('server.mjs') -Environment $nodeEnvironment $projectPath = Join-Path $repositoryRoot 'src\Eis.Web\Eis.Web.csproj' $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 '