Files

310 lines
19 KiB
Go
Raw Permalink Normal View History

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