You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

245 lines
16 KiB

$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
}