package saas import ( "context" "errors" "os" "strings" "testing" "time" "data-terminal-cloud/internal/identity" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" ) type homeFixture struct { pool *pgxpool.Pool service *Service subject, public, device, snapshot, contextID string owner identity.Principal } func newHomeFixture(t *testing.T, zone string) homeFixture { t.Helper() url := os.Getenv("TEST_DATABASE_URL") if url == "" { t.Skip("TEST_DATABASE_URL required") } pool, err := pgxpool.New(context.Background(), url) if err != nil { t.Fatal(err) } t.Cleanup(pool.Close) f := homeFixture{pool: pool, service: NewService(pool, nil, nil, nil), subject: uuid.NewString(), public: uuid.NewString(), device: uuid.NewString(), snapshot: uuid.NewString(), contextID: uuid.NewString(), owner: identity.Principal{Issuer: testIssuer, Subject: testOwnerSubject}} f.exec(t, `INSERT INTO subject_profile(id,public_id,tenant_id,household_id,preferred_name,status,timezone) VALUES($1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000001','10000000-0000-0000-0000-000000000201','home-test','active',$3)`, f.subject, f.public, zone) f.exec(t, `INSERT INTO memory_user_state(subject_id,tenant_id,legacy_projection_pending) VALUES($1::uuid,'10000000-0000-0000-0000-000000000001',false)`, f.subject) f.exec(t, `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`, f.subject) f.exec(t, `INSERT INTO consent_snapshot(id,tenant_id,subject_id,snapshot_version,effective_consents,source_consent_ids) VALUES($1::uuid,'10000000-0000-0000-0000-000000000001',$2::uuid,1,'{"voice_chat":true}','[]')`, f.snapshot, f.subject) f.exec(t, `INSERT INTO context_snapshot(id,tenant_id,subject_id,profile_version,consent_snapshot_id,prompt_bundle_version,engine_mode,content_digest) VALUES($1::uuid,'10000000-0000-0000-0000-000000000001',$2::uuid,1,$3::uuid,'test','s2s_hybrid',$4)`, f.contextID, f.subject, f.snapshot, strings.Repeat("0", 64)) f.exec(t, `INSERT INTO device(id,tenant_id,serial_digest,model,hardware_revision,status,credential_ref,minimum_security_version,minimum_firmware_version) VALUES($1::uuid,'10000000-0000-0000-0000-000000000001',$2,'test','test','registered','local-dev://home-test','1','1')`, f.device, strings.ReplaceAll(f.device, "-", "")+strings.ReplaceAll(f.device, "-", "")) return f } func (f homeFixture) exec(t *testing.T, q string, args ...any) { t.Helper() if _, err := f.pool.Exec(context.Background(), q, args...); err != nil { t.Fatal(err) } } func (f homeFixture) read(t *testing.T, now time.Time) HomeOverview { t.Helper() v, err := f.service.subjectHomeOverviewAt(context.Background(), f.owner, f.public, now) if err != nil { t.Fatal(err) } return v } func (f homeFixture) direct(t *testing.T, start time.Time, duration time.Duration, status string, speakers ...string) string { t.Helper() id := uuid.NewString() var end any = start.Add(duration) if duration < 0 { end = nil } 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,transcript_state,transcript_revision,memory_mode,budget_snapshot,created_at,ended_at) VALUES($1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000001',$3::uuid,$1::uuid,$1,0,0,$4,'COMPLETE',1,'HOT_ONLY','{"protocol":"DIRECT-CHAT-1"}',$5,$6)`, id, f.device, f.subject, status, start, end) for _, speaker := range speakers { f.utterance(t, id, speaker, "fixture utterance", start.Add(time.Second)) } return id } func (f homeFixture) utterance(t *testing.T, id, speaker, content string, at time.Time) { t.Helper() f.exec(t, `INSERT INTO call_utterance(session_id,utterance_id,source_revision,speaker,text_content,interrupted,source_at) VALUES($1::uuid,$2,1,$3,$4,false,$5)`, id, uuid.NewString(), speaker, content, at) } func (f homeFixture) legacy(t *testing.T, start time.Time, duration time.Duration, status string) (string, string) { t.Helper() id, pub := uuid.NewString(), uuid.NewString() f.exec(t, `INSERT INTO conversation(id,public_id,tenant_id,household_id,subject_id,device_id,consent_snapshot_id,context_snapshot_id,provider_task_ref,engine_mode,retention_mode,status,allocation_expires_at,started_at,ended_at,created_at) VALUES($1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000001','10000000-0000-0000-0000-000000000201',$3::uuid,$4::uuid,$5::uuid,$6::uuid,$1,'s2s_hybrid','transcript_only',$7,$8,$8,$9,$8)`, id, pub, f.subject, f.device, f.snapshot, f.contextID, status, start, start.Add(duration)) return id, pub } func (f homeFixture) memory(t *testing.T, session, content string) string { t.Helper() id, version := uuid.NewString(), uuid.NewString() f.exec(t, `INSERT INTO curated_memory(id,tenant_id,subject_id,memory_type,sensitivity) VALUES($1::uuid,'10000000-0000-0000-0000-000000000001',$2::uuid,'EVENT','normal')`, id, f.subject) f.exec(t, `INSERT INTO memory_version(id,memory_id,version_no,content,source_refs,content_digest) VALUES($1::uuid,$2::uuid,1,$3,jsonb_build_array(jsonb_build_object('session_id',$4::text)),$5)`, version, id, content, session, strings.Repeat("0", 64)) f.exec(t, `UPDATE curated_memory SET current_version_id=$2::uuid WHERE id=$1::uuid`, id, version) f.exec(t, `INSERT INTO managed_memory_origin(memory_id,subject_id,session_id,provider_id,provider_digest) VALUES($1::uuid,$2::uuid,$3::uuid,$1,$4)`, id, f.subject, session, strings.Repeat("0", 64)) return id } func TestHomeOverviewWholeWeekAndDirectInterruptedCalls(t *testing.T) { f := newHomeFixture(t, "Asia/Shanghai") other := newHomeFixture(t, "Asia/Shanghai") loc, _ := time.LoadLocation("Asia/Shanghai") monday := time.Date(2026, 9, 28, 0, 0, 0, 0, loc) now := monday.AddDate(0, 0, 4).Add(12 * time.Hour) for i := 0; i < 25; i++ { f.direct(t, monday.Add(24*time.Hour+time.Duration(i)*time.Minute), time.Minute, "ENDED", "USER", "ASSISTANT", "ASSISTANT") } f.legacy(t, monday.Add(48*time.Hour), 2*time.Minute, "COMPLETED") f.direct(t, monday.Add(23*time.Hour+59*time.Minute), 2*time.Minute, "ENDED", "ASSISTANT") latest := f.direct(t, monday.Add(72*time.Hour), 98*time.Second, "FAILED", "USER", "ASSISTANT") f.memory(t, latest, "latest direct recap") f.direct(t, monday.Add(-time.Minute), 2*time.Minute, "ENDED", "USER", "ASSISTANT") // starts in the prior week f.direct(t, now.Add(-time.Hour), time.Minute, "FAILED") // failed preparation, no interaction deleted := f.direct(t, now.Add(-30*time.Minute), time.Minute, "ENDED") f.utterance(t, deleted, "USER", "[deleted]", now.Add(-29*time.Minute)) f.direct(t, now.Add(-time.Minute), -1, "TALKING", "USER") f.direct(t, now.Add(time.Hour), time.Minute, "ENDED", "USER") other.direct(t, now.Add(-time.Minute), time.Second, "ENDED", "USER") result := f.read(t, now) if result.ConversationCount != 28 || result.TotalDurationMS != 1838000 || len(result.Daily) != 7 { t.Fatalf("week=%+v", result) } if result.WeekStart != "2026-09-28" || result.WeekEnd != "2026-10-05" || result.Daily[0].DurationMS != 120000 || result.Daily[1].ConversationCount != 25 { t.Fatalf("calendar=%+v", result) } if result.LatestConversation == nil || result.LatestConversation.ID != latest || result.LatestConversation.Status != "FAILED" || result.LatestConversation.DurationMS != 98000 || result.LatestConversation.SummaryKind != "memory" || result.LatestConversation.Summary != "latest direct recap" { t.Fatalf("latest=%+v", result.LatestConversation) } } func TestHomeOverviewLatestOutsideWeekAndDST(t *testing.T) { f := newHomeFixture(t, "America/New_York") loc, _ := time.LoadLocation("America/New_York") now := time.Date(2026, 3, 9, 12, 0, 0, 0, loc) a := f.direct(t, time.Date(2026, 3, 8, 1, 59, 0, 0, loc), 2*time.Minute, "ENDED", "USER") b := f.direct(t, time.Date(2026, 3, 8, 1, 59, 0, 0, loc), 2*time.Minute, "ENDED", "USER") latest := a if b > a { latest = b } result := f.read(t, now) if result.ConversationCount != 0 || result.TotalDurationMS != 0 || result.LatestConversation == nil || result.LatestConversation.ID != latest || result.WeekStart != "2026-03-09" || result.WeekEnd != "2026-03-16" { t.Fatalf("outside week=%+v", result) } // The historical New York profile does not change the Beijing calendar. // Query one second before Monday in Beijing, still in the prior week. result = f.read(t, time.Date(2026, 3, 8, 15, 59, 59, 0, time.UTC)) if result.WeekStart != "2026-03-02" || result.WeekEnd != "2026-03-09" || result.Daily[6].DurationMS != 240000 || result.ConversationCount != 2 { t.Fatalf("DST week=%+v", result) } } func TestHomeOverviewHistoricalProfileTimezoneUsesBeijingWeek(t *testing.T) { f := newHomeFixture(t, "America/Los_Angeles") before, _ := time.Parse(time.RFC3339, "2026-10-04T15:58:00Z") after, _ := time.Parse(time.RFC3339, "2026-10-04T16:01:00Z") f.direct(t, before, time.Minute, "ENDED", "USER") id := f.direct(t, after, 12*time.Second, "FAILED", "ASSISTANT") result := f.read(t, after.Add(time.Minute)) if result.Timezone != "Asia/Shanghai" || result.WeekStart != "2026-10-05" || result.WeekEnd != "2026-10-12" || result.ConversationCount != 1 || result.TotalDurationMS != 12000 || result.LatestConversation == nil || result.LatestConversation.ID != id { t.Fatalf("Beijing week for historical profile=%+v", result) } var savedTimezone string if err := f.pool.QueryRow(context.Background(), `SELECT timezone FROM subject_profile WHERE id=$1::uuid`, f.subject).Scan(&savedTimezone); err != nil { t.Fatal(err) } if savedTimezone != "America/Los_Angeles" { t.Fatalf("profile timezone was modified: %s", savedTimezone) } } func TestHomeOverviewDirectSummaryStatesAndNoOlderFallback(t *testing.T) { f := newHomeFixture(t, "Asia/Shanghai") now := time.Now().UTC().Truncate(24 * time.Hour).Add(12 * time.Hour) old := f.direct(t, now.Add(-time.Hour), time.Minute, "ENDED", "USER", "ASSISTANT") f.memory(t, old, "older recap must not substitute") latest := f.direct(t, now.Add(-10*time.Minute), time.Minute, "FAILED", "ASSISTANT") f.exec(t, `UPDATE call_session SET transcript_state='DRAINING' WHERE id=$1::uuid`, latest) assert := func(want string) { t.Helper() v := f.read(t, now).LatestConversation if v == nil || v.ID != latest || v.SummaryState != want || v.Summary != "" { t.Fatalf("want %s latest=%+v", want, v) } } assert("PROCESSING") f.exec(t, `UPDATE call_session SET transcript_state='PARTIAL' WHERE id=$1::uuid`, latest) assert("INSUFFICIENT_TRANSCRIPT") f.utterance(t, latest, "USER", "new user turn", now.Add(-9*time.Minute)) assert("PROCESSING") f.exec(t, `INSERT INTO call_memory_sync(session_id,transcript_revision,state,last_error) VALUES($1::uuid,1,'PENDING','provider unavailable')`, latest) assert("SYNC_FAILED") f.exec(t, `UPDATE call_memory_sync SET state='UPLOADED',last_error=NULL WHERE session_id=$1::uuid`, latest) assert("PROCESSING") f.exec(t, `UPDATE call_memory_sync SET state='SYNCED' WHERE session_id=$1::uuid`, latest) assert("NO_SUMMARY") // A result for an older transcript revision cannot claim the current one synced. f.exec(t, `UPDATE call_session SET transcript_revision=2 WHERE id=$1::uuid`, latest) assert("PROCESSING") } func TestHomeOverviewSummaryAuthorizationAndPrivacy(t *testing.T) { f := newHomeFixture(t, "Asia/Shanghai") now := time.Now().UTC().Truncate(24 * time.Hour).Add(12 * time.Hour) id := f.direct(t, now.Add(-time.Hour), time.Minute, "ENDED", "USER", "ASSISTANT") memory := f.memory(t, id, "normal recap") editor := identity.Principal{Issuer: testIssuer, Subject: testEditorSubject} readEditor := func(want string) { t.Helper() v, err := f.service.subjectHomeOverviewAt(context.Background(), editor, f.public, now) if err != nil || v.ConversationCount != 1 || v.LatestConversation == nil || v.LatestConversation.SummaryState != want { t.Fatalf("editor=%+v err=%v", v, err) } if want != "READY" && v.LatestConversation.Summary != "" { t.Fatal("text exposed without permission") } } readEditor("UNAVAILABLE") f.exec(t, `INSERT INTO memory_access_grant(subject_id,user_id,can_read,can_write,sensitive_read) VALUES($1::uuid,'10000000-0000-0000-0000-000000000102',true,false,false)`, f.subject) readEditor("READY") if _, err := f.service.subjectHomeOverviewAt(context.Background(), identity.Principal{Issuer: testIssuer, Subject: testOutsideSubject}, f.public, now); !errors.Is(err, ErrAccessDenied) { t.Fatalf("outsider error=%v", err) } f.exec(t, `UPDATE memory_user_state SET privacy_epoch=privacy_epoch+1 WHERE subject_id=$1::uuid`, f.subject) readEditor("UNAVAILABLE") f.exec(t, `UPDATE call_session SET privacy_epoch=(SELECT privacy_epoch FROM memory_user_state WHERE subject_id=$2::uuid) WHERE id=$1::uuid`, id, f.subject) readEditor("READY") f.exec(t, `UPDATE consent_record SET withdrawn_at=now() WHERE subject_id=$1::uuid AND consent_type='transcript_storage'`, f.subject) readEditor("UNAVAILABLE") // Even metadata remains subject-scoped; summary consent does not remove the // actual completed duration, while explicit source deletion removes the row. f.exec(t, `INSERT INTO memory_source_suppression(subject_id,memory_id,source_refs,privacy_epoch,scope) VALUES($1::uuid,$3::uuid,jsonb_build_array(jsonb_build_object('session_id',$2::text)),1,'SOURCE_AND_DERIVED')`, f.subject, id, memory) result := f.read(t, now) if result.LatestConversation != nil || result.ConversationCount != 0 { t.Fatalf("deleted source resurfaced=%+v", result) } } func TestHomeOverviewExpiredConsentAndDisabledMemory(t *testing.T) { for _, kind := range []string{"personalized_memory", "transcript_storage"} { t.Run(kind, func(t *testing.T) { f := newHomeFixture(t, "Asia/Shanghai") now := time.Now().UTC().Truncate(24 * time.Hour).Add(12 * time.Hour) id := f.direct(t, now.Add(-time.Hour), time.Minute, "ENDED", "USER", "ASSISTANT") f.memory(t, id, "must remain private") f.exec(t, `UPDATE consent_record SET granted_at=now()-interval '1 day',expires_at=now()-interval '1 second' WHERE subject_id=$1::uuid AND consent_type=$2`, f.subject, kind) // Re-align epoch so the authorization check itself, not just the fence, is tested. f.exec(t, `UPDATE call_session SET privacy_epoch=(SELECT privacy_epoch FROM memory_user_state WHERE subject_id=$2::uuid) WHERE id=$1::uuid`, id, f.subject) v := f.read(t, now) if v.LatestConversation.SummaryState != "UNAVAILABLE" || v.LatestConversation.Summary != "" { t.Fatalf("expired %s=%+v", kind, v) } }) } f := newHomeFixture(t, "Asia/Shanghai") now := time.Now().UTC().Truncate(24 * time.Hour).Add(12 * time.Hour) id := f.direct(t, now.Add(-time.Hour), time.Minute, "ENDED", "USER", "ASSISTANT") memory := f.memory(t, id, "disabled recap") f.exec(t, `UPDATE curated_memory SET status='DISABLED' WHERE id=$1::uuid`, memory) f.exec(t, `UPDATE call_session SET privacy_epoch=(SELECT privacy_epoch FROM memory_user_state WHERE subject_id=$2::uuid) WHERE id=$1::uuid`, id, f.subject) f.exec(t, `INSERT INTO call_memory_sync(session_id,transcript_revision,state) VALUES($1::uuid,1,'SYNCED')`, id) v := f.read(t, now) if v.LatestConversation.SummaryState != "NO_SUMMARY" || v.LatestConversation.Summary != "" { t.Fatalf("disabled=%+v", v) } } func TestHomeOverviewLegacySummaryAndSensitiveGrant(t *testing.T) { f := newHomeFixture(t, "Asia/Shanghai") now := time.Now().UTC().Truncate(24 * time.Hour).Add(12 * time.Hour) id, pub := f.legacy(t, now.Add(-time.Hour), time.Minute, "FAILED") turn := uuid.NewString() f.exec(t, `INSERT INTO conversation_turn(id,tenant_id,subject_id,conversation_id,turn_key,sequence_no,speaker,start_at,end_at,final_text,source_type) VALUES($1::uuid,'10000000-0000-0000-0000-000000000001',$2::uuid,$3::uuid,$1,1,'subject',$4,$4,'fixture user text','provider')`, turn, f.subject, id, now.Add(-time.Hour)) f.exec(t, `INSERT INTO conversation_summary(tenant_id,subject_id,conversation_id,summary_type,content,source_turn_ids,model_ref,prompt_version,generation_version) VALUES('10000000-0000-0000-0000-000000000001',$1::uuid,$2::uuid,'conversation_summary','legacy recap',jsonb_build_array($3::text),'test','test',1)`, f.subject, id, turn) result := f.read(t, now) if result.LatestConversation.ID != pub || result.LatestConversation.Source != "conversation" || result.LatestConversation.Summary != "legacy recap" || result.LatestConversation.SummaryKind != "conversation_summary" { t.Fatalf("legacy=%+v", result) } f.exec(t, `INSERT INTO memory_access_grant(subject_id,user_id,can_read,can_write,sensitive_read) VALUES($1::uuid,'10000000-0000-0000-0000-000000000102',true,false,false)`, f.subject) editor := identity.Principal{Issuer: testIssuer, Subject: testEditorSubject} result, err := f.service.subjectHomeOverviewAt(context.Background(), editor, f.public, now) if err != nil || result.LatestConversation.SummaryState != "UNAVAILABLE" || result.LatestConversation.Summary != "" { t.Fatalf("legacy sensitive grant=%+v err=%v", result, err) } f.exec(t, `UPDATE memory_access_grant SET sensitive_read=true WHERE subject_id=$1::uuid`, f.subject) result, err = f.service.subjectHomeOverviewAt(context.Background(), editor, f.public, now) if err != nil || result.LatestConversation.Summary != "legacy recap" { t.Fatalf("legacy authorized=%+v err=%v", result, err) } f.exec(t, `UPDATE consent_record SET withdrawn_at=now() WHERE subject_id=$1::uuid AND consent_type='story_extraction'`, f.subject) result = f.read(t, now) if result.LatestConversation.SummaryState != "UNAVAILABLE" || result.LatestConversation.Summary != "" { t.Fatalf("legacy consent=%+v", result) } } func TestHomeOverviewMemoryCorrectionPreservesCallStatistics(t *testing.T) { f := newHomeFixture(t, "Asia/Shanghai") now := time.Now().UTC().Truncate(24 * time.Hour).Add(12 * time.Hour) id := f.direct(t, now.Add(-time.Hour), time.Minute, "FAILED", "USER", "ASSISTANT") memory := f.memory(t, id, "original recap") before := f.read(t, now) // A critical edit creates MEMORY_AND_DERIVED suppression and increments the // epoch. Neither event means the historical call itself has been deleted. f.exec(t, `UPDATE curated_memory SET sensitivity='sensitive' WHERE id=$1::uuid`, memory) after := f.read(t, now) if after.ConversationCount != before.ConversationCount || after.TotalDurationMS != before.TotalDurationMS || after.LatestConversation == nil || after.LatestConversation.ID != id || after.LatestConversation.Summary != "" { t.Fatalf("memory correction changed call facts: before=%+v after=%+v", before, after) } // Re-aligning the epoch still cannot expose the sensitive memory in a normal // homepage recap, even to an owner who can separately read sensitive records. f.exec(t, `UPDATE call_session SET privacy_epoch=(SELECT privacy_epoch FROM memory_user_state WHERE subject_id=$2::uuid) WHERE id=$1::uuid`, id, f.subject) f.exec(t, `INSERT INTO call_memory_sync(session_id,transcript_revision,state) VALUES($1::uuid,1,'SYNCED')`, id) after = f.read(t, now) if after.LatestConversation.SummaryState != "NO_SUMMARY" || after.LatestConversation.Summary != "" { t.Fatalf("sensitive recap exposed=%+v", after) } }