$ErrorActionPreference = 'Stop' Push-Location $PSScriptRoot $pass = 0 $fail = 0 $fixtureUsers = @() $fixturePhones = @() $fixtureAbilityUser = $null Add-Type -AssemblyName System.Net.Http function Pass([string]$name) { $script:pass++ Write-Output "PASS $name" } function Fail([string]$name, [string]$detail = '') { $script:fail++ Write-Output ("FAIL {0}{1}" -f $name, $(if ($detail) { " :: $detail" } else { '' })) } function Assert([bool]$condition, [string]$name, [string]$detail = '') { if ($condition) { Pass $name } else { Fail $name $detail } } function Sql([string]$query) { $output = $query | docker compose --env-file .env exec -T mysql sh -lc 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot -N -B mydreamjob' 2>$null if ($LASTEXITCODE -ne 0) { throw 'mysql query failed' } $rows = @($output | ForEach-Object { "$($_)".Trim() } | Where-Object { $_ -ne '' }) return ,$rows } function Rabbit { $output = docker compose --env-file .env exec -T rabbitmq rabbitmqctl list_queues name messages_ready messages_unacknowledged 2>$null if ($LASTEXITCODE -ne 0) { throw 'rabbitmq query failed' } $rows = @($output | ForEach-Object { "$($_)".Trim() } | Where-Object { $_ -ne '' }) return ,$rows } function Api([string]$method, [string]$path, [string]$token = $null, $body = $null) { $client = [System.Net.Http.HttpClient]::new() try { $request = [System.Net.Http.HttpRequestMessage]::new( [System.Net.Http.HttpMethod]::new($method), ('http://127.0.0.1:8080/api/v1' + $path)) $request.Headers.Add('X-Request-Id', ('v41-blackbox-' + [guid]::NewGuid().ToString('N'))) if ($token) { $request.Headers.Authorization = [System.Net.Http.Headers.AuthenticationHeaderValue]::new('Bearer', $token) } if ($null -ne $body) { $json = $body | ConvertTo-Json -Depth 30 -Compress $request.Content = [System.Net.Http.StringContent]::new($json, [Text.Encoding]::UTF8, 'application/json') } $response = $client.SendAsync($request).GetAwaiter().GetResult() $raw = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult() $parsed = $null if ($raw) { try { $parsed = $raw | ConvertFrom-Json } catch {} } return [pscustomobject]@{ Status = [int]$response.StatusCode; Json = $parsed; Raw = $raw } } finally { $client.Dispose() } } function Login([long]$userId) { $phone = (Sql "SELECT phone FROM users WHERE id=$userId LIMIT 1")[0] if (-not $phone) { throw "login user not found" } docker compose --env-file .env exec -T redis redis-cli DEL ("sms:rate:" + $phone) 2>$null | Out-Null $sent = Api 'POST' ("/auth/sms/send?phone=$phone&purpose=login") $null $null if ($sent.Status -ne 200) { throw 'sms send failed' } $code = (Sql "SELECT code FROM sms_verification_codes WHERE phone='$phone' AND purpose='login' ORDER BY id DESC LIMIT 1")[0] $login = Api 'POST' '/auth/login' $null @{ phone = $phone; code = $code; purpose = 'login' } if ($login.Status -ne 200 -or -not $login.Json.data.accessToken) { throw 'login failed' } return [string]$login.Json.data.accessToken } function NewFixtureStudent([string]$label, [bool]$withPreference) { $phone = '19' + (([DateTimeOffset]::Now.ToUnixTimeMilliseconds() % 1000000000) + $script:fixtureUsers.Count + 1).ToString('D9') $script:fixturePhones += $phone $sql = @" INSERT INTO users(phone,nickname,role,is_active,is_verified) VALUES ('$phone','$label','STUDENT',1,1); SELECT LAST_INSERT_ID(); "@ $id = [long]((Sql $sql)[0]) $script:fixtureUsers += $id Sql @" INSERT INTO user_roles(user_id,role) VALUES ($id,'STUDENT'); INSERT INTO student_profiles(user_id,location,education,graduation_year,skills_json) SELECT $id,j.city,'Bachelor',2026,'["$label","Java","Spring Boot","SQL"]' FROM jobs j WHERE j.id=1; INSERT INTO student_talent_discovery_preferences(student_user_id,enabled,version) VALUES ($id,1,0); "@ | Out-Null if ($withPreference) { Sql @" INSERT INTO student_preferences(student_user_id,expected_salary_min,expected_salary_max,preferred_cities,employment_types,preferred_industries,preferred_job_types,remote_allowed) SELECT $id,NULL,NULL,JSON_ARRAY(j.city),JSON_ARRAY(j.employment_type),JSON_ARRAY(p.industry_code),JSON_ARRAY(p.job_family_code),0 FROM jobs j JOIN job_recommendation_profiles p ON p.job_id=j.id WHERE j.id=1; "@ | Out-Null } return $id } function Cleanup { if (-not $script:fixtureUsers.Count) { return } $ids = $script:fixtureUsers -join ',' $phones = ($script:fixturePhones | ForEach-Object { "'$_'" }) -join ',' try { Sql "DELETE FROM audit_logs WHERE user_id IN ($ids) OR (action='SMS_SEND' AND request_id LIKE 'v41-blackbox-%')" | Out-Null } catch {} try { Sql "DELETE FROM ability_results WHERE attempt_id IN (SELECT id FROM ability_attempts WHERE student_user_id IN ($ids))" | Out-Null } catch {} try { Sql "DELETE FROM ability_attempts WHERE student_user_id IN ($ids)" | Out-Null } catch {} try { Sql "DELETE FROM applications WHERE student_user_id IN ($ids)" | Out-Null } catch {} try { Sql "DELETE FROM student_preferences WHERE student_user_id IN ($ids)" | Out-Null } catch {} try { Sql "DELETE FROM student_talent_discovery_preferences WHERE student_user_id IN ($ids)" | Out-Null } catch {} try { Sql "DELETE FROM student_profiles WHERE user_id IN ($ids)" | Out-Null } catch {} try { Sql "DELETE FROM account_deletions WHERE user_id IN ($ids)" | Out-Null } catch {} try { Sql "DELETE FROM user_roles WHERE user_id IN ($ids)" | Out-Null } catch {} try { Sql "DELETE FROM sms_verification_codes WHERE phone IN ($phones)" | Out-Null } catch {} try { Sql "DELETE FROM users WHERE id IN ($ids)" | Out-Null } catch {} } try { $flywayRow = (Sql "SELECT version,success FROM flyway_schema_history ORDER BY installed_rank DESC LIMIT 1")[0] $flyway = @($flywayRow -split '\s+') Assert ($flyway.Count -ge 2 -and $flyway[0] -eq '39' -and $flyway[1] -eq '1') 'Flyway V39 success' $table = Sql "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='mydreamjob' AND table_name='student_talent_discovery_preferences'" Assert ([int]$table[0] -eq 1) 'V39 table exists' $constraints = Sql "SELECT constraint_type FROM information_schema.table_constraints WHERE table_schema='mydreamjob' AND table_name='student_talent_discovery_preferences'" Assert (@($constraints | Where-Object { $_ -eq 'UNIQUE' }).Count -ge 1) 'UNIQUE student_user_id' $check = Sql "SELECT COUNT(*) FROM information_schema.table_constraints WHERE constraint_schema='mydreamjob' AND table_name='student_talent_discovery_preferences' AND constraint_type='CHECK'" Assert ([int]$check[0] -ge 1) 'CHECK enabled constraint' $health = Api 'GET' '/health' Assert ($health.Status -eq 200 -and $health.Json.data.status -eq 'UP') 'health 200' $docs = Api 'GET' '/v3/api-docs' $pathNames = @($docs.Json.paths.PSObject.Properties.Name) $opCount = 0 foreach ($property in $docs.Json.paths.PSObject.Properties) { $opCount += @($property.Value.PSObject.Properties.Name | Where-Object { $_ -in @('get','post','put','patch','delete','options','head','trace') }).Count } Assert ($pathNames.Count -eq 161 -and $opCount -eq 199) 'runtime OpenAPI 161/199' Assert ($pathNames -contains '/profile/me/talent-discovery' -and $pathNames -contains '/companies/me/jobs/{jobId}/talent-recommendations') 'V41 paths present' $baseScores = [int](Sql 'SELECT COUNT(*) FROM candidate_dimension_scores')[0] $a = NewFixtureStudent 'V41BB-A' $true $b = NewFixtureStudent 'V41BB-B' $true $c = NewFixtureStudent 'V41BB-C' $true $d = NewFixtureStudent 'V41BB-D' $false $e = NewFixtureStudent 'V41BB-E' $true $script:fixtureAbilityUser = $d Sql "INSERT INTO applications(job_id,student_user_id,status) VALUES (1,$b,'REJECTED'),(13,$c,'WITHDRAWN');" | Out-Null Sql "INSERT INTO account_deletions(user_id,status,requested_at,version) VALUES ($e,'PENDING',NOW(),0);" | Out-Null Sql @" INSERT INTO ability_attempts(student_user_id,definition_id,status,question_set_version,answered_count,active_key,idempotency_key,completed_at,submitted_at,started_at) VALUES ($d,1,'COMPLETED','ability-v0.1',24,NULL,NULL,DATE_SUB(NOW(),INTERVAL 2 DAY),DATE_SUB(NOW(),INTERVAL 2 DAY),DATE_SUB(NOW(),INTERVAL 2 DAY)); SET @high_attempt=LAST_INSERT_ID(); INSERT INTO ability_results(attempt_id,dimension_scores_json,overall_score,valid_until,question_set_version,rules_version,generated_at) VALUES (@high_attempt,'[{"code":"LOGIC","score":100,"answeredCount":6},{"code":"COMMUNICATION","score":100,"answeredCount":6},{"code":"EXECUTION","score":100,"answeredCount":6},{"code":"LEARNING","score":100,"answeredCount":6}]',100,DATE_ADD(NOW(),INTERVAL 7 DAY),'ability-v0.1','ability-v0.1',NOW()); INSERT INTO ability_attempts(student_user_id,definition_id,status,question_set_version,answered_count,active_key,idempotency_key,completed_at,submitted_at,started_at) VALUES ($d,1,'COMPLETED','ability-v0.1',24,NULL,NULL,DATE_SUB(NOW(),INTERVAL 1 HOUR),DATE_SUB(NOW(),INTERVAL 1 HOUR),DATE_SUB(NOW(),INTERVAL 1 HOUR)); SET @low_attempt=LAST_INSERT_ID(); INSERT INTO ability_results(attempt_id,dimension_scores_json,overall_score,valid_until,question_set_version,rules_version,generated_at) VALUES (@low_attempt,'[{"code":"LOGIC","score":0,"answeredCount":6},{"code":"COMMUNICATION","score":0,"answeredCount":6},{"code":"EXECUTION","score":0,"answeredCount":6},{"code":"LEARNING","score":0,"answeredCount":6}]',0,DATE_ADD(NOW(),INTERVAL 7 DAY),'ability-v0.1','ability-v0.1',NOW()); "@ | Out-Null $tokenA = Login $a $tokenB = Login $b $tokenC = Login $c $tokenD = Login $d $tokenCompany = Login 3 $tokenAdmin = Login 1 $tokenUnverified = Login 84 $pref = Api 'GET' '/profile/me/talent-discovery' $tokenA Assert ($pref.Status -eq 200 -and $pref.Json.data.enabled -eq $true -and [int]$pref.Json.data.version -eq 0) 'student preference GET' $updated = Api 'PUT' '/profile/me/talent-discovery' $tokenA @{ enabled = $false; expectedVersion = 0 } Assert ($updated.Status -eq 200 -and $updated.Json.data.enabled -eq $false -and [int]$updated.Json.data.version -eq 1) 'student preference update/version' $stale = Api 'PUT' '/profile/me/talent-discovery' $tokenA @{ enabled = $true; expectedVersion = 0 } Assert ($stale.Status -eq 409 -and [int]$stale.Json.code -eq 9201) 'stale preference conflict 9201' $restored = Api 'PUT' '/profile/me/talent-discovery' $tokenA @{ enabled = $true; expectedVersion = 1 } Assert ($restored.Status -eq 200 -and $restored.Json.data.enabled -eq $true -and [int]$restored.Json.data.version -eq 2) 'preference restored' $studentForbidden = Api 'GET' '/profile/me/talent-discovery' $tokenCompany Assert ($studentForbidden.Status -eq 403 -and [int]$studentForbidden.Json.code -eq 2002) 'company denied student preference' $anon = Api 'GET' '/profile/me/talent-discovery' Assert ($anon.Status -eq 401) 'anonymous denied preference' $unavailable = Api 'GET' '/companies/me/jobs/7/talent-recommendations?page=0&size=20' $tokenCompany Assert ($unavailable.Status -eq 200 -and $unavailable.Json.data.mode -eq 'UNAVAILABLE' -and [int]$unavailable.Json.data.total -eq 0) 'unavailable job profile' $page = Api 'GET' '/companies/me/jobs/1/talent-recommendations?page=0&size=50' $tokenCompany Assert ($page.Status -eq 200 -and $page.Json.data.mode -eq 'PERSONALIZED') 'personalized recommendations' $items = @($page.Json.data.items) $idsInRaw = $page.Raw Assert ($items.Count -ge 3 -and [int]$page.Json.data.total -ge 3) 'eligible candidates returned' $keys = @($items | ForEach-Object { $_.candidateKey }) Assert ($keys -contains $null -eq $false -and ($keys | Select-Object -Unique).Count -eq $keys.Count) 'opaque candidate keys' Assert (-not ($idsInRaw -match 'studentUserId|candidateId|fitScore|dimensionScores|overallScore|attemptId|resultId|answerKey|scoringRule|questionCode|competencyTag|resumeUrl|phone|email|gender|birthDate|password|token')) 'candidate JSON redacted' $bands = @($items | ForEach-Object { $_.fitBand }) Assert ($bands -contains 'BASIC') 'latest low ability beats historical high (BASIC)' $signals = @($items | ForEach-Object { $_.signalsAvailable -join ',' }) Assert (($signals -contains 'PREFERENCE') -and ($signals -contains 'ABILITY')) 'signals available' $currentRaw = ($items | ConvertTo-Json -Depth 20 -Compress) $skillText = ($items | ForEach-Object { $_.skills -join ',' }) -join ',' Assert ($skillText -match 'V41BB-A' -and $skillText -match 'V41BB-C' -and $skillText -match 'V41BB-D' -and $skillText -notmatch 'V41BB-B') 'same-company application excluded; other-company retained' Assert ($skillText -notmatch 'V41BB-E') 'pending account deletion excluded' $dCard = @($items | Where-Object { ($_.skills -join ',') -match 'V41BB-D' })[0] Assert ($null -ne $dCard -and ($dCard.signalsAvailable -join ',') -eq 'ABILITY' -and $dCard.fitBand -eq 'BASIC') 'latest low ability selected over historical high' $p0 = Api 'GET' '/companies/me/jobs/1/talent-recommendations?page=0&size=1' $tokenCompany $pLast = Api 'GET' ("/companies/me/jobs/1/talent-recommendations?page=" + ([int]$page.Json.data.total - 1) + '&size=1') $tokenCompany Assert ($p0.Status -eq 200 -and $pLast.Status -eq 200 -and $p0.Json.data.items[0].fitBand -ne 'BASIC' -and $pLast.Json.data.items[0].fitBand -eq 'BASIC') 'global sort before pagination' $afterScores = [int](Sql 'SELECT COUNT(*) FROM candidate_dimension_scores')[0] Assert ($afterScores -eq $baseScores) 'V41 does not write candidate_dimension_scores' $countB = [int](Sql "SELECT COUNT(*) FROM applications WHERE student_user_id=$b AND job_id=1")[0] $countC = [int](Sql "SELECT COUNT(*) FROM applications WHERE student_user_id=$c AND job_id=13")[0] Assert ($countB -eq 1 -and $countC -eq 1) 'fixture historical applications recorded' $studentCompany = Api 'GET' '/companies/me/jobs/1/talent-recommendations' $tokenA Assert ($studentCompany.Status -eq 403 -and [int]$studentCompany.Json.code -eq 2002) 'student denied company recommendations' $adminCompany = Api 'GET' '/companies/me/jobs/1/talent-recommendations' $tokenAdmin Assert ($adminCompany.Status -eq 403 -and [int]$adminCompany.Json.code -eq 2002) 'admin denied company recommendations' $cross = Api 'GET' '/companies/me/jobs/13/talent-recommendations' $tokenCompany Assert ($cross.Status -eq 403 -and [int]$cross.Json.code -eq 2002) 'cross-company job denied' $unverified = Api 'GET' '/companies/me/jobs/13/talent-recommendations' $tokenUnverified Assert ($unverified.Status -eq 403 -and [int]$unverified.Json.code -eq 2002) 'unverified company denied' $anonCompany = Api 'GET' '/companies/me/jobs/1/talent-recommendations' Assert ($anonCompany.Status -eq 401) 'anonymous denied company recommendations' $badPage = Api 'GET' '/companies/me/jobs/1/talent-recommendations?page=-1&size=51' $tokenCompany Assert ($badPage.Status -eq 400 -and [int]$badPage.Json.code -in @(400,1001)) 'page/size validation' $legacy = Api 'GET' '/profile/me/job-recommendations?page=0&size=1' $tokenA Assert ($legacy.Status -eq 200) 'V39 job recommendation regression' $queues = Rabbit $known = @('queue.notification','queue.audit','queue.notification.dlq') foreach ($q in $known) { $line = @($queues | Where-Object { $_ -match "^$q\s" }) if ($line.Count -eq 0) { Fail "Rabbit queue exists $q"; continue } $fields = ($line[0] -split '\s+') Assert ([int]$fields[-2] -eq 0 -and [int]$fields[-1] -eq 0) "Rabbit queue drained $q" } $constraintRow = Sql "SELECT COUNT(*) FROM student_talent_discovery_preferences WHERE student_user_id=$a AND enabled=1" Assert ([int]$constraintRow[0] -eq 1) 'preference persisted in MySQL' Write-Output ("V41_BLACKBOX_PASS={0} FAIL={1}" -f $pass, $fail) if ($fail -gt 0) { exit 1 } } catch { Fail 'blackbox execution' $_.Exception.Message Write-Output ("V41_BLACKBOX_PASS={0} FAIL={1}" -f $pass, $fail) exit 1 } finally { Cleanup Pop-Location }