Files
story/internal/elderagent/sync_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

248 lines
11 KiB
Go

package elderagent
import (
"context"
"errors"
"os"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
type personaProvider struct {
updates int
config, agent map[string]any
fail, verifyFail bool
onUpdate func()
}
func (p *personaProvider) Ensure(_ context.Context, name string, c, a map[string]any) (Bot, error) {
p.config = c
p.agent = a
return Bot{ID: "bot-" + name, Name: name}, nil
}
func (p *personaProvider) Update(_ context.Context, _ string, c, a map[string]any) error {
p.updates++
p.config = c
p.agent = a
if p.onUpdate != nil {
p.onUpdate()
}
if p.fail {
return errors.New("SECRET_PROVIDER_ERROR")
}
return nil
}
func (p *personaProvider) VerifyConfiguration(context.Context, string, map[string]any, map[string]any) error {
if p.verifyFail {
return errors.New("SECRET_VERIFY_ERROR")
}
return nil
}
func (p *personaProvider) role() string {
v, _ := configurationField(p.config, []string{"S2SConfig", "ProviderParams", "dialog", "system_role"})
s, _ := v.(string)
return s
}
type personaFixture struct {
s *Service
p *personaProvider
pool *pgxpool.Pool
id, public, device string
}
func personaTestFixture(t *testing.T) personaFixture {
t.Helper()
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL required")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, url)
if err != nil {
t.Fatal(err)
}
t.Cleanup(pool.Close)
f := personaFixture{pool: pool, id: uuid.NewString(), public: uuid.NewString(), device: uuid.NewString(), p: &personaProvider{}}
f.s = &Service{Pool: pool, Provider: f.p, CallbackURL: "https://example.com/callback", CallbackSecret: []byte(strings.Repeat("k", 32))}
f.exec(t, `INSERT INTO subject_profile(id,public_id,tenant_id,household_id,preferred_name,status,profile_data,conversation_preferences) VALUES($1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000001','10000000-0000-0000-0000-000000000201','旧称呼','active','{"background":"退休教师","interests":["京剧"]}','{"conversation_goal":"轻松交流","pace":"slow","preferred_topics":["家乡"],"avoided_topics":["收入"]}')`, f.id, f.public)
f.exec(t, `INSERT INTO consent_record(tenant_id,subject_id,consent_type,scope,policy_version,notice_digest,actor_user_id,evidence_ref) SELECT tenant_id,$1::uuid,consent_type,scope,policy_version,notice_digest,actor_user_id,evidence_ref FROM consent_record WHERE subject_id='10000000-0000-0000-0000-000000000401' AND withdrawn_at IS NULL`, f.id)
f.exec(t, `INSERT INTO consent_snapshot(tenant_id,subject_id,snapshot_version,effective_consents,source_consent_ids) VALUES('10000000-0000-0000-0000-000000000001',$1::uuid,1,'{"voice_chat":true}','[]')`, f.id)
raw := `{"S2SConfig":{"Provider":"volcano","ProviderParams":{"app":{"appid":"test-app","token":"test-token"},"tts":{"speaker":"voice"},"dialog":{"system_role":"基础人设\n你正在陪伴的长者称呼是旧称呼。","extra":{"model":"O"}}}},"MemoryConfig":{"Enable":false}}`
agent := `{"WelcomeMessage":"原开场","ServerMessageURLForRTS":"https://example.com/base","ServerMessageSignatureForRTS":"base-signature"}`
f.exec(t, `INSERT INTO subject_voice_agent(subject_id,bot_name,bot_id,status,config,agent_config,persona_base_role) VALUES($1::uuid,$2,$2,'READY',$3::jsonb,$4::jsonb,'基础人设')`, f.id, "test-"+f.id, raw, agent)
f.exec(t, `INSERT INTO device(id,tenant_id,serial_digest,model,hardware_revision,status,credential_ref,minimum_security_version,minimum_firmware_version) VALUES($1::uuid,'10000000-0000-0000-0000-000000000001',encode(sha256($1::text::bytea),'hex'),'test','test','registered',$1,'1','1')`, f.device)
return f
}
func (f personaFixture) exec(t *testing.T, q string, args ...any) {
t.Helper()
if _, err := f.pool.Exec(context.Background(), q, args...); err != nil {
t.Fatal(err)
}
}
func (f personaFixture) sync(t *testing.T) Info {
t.Helper()
i, err := f.s.Sync(context.Background(), f.public)
if err != nil {
t.Fatal(err)
}
return i
}
func TestPersonaSyncBackfillLatestAndClear(t *testing.T) {
f := personaTestFixture(t)
i := f.sync(t)
if i.PersonaSyncStatus != "SYNCED" || !i.PersonalizationIncluded || !strings.Contains(f.p.role(), "京剧") {
t.Fatal(i)
}
f.exec(t, `UPDATE subject_profile SET preferred_name='中间称呼',profile_data='{"interests":["养花"]}' WHERE id=$1::uuid`, f.id)
f.exec(t, `UPDATE subject_profile SET preferred_name='最新称呼',profile_data='{"interests":["编织"],"avatar_data_url":"SECRET_AVATAR"}',conversation_preferences='{"conversation_goal":"聊聊生活","preferred_topics":["手工"],"avoided_topics":["家庭矛盾"]}' WHERE id=$1::uuid`, f.id)
i = f.sync(t)
for _, v := range []string{"最新称呼", "编织", "聊聊生活", "手工", "家庭矛盾"} {
if !strings.Contains(f.p.role(), v) {
t.Fatal("missing field", v)
}
}
for _, v := range []string{"中间称呼", "旧称呼", "京剧", "养花", "SECRET_AVATAR"} {
if strings.Contains(f.p.role(), v) {
t.Fatal("stale or private field", v)
}
}
if i.PersonaSyncedRevision != i.PersonaRevision || f.p.updates != 2 {
t.Fatal("did not coalesce changes", i, f.p.updates)
}
rev := i.PersonaRevision
f.exec(t, `UPDATE subject_profile SET profile_data=profile_data||'{"avatar_data_url":"NEW_AVATAR"}'::jsonb,profile_version=profile_version+1 WHERE id=$1::uuid`, f.id)
if i = f.sync(t); i.PersonaRevision != rev || f.p.updates != 2 {
t.Fatal("avatar caused provider update")
}
f.exec(t, `UPDATE subject_profile SET profile_data='{}',conversation_preferences='{}' WHERE id=$1::uuid`, f.id)
f.sync(t)
if strings.Contains(f.p.role(), "编织") || strings.Contains(f.p.role(), "家庭矛盾") || strings.Contains(f.p.role(), "聊聊生活") {
t.Fatal("cleared field retained")
}
f.exec(t, `UPDATE subject_voice_agent SET config=jsonb_set(config,'{S2SConfig,ProviderParams,dialog,system_role}','"新版基础角色"') WHERE subject_id=$1::uuid`, f.id)
f.sync(t)
if !strings.HasPrefix(f.p.role(), "新版基础角色") {
t.Fatal("ignored base persona change")
}
}
func TestPersonaSyncRetryReadbackAndConsentRace(t *testing.T) {
f := personaTestFixture(t)
f.p.fail = true
i, err := f.s.Sync(context.Background(), f.public)
if err == nil || i.Status != "READY" || i.PersonaSyncStatus != "FAILED" || strings.Contains(err.Error(), "SECRET") {
t.Fatal(i, err)
}
f.p.fail = false
f.p.verifyFail = true
if _, err = f.s.Sync(context.Background(), f.public); err == nil {
t.Fatal("acknowledged unverified update")
}
f.p.verifyFail = false
f.exec(t, `UPDATE subject_profile SET preferred_name='重试最新称呼' WHERE id=$1::uuid`, f.id)
if err = f.s.SyncPending(context.Background()); err != nil {
t.Fatal(err)
}
i, err = f.s.Get(context.Background(), f.public)
if err != nil || i.PersonaSyncStatus != "SYNCED" || !strings.Contains(f.p.role(), "重试最新称呼") {
t.Fatal(i, err)
}
f.exec(t, `UPDATE subject_profile SET preferred_name='并发授权测试' WHERE id=$1::uuid`, f.id)
f.p.onUpdate = func() {
f.exec(t, `UPDATE consent_record SET withdrawn_at=now() WHERE subject_id=$1::uuid AND consent_type='personalized_memory'`, f.id)
f.p.onUpdate = nil
}
i = f.sync(t)
if i.PersonaSyncStatus == "SYNCED" || i.PersonaRevision == i.PersonaSyncedRevision {
t.Fatal("old request acknowledged new consent", i)
}
i = f.sync(t)
if i.PersonalizationIncluded || strings.Contains(f.p.role(), "退休教师") || strings.Contains(f.p.role(), "京剧") || !strings.Contains(f.p.role(), "收入") {
t.Fatal("withdrawal failed to clear personal facts or retain boundary", i)
}
}
func TestPersonaSyncDefersActiveCallAndCleansSessionContext(t *testing.T) {
f := personaTestFixture(t)
f.sync(t)
ctx := context.Background()
sid := uuid.NewString()
bot := "test-" + f.id
if err := f.s.PrepareWithWelcome(ctx, f.id, bot, sid, "本通临时记忆", "本通开场"); err != nil {
t.Fatal(err)
}
f.exec(t, `INSERT INTO call_session(id,device_id,tenant_id,subject_id,boot_id,hook_event_id,call_generation,binding_generation,manifest_status,budget_snapshot,call_state,remote_state) VALUES($1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000001',$3::uuid,$1::uuid,$1,0,0,'ISSUED','{"protocol":"DIRECT-CHAT-1"}','TALKING','UNKNOWN')`, sid, f.device, f.id)
f.exec(t, `UPDATE subject_profile SET preferred_name='通话后称呼' WHERE id=$1::uuid`, f.id)
updates := f.p.updates
if i := f.sync(t); i.PersonaSyncStatus != "DEFERRED" || f.p.updates != updates {
t.Fatal("overwrote active call", i)
}
f.exec(t, `UPDATE call_session SET call_state='ENDED',manifest_status='DISCARDED',local_audio_off=true,ended_at=now() WHERE id=$1::uuid`, sid)
if i := f.sync(t); i.PersonaSyncStatus != "DEFERRED" || f.p.updates != updates {
t.Fatal("overwrote SDK still running", i)
}
f.exec(t, `UPDATE call_session SET sdk_stopped=true WHERE id=$1::uuid`, sid)
i := f.sync(t)
if i.PersonaSyncStatus != "SYNCED" || strings.Contains(f.p.role(), "本通临时记忆") || strings.Contains(f.p.role(), "本通开场") || !strings.Contains(f.p.role(), "通话后称呼") || f.p.agent["WelcomeMessage"] != "原开场" {
t.Fatal("session context persisted", i)
}
var saved []byte
if err := f.pool.QueryRow(ctx, `SELECT config FROM subject_voice_agent WHERE subject_id=$1::uuid`, f.id).Scan(&saved); err != nil {
t.Fatal(err)
}
if strings.Contains(string(saved), "本通临时记忆") {
t.Fatal("modified stable source config")
}
// Opening preparation itself uses the latest profile, even before the worker.
f.exec(t, `UPDATE subject_profile SET profile_data='{"interests":["养花"]}' WHERE id=$1::uuid`, f.id)
if err := f.s.PrepareWithWelcome(ctx, f.id, bot, uuid.NewString(), "本通第二份记忆", "第二通开场"); err != nil {
t.Fatal(err)
}
if !strings.Contains(f.p.role(), "养花") || strings.Contains(f.p.role(), "京剧") || strings.Contains(f.p.role(), "本通临时记忆") {
t.Fatal("opening used stale persona")
}
}
func TestPersonaSyncConsentExpiryWithoutProfileEdit(t *testing.T) {
f := personaTestFixture(t)
f.exec(t, `UPDATE consent_record SET expires_at=now()+interval '300 milliseconds' WHERE subject_id=$1::uuid AND consent_type='personalized_memory'`, f.id)
f.sync(t)
time.Sleep(350 * time.Millisecond)
if err := f.s.SyncPending(context.Background()); err != nil {
t.Fatal(err)
}
if strings.Contains(f.p.role(), "京剧") {
t.Fatal("expired personal facts remained on provider")
}
}
func TestPersonaProfileLockAndRollback(t *testing.T) {
f := personaTestFixture(t)
ctx := context.Background()
f.sync(t)
tx, err := f.pool.Begin(ctx)
if err != nil {
t.Fatal(err)
}
defer tx.Rollback(ctx)
if _, err = tx.Exec(ctx, `UPDATE subject_profile SET preferred_name='未提交称呼' WHERE id=$1::uuid`, f.id); err != nil {
t.Fatal(err)
}
n := f.p.updates
i, err := f.s.Sync(ctx, f.public)
if err != nil || f.p.updates != n {
t.Fatal("published uncommitted edit", i, err)
}
tx.Rollback(ctx)
i = f.sync(t)
if strings.Contains(f.p.role(), "未提交称呼") || i.PersonaRevision != i.PersonaSyncedRevision {
t.Fatal("rollback left queued revision", i)
}
}