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