310 lines
19 KiB
Go
310 lines
19 KiB
Go
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)
|
||
|
|
}
|
||
|
|
}
|