Files
story/internal/saas/voice_agent_integration_test.go
Codex Local bf1233d5df
ci / verify (push) Canceled after 0s
feat: expand companion platform, device controls, and memoir workflows
Add customer and operator login, BLE provisioning, device fleet controls, call memory, memoir generation, and refreshed family and maintenance portals. Include API contracts, migrations, firmware sources, tests, assets, and deployment documentation.
2026-10-08 10:06:44 +08:00

149 lines
5.7 KiB
Go

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