package saas import ( "context" "errors" "os" "strings" "testing" "data-terminal-cloud/internal/audit" "data-terminal-cloud/internal/elderagent" "data-terminal-cloud/internal/identity" "github.com/jackc/pgx/v5/pgxpool" ) type elderProvider struct { calls int fail bool updates int config map[string]any } func (p *elderProvider) Ensure(ctx context.Context, name string, c, a map[string]any) (elderagent.Bot, error) { p.calls++ if p.fail { return elderagent.Bot{}, errors.New("provider unavailable") } return elderagent.Bot{ID: "bot-" + name, Name: name, Config: c, AgentConfig: a}, nil } func (p *elderProvider) Update(_ context.Context, _ string, c, a map[string]any) error { p.updates++ p.config = c if p.fail { return errors.New("SECRET_PROVIDER_FAILURE") } return nil } func TestSubjectEditSynchronizesPersonaWithoutLosingSavedProfile(t *testing.T) { if os.Getenv("TEST_DATABASE_URL") == "" { t.Skip("integration database required") } ctx := context.Background() pool, err := pgxpool.New(ctx, os.Getenv("TEST_DATABASE_URL")) if err != nil { t.Fatal(err) } defer pool.Close() s := NewService(pool, audit.NewRepository(), nil, nil) provider := &elderProvider{} agents := &elderagent.Service{Pool: pool, Provider: provider, Template: []byte(`{"Config":{"S2SConfig":{"Provider":"volcano","ProviderParams":{"app":{"appid":"test","token":"test"},"dialog":{"system_role":"基础角色"}}}}}`), CallbackURL: "https://example.com/subtitles", CallbackSecret: []byte(strings.Repeat("k", 32))} s.ConfigureVoiceAgents(agents) owner := identity.Principal{Issuer: testIssuer, Subject: testOwnerSubject} h, err := s.CreateHousehold(ctx, owner, testTenantPublicID, "资料同步测试", "persona-save-test") if err != nil { t.Fatal(err) } sp, err := s.CreateSubject(ctx, owner, h.ID, "persona-save-test", CreateSubjectInput{PreferredName: "原称呼", Language: "zh-CN", Timezone: "Asia/Shanghai", ProfileData: map[string]any{}, ConversationPreferences: map[string]any{}}) if err != nil { t.Fatal(err) } name := "修改后的称呼" provider.fail = true sp, err = s.PatchSubject(ctx, owner, sp.ID, "persona-save-test", sp.Version, PatchSubjectInput{PreferredName: &name}) if err != nil || sp.PreferredName != name || sp.VoiceAgent == nil || sp.VoiceAgent.Status != "READY" || sp.VoiceAgent.PersonaSyncStatus != "FAILED" { t.Fatal("provider failure undid save or lost status", sp, err) } loaded, err := s.GetSubject(ctx, owner, sp.ID) if err != nil || loaded.PreferredName != name { t.Fatal("save was not durable", err) } updates := provider.updates if _, err = s.PatchSubject(ctx, owner, sp.ID, "persona-save-test-stale", sp.Version-1, PatchSubjectInput{PreferredName: &name}); !errors.Is(err, ErrVersionConflict) || provider.updates != updates { t.Fatal("stale edit changed provider", err) } provider.fail = false i, err := s.EnsureSubjectVoiceAgent(ctx, owner, sp.ID) if err != nil || i.PersonaSyncStatus != "SYNCED" { t.Fatal("manual sync retry failed", i, err) } // Corrections share the database trigger, including changes made through privacy. _, _, err = s.CreatePrivacyRequest(ctx, owner, sp.ID, "persona-correction-test", "persona-correction-test", CreatePrivacyRequestInput{RequestType: "CORRECTION", Scope: map[string]any{"corrections": map[string]any{"preferred_name": "更正后的称呼"}}}) if err != nil { t.Fatal(err) } i, err = agents.Get(ctx, sp.ID) if err != nil || i.PersonaSyncStatus != "PENDING" { t.Fatal("correction not queued", i, err) } if err = agents.SyncPending(ctx); err != nil { t.Fatal(err) } i, err = agents.Get(ctx, sp.ID) if err != nil || i.PersonaSyncStatus != "SYNCED" { t.Fatal("correction not synchronized", i, err) } } func TestCreatingElderCreatesPersistentAgentAndRetry(t *testing.T) { if os.Getenv("TEST_DATABASE_URL") == "" { t.Skip("integration database required") } ctx := context.Background() pool, err := pgxpool.New(ctx, os.Getenv("TEST_DATABASE_URL")) if err != nil { t.Fatal(err) } defer pool.Close() s := NewService(pool, audit.NewRepository(), nil, nil) provider := &elderProvider{} agents := &elderagent.Service{Pool: pool, Provider: provider, Template: []byte(`{"Config":{"S2SConfig":{"Provider":"volcano","ProviderParams":{"app":{"appid":"test","token":"test"}}}}}`), CallbackURL: "https://example.com/subtitles", CallbackSecret: []byte(strings.Repeat("k", 32))} s.ConfigureVoiceAgents(agents) owner := identity.Principal{Issuer: testIssuer, Subject: testOwnerSubject} h, err := s.CreateHousehold(ctx, owner, testTenantPublicID, "专属智能体测试", "elder-agent-test") if err != nil { t.Fatal(err) } create := func(name string) Subject { t.Helper() sp, err := s.CreateSubject(ctx, owner, h.ID, "elder-agent-test", CreateSubjectInput{PreferredName: name, Language: "zh-CN", Timezone: "Asia/Shanghai", ProfileData: map[string]any{}, ConversationPreferences: map[string]any{}}) if err != nil { t.Fatal(err) } return sp } first := create("第一位长者") second := create("第二位长者") if first.VoiceAgent == nil || first.VoiceAgent.Status != "READY" || first.VoiceAgent.BotID == second.VoiceAgent.BotID { t.Fatal(first, second) } if _, err = s.EnsureSubjectVoiceAgent(ctx, owner, first.ID); err != nil || provider.calls != 2 { t.Fatal("retry created another bot", err, provider.calls) } provider.fail = true failed := create("失败后重试") if failed.ID == "" || failed.VoiceAgent.Status != "FAILED" { t.Fatal(failed) } loaded, err := s.GetSubject(ctx, owner, failed.ID) if err != nil || loaded.VoiceAgent.Status != "FAILED" { t.Fatal(loaded, err) } provider.fail = false fixed, err := s.EnsureSubjectVoiceAgent(ctx, owner, failed.ID) if err != nil || fixed.Status != "READY" { t.Fatal(fixed, err) } }