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.
250 lines
10 KiB
Go
250 lines
10 KiB
Go
package vikingmemory
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
|
|
"data-terminal-cloud/internal/audit"
|
|
"data-terminal-cloud/internal/callmemory"
|
|
"data-terminal-cloud/internal/deviceauth"
|
|
"data-terminal-cloud/internal/identity"
|
|
"data-terminal-cloud/internal/saas"
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
func TestManagedUploadPreservesScopeAndOnlyExtractsManagedEvents(t *testing.T) {
|
|
c := testClient(t, func(w http.ResponseWriter, r *http.Request) {
|
|
var p map[string]any
|
|
json.NewDecoder(r.Body).Decode(&p)
|
|
if p["session_id"] != "c_test" || p["resource_id"] != "mem-test" || p["store_file"] != false {
|
|
t.Fatal("wrong upload destination")
|
|
}
|
|
types := p["extract_memory_type"].([]any)
|
|
if len(types) != 1 || types[0] != "event_v1" {
|
|
t.Fatal("independent profile extraction enabled")
|
|
}
|
|
meta := p["metadata"].(map[string]any)
|
|
if meta["default_user_id"] != "elder-a" || meta["default_assistant_id"] != ManagedAssistantID {
|
|
t.Fatal("wrong elder")
|
|
}
|
|
fmt.Fprint(w, `{"code":0}`)
|
|
})
|
|
if e := c.AddManagedSession(context.Background(), "c_test", "elder-a", []Message{{Role: "user", RoleID: "elder-a", Content: "茶"}}, 1); e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
}
|
|
|
|
func TestManagedTranscriptToMemoryAndNextCall(t *testing.T) {
|
|
db := os.Getenv("TEST_DATABASE_URL")
|
|
if db == "" {
|
|
t.Skip("TEST_DATABASE_URL required")
|
|
}
|
|
ctx := context.Background()
|
|
p, e := pgxpool.New(ctx, db)
|
|
if e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
defer p.Close()
|
|
exec := func(q string, args ...any) {
|
|
t.Helper()
|
|
if _, e := p.Exec(ctx, q, args...); e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
}
|
|
subject, elder, device, sid := uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString()
|
|
tenant := "10000000-0000-0000-0000-000000000001"
|
|
exec(`INSERT INTO subject_profile(id,public_id,tenant_id,household_id,preferred_name,status) VALUES($1::uuid,$2::uuid,$3::uuid,'10000000-0000-0000-0000-000000000201','managed-test','active')`, subject, elder, tenant)
|
|
exec(`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`, subject)
|
|
exec(`INSERT INTO consent_snapshot(tenant_id,subject_id,snapshot_version,effective_consents,source_consent_ids) VALUES($1::uuid,$2::uuid,1,'{"voice_chat":true}','[]')`, tenant, subject)
|
|
exec(`INSERT INTO memory_user_state(subject_id,tenant_id) VALUES($1::uuid,$2::uuid)`, subject, tenant)
|
|
exec(`INSERT INTO device(id,tenant_id,serial_digest,model,hardware_revision,status,credential_ref,activation_state) VALUES($1::uuid,$2::uuid,$3,'test','test','registered','local-dev://test','ACTIVE')`, device, tenant, strings.Repeat("a", 28)+strings.ReplaceAll(device, "-", ""))
|
|
exec(`INSERT INTO device_binding(tenant_id,device_id,household_id,subject_id,bound_by,reason,generation) VALUES($1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000201',$3::uuid,'10000000-0000-0000-0000-000000000101','test',1)`, tenant, device, subject)
|
|
exec(`INSERT INTO subject_voice_agent(subject_id,bot_name,bot_id,status) VALUES($1::uuid,$2,$2,'READY')`, subject, "test-"+subject)
|
|
exec(`INSERT INTO device_activation_job(device_id,tenant_id,provider_device_name,provider_bot_id,status,binding_generation,config_version,sdk_registered_at) VALUES($1::uuid,$2::uuid,$3,'bot','READY',1,1,now())`, device, tenant, "test-"+device)
|
|
exec(`INSERT INTO call_session(id,tenant_id,subject_id,device_id,boot_id,hook_event_id,call_generation,binding_generation,call_state,transcript_state,ended_at,memory_mode,transcript_revision,budget_snapshot) VALUES($1::uuid,$2::uuid,$3::uuid,$4::uuid,$1::uuid,$1,1,1,'ENDED','PARTIAL',now(),'HOT_ONLY',2,'{"protocol":"DIRECT-CHAT-1"}')`, sid, tenant, subject, device)
|
|
exec(`INSERT INTO call_utterance(session_id,utterance_id,source_revision,speaker,text_content,interrupted,source_at) VALUES($1::uuid,'u',0,'USER','我喜欢淡茶',false,now()),($1::uuid,'a',0,'ASSISTANT','记住了',false,now()+interval '1 second')`, sid)
|
|
user := UserID(tenant, subject)
|
|
remote := SessionID(sid, 0)
|
|
uploads := 0
|
|
fail := true
|
|
deleted := false
|
|
cloudText := "喜欢淡茶"
|
|
c := testClient(t, func(w http.ResponseWriter, r *http.Request) {
|
|
var in map[string]any
|
|
json.NewDecoder(r.Body).Decode(&in)
|
|
switch {
|
|
case strings.HasSuffix(r.URL.Path, "/session/add"):
|
|
uploads++
|
|
if fail {
|
|
w.WriteHeader(503)
|
|
fmt.Fprint(w, `{"code":9}`)
|
|
return
|
|
}
|
|
msgs := in["messages"].([]any)
|
|
if len(msgs) != 2 || in["session_id"] != remote {
|
|
t.Fatal("lost or duplicated transcript")
|
|
}
|
|
for _, msg := range msgs {
|
|
v := msg.(map[string]any)
|
|
if v["role"] == "user" && v["role_id"] != user {
|
|
t.Fatal("wrong upload elder")
|
|
}
|
|
}
|
|
fmt.Fprint(w, `{"code":0}`)
|
|
case strings.HasSuffix(r.URL.Path, "/search"):
|
|
f := in["filter"].(map[string]any)
|
|
if f["user_id"].([]any)[0] != user || f["session_id"].([]any)[0] != remote {
|
|
t.Fatal("unscoped search")
|
|
}
|
|
items := []any{map[string]any{"id": "foreign", "user_id": []string{"other-elder"}, "assistant_id": []string{ManagedAssistantID}, "session_id": remote, "memory_info": map[string]any{"summary": "不能串人"}}}
|
|
if !deleted {
|
|
items = append(items, map[string]any{"id": "event-one", "user_id": []string{user}, "assistant_id": []string{ManagedAssistantID}, "session_id": remote, "memory_info": map[string]any{"summary": cloudText}})
|
|
}
|
|
json.NewEncoder(w).Encode(map[string]any{"code": 0, "data": map[string]any{"result_list": items}})
|
|
case strings.HasSuffix(r.URL.Path, "/update"):
|
|
if in["event_id"] != "event-one" {
|
|
t.Fatal("wrong update target")
|
|
}
|
|
cloudText = in["memory_info"].(map[string]any)["summary"].(string)
|
|
fmt.Fprint(w, `{"code":0}`)
|
|
case strings.HasSuffix(r.URL.Path, "/delete"):
|
|
if in["event_id"] != "event-one" {
|
|
t.Fatal("wrong delete target")
|
|
}
|
|
deleted = true
|
|
fmt.Fprint(w, `{"code":0}`)
|
|
case strings.HasSuffix(r.URL.Path, "/batch_delete"):
|
|
f := in["filter"].(map[string]any)
|
|
if f["user_id"].([]any)[0] != user || f["assistant_id"].([]any)[0] != ManagedAssistantID {
|
|
t.Fatal("unscoped withdrawal")
|
|
}
|
|
deleted = true
|
|
fmt.Fprint(w, `{"code":0}`)
|
|
default:
|
|
t.Fatal("unexpected endpoint", r.URL.Path)
|
|
}
|
|
})
|
|
m := NewManaged(p, c)
|
|
s := callmemory.NewService(p, nil)
|
|
s.ConfigureManagedSync(m.SyncNext)
|
|
s.ConfigureProjection(m)
|
|
due := func() { exec(`UPDATE call_memory_sync SET next_attempt_at=now() WHERE session_id=$1::uuid`, sid) }
|
|
if e = m.SyncNext(ctx); e == nil {
|
|
t.Fatal("failed upload reported successful")
|
|
}
|
|
fail = false
|
|
due()
|
|
if e = m.SyncNext(ctx); e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
due()
|
|
if e = m.SyncNext(ctx); e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
due()
|
|
if e = m.SyncNext(ctx); e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
if uploads != 2 {
|
|
t.Fatal("acknowledged upload repeated", uploads)
|
|
}
|
|
var n int
|
|
var id string
|
|
if e = p.QueryRow(ctx, `SELECT count(*),min(public_id::text) FROM curated_memory WHERE subject_id=$1::uuid`, subject).Scan(&n, &id); e != nil || n != 1 {
|
|
t.Fatal("import duplicate or foreign memory", n, e)
|
|
}
|
|
if e = s.ProjectNext(ctx); e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
// A provider correction may refresh an untouched auto-memory.
|
|
cloudText = "喜欢不加糖的淡茶"
|
|
due()
|
|
if e = m.SyncNext(ctx); e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
var contextText string
|
|
s.ConfigureDirect(callmemory.DirectOptions{CallbackURL: "https://example.com", CallbackSecret: []byte(strings.Repeat("k", 32)), Prepare: func(_ context.Context, gotSubject, bot, session, text string) error {
|
|
if gotSubject != subject {
|
|
t.Fatal("wrong preparation elder")
|
|
}
|
|
contextText = text
|
|
return nil
|
|
}})
|
|
next := uuid.NewString()
|
|
d := deviceauth.Identity{ID: device, TenantID: tenant, BindingGeneration: 1}
|
|
if _, e = s.DirectRequest(ctx, d, "start", callmemory.DirectInput{SessionID: next}); e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
if !strings.Contains(contextText, "淡茶") || strings.Contains(contextText, "串人") {
|
|
t.Fatal("next call did not receive scoped memory")
|
|
}
|
|
if _, e = s.DirectRequest(ctx, d, "end", callmemory.DirectInput{SessionID: next, Reason: "hangup", SDKStopped: true}); e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
principal := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000101"}
|
|
portal := saas.NewService(p, audit.NewRepository(), nil, nil)
|
|
visible, e := portal.ListSubjectMemories(ctx, principal, elder, "")
|
|
if e != nil || len(visible) != 1 || visible[0].ConfirmedBy != "" {
|
|
t.Fatal("mini-program cannot list automatically extracted memory", e, len(visible))
|
|
}
|
|
text := "喜欢温开水"
|
|
if _, e = portal.UpdateMemory(ctx, principal, id, "managed-test-edit", 2, saas.UpdateMemoryInput{Content: &text, Reason: "更正测试"}); e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
if e = s.ProjectNext(ctx); e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
if cloudText != text {
|
|
t.Fatal("edit not projected")
|
|
}
|
|
if e = s.ProjectNext(ctx); e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
due()
|
|
if e = m.SyncNext(ctx); e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
if e = p.QueryRow(ctx, `SELECT count(*) FROM call_utterance WHERE session_id=$1::uuid`, sid).Scan(&n); e != nil || n != 2 {
|
|
t.Fatal("chat records lost", e)
|
|
}
|
|
// Use the actual user deletion API; repeated import cannot resurrect it.
|
|
if _, e = portal.RequestMemoryDeletion(ctx, principal, id, "测试删除", "managed-test-delete", 3); e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
due()
|
|
if e = m.SyncNext(ctx); e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
var status string
|
|
if e = p.QueryRow(ctx, `SELECT status FROM curated_memory WHERE public_id=$1::uuid`, id).Scan(&status); e != nil || status != "PENDING_DELETE" {
|
|
t.Fatal("deleted record resurrected", status, e)
|
|
}
|
|
// Exact deletion projection and its confirmed absence; no second model involved.
|
|
r := callmemory.ProjectionRecord{ID: id, Subject: subject, Tenant: tenant, Status: "PENDING_DELETE", Policy: "NEVER"}
|
|
if _, e = m.Project(ctx, r, "", "", false); e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
if !deleted {
|
|
t.Fatal("remote deletion missing")
|
|
}
|
|
result, e := m.Project(ctx, r, "", "", true)
|
|
if e != nil || !result.Visible {
|
|
t.Fatal("cleanup acknowledgement missing", e)
|
|
}
|
|
deleted = false
|
|
exec(`UPDATE consent_record SET withdrawn_at=now() WHERE subject_id=$1::uuid AND consent_type='personalized_memory'`, subject)
|
|
due()
|
|
if e = m.SyncNext(ctx); e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
if !deleted {
|
|
t.Fatal("withdrawal did not clean managed cloud scope")
|
|
}
|
|
}
|