feat: expand companion platform, device controls, and memoir workflows
ci / verify (push) Canceled after 0s
ci / verify (push) Canceled after 0s
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.
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user