chore: establish local platform baseline
This commit is contained in:
@@ -0,0 +1,506 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$PSNativeCommandUseErrorActionPreference = $true
|
||||
|
||||
$projectRoot = Split-Path -Parent $PSScriptRoot
|
||||
Set-Location -LiteralPath $projectRoot
|
||||
|
||||
$apiBaseUrl = 'http://localhost:58080'
|
||||
$deviceId = '10000000-0000-0000-0000-000000000511'
|
||||
$deviceSecret = [Text.Encoding]::UTF8.GetBytes('local-simulated-device-secret-32-bytes-minimum')
|
||||
$conversationId = $null
|
||||
$memoryConversationId = $null
|
||||
$disabledMemoryConversationId = $null
|
||||
$deletedMemoryConversationId = $null
|
||||
$tokenUrl = 'http://localhost:58180/realms/voice-local/protocol/openid-connect/token'
|
||||
|
||||
function Get-LocalAccessToken([string]$Username) {
|
||||
return (Invoke-RestMethod -Method Post -Uri $tokenUrl -ContentType 'application/x-www-form-urlencoded' -Body @{
|
||||
grant_type = 'password'; client_id = 'voice-platform-web'; username = $Username
|
||||
password = 'LocalOnly123!'; scope = 'openid'
|
||||
}).access_token
|
||||
}
|
||||
|
||||
function Assert-Equal([object]$Actual, [object]$Expected, [string]$Message) {
|
||||
if ([string]$Actual -ne [string]$Expected) {
|
||||
throw "$Message。实际值:$Actual;预期值:$Expected"
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-SignedPost([string]$Path, [string]$Body, [string]$IdempotencyKey) {
|
||||
$bodyBytes = [Text.Encoding]::UTF8.GetBytes($Body)
|
||||
$digest = [Convert]::ToHexString(
|
||||
[Security.Cryptography.SHA256]::HashData($bodyBytes)
|
||||
).ToLowerInvariant()
|
||||
$timestamp = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ')
|
||||
$nonce = [Guid]::NewGuid().ToString('N')
|
||||
$canonical = "POST`n$Path`n$timestamp`n$nonce`n$digest"
|
||||
$hmac = [Security.Cryptography.HMACSHA256]::new($deviceSecret)
|
||||
try {
|
||||
$signature = [Convert]::ToBase64String(
|
||||
$hmac.ComputeHash([Text.Encoding]::UTF8.GetBytes($canonical))
|
||||
).TrimEnd('=').Replace('+', '-').Replace('/', '_')
|
||||
} finally {
|
||||
$hmac.Dispose()
|
||||
}
|
||||
|
||||
$headers = @{
|
||||
'X-Device-Id' = $deviceId
|
||||
'X-Device-Timestamp' = $timestamp
|
||||
'X-Device-Nonce' = $nonce
|
||||
'X-Device-Content-SHA256' = $digest
|
||||
'X-Device-Signature' = $signature
|
||||
}
|
||||
if ($IdempotencyKey) {
|
||||
$headers['Idempotency-Key'] = $IdempotencyKey
|
||||
}
|
||||
return Invoke-WebRequest -UseBasicParsing -Method Post -Uri ($apiBaseUrl + $Path) `
|
||||
-ContentType 'application/json' -Body $Body -Headers $headers
|
||||
}
|
||||
|
||||
function Invoke-ScalarQuery([string]$Sql) {
|
||||
$value = docker compose exec -T postgres psql -U voice -d voice_platform `
|
||||
-v ON_ERROR_STOP=1 -t -A -c $Sql
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw '数据库断言查询失败。'
|
||||
}
|
||||
return ($value | Out-String).Trim()
|
||||
}
|
||||
|
||||
function Remove-SmokeConversation([string]$PublicId) {
|
||||
if ($PublicId -notmatch '^[0-9a-fA-F-]{36}$') {
|
||||
throw '拒绝清理格式不正确的通话编号。'
|
||||
}
|
||||
$cleanupSql = @"
|
||||
BEGIN;
|
||||
CREATE TEMP TABLE smoke_cleanup AS
|
||||
SELECT id, public_id, device_id, context_snapshot_id, consent_snapshot_id
|
||||
FROM conversation WHERE public_id = '$PublicId'::uuid;
|
||||
CREATE TEMP TABLE smoke_story_cleanup AS
|
||||
SELECT id, public_id, current_revision_id FROM story_draft WHERE conversation_id IN (SELECT id FROM smoke_cleanup);
|
||||
CREATE TEMP TABLE smoke_memory_cleanup AS
|
||||
SELECT DISTINCT cm.id, cm.public_id, cm.current_version_id
|
||||
FROM curated_memory cm
|
||||
JOIN memory_version mv ON mv.memory_id=cm.id
|
||||
WHERE mv.source_refs @> ('[{"type":"story","story_id":"' ||
|
||||
(SELECT sd.public_id::text FROM story_draft sd WHERE sd.id IN (SELECT id FROM smoke_story_cleanup) LIMIT 1) || '"}]')::jsonb;
|
||||
DELETE FROM outbox_event WHERE aggregate_id IN (SELECT id FROM smoke_memory_cleanup);
|
||||
DELETE FROM audit_event WHERE resource_id IN (SELECT public_id FROM smoke_memory_cleanup);
|
||||
DELETE FROM audit_event WHERE resource_id IN (SELECT public_id FROM smoke_story_cleanup);
|
||||
DELETE FROM web_event WHERE resource_public_id IN (SELECT public_id FROM smoke_story_cleanup UNION SELECT public_id FROM smoke_cleanup)
|
||||
OR resource_public_id IN (SELECT public_id FROM device WHERE id IN (SELECT device_id FROM smoke_cleanup));
|
||||
DELETE FROM device_presence_state WHERE device_id IN (SELECT device_id FROM smoke_cleanup);
|
||||
DELETE FROM memory_index_entry WHERE memory_id IN (SELECT id FROM smoke_memory_cleanup);
|
||||
UPDATE curated_memory SET current_version_id=NULL WHERE id IN (SELECT id FROM smoke_memory_cleanup);
|
||||
DELETE FROM memory_version WHERE memory_id IN (SELECT id FROM smoke_memory_cleanup);
|
||||
DELETE FROM curated_memory WHERE id IN (SELECT id FROM smoke_memory_cleanup);
|
||||
DELETE FROM fact_candidate WHERE story_id IN (SELECT id FROM smoke_story_cleanup);
|
||||
DELETE FROM story_source WHERE story_revision_id IN (SELECT id FROM story_revision WHERE story_id IN (SELECT id FROM smoke_story_cleanup));
|
||||
UPDATE story_draft SET current_revision_id=NULL WHERE id IN (SELECT id FROM smoke_story_cleanup);
|
||||
DELETE FROM story_revision WHERE story_id IN (SELECT id FROM smoke_story_cleanup);
|
||||
DELETE FROM story_draft WHERE id IN (SELECT id FROM smoke_story_cleanup);
|
||||
DELETE FROM conversation_summary WHERE conversation_id IN (SELECT id FROM smoke_cleanup);
|
||||
DELETE FROM conversation_turn WHERE conversation_id IN (SELECT id FROM smoke_cleanup);
|
||||
DELETE FROM provider_event WHERE conversation_id IN (SELECT id FROM smoke_cleanup);
|
||||
DELETE FROM conversation_state_history WHERE conversation_id IN (SELECT id FROM smoke_cleanup);
|
||||
DELETE FROM rtc_task WHERE conversation_id IN (SELECT id FROM smoke_cleanup);
|
||||
DELETE FROM outbox_event WHERE aggregate_id IN (SELECT id FROM smoke_cleanup);
|
||||
DELETE FROM audit_event WHERE resource_id IN (SELECT id FROM smoke_cleanup);
|
||||
DELETE FROM idempotency_record WHERE resource_id IN (SELECT id FROM smoke_cleanup);
|
||||
DELETE FROM conversation WHERE id IN (SELECT id FROM smoke_cleanup);
|
||||
DELETE FROM context_snapshot WHERE id IN (SELECT context_snapshot_id FROM smoke_cleanup);
|
||||
DELETE FROM consent_snapshot WHERE id IN (SELECT consent_snapshot_id FROM smoke_cleanup);
|
||||
COMMIT;
|
||||
"@
|
||||
docker compose exec -T postgres psql -U voice -d voice_platform `
|
||||
-v ON_ERROR_STOP=1 -q -c $cleanupSql | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw '冒烟数据清理失败。'
|
||||
}
|
||||
docker compose exec -T redis redis-cli DEL `
|
||||
"voice-platform:conversation:lease:$PublicId" `
|
||||
"voice-platform:device:presence:$deviceId" | Out-Null
|
||||
}
|
||||
|
||||
try {
|
||||
foreach ($healthUrl in @(
|
||||
'http://localhost:58080/health/live',
|
||||
'http://localhost:58081/health/live',
|
||||
'http://localhost:58082/health/live',
|
||||
'http://localhost:59200/health/live'
|
||||
)) {
|
||||
$health = Invoke-WebRequest -UseBasicParsing -Uri $healthUrl
|
||||
Assert-Equal $health.StatusCode 200 "$healthUrl 存活检查失败"
|
||||
}
|
||||
|
||||
$createBody = @{
|
||||
device_time = [DateTime]::UtcNow.ToString('o')
|
||||
monotonic_ms = 12345
|
||||
capabilities = @{
|
||||
rtc = $true
|
||||
audio_tracks = @('near', 'far')
|
||||
upload_resume = $true
|
||||
}
|
||||
network = @{ type = 'wifi'; rssi = -48 }
|
||||
requested_goal = 'open_conversation'
|
||||
} | ConvertTo-Json -Compress -Depth 5
|
||||
$createKey = 'smoke-create-' + [Guid]::NewGuid().ToString('N')
|
||||
$createResponse = Invoke-SignedPost '/device/v2/conversations' $createBody $createKey
|
||||
Assert-Equal $createResponse.StatusCode 201 '通话创建失败'
|
||||
$created = ($createResponse.Content | ConvertFrom-Json).data
|
||||
$conversationId = [string]$created.conversation_id
|
||||
if ($conversationId -notmatch '^[0-9a-fA-F-]{36}$') {
|
||||
throw "服务端返回了格式不正确的通话编号:$conversationId"
|
||||
}
|
||||
|
||||
$activationPath = "/device/v2/conversations/$conversationId/activate"
|
||||
$activationBody = @{
|
||||
joined_at = [DateTime]::UtcNow.ToString('o')
|
||||
room_id = $created.rtc.room_id
|
||||
rtc_user_id = $created.rtc.user_id
|
||||
rtc_join_result = 'success'
|
||||
config_revision = 1
|
||||
connection_reference = 'local-e2e-smoke'
|
||||
} | ConvertTo-Json -Compress
|
||||
$activationKey = 'smoke-activate-' + [Guid]::NewGuid().ToString('N')
|
||||
$activationResponse = Invoke-SignedPost $activationPath $activationBody $activationKey
|
||||
Assert-Equal $activationResponse.StatusCode 202 '首次激活失败'
|
||||
Assert-Equal (($activationResponse.Content | ConvertFrom-Json).data.status) 'STARTING_AGENT' '首次激活状态不正确'
|
||||
|
||||
$replayedResponse = Invoke-SignedPost $activationPath $activationBody $activationKey
|
||||
Assert-Equal $replayedResponse.StatusCode 202 '激活幂等重放失败'
|
||||
Assert-Equal $replayedResponse.Headers['Idempotency-Replayed'][0] 'true' '激活响应缺少幂等重放标记'
|
||||
|
||||
$deadline = [DateTime]::UtcNow.AddSeconds(15)
|
||||
do {
|
||||
Start-Sleep -Milliseconds 500
|
||||
$currentState = Invoke-ScalarQuery "SELECT status FROM conversation WHERE public_id='$conversationId'::uuid;"
|
||||
} while ($currentState -ne 'ACTIVE' -and [DateTime]::UtcNow -lt $deadline)
|
||||
Assert-Equal $currentState 'ACTIVE' '供应商启动回调未使通话进入ACTIVE'
|
||||
|
||||
$heartbeatPath = "/device/v2/conversations/$conversationId/heartbeat"
|
||||
$heartbeatBody = @{
|
||||
sequence_no = 2
|
||||
device_time = [DateTime]::UtcNow.ToString('o')
|
||||
monotonic_ms = 20000
|
||||
conversation_status = 'ACTIVE'
|
||||
rtc = @{ joined = $true; uplink = $true; downlink = $true }
|
||||
network = @{ type = 'wifi'; rssi = -51 }
|
||||
firmware_version = 'local-smoke-1.0.0'
|
||||
reported_config_revision = 1
|
||||
rtc_credential_expires_at = [string]$created.rtc.expires_at
|
||||
recording_queue = @{ pending_segments = 2; failed_segments = 0 }
|
||||
command_results = @()
|
||||
} | ConvertTo-Json -Compress -Depth 5
|
||||
$heartbeatResponse = Invoke-SignedPost $heartbeatPath $heartbeatBody ''
|
||||
Assert-Equal $heartbeatResponse.StatusCode 200 '通话心跳失败'
|
||||
$heartbeatData = ($heartbeatResponse.Content | ConvertFrom-Json).data
|
||||
Assert-Equal $heartbeatData.actions[0].type 'continue' 'ACTIVE心跳没有返回continue动作'
|
||||
$originalLease = Invoke-ScalarQuery "SELECT lease_expires_at::text FROM conversation WHERE public_id='$conversationId'::uuid;"
|
||||
|
||||
$staleHeartbeatBody = $heartbeatBody | ConvertFrom-Json
|
||||
$staleHeartbeatBody.sequence_no = 1
|
||||
$staleHeartbeatBody.device_time = [DateTime]::UtcNow.ToString('o')
|
||||
$staleHeartbeatResponse = Invoke-SignedPost $heartbeatPath ($staleHeartbeatBody | ConvertTo-Json -Compress -Depth 5) ''
|
||||
Assert-Equal $staleHeartbeatResponse.StatusCode 200 '乱序心跳没有安全返回'
|
||||
$leaseAfterStale = Invoke-ScalarQuery "SELECT lease_expires_at::text FROM conversation WHERE public_id='$conversationId'::uuid;"
|
||||
Assert-Equal $leaseAfterStale $originalLease '乱序心跳错误延长了数据库租约'
|
||||
$cachedSequence = docker compose exec -T redis redis-cli --raw HGET `
|
||||
"voice-platform:conversation:lease:$conversationId" sequence_no
|
||||
Assert-Equal (($cachedSequence | Out-String).Trim()) 2 'Redis租约心跳序号不正确'
|
||||
|
||||
$repeatResponse = Invoke-SignedPost $activationPath $activationBody `
|
||||
('smoke-activate-repeat-' + [Guid]::NewGuid().ToString('N'))
|
||||
Assert-Equal $repeatResponse.StatusCode 202 'ACTIVE状态下重复确认失败'
|
||||
Assert-Equal (($repeatResponse.Content | ConvertFrom-Json).data.status) 'ACTIVE' '重复确认导致状态回退'
|
||||
|
||||
$stateEvidence = Invoke-ScalarQuery @"
|
||||
SELECT c.status || '|' || rt.status || '|' || rt.start_attempts
|
||||
FROM conversation c JOIN rtc_task rt ON rt.conversation_id=c.id
|
||||
WHERE c.public_id='$conversationId'::uuid;
|
||||
"@
|
||||
Assert-Equal $stateEvidence 'ACTIVE|ACTIVE|1' '通话、RTC任务或供应商调用次数不正确'
|
||||
|
||||
$historyCount = Invoke-ScalarQuery @"
|
||||
SELECT count(*) FROM conversation_state_history h
|
||||
JOIN conversation c ON c.id=h.conversation_id
|
||||
WHERE c.public_id='$conversationId'::uuid;
|
||||
"@
|
||||
Assert-Equal $historyCount 4 '通话状态历史不完整或出现重复迁移'
|
||||
|
||||
$providerEventCount = Invoke-ScalarQuery @"
|
||||
SELECT count(*) FROM provider_event pe
|
||||
JOIN conversation c ON c.id=pe.conversation_id
|
||||
WHERE c.public_id='$conversationId'::uuid
|
||||
AND pe.event_type='voice_chat.started'
|
||||
AND pe.processing_status='processed';
|
||||
"@
|
||||
Assert-Equal $providerEventCount 1 '重复供应商回调没有正确去重'
|
||||
|
||||
$startOutboxCount = Invoke-ScalarQuery @"
|
||||
SELECT count(*) FROM outbox_event o
|
||||
JOIN conversation c ON c.id=o.aggregate_id
|
||||
WHERE c.public_id='$conversationId'::uuid
|
||||
AND o.event_type='conversation.agent_start_requested'
|
||||
AND o.status='completed' AND o.attempt_count=1;
|
||||
"@
|
||||
Assert-Equal $startOutboxCount 1 '语音供应商启动待办执行次数不正确'
|
||||
|
||||
$stopPath = "/device/v2/conversations/$conversationId/stop"
|
||||
$stopBody = @{
|
||||
reason = 'handset_on_hook'
|
||||
device_time = [DateTime]::UtcNow.ToString('o')
|
||||
last_heartbeat_sequence = 2
|
||||
rtc_left = $true
|
||||
recording = @{ near_segments = 2; far_segments = 2 }
|
||||
} | ConvertTo-Json -Compress -Depth 4
|
||||
$stopKey = 'smoke-stop-' + [Guid]::NewGuid().ToString('N')
|
||||
$stopResponse = Invoke-SignedPost $stopPath $stopBody $stopKey
|
||||
Assert-Equal $stopResponse.StatusCode 202 '停止通话请求失败'
|
||||
Assert-Equal (($stopResponse.Content | ConvertFrom-Json).data.status) 'STOPPING' '停止请求没有进入STOPPING'
|
||||
$stopReplay = Invoke-SignedPost $stopPath $stopBody $stopKey
|
||||
Assert-Equal $stopReplay.StatusCode 202 '停止请求幂等重放失败'
|
||||
Assert-Equal $stopReplay.Headers['Idempotency-Replayed'][0] 'true' '停止响应缺少幂等重放标记'
|
||||
|
||||
$deadline = [DateTime]::UtcNow.AddSeconds(15)
|
||||
do {
|
||||
Start-Sleep -Milliseconds 500
|
||||
$currentState = Invoke-ScalarQuery "SELECT status FROM conversation WHERE public_id='$conversationId'::uuid;"
|
||||
} while ($currentState -ne 'COMPLETED' -and [DateTime]::UtcNow -lt $deadline)
|
||||
Assert-Equal $currentState 'COMPLETED' '供应商停止回调未使通话进入COMPLETED'
|
||||
|
||||
$deadline = [DateTime]::UtcNow.AddSeconds(20)
|
||||
do {
|
||||
Start-Sleep -Milliseconds 500
|
||||
$contentEvidence = Invoke-ScalarQuery @"
|
||||
SELECT
|
||||
(SELECT count(*) FROM conversation_turn ct JOIN conversation c ON c.id=ct.conversation_id WHERE c.public_id='$conversationId'::uuid)::text || '|' ||
|
||||
(SELECT count(*) FROM conversation_summary cs JOIN conversation c ON c.id=cs.conversation_id WHERE c.public_id='$conversationId'::uuid)::text || '|' ||
|
||||
(SELECT count(*) FROM story_draft sd JOIN conversation c ON c.id=sd.conversation_id WHERE c.public_id='$conversationId'::uuid)::text;
|
||||
"@
|
||||
} while ($contentEvidence -ne '2|2|1' -and [DateTime]::UtcNow -lt $deadline)
|
||||
Assert-Equal $contentEvidence '2|2|1' '最终转写、摘要或故事候选没有按期生成'
|
||||
$autoMemoryCount = Invoke-ScalarQuery "SELECT count(*) FROM curated_memory cm JOIN story_draft sd ON sd.subject_id=cm.subject_id JOIN conversation c ON c.id=sd.conversation_id WHERE c.public_id='$conversationId'::uuid AND cm.created_at>=sd.created_at;"
|
||||
Assert-Equal $autoMemoryCount 0 '未经人工确认不应自动生成长期记忆'
|
||||
|
||||
$transcriptPath = "/device/v2/conversations/$conversationId/turns:batch"
|
||||
$transcriptBody = Invoke-ScalarQuery @"
|
||||
SELECT jsonb_build_object('turns',jsonb_agg(jsonb_build_object(
|
||||
'turn_key',ct.turn_key,'sequence_no',ct.sequence_no,'speaker',ct.speaker,
|
||||
'start_at',ct.start_at,'end_at',ct.end_at,'final_text',ct.final_text,'interrupted',ct.interrupted
|
||||
) ORDER BY ct.sequence_no))::text
|
||||
FROM conversation_turn ct JOIN conversation c ON c.id=ct.conversation_id
|
||||
WHERE c.public_id='$conversationId'::uuid;
|
||||
"@
|
||||
$transcriptKey = 'smoke-transcript-' + [Guid]::NewGuid().ToString('N')
|
||||
$transcriptResponse = Invoke-SignedPost $transcriptPath $transcriptBody $transcriptKey
|
||||
Assert-Equal $transcriptResponse.StatusCode 200 '设备终稿补传去重请求失败'
|
||||
$transcriptData = ($transcriptResponse.Content | ConvertFrom-Json).data
|
||||
Assert-Equal $transcriptData.accepted_count 0 '已有供应商终稿被错误重复保存'
|
||||
Assert-Equal $transcriptData.duplicate_count 2 '已有供应商终稿没有全部判定为重复'
|
||||
$transcriptReplay = Invoke-SignedPost $transcriptPath $transcriptBody $transcriptKey
|
||||
Assert-Equal $transcriptReplay.Headers['Idempotency-Replayed'][0] 'true' '终稿补传响应缺少幂等重放标记'
|
||||
$conflictingTranscript = $transcriptBody | ConvertFrom-Json
|
||||
$conflictingTranscript.turns[0].final_text = ([string]$conflictingTranscript.turns[0].final_text) + '(冲突)'
|
||||
try {
|
||||
Invoke-SignedPost $transcriptPath ($conflictingTranscript | ConvertTo-Json -Compress -Depth 5) `
|
||||
('smoke-transcript-conflict-' + [Guid]::NewGuid().ToString('N')) | Out-Null
|
||||
throw '不同内容占用同一终稿业务键不应成功。'
|
||||
} catch {
|
||||
if ($null -eq $_.Exception.Response -or [int]$_.Exception.Response.StatusCode -ne 409) { throw }
|
||||
}
|
||||
Assert-Equal (Invoke-ScalarQuery "SELECT count(*) FROM conversation_turn ct JOIN conversation c ON c.id=ct.conversation_id WHERE c.public_id='$conversationId'::uuid;") 2 '终稿冲突请求没有整批回滚'
|
||||
|
||||
$repeatStop = Invoke-SignedPost $stopPath $stopBody `
|
||||
('smoke-stop-repeat-' + [Guid]::NewGuid().ToString('N'))
|
||||
Assert-Equal $repeatStop.StatusCode 202 '终态重复停止没有安全返回'
|
||||
Assert-Equal (($repeatStop.Content | ConvertFrom-Json).data.status) 'COMPLETED' '终态重复停止返回状态不正确'
|
||||
|
||||
$finalEvidence = Invoke-ScalarQuery @"
|
||||
SELECT c.status || '|' || rt.status || '|' || rt.start_attempts || '|' || rt.stop_attempts || '|' ||
|
||||
(c.lease_expires_at IS NULL)::text
|
||||
FROM conversation c JOIN rtc_task rt ON rt.conversation_id=c.id
|
||||
WHERE c.public_id='$conversationId'::uuid;
|
||||
"@
|
||||
Assert-Equal $finalEvidence 'COMPLETED|COMPLETED|1|1|true' '停止后的通话、任务、调用次数或租约不正确'
|
||||
$finalHistoryCount = Invoke-ScalarQuery @"
|
||||
SELECT count(*) FROM conversation_state_history h JOIN conversation c ON c.id=h.conversation_id
|
||||
WHERE c.public_id='$conversationId'::uuid;
|
||||
"@
|
||||
Assert-Equal $finalHistoryCount 6 '完整创建到停止状态链不正确'
|
||||
$stopProviderEventCount = Invoke-ScalarQuery @"
|
||||
SELECT count(*) FROM provider_event pe JOIN conversation c ON c.id=pe.conversation_id
|
||||
WHERE c.public_id='$conversationId'::uuid AND pe.event_type='voice_chat.stopped'
|
||||
AND pe.processing_status='processed';
|
||||
"@
|
||||
Assert-Equal $stopProviderEventCount 1 '重复停止回调没有正确去重'
|
||||
$stopOutboxCount = Invoke-ScalarQuery @"
|
||||
SELECT count(*) FROM outbox_event o JOIN conversation c ON c.id=o.aggregate_id
|
||||
WHERE c.public_id='$conversationId'::uuid
|
||||
AND o.event_type='conversation.agent_stop_requested'
|
||||
AND o.status='completed' AND o.attempt_count=1;
|
||||
"@
|
||||
Assert-Equal $stopOutboxCount 1 '供应商停止待办执行次数不正确'
|
||||
$cachedLeaseExists = docker compose exec -T redis redis-cli --raw EXISTS `
|
||||
"voice-platform:conversation:lease:$conversationId"
|
||||
Assert-Equal (($cachedLeaseExists | Out-String).Trim()) 0 '停止后Redis热租约未失效'
|
||||
|
||||
$ownerToken = Get-LocalAccessToken 'owner'
|
||||
$queryHeaders = @{ Authorization = "Bearer $ownerToken" }
|
||||
$queryResponse = Invoke-WebRequest -Uri "$apiBaseUrl/api/v1/subjects/10000000-0000-0000-0000-000000000411/conversations?limit=1&status=COMPLETED" -Headers $queryHeaders
|
||||
$queryData = $queryResponse.Content | ConvertFrom-Json
|
||||
Assert-Equal $queryData.data.items[0].id $conversationId '网页通话列表没有返回刚完成的通话'
|
||||
$detailResponse = Invoke-WebRequest -Uri "$apiBaseUrl/api/v1/conversations/$conversationId" -Headers $queryHeaders
|
||||
$detailData = $detailResponse.Content | ConvertFrom-Json
|
||||
Assert-Equal $detailData.data.history.Count 6 '网页通话详情状态历史不完整'
|
||||
Assert-Equal $detailData.data.snapshots.consent_snapshot_version 2 '网页通话详情授权快照版本不正确'
|
||||
if ($detailResponse.Content -match 'token|provider_task_ref|room_id') { throw '网页通话详情泄露了禁止返回的运行凭据或供应商引用。' }
|
||||
|
||||
$turnResponse = Invoke-WebRequest -Uri "$apiBaseUrl/api/v1/conversations/$conversationId/turns?limit=1" -Headers $queryHeaders
|
||||
$turnData = ($turnResponse.Content | ConvertFrom-Json).data
|
||||
Assert-Equal $turnData.items.Count 1 '话轮分页第一页数量不正确'
|
||||
if (-not $turnData.next_cursor) { throw '话轮分页缺少下一页游标。' }
|
||||
$turnPage2 = (Invoke-WebRequest -Uri "$apiBaseUrl/api/v1/conversations/$conversationId/turns?limit=1&cursor=$([uri]::EscapeDataString($turnData.next_cursor))" -Headers $queryHeaders).Content | ConvertFrom-Json
|
||||
Assert-Equal $turnPage2.data.items.Count 1 '话轮分页第二页数量不正确'
|
||||
$summaryData = (Invoke-WebRequest -Uri "$apiBaseUrl/api/v1/conversations/$conversationId/summaries" -Headers $queryHeaders).Content | ConvertFrom-Json
|
||||
Assert-Equal $summaryData.data.items.Count 2 '通话摘要和开放话题数量不正确'
|
||||
|
||||
$storiesData = (Invoke-WebRequest -Uri "$apiBaseUrl/api/v1/subjects/10000000-0000-0000-0000-000000000411/stories?status=CANDIDATE" -Headers $queryHeaders).Content | ConvertFrom-Json
|
||||
Assert-Equal $storiesData.data.items.Count 1 '故事候选列表数量不正确'
|
||||
$storyId = [string]$storiesData.data.items[0].id
|
||||
$storyResponse = Invoke-WebRequest -Uri "$apiBaseUrl/api/v1/stories/$storyId" -Headers $queryHeaders
|
||||
$storyData = ($storyResponse.Content | ConvertFrom-Json).data
|
||||
Assert-Equal $storyData.sources.Count 1 '故事候选没有保留长者原话来源'
|
||||
Assert-Equal $storyData.facts.Count 1 '故事候选没有生成可审核事实'
|
||||
$confirmHeaders = @{ Authorization = "Bearer $ownerToken"; 'If-Match' = $storyResponse.Headers['ETag'][0] }
|
||||
$confirmBody = @{ reason = '端到端冒烟人工确认' } | ConvertTo-Json -Compress
|
||||
$confirmData = (Invoke-WebRequest -Method Post -Uri "$apiBaseUrl/api/v1/stories/$storyId/confirm" -Headers $confirmHeaders -ContentType 'application/json' -Body $confirmBody).Content | ConvertFrom-Json
|
||||
Assert-Equal $confirmData.data.story.status 'CONFIRMED' '故事人工确认状态不正确'
|
||||
$memoryId = [string]$confirmData.data.memory.id
|
||||
if (-not $memoryId) { throw '人工确认后没有生成长期记忆。' }
|
||||
$deadline = [DateTime]::UtcNow.AddSeconds(20)
|
||||
do {
|
||||
Start-Sleep -Milliseconds 500
|
||||
$memoryData = (Invoke-WebRequest -Uri "$apiBaseUrl/api/v1/memories/$memoryId" -Headers $queryHeaders).Content | ConvertFrom-Json
|
||||
} while ($memoryData.data.index_status -ne 'ACTIVE' -and [DateTime]::UtcNow -lt $deadline)
|
||||
Assert-Equal $memoryData.data.index_status 'ACTIVE' '人工确认后的记忆没有完成本地开发索引'
|
||||
$memoryList = (Invoke-WebRequest -Uri "$apiBaseUrl/api/v1/subjects/10000000-0000-0000-0000-000000000411/memories" -Headers $queryHeaders).Content | ConvertFrom-Json
|
||||
Assert-Equal $memoryList.data.items.Count 1 '长者记忆列表没有返回已确认记忆'
|
||||
$memorySearchTerm = if ([string]$memoryData.data.content -and ([string]$memoryData.data.content).Length -gt 4) {
|
||||
([string]$memoryData.data.content).Substring(0, 4)
|
||||
} else {
|
||||
[string]$memoryData.data.content
|
||||
}
|
||||
$memorySearchHit = (Invoke-WebRequest -Uri "$apiBaseUrl/api/v1/subjects/10000000-0000-0000-0000-000000000411/memories?q=$([uri]::EscapeDataString($memorySearchTerm))" -Headers $queryHeaders).Content | ConvertFrom-Json
|
||||
Assert-Equal $memorySearchHit.data.items.Count 1 '记忆文本搜索没有命中已确认记忆'
|
||||
$memorySearchMissTerm = 'smoke-no-match-' + [Guid]::NewGuid().ToString('N')
|
||||
$memorySearchMiss = (Invoke-WebRequest -Uri "$apiBaseUrl/api/v1/subjects/10000000-0000-0000-0000-000000000411/memories?q=$memorySearchMissTerm" -Headers $queryHeaders).Content | ConvertFrom-Json
|
||||
Assert-Equal $memorySearchMiss.data.items.Count 0 '记忆文本搜索对不相关查询返回了误命中'
|
||||
|
||||
$memoryCreateKey = 'smoke-memory-create-' + [Guid]::NewGuid().ToString('N')
|
||||
$memoryConversationResponse = Invoke-SignedPost '/device/v2/conversations' $createBody $memoryCreateKey
|
||||
Assert-Equal $memoryConversationResponse.StatusCode 201 '确认记忆后的下一次通话创建失败'
|
||||
$memoryConversationId = [string](($memoryConversationResponse.Content | ConvertFrom-Json).data.conversation_id)
|
||||
$memoryContextEvidence = Invoke-ScalarQuery @"
|
||||
SELECT jsonb_array_length(cs.memory_versions)::text || '|' || cs.prompt_bundle_version
|
||||
FROM conversation c JOIN context_snapshot cs ON cs.id=c.context_snapshot_id
|
||||
WHERE c.public_id='$memoryConversationId'::uuid;
|
||||
"@
|
||||
Assert-Equal $memoryContextEvidence '1|minimal-v2' '下一次通话没有按服务端固定边界冻结已确认记忆版本'
|
||||
Remove-SmokeConversation $memoryConversationId
|
||||
Write-Host "已清理记忆召回验证通话:$memoryConversationId"
|
||||
$memoryConversationId = $null
|
||||
|
||||
$memoryEtag = (Invoke-WebRequest -Uri "$apiBaseUrl/api/v1/memories/$memoryId" -Headers $queryHeaders).Headers['ETag'][0]
|
||||
$editMemoryBody = @{ content = '我小时候常和家人在河边散步,这条内容经过家庭成员再次核对。'; reason = '端到端冒烟修订记忆' } | ConvertTo-Json -Compress
|
||||
$editMemoryResponse = Invoke-WebRequest -Method Patch -Uri "$apiBaseUrl/api/v1/memories/$memoryId" -Headers @{ Authorization = "Bearer $ownerToken"; 'If-Match' = $memoryEtag } -ContentType 'application/json' -Body $editMemoryBody
|
||||
$editedMemory = ($editMemoryResponse.Content | ConvertFrom-Json).data
|
||||
Assert-Equal $editedMemory.version_no 2 '修改记忆没有生成新版本'
|
||||
$deadline = [DateTime]::UtcNow.AddSeconds(20)
|
||||
do {
|
||||
Start-Sleep -Milliseconds 500
|
||||
$editedMemory = ((Invoke-WebRequest -Uri "$apiBaseUrl/api/v1/memories/$memoryId" -Headers $queryHeaders).Content | ConvertFrom-Json).data
|
||||
} while ($editedMemory.index_status -ne 'ACTIVE' -and [DateTime]::UtcNow -lt $deadline)
|
||||
Assert-Equal $editedMemory.index_status 'ACTIVE' '记忆新版本没有完成重新索引'
|
||||
|
||||
$disableMemoryBody = @{ status = 'DISABLED'; reason = '端到端冒烟验证停用' } | ConvertTo-Json -Compress
|
||||
$disabledMemoryResponse = Invoke-WebRequest -Method Patch -Uri "$apiBaseUrl/api/v1/memories/$memoryId" -Headers @{ Authorization = "Bearer $ownerToken"; 'If-Match' = '"v2"' } -ContentType 'application/json' -Body $disableMemoryBody
|
||||
$disabledMemory = ($disabledMemoryResponse.Content | ConvertFrom-Json).data
|
||||
Assert-Equal $disabledMemory.status 'DISABLED' '记忆停用状态不正确'
|
||||
Assert-Equal $disabledMemory.index_status 'STALE' '停用记忆的索引没有立即失效'
|
||||
|
||||
$disabledCreateKey = 'smoke-disabled-memory-create-' + [Guid]::NewGuid().ToString('N')
|
||||
$disabledConversationResponse = Invoke-SignedPost '/device/v2/conversations' $createBody $disabledCreateKey
|
||||
Assert-Equal $disabledConversationResponse.StatusCode 201 '停用记忆后的通话创建失败'
|
||||
$disabledMemoryConversationId = [string](($disabledConversationResponse.Content | ConvertFrom-Json).data.conversation_id)
|
||||
$disabledContextCount = Invoke-ScalarQuery "SELECT jsonb_array_length(cs.memory_versions) FROM conversation c JOIN context_snapshot cs ON cs.id=c.context_snapshot_id WHERE c.public_id='$disabledMemoryConversationId'::uuid;"
|
||||
Assert-Equal $disabledContextCount 0 '停用记忆仍被召回到新通话'
|
||||
|
||||
$enableMemoryBody = @{ status = 'ACTIVE'; reason = '端到端冒烟恢复有效记忆' } | ConvertTo-Json -Compress
|
||||
$enabledMemoryResponse = Invoke-WebRequest -Method Patch -Uri "$apiBaseUrl/api/v1/memories/$memoryId" -Headers @{ Authorization = "Bearer $ownerToken"; 'If-Match' = '"v3"' } -ContentType 'application/json' -Body $enableMemoryBody
|
||||
Assert-Equal (($enabledMemoryResponse.Content | ConvertFrom-Json).data.status) 'ACTIVE' '记忆重新启用状态不正确'
|
||||
$deadline = [DateTime]::UtcNow.AddSeconds(20)
|
||||
do {
|
||||
Start-Sleep -Milliseconds 500
|
||||
$enabledMemory = ((Invoke-WebRequest -Uri "$apiBaseUrl/api/v1/memories/$memoryId" -Headers $queryHeaders).Content | ConvertFrom-Json).data
|
||||
} while ($enabledMemory.index_status -ne 'ACTIVE' -and [DateTime]::UtcNow -lt $deadline)
|
||||
Assert-Equal $enabledMemory.index_status 'ACTIVE' '重新启用的记忆没有恢复索引'
|
||||
|
||||
Remove-SmokeConversation $disabledMemoryConversationId
|
||||
Write-Host "已清理停用记忆验证通话:$disabledMemoryConversationId"
|
||||
$disabledMemoryConversationId = $null
|
||||
|
||||
$deleteMemoryBody = @{ reason = '端到端冒烟永久删除记忆' } | ConvertTo-Json -Compress
|
||||
$deleteMemoryResponse = Invoke-WebRequest -Method Delete -Uri "$apiBaseUrl/api/v1/memories/$memoryId" -Headers @{ Authorization = "Bearer $ownerToken"; 'If-Match' = '"v4"' } -ContentType 'application/json' -Body $deleteMemoryBody
|
||||
Assert-Equal $deleteMemoryResponse.StatusCode 202 '记忆删除请求未被受理'
|
||||
$deadline = [DateTime]::UtcNow.AddSeconds(20)
|
||||
do {
|
||||
Start-Sleep -Milliseconds 500
|
||||
$deletedMemory = ((Invoke-WebRequest -Uri "$apiBaseUrl/api/v1/memories/$memoryId" -Headers $queryHeaders).Content | ConvertFrom-Json).data
|
||||
} while ($deletedMemory.status -ne 'DELETED' -and [DateTime]::UtcNow -lt $deadline)
|
||||
Assert-Equal $deletedMemory.status 'DELETED' '记忆后台删除没有完成'
|
||||
Assert-Equal $deletedMemory.index_status 'DELETED' '记忆索引没有完成删除'
|
||||
Assert-Equal $deletedMemory.content '[已按用户请求删除]' '记忆数据库正文没有被清除'
|
||||
|
||||
$deletedCreateKey = 'smoke-deleted-memory-create-' + [Guid]::NewGuid().ToString('N')
|
||||
$deletedConversationResponse = Invoke-SignedPost '/device/v2/conversations' $createBody $deletedCreateKey
|
||||
Assert-Equal $deletedConversationResponse.StatusCode 201 '删除记忆后的通话创建失败'
|
||||
$deletedMemoryConversationId = [string](($deletedConversationResponse.Content | ConvertFrom-Json).data.conversation_id)
|
||||
$deletedContextCount = Invoke-ScalarQuery "SELECT jsonb_array_length(cs.memory_versions) FROM conversation c JOIN context_snapshot cs ON cs.id=c.context_snapshot_id WHERE c.public_id='$deletedMemoryConversationId'::uuid;"
|
||||
Assert-Equal $deletedContextCount 0 '已删除记忆仍被召回到新通话'
|
||||
$outsiderToken = Get-LocalAccessToken 'outsider'
|
||||
try {
|
||||
Invoke-WebRequest -Uri "$apiBaseUrl/api/v1/conversations/$conversationId" -Headers @{ Authorization = "Bearer $outsiderToken" } | Out-Null
|
||||
throw '跨租户查看通话详情不应成功。'
|
||||
} catch {
|
||||
if ($null -eq $_.Exception.Response -or [int]$_.Exception.Response.StatusCode -ne 403) { throw }
|
||||
}
|
||||
|
||||
Write-Host "端到端通话冒烟通过:$conversationId"
|
||||
Write-Host '状态链:PREPARING -> ROOM_ISSUED -> STARTING_AGENT -> ACTIVE -> STOPPING -> COMPLETED'
|
||||
Write-Host '供应商启动调用1次;重复回调仅处理1次;激活幂等重放通过。'
|
||||
Write-Host '心跳刷新PostgreSQL/Redis租约;乱序心跳未延长租约。'
|
||||
Write-Host '供应商停止调用1次;重复停止回调仅处理1次;最终租约已释放。'
|
||||
Write-Host '网页通话列表、脱敏详情、六段状态历史和跨租户403验证通过。'
|
||||
Write-Host '最终话轮去重、摘要、故事来源、人工确认、记忆生成与本地开发索引验证通过。'
|
||||
Write-Host '设备终稿补传对供应商终稿完成语义去重、幂等重放和冲突整批回滚。'
|
||||
Write-Host '新确认记忆已在下一次通话创建时按租户、长者、授权和状态边界冻结到背景快照。'
|
||||
Write-Host '记忆修订生成新版本并重新索引;停用后新通话召回为0;重新启用进入索引流程。'
|
||||
Write-Host '永久删除任务完成索引清理和数据库正文清除;删除后的新通话召回为0。'
|
||||
} finally {
|
||||
if ($deletedMemoryConversationId) {
|
||||
Remove-SmokeConversation $deletedMemoryConversationId
|
||||
Write-Host "已清理删除记忆验证通话:$deletedMemoryConversationId"
|
||||
}
|
||||
if ($disabledMemoryConversationId) {
|
||||
Remove-SmokeConversation $disabledMemoryConversationId
|
||||
Write-Host "已清理停用记忆验证通话:$disabledMemoryConversationId"
|
||||
}
|
||||
if ($memoryConversationId) {
|
||||
Remove-SmokeConversation $memoryConversationId
|
||||
Write-Host "已清理记忆召回验证通话:$memoryConversationId"
|
||||
}
|
||||
if ($conversationId) {
|
||||
Remove-SmokeConversation $conversationId
|
||||
Write-Host "已清理本次合成通话数据:$conversationId"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user