Files
story/internal/saas/home_overview_integration_test.go
Codex Local bf1233d5df
ci / verify (push) Canceled after 0s
feat: expand companion platform, device controls, and memoir workflows
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.
2026-10-08 10:06:44 +08:00

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