315 lines
20 KiB
Go
315 lines
20 KiB
Go
package diagnostics
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"errors"
|
||
|
|
"os"
|
||
|
|
"strings"
|
||
|
|
"testing"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"data-terminal-cloud/internal/audit"
|
||
|
|
"data-terminal-cloud/internal/database"
|
||
|
|
"data-terminal-cloud/internal/idempotency"
|
||
|
|
"data-terminal-cloud/internal/identity"
|
||
|
|
|
||
|
|
"github.com/google/uuid"
|
||
|
|
)
|
||
|
|
|
||
|
|
func TestDiagnosticSearchAndConfigRefreshAreTenantScopedAndIdempotent(t *testing.T) {
|
||
|
|
databaseURL := os.Getenv("TEST_DATABASE_URL")
|
||
|
|
if databaseURL == "" {
|
||
|
|
t.Skip("TEST_DATABASE_URL is not set")
|
||
|
|
}
|
||
|
|
ctx := context.Background()
|
||
|
|
pool, err := database.Open(ctx, databaseURL, "diagnostic-integration-test")
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
defer pool.Close()
|
||
|
|
service, err := NewService(pool, idempotency.NewExecutor(pool), audit.NewRepository())
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
admin := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000101"}
|
||
|
|
tenantPublicID := "10000000-0000-0000-0000-000000000010"
|
||
|
|
devicePublicID := "10000000-0000-0000-0000-000000000511"
|
||
|
|
key := "diagnostic-refresh-integration"
|
||
|
|
defer func() {
|
||
|
|
_, _ = pool.Exec(context.Background(), `DELETE FROM audit_event WHERE request_id='diagnostic-refresh-request'`)
|
||
|
|
_, _ = pool.Exec(context.Background(), `DELETE FROM device_command WHERE tenant_id='10000000-0000-0000-0000-000000000001'::uuid AND idempotency_key=$1`, "ops-refresh:"+key)
|
||
|
|
_, _ = pool.Exec(context.Background(), `DELETE FROM idempotency_record WHERE actor_key='ops:10000000-0000-0000-0000-000000000111' AND idempotency_key=$1`, key)
|
||
|
|
}()
|
||
|
|
items, err := service.Search(ctx, admin, tenantPublicID, "device_id", devicePublicID)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if len(items) == 0 || items[0].DeviceID != devicePublicID || items[0].DesiredConfigRevision < 1 {
|
||
|
|
t.Fatalf("items=%+v", items)
|
||
|
|
}
|
||
|
|
first, created, err := service.RefreshDeviceConfig(ctx, admin, tenantPublicID, devicePublicID, key, "diagnostic-refresh-request", "验证配置刷新")
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
second, createdAgain, err := service.RefreshDeviceConfig(ctx, admin, tenantPublicID, devicePublicID, key, "diagnostic-refresh-request", "验证配置刷新")
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if !created || createdAgain || first.CommandID == "" || first.CommandID != second.CommandID || !first.Queued {
|
||
|
|
t.Fatalf("first=%+v second=%+v created=%t/%t", first, second, created, createdAgain)
|
||
|
|
}
|
||
|
|
otherTenant := "20000000-0000-0000-0000-000000000010"
|
||
|
|
if _, err = service.Search(ctx, admin, otherTenant, "device_id", devicePublicID); !errors.Is(err, ErrAccessDenied) {
|
||
|
|
t.Fatalf("cross tenant error=%v", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestDiagnosticConversationActionsCoverStableAndProviderStates(t *testing.T) {
|
||
|
|
databaseURL := os.Getenv("TEST_DATABASE_URL")
|
||
|
|
if databaseURL == "" {
|
||
|
|
t.Skip("TEST_DATABASE_URL is not set")
|
||
|
|
}
|
||
|
|
ctx := context.Background()
|
||
|
|
pool, err := database.Open(ctx, databaseURL, "diagnostic-actions-integration-test")
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
defer pool.Close()
|
||
|
|
service, err := NewService(pool, idempotency.NewExecutor(pool), audit.NewRepository())
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
admin := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000101"}
|
||
|
|
approver := identity.Principal{Issuer: admin.Issuer, Subject: "00000000-0000-0000-0000-000000000102"}
|
||
|
|
tenantPublicID := "10000000-0000-0000-0000-000000000010"
|
||
|
|
tenantID := "10000000-0000-0000-0000-000000000001"
|
||
|
|
householdID := "10000000-0000-0000-0000-000000000201"
|
||
|
|
subjectID := "10000000-0000-0000-0000-000000000401"
|
||
|
|
deviceID := "10000000-0000-0000-0000-000000000501"
|
||
|
|
consentID, contextID := uuid.NewString(), uuid.NewString()
|
||
|
|
conversationIDs := []string{uuid.NewString(), uuid.NewString()}
|
||
|
|
internalIDs := []string{uuid.NewString(), uuid.NewString()}
|
||
|
|
keys := []string{"diagnostic-stop-before-start", "diagnostic-reconcile-starting", "diagnostic-stop-active", "diagnostic-reconcile-stopping"}
|
||
|
|
defer func() {
|
||
|
|
_, _ = pool.Exec(context.Background(), `DELETE FROM conversation_state_history WHERE conversation_id=ANY($1::uuid[])`, internalIDs)
|
||
|
|
_, _ = pool.Exec(context.Background(), `DELETE FROM rtc_task WHERE conversation_id=ANY($1::uuid[])`, internalIDs)
|
||
|
|
_, _ = pool.Exec(context.Background(), `DELETE FROM outbox_event WHERE aggregate_id=ANY($1::uuid[])`, internalIDs)
|
||
|
|
_, _ = pool.Exec(context.Background(), `DELETE FROM audit_event WHERE request_id LIKE 'diagnostic-actions-%'`)
|
||
|
|
_, _ = pool.Exec(context.Background(), `DELETE FROM idempotency_record WHERE actor_key='ops:10000000-0000-0000-0000-000000000111' AND idempotency_key=ANY($1::text[])`, keys)
|
||
|
|
_, _ = pool.Exec(context.Background(), `DELETE FROM web_event WHERE resource_public_id=ANY($1::uuid[])`, conversationIDs)
|
||
|
|
_, _ = pool.Exec(context.Background(), `DELETE FROM conversation WHERE id=ANY($1::uuid[])`, internalIDs)
|
||
|
|
_, _ = pool.Exec(context.Background(), `DELETE FROM context_snapshot WHERE id=$1::uuid`, contextID)
|
||
|
|
_, _ = pool.Exec(context.Background(), `DELETE FROM consent_snapshot WHERE id=$1::uuid`, consentID)
|
||
|
|
}()
|
||
|
|
if _, err = pool.Exec(ctx, `INSERT INTO consent_snapshot(id,tenant_id,subject_id,snapshot_version,effective_consents) VALUES($1::uuid,$2::uuid,$3::uuid,9001,'{"voice_chat":true}'::jsonb)`, consentID, tenantID, subjectID); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if _, err = pool.Exec(ctx, `INSERT INTO context_snapshot(id,tenant_id,subject_id,profile_version,consent_snapshot_id,prompt_bundle_version,engine_mode,content_digest) VALUES($1::uuid,$2::uuid,$3::uuid,1,$4::uuid,'diagnostic-test','s2s_hybrid',repeat('9',64))`, contextID, tenantID, subjectID, consentID); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
insertConversation := func(index int, status, rtcStatus string) {
|
||
|
|
t.Helper()
|
||
|
|
taskRef, roomID := "diagnostic-task-"+conversationIDs[index], "diagnostic-room-"+conversationIDs[index]
|
||
|
|
if _, insertErr := pool.Exec(ctx, `INSERT INTO conversation(id,public_id,tenant_id,household_id,subject_id,device_id,consent_snapshot_id,context_snapshot_id,provider_task_ref,engine_mode,retention_mode,status,allocation_expires_at) VALUES($1::uuid,$2::uuid,$3::uuid,$4::uuid,$5::uuid,$6::uuid,$7::uuid,$8::uuid,$9,'s2s_hybrid','transcript_only',$10,now()+interval '5 minutes')`, internalIDs[index], conversationIDs[index], tenantID, householdID, subjectID, deviceID, consentID, contextID, taskRef, status); insertErr != nil {
|
||
|
|
t.Fatal(insertErr)
|
||
|
|
}
|
||
|
|
if _, insertErr := pool.Exec(ctx, `INSERT INTO rtc_task(conversation_id,provider,provider_task_ref,room_id,device_rtc_user_id,agent_rtc_user_id,status) VALUES($1::uuid,'mock',$2,$3,'device-test','agent-test',$4)`, internalIDs[index], taskRef, roomID, rtcStatus); insertErr != nil {
|
||
|
|
t.Fatal(insertErr)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
insertConversation(0, "ROOM_ISSUED", "ALLOCATED")
|
||
|
|
stopped, created, err := service.StopConversation(ctx, admin, tenantPublicID, conversationIDs[0], keys[0], "diagnostic-actions-stop-before", "联调启动前停止")
|
||
|
|
if err != nil || !created || stopped.ConversationStatus != "EXPIRED" || stopped.Queued {
|
||
|
|
t.Fatalf("pre-start stop=%+v created=%t err=%v", stopped, created, err)
|
||
|
|
}
|
||
|
|
var preStartRTCStatus string
|
||
|
|
if err = pool.QueryRow(ctx, `SELECT status FROM rtc_task WHERE conversation_id=$1::uuid`, internalIDs[0]).Scan(&preStartRTCStatus); err != nil || preStartRTCStatus != "COMPLETED" {
|
||
|
|
t.Fatalf("pre-start rtc status=%s err=%v", preStartRTCStatus, err)
|
||
|
|
}
|
||
|
|
replayed, createdAgain, err := service.StopConversation(ctx, admin, tenantPublicID, conversationIDs[0], keys[0], "diagnostic-actions-stop-before", "联调启动前停止")
|
||
|
|
if err != nil || createdAgain || replayed != stopped {
|
||
|
|
t.Fatalf("replayed stop=%+v created=%t err=%v", replayed, createdAgain, err)
|
||
|
|
}
|
||
|
|
|
||
|
|
insertConversation(1, "STARTING_AGENT", "STARTING")
|
||
|
|
createdAt := time.Date(2026, time.August, 23, 10, 0, 0, 0, time.UTC)
|
||
|
|
roomIssuedAt := createdAt.Add(250 * time.Millisecond)
|
||
|
|
deviceActivatedAt := roomIssuedAt.Add(2 * time.Second)
|
||
|
|
if _, err = pool.Exec(ctx, `UPDATE conversation SET created_at=$2 WHERE id=$1::uuid`, internalIDs[1], createdAt); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if _, err = pool.Exec(ctx, `INSERT INTO conversation_state_history(conversation_id,from_status,to_status,reason,source,occurred_at) VALUES($1::uuid,'PREPARING','ROOM_ISSUED','timing fixture','platform',$2),($1::uuid,'ROOM_ISSUED','STARTING_AGENT','timing fixture','device',$3)`, internalIDs[1], roomIssuedAt, deviceActivatedAt); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
timingItems, err := service.Search(ctx, admin, tenantPublicID, "conversation_id", conversationIDs[1])
|
||
|
|
if err != nil || len(timingItems) != 1 || timingItems[0].Timings == nil {
|
||
|
|
t.Fatalf("timing search items=%+v err=%v", timingItems, err)
|
||
|
|
}
|
||
|
|
if timingItems[0].Timings.RoomIssueMS == nil || *timingItems[0].Timings.RoomIssueMS != 250 || timingItems[0].Timings.DeviceJoinMS == nil || *timingItems[0].Timings.DeviceJoinMS != 2000 || timingItems[0].Timings.AgentStartMS != nil {
|
||
|
|
t.Fatalf("unexpected handshake timings: %+v", timingItems[0].Timings)
|
||
|
|
}
|
||
|
|
if timingItems[0].CredentialIssuedAt == nil || timingItems[0].CredentialExpiresAt == nil || !timingItems[0].CredentialExpiresAt.After(*timingItems[0].CredentialIssuedAt) {
|
||
|
|
t.Fatalf("credential metadata is missing or invalid: %+v", timingItems[0])
|
||
|
|
}
|
||
|
|
reconciled, created, err := service.ReconcileConversation(ctx, admin, tenantPublicID, conversationIDs[1], keys[1], "diagnostic-actions-reconcile-start", "核对启动结果")
|
||
|
|
if err != nil || !created || !reconciled.Queued || reconciled.ConversationStatus != "STARTING_AGENT" {
|
||
|
|
t.Fatalf("start reconcile=%+v created=%t err=%v", reconciled, created, err)
|
||
|
|
}
|
||
|
|
if _, _, err = service.StopConversation(ctx, approver, tenantPublicID, conversationIDs[1], "diagnostic-approver-denied", "diagnostic-actions-denied", "只读审批角色不应停止"); !errors.Is(err, ErrAccessDenied) {
|
||
|
|
t.Fatalf("approver stop error=%v", err)
|
||
|
|
}
|
||
|
|
stopped, created, err = service.StopConversation(ctx, admin, tenantPublicID, conversationIDs[1], keys[2], "diagnostic-actions-stop-active", "停止可能已经启动的任务")
|
||
|
|
if err != nil || !created || !stopped.Queued || stopped.ConversationStatus != "STOPPING" {
|
||
|
|
t.Fatalf("active stop=%+v created=%t err=%v", stopped, created, err)
|
||
|
|
}
|
||
|
|
reconciled, created, err = service.ReconcileConversation(ctx, admin, tenantPublicID, conversationIDs[1], keys[3], "diagnostic-actions-reconcile-stop", "核对停止结果")
|
||
|
|
if err != nil || !created || !reconciled.Queued || reconciled.ConversationStatus != "STOPPING" {
|
||
|
|
t.Fatalf("stop reconcile=%+v created=%t err=%v", reconciled, created, err)
|
||
|
|
}
|
||
|
|
var stopEvents, reconcileEvents int
|
||
|
|
if err = pool.QueryRow(ctx, `SELECT count(*) FILTER(WHERE event_type='conversation.agent_stop_requested'),count(*) FILTER(WHERE event_type LIKE 'conversation.agent_%_reconcile_requested') FROM outbox_event WHERE aggregate_id=$1::uuid`, internalIDs[1]).Scan(&stopEvents, &reconcileEvents); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if stopEvents != 1 || reconcileEvents != 2 {
|
||
|
|
t.Fatalf("stop events=%d reconcile events=%d", stopEvents, reconcileEvents)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestDiagnosticJobRetryBundleAndDeviceRevocation(t *testing.T) {
|
||
|
|
databaseURL := os.Getenv("TEST_DATABASE_URL")
|
||
|
|
if databaseURL == "" {
|
||
|
|
t.Skip("TEST_DATABASE_URL is not set")
|
||
|
|
}
|
||
|
|
ctx := context.Background()
|
||
|
|
pool, err := database.Open(ctx, databaseURL, "diagnostic-recovery-integration-test")
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
defer pool.Close()
|
||
|
|
service, err := NewService(pool, idempotency.NewExecutor(pool), audit.NewRepository())
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
admin := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000101"}
|
||
|
|
tenantPublicID, tenantID := "10000000-0000-0000-0000-000000000010", "10000000-0000-0000-0000-000000000001"
|
||
|
|
deviceID, devicePublicID := uuid.NewString(), uuid.NewString()
|
||
|
|
jobInternalID, aggregateID := uuid.NewString(), uuid.NewString()
|
||
|
|
unknownJobInternalID := uuid.NewString()
|
||
|
|
consentID, contextID := uuid.NewString(), uuid.NewString()
|
||
|
|
conversationInternalID, conversationPublicID := uuid.NewString(), uuid.NewString()
|
||
|
|
keys := []string{"diagnostic-job-retry", "diagnostic-bundle-export", "diagnostic-device-revoke"}
|
||
|
|
defer func() {
|
||
|
|
_, _ = pool.Exec(context.Background(), `DELETE FROM audit_event WHERE request_id LIKE 'diagnostic-recovery-%'`)
|
||
|
|
_, _ = pool.Exec(context.Background(), `DELETE FROM idempotency_record WHERE actor_key='ops:10000000-0000-0000-0000-000000000111' AND idempotency_key=ANY($1::text[])`, keys)
|
||
|
|
_, _ = pool.Exec(context.Background(), `DELETE FROM outbox_event WHERE id=ANY($1::uuid[]) OR aggregate_id=$2::uuid`, []string{jobInternalID, unknownJobInternalID}, conversationInternalID)
|
||
|
|
_, _ = pool.Exec(context.Background(), `DELETE FROM conversation_state_history WHERE conversation_id=$1::uuid`, conversationInternalID)
|
||
|
|
_, _ = pool.Exec(context.Background(), `DELETE FROM rtc_task WHERE conversation_id=$1::uuid`, conversationInternalID)
|
||
|
|
_, _ = pool.Exec(context.Background(), `DELETE FROM web_event WHERE resource_public_id=$1::uuid`, conversationPublicID)
|
||
|
|
_, _ = pool.Exec(context.Background(), `DELETE FROM conversation WHERE id=$1::uuid`, conversationInternalID)
|
||
|
|
_, _ = pool.Exec(context.Background(), `DELETE FROM context_snapshot WHERE id=$1::uuid`, contextID)
|
||
|
|
_, _ = pool.Exec(context.Background(), `DELETE FROM consent_snapshot WHERE id=$1::uuid`, consentID)
|
||
|
|
_, _ = pool.Exec(context.Background(), `DELETE FROM device_twin WHERE device_id=$1::uuid`, deviceID)
|
||
|
|
_, _ = pool.Exec(context.Background(), `DELETE FROM device WHERE id=$1::uuid`, deviceID)
|
||
|
|
}()
|
||
|
|
if _, err = pool.Exec(ctx, `INSERT INTO device(id,public_id,tenant_id,serial_digest,model,status,credential_ref) VALUES($1::uuid,$2::uuid,$3::uuid,$4,'diagnostic-test','registered','test://diagnostic-recovery')`, deviceID, devicePublicID, tenantID, strings.Repeat("8", 64)); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if _, err = pool.Exec(ctx, `INSERT INTO device_twin(device_id,tenant_id,desired_revision) VALUES($1::uuid,$2::uuid,3)`, deviceID, tenantID); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if _, err = pool.Exec(ctx, `INSERT INTO consent_snapshot(id,tenant_id,subject_id,snapshot_version,effective_consents) VALUES($1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000401'::uuid,9101,'{"voice_chat":true}'::jsonb)`, consentID, tenantID); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if _, err = pool.Exec(ctx, `INSERT INTO context_snapshot(id,tenant_id,subject_id,profile_version,consent_snapshot_id,prompt_bundle_version,engine_mode,content_digest) VALUES($1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000401'::uuid,1,$3::uuid,'diagnostic-revoke','s2s_hybrid',repeat('7',64))`, contextID, tenantID, consentID); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
taskRef := "diagnostic-revoke-task-" + conversationPublicID
|
||
|
|
if _, err = pool.Exec(ctx, `INSERT INTO conversation(id,public_id,tenant_id,household_id,subject_id,device_id,consent_snapshot_id,context_snapshot_id,provider_task_ref,engine_mode,retention_mode,status,allocation_expires_at) VALUES($1::uuid,$2::uuid,$3::uuid,'10000000-0000-0000-0000-000000000201'::uuid,'10000000-0000-0000-0000-000000000401'::uuid,$4::uuid,$5::uuid,$6::uuid,$7,'s2s_hybrid','transcript_only','ACTIVE',now()+interval '5 minutes')`, conversationInternalID, conversationPublicID, tenantID, deviceID, consentID, contextID, taskRef); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if _, err = pool.Exec(ctx, `INSERT INTO rtc_task(conversation_id,provider,provider_task_ref,room_id,device_rtc_user_id,agent_rtc_user_id,status) VALUES($1::uuid,'mock',$2,$3,'diagnostic-device','diagnostic-agent','ACTIVE')`, conversationInternalID, taskRef, "diagnostic-revoke-room-"+conversationPublicID); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if _, err = pool.Exec(ctx, `INSERT INTO outbox_event(id,tenant_id,aggregate_type,aggregate_id,event_type,payload,status,attempt_count,last_error_code,dead_at,payload_hash) VALUES($1::uuid,$2::uuid,'platform',$3::uuid,'platform.noop','{}'::jsonb,'dead',8,'TEMPORARY_DEPENDENCY_FAILURE',now(),repeat('0',64))`, jobInternalID, tenantID, aggregateID); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if _, err = pool.Exec(ctx, `INSERT INTO outbox_event(id,tenant_id,aggregate_type,aggregate_id,event_type,payload,status,attempt_count,last_error_code,dead_at,payload_hash) VALUES($1::uuid,$2::uuid,'platform',$3::uuid,'unsupported.diagnostic_job','{}'::jsonb,'dead',8,'HANDLER_NOT_REGISTERED',now(),repeat('1',64))`, unknownJobInternalID, tenantID, aggregateID); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
jobs, err := service.ListDeadJobs(ctx, admin, tenantPublicID)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
var job Job
|
||
|
|
for _, candidate := range jobs {
|
||
|
|
if candidate.EventType == "platform.noop" && candidate.AttemptCount == 8 {
|
||
|
|
job = candidate
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if job.ID == "" {
|
||
|
|
t.Fatalf("dead job not listed: %+v", jobs)
|
||
|
|
}
|
||
|
|
retried, created, err := service.RetryDeadJob(ctx, admin, tenantPublicID, job.ID, keys[0], "diagnostic-recovery-job", "依赖恢复后受控重试")
|
||
|
|
if err != nil || !created || !retried.Queued || retried.ManualRetryCount != 1 {
|
||
|
|
t.Fatalf("retry=%+v created=%t err=%v", retried, created, err)
|
||
|
|
}
|
||
|
|
replayed, createdAgain, err := service.RetryDeadJob(ctx, admin, tenantPublicID, job.ID, keys[0], "diagnostic-recovery-job", "依赖恢复后受控重试")
|
||
|
|
if err != nil || createdAgain || replayed != retried {
|
||
|
|
t.Fatalf("retry replay=%+v created=%t err=%v", replayed, createdAgain, err)
|
||
|
|
}
|
||
|
|
var manualRetries int
|
||
|
|
if err = pool.QueryRow(ctx, `SELECT manual_retry_count FROM outbox_event WHERE id=$1::uuid`, jobInternalID).Scan(&manualRetries); err != nil || manualRetries != 1 {
|
||
|
|
t.Fatalf("manual retries=%d err=%v", manualRetries, err)
|
||
|
|
}
|
||
|
|
var unknownJobPublicID string
|
||
|
|
if err = pool.QueryRow(ctx, `SELECT public_id::text FROM outbox_event WHERE id=$1::uuid`, unknownJobInternalID).Scan(&unknownJobPublicID); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if _, _, err = service.RetryDeadJob(ctx, admin, tenantPublicID, unknownJobPublicID, "diagnostic-unknown-job", "diagnostic-recovery-unknown-job", "不支持的作业类型不得重试"); !errors.Is(err, ErrStateConflict) {
|
||
|
|
t.Fatalf("unknown job retry error=%v", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
bundle, created, err := service.ExportBundle(ctx, admin, tenantPublicID, "device_id", devicePublicID, keys[1], "diagnostic-recovery-bundle", "导出脱敏联调证据")
|
||
|
|
if err != nil || !created || bundle.ID == "" || len(bundle.Items) != 1 || bundle.Items[0].DeviceID != devicePublicID || bundle.RedactionPolicy != "ops-diagnostic-minimal-v1" {
|
||
|
|
t.Fatalf("bundle=%+v created=%t err=%v", bundle, created, err)
|
||
|
|
}
|
||
|
|
revoked, created, err := service.RevokeDevice(ctx, admin, tenantPublicID, devicePublicID, keys[2], "diagnostic-recovery-revoke", "设备证书疑似泄露,立即吊销")
|
||
|
|
if err != nil || !created || revoked.Action != "revoke_device" || !revoked.Queued || revoked.ConversationID != conversationPublicID || revoked.ConversationStatus != "STOPPING" {
|
||
|
|
t.Fatalf("revoke=%+v created=%t err=%v", revoked, created, err)
|
||
|
|
}
|
||
|
|
var deviceStatus string
|
||
|
|
var revokedAtPresent bool
|
||
|
|
if err = pool.QueryRow(ctx, `SELECT status,revoked_at IS NOT NULL FROM device WHERE id=$1::uuid`, deviceID).Scan(&deviceStatus, &revokedAtPresent); err != nil || deviceStatus != "revoked" || !revokedAtPresent {
|
||
|
|
t.Fatalf("device status=%s revoked_at=%t err=%v", deviceStatus, revokedAtPresent, err)
|
||
|
|
}
|
||
|
|
var stopEvents int
|
||
|
|
if err = pool.QueryRow(ctx, `SELECT count(*) FROM outbox_event WHERE aggregate_id=$1::uuid AND event_type='conversation.agent_stop_requested'`, conversationInternalID).Scan(&stopEvents); err != nil || stopEvents != 1 {
|
||
|
|
t.Fatalf("revoke stop events=%d err=%v", stopEvents, err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestDiagnosticInputValidationDoesNotReachDependencies(t *testing.T) {
|
||
|
|
if _, err := NewService(nil, nil, nil); err == nil {
|
||
|
|
t.Fatal("nil dependencies must be rejected")
|
||
|
|
}
|
||
|
|
service := &Service{}
|
||
|
|
principal := identity.Principal{}
|
||
|
|
if _, err := service.Search(context.Background(), principal, "bad-tenant", "unknown", "x"); !errors.Is(err, ErrInvalid) {
|
||
|
|
t.Fatalf("invalid search error=%v", err)
|
||
|
|
}
|
||
|
|
if _, _, err := service.StopConversation(context.Background(), principal, "bad-tenant", "not-a-uuid", "key", "request", "reason"); !errors.Is(err, ErrInvalid) {
|
||
|
|
t.Fatalf("invalid stop error=%v", err)
|
||
|
|
}
|
||
|
|
if _, _, err := service.ReconcileConversation(context.Background(), principal, "bad-tenant", uuid.NewString(), "key", "request", ""); !errors.Is(err, ErrInvalid) {
|
||
|
|
t.Fatalf("invalid reconcile error=%v", err)
|
||
|
|
}
|
||
|
|
if _, _, err := service.RefreshDeviceConfig(context.Background(), principal, "bad-tenant", "not-a-uuid", "key", "request", "reason"); !errors.Is(err, ErrInvalid) {
|
||
|
|
t.Fatalf("invalid refresh error=%v", err)
|
||
|
|
}
|
||
|
|
if got := (Record{DeviceID: "device", ConversationID: "conversation"}).String(); got != "device=device conversation=conversation" {
|
||
|
|
t.Fatalf("record string=%q", got)
|
||
|
|
}
|
||
|
|
}
|