Files
story/internal/callmemory/direct_introduction_test.go
T

281 lines
13 KiB
Go
Raw Normal View History

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