281 lines
13 KiB
Go
281 lines
13 KiB
Go
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)
|
||
|
|
}
|