package callmemory import ( "context" "errors" "strings" "testing" "data-terminal-cloud/internal/companionopening" "data-terminal-cloud/internal/deviceauth" "github.com/google/uuid" ) // These tests exercise the authenticated device service against TEST_DATABASE_URL. // Provider preparation is always a local substitute: no device or supplier call // is made. Normal end is an operational completion, not proof of audio playback. func introductionFixture(t *testing.T) (fixture, deviceauth.Identity) { t.Helper() f, d := directFixture(t) f.s.direct.PrepareWithWelcome = func(context.Context, string, string, string, string, string) error { return nil } return f, d } func introductionStart(t *testing.T, f fixture, d deviceauth.Identity, sid string, wantIntroduction bool) any { t.Helper() out, err := f.s.DirectRequest(context.Background(), d, "start", DirectInput{SessionID: sid}) if err != nil { t.Fatal(err) } var raw []byte if err = f.p.QueryRow(context.Background(), `SELECT budget_snapshot->'opening' FROM call_session WHERE id=$1::uuid`, sid).Scan(&raw); err != nil { t.Fatal(err) } var metadata directOpeningMetadata if len(raw) != 0 { if err = StrictJSON(raw, &metadata); err != nil { t.Fatal(err) } } if wantIntroduction { if openingWelcome(out) != companionopening.Introduction().Text { t.Fatalf("first call did not receive the approved introduction: %q", openingWelcome(out)) } if metadata.ID != "introduction-v1" || metadata.Category != "introduction" || metadata.Version != "companion-opening-v2" { t.Fatalf("wrong introduction metadata: %+v", metadata) } if strings.Contains(string(raw), companionopening.Introduction().Text) { t.Fatal("opening metadata retained spoken text instead of bounded template keys") } } else if openingWelcome(out) == companionopening.Introduction().Text || metadata.ID == "introduction-v1" || metadata.Category == "introduction" { t.Fatalf("completed binding received the introduction again: %+v", metadata) } return out } func introductionEnd(t *testing.T, f fixture, d deviceauth.Identity, sid, reason string, stopped bool) { t.Helper() if _, err := f.s.DirectRequest(context.Background(), d, "end", DirectInput{SessionID: sid, Reason: reason, SDKStopped: stopped}); err != nil { t.Fatal(err) } } func TestDirectIntroductionWithoutDynamicOpeningOrStoredMemory(t *testing.T) { for _, tc := range []struct { name string preferences string revoke []string }{ {name: "dynamic_disabled", preferences: `{"dynamic_opening":false}`}, {name: "no_memory_or_transcripts", preferences: `{"dynamic_opening":false}`, revoke: []string{"personalized_memory", "transcript_storage"}}, {name: "no_transcripts", preferences: `{"dynamic_opening":true}`, revoke: []string{"transcript_storage"}}, {name: "unsupported_preferences", preferences: `{"dynamic_opening":"unsupported"}`}, } { t.Run(tc.name, func(t *testing.T) { f, d := introductionFixture(t) f.exec(t, `UPDATE subject_profile SET conversation_preferences=$2::jsonb WHERE id=$1::uuid`, f.subject, tc.preferences) for _, consent := range tc.revoke { f.exec(t, `UPDATE consent_record SET withdrawn_at=now() WHERE subject_id=$1::uuid AND consent_type=$2 AND withdrawn_at IS NULL`, f.subject, consent) } var prepared []string f.s.direct.PrepareWithWelcome = func(_ context.Context, subject, bot, sid, contextText, welcome string) error { if subject != f.subject || bot != "elder-bot-"+f.subject || !validUUID(sid) { t.Fatal("preparation crossed the current elder or session") } prepared = append(prepared, welcome) return nil } sid := uuid.NewString() first := introductionStart(t, f, d, sid, true) if len(prepared) != 1 || prepared[0] != openingWelcome(first) { t.Fatal("device and provider did not receive the same introduction") } if tc.name == "no_memory_or_transcripts" { var mode string if err := f.p.QueryRow(context.Background(), `SELECT memory_mode FROM call_session WHERE id=$1::uuid`, sid).Scan(&mode); err != nil || mode != "OFF" { t.Fatal("introduction enabled memory without permission", mode, err) } } introductionEnd(t, f, d, sid, "hangup", true) next := uuid.NewString() introductionStart(t, f, d, next, false) introductionEnd(t, f, d, next, "hangup", true) }) } } func TestDirectIntroductionFrozenForSameSession(t *testing.T) { f, d := introductionFixture(t) prepared := 0 f.s.direct.PrepareWithWelcome = func(context.Context, string, string, string, string, string) error { prepared++ return nil } sid := uuid.NewString() first := introductionStart(t, f, d, sid, true) f.exec(t, `UPDATE subject_profile SET conversation_preferences='{"dynamic_opening":true}' WHERE id=$1::uuid`, f.subject) f.exec(t, `UPDATE subject_voice_agent SET agent_config='{"WelcomeMessage":"后来保存的普通问候。"}' WHERE subject_id=$1::uuid`, f.subject) retry := introductionStart(t, f, d, sid, true) if prepared != 1 || string(jsonBytes(first.(map[string]any)["params"])) != string(jsonBytes(retry.(map[string]any)["params"])) { t.Fatal("renewal changed the frozen introduction or prepared the provider again", prepared) } introductionEnd(t, f, d, sid, "hangup", true) next := uuid.NewString() introductionStart(t, f, d, next, false) if prepared != 2 { t.Fatal("next session did not prepare exactly once", prepared) } introductionEnd(t, f, d, next, "hangup", true) } func TestDirectIntroductionPreparationAndFailedCallsCanRetry(t *testing.T) { f, d := introductionFixture(t) f.s.direct.PrepareWithWelcome = func(context.Context, string, string, string, string, string) error { return errors.New("test provider unavailable") } failedPrepare := uuid.NewString() if _, err := f.s.DirectRequest(context.Background(), d, "start", DirectInput{SessionID: failedPrepare}); err == nil || err.Error() != "VOICE_AGENT_CONFIG_FAILED" { t.Fatal("failed preparation unexpectedly started a call", err) } var count int if err := f.p.QueryRow(context.Background(), `SELECT count(*) FROM call_session WHERE id=$1::uuid`, failedPrepare).Scan(&count); err != nil || count != 0 { t.Fatal("failed preparation committed an introduction", count, err) } f.s.direct.PrepareWithWelcome = func(context.Context, string, string, string, string, string) error { return nil } for _, reason := range []string{"start_failed", "disconnected", "timeout"} { sid := uuid.NewString() introductionStart(t, f, d, sid, true) introductionEnd(t, f, d, sid, reason, true) var state string if err := f.p.QueryRow(context.Background(), `SELECT call_state FROM call_session WHERE id=$1::uuid`, sid).Scan(&state); err != nil || state != "FAILED" { t.Fatal("failed call was promoted to completion", state, err) } } sid := uuid.NewString() introductionStart(t, f, d, sid, true) introductionEnd(t, f, d, sid, "hangup", true) next := uuid.NewString() introductionStart(t, f, d, next, false) introductionEnd(t, f, d, next, "hangup", true) } func TestDirectIntroductionRequiresLocalAudioOffAndSDKStopped(t *testing.T) { for _, tc := range []struct { name string sdkStopped bool audioOff bool }{ {name: "sdk_not_stopped", sdkStopped: false, audioOff: true}, {name: "audio_not_off", sdkStopped: true, audioOff: false}, } { t.Run(tc.name, func(t *testing.T) { f, d := introductionFixture(t) sid := uuid.NewString() introductionStart(t, f, d, sid, true) introductionEnd(t, f, d, sid, "hangup", tc.sdkStopped) // Direct end currently sets audio-off. Seed an incomplete legacy state // to ensure selection checks both safety fields, not just ENDED. if !tc.audioOff { f.exec(t, `UPDATE call_session SET local_audio_off=false WHERE id=$1::uuid`, sid) } next := uuid.NewString() introductionStart(t, f, d, next, true) introductionEnd(t, f, d, next, "hangup", true) }) } } func TestDirectIntroductionCompletionSurvivesMissingSubtitlesHistoryAndEpoch(t *testing.T) { f, d := introductionFixture(t) first := uuid.NewString() introductionStart(t, f, d, first, true) introductionEnd(t, f, d, first, "hangup", true) var transcriptCount int if err := f.p.QueryRow(context.Background(), `SELECT count(*) FROM call_utterance WHERE session_id=$1::uuid`, first).Scan(&transcriptCount); err != nil || transcriptCount != 0 { t.Fatal("fixture unexpectedly relied on subtitle evidence", transcriptCount, err) } // Completion is per binding, independent of memory epochs and the five-item // opening rotation window. Later failed sessions must not erase it. f.exec(t, `UPDATE memory_user_state SET privacy_epoch=privacy_epoch+1 WHERE subject_id=$1::uuid`, f.subject) f.exec(t, `UPDATE subject_profile SET conversation_preferences='{"dynamic_opening":true}' WHERE id=$1::uuid`, f.subject) for i := 0; i < 7; i++ { sid := uuid.NewString() introductionStart(t, f, d, sid, false) introductionEnd(t, f, d, sid, "start_failed", true) } next := uuid.NewString() introductionStart(t, f, d, next, false) introductionEnd(t, f, d, next, "hangup", true) } func TestDirectIntroductionExistingOrdinaryCallReceivesOneIntroduction(t *testing.T) { f, d := introductionFixture(t) old := uuid.NewString() f.exec(t, `INSERT INTO call_session(id,device_id,tenant_id,subject_id,boot_id,hook_event_id,call_generation,binding_generation,call_state,manifest_status,local_audio_off,sdk_stopped,created_at,ended_at,budget_snapshot) VALUES($1::uuid,$2::uuid,$3::uuid,$4::uuid,$1::uuid,$1,0,$5,'ENDED','DISCARDED',true,true,now()-interval '1 minute',now(),'{"protocol":"DIRECT-CHAT-1","opening":{"version":"companion-opening-v1","id":"legacy-neutral","topic_key":"neutral","category":"neutral"}}')`, old, f.device, f.tenant, f.subject, d.BindingGeneration) first := uuid.NewString() introductionStart(t, f, d, first, true) introductionEnd(t, f, d, first, "hangup", true) next := uuid.NewString() introductionStart(t, f, d, next, false) introductionEnd(t, f, d, next, "hangup", true) } func introductionRebind(t *testing.T, f fixture, d deviceauth.Identity, subject string) deviceauth.Identity { t.Helper() // Exercise the real generation trigger rather than manually assigning a // generation: returning to the same elder must create a fresh binding. f.exec(t, `UPDATE device_binding SET unbound_at=now() WHERE device_id=$1::uuid AND unbound_at IS NULL`, d.ID) f.exec(t, `INSERT INTO device_binding(tenant_id,device_id,household_id,subject_id,bound_by,reason) VALUES($1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000201',$3::uuid,'10000000-0000-0000-0000-000000000101','introduction-test-rebind')`, f.tenant, d.ID, subject) if err := f.p.QueryRow(context.Background(), `SELECT binding_generation FROM device WHERE id=$1::uuid`, d.ID).Scan(&d.BindingGeneration); err != nil { t.Fatal(err) } f.exec(t, `UPDATE device_activation_job SET binding_generation=$2,status='READY',config_ack_at=now(),engine_ready_at=now(),sdk_registered_at=now(),config_expires_at=now()+interval '1 hour',ready_binding_generation=$2 WHERE device_id=$1::uuid`, d.ID, d.BindingGeneration) return d } func TestDirectIntroductionRebindingAToBToA(t *testing.T) { f, d := introductionFixture(t) other, _ := introductionFixture(t) original := d for i, subject := range []string{f.subject, other.subject, f.subject} { if i > 0 { d = introductionRebind(t, f, d, subject) } sid := uuid.NewString() introductionStart(t, f, d, sid, true) introductionEnd(t, f, d, sid, "hangup", true) next := uuid.NewString() introductionStart(t, f, d, next, false) introductionEnd(t, f, d, next, "hangup", true) } if d.BindingGeneration <= original.BindingGeneration { t.Fatal("fixture did not advance the binding generation") } if _, err := f.s.DirectRequest(context.Background(), original, "start", DirectInput{SessionID: uuid.NewString()}); err == nil || err.Error() != "DEVICE_BINDING_CHANGED" { t.Fatal("stale device generation was accepted", err) } } func TestDirectIntroductionAnotherDeviceForSameElder(t *testing.T) { f, d := introductionFixture(t) first := uuid.NewString() introductionStart(t, f, d, first, true) introductionEnd(t, f, d, first, "hangup", true) otherDevice, otherBoot := uuid.NewString(), uuid.NewString() f.exec(t, `INSERT INTO device(id,tenant_id,serial_digest,model,hardware_revision,status,credential_ref,minimum_security_version,minimum_firmware_version,onboarding_boot_id,binding_generation,activation_state) VALUES($1::uuid,$2::uuid,$3,'test','test','registered','local-dev://call-test','1','1',$4::uuid,0,'ACTIVE')`, otherDevice, f.tenant, hash([]byte(otherDevice)), otherBoot) f.exec(t, `INSERT INTO device_binding(tenant_id,device_id,household_id,subject_id,bound_by,reason) VALUES($1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000201',$3::uuid,'10000000-0000-0000-0000-000000000101','introduction-test-second-device')`, f.tenant, otherDevice, f.subject) f.exec(t, `INSERT INTO device_activation_job(device_id,tenant_id,provider_device_name,provider_bot_id,status,binding_generation,config_version,config_ack_at,engine_ready_at,sdk_registered_at,config_expires_at,ready_binding_generation) VALUES($1::uuid,$2::uuid,$3,'bot','READY',1,1,now(),now(),now(),now()+interval '1 hour',1)`, otherDevice, f.tenant, "test-"+otherDevice) other := deviceauth.Identity{ID: otherDevice, TenantID: f.tenant, BindingGeneration: 1} secondFirst := uuid.NewString() introductionStart(t, f, other, secondFirst, true) introductionEnd(t, f, other, secondFirst, "hangup", true) next := uuid.NewString() introductionStart(t, f, d, next, false) introductionEnd(t, f, d, next, "hangup", true) }