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,280 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user