package billing import ( "context" "errors" "os" "strings" "testing" "time" "data-terminal-cloud/internal/audit" "data-terminal-cloud/internal/database" "data-terminal-cloud/internal/identity" "github.com/google/uuid" ) func TestUsageOverviewIncludesCallSessionsIntegration(t *testing.T) { url := os.Getenv("TEST_DATABASE_URL") if url == "" { t.Skip("TEST_DATABASE_URL is not set") } ctx := context.Background() pool, err := database.Open(ctx, url, "usage-call-session-test") if err != nil { t.Fatal(err) } defer pool.Close() service, err := NewService(pool, audit.NewRepository()) if err != nil { t.Fatal(err) } exec := func(q string, args ...any) { t.Helper() if _, err := pool.Exec(ctx, q, args...); err != nil { t.Fatal(err) } } tenant := "10000000-0000-0000-0000-000000000001" household, subject, device, legacyDevice, snapshot, contextID := uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString() // Each fixture has its own household, so other billing tests cannot alter its totals. exec(`INSERT INTO household(id,public_id,tenant_id,name,owner_user_id,status) VALUES($1::uuid,$1::uuid,$2::uuid,'usage fixture','10000000-0000-0000-0000-000000000101','active')`, household, tenant) exec(`INSERT INTO household_member(tenant_id,household_id,user_id,role,status) VALUES($1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000101','owner','active')`, tenant, household) exec(`INSERT INTO subject_profile(id,public_id,tenant_id,household_id,preferred_name,status) VALUES($1::uuid,$1::uuid,$2::uuid,$3::uuid,'usage subject','active')`, subject, tenant, household) for _, id := range []string{device, legacyDevice} { exec(`INSERT INTO device(id,public_id,tenant_id,serial_digest,model,hardware_revision,status,credential_ref,minimum_security_version,minimum_firmware_version) VALUES($1::uuid,$1::uuid,$2::uuid,$3,'usage device','test','registered','local-dev://usage','1','1')`, id, tenant, strings.Repeat(strings.ReplaceAll(id, "-", ""), 2)) } exec(`INSERT INTO consent_snapshot(id,tenant_id,subject_id,snapshot_version,effective_consents,source_consent_ids) VALUES($1::uuid,$2::uuid,$3::uuid,1,'{}','[]')`, snapshot, tenant, subject) exec(`INSERT INTO context_snapshot(id,tenant_id,subject_id,profile_version,consent_snapshot_id,prompt_bundle_version,engine_mode,content_digest) VALUES($1::uuid,$2::uuid,$3::uuid,1,$4::uuid,'test','s2s_hybrid',$5)`, contextID, tenant, subject, snapshot, strings.Repeat("0", 64)) defer func() { for _, q := range []string{ `DELETE FROM call_utterance WHERE session_id IN (SELECT id FROM call_session WHERE subject_id=$1::uuid)`, `DELETE FROM call_session WHERE subject_id=$1::uuid`, `DELETE FROM conversation WHERE subject_id=$1::uuid`, `DELETE FROM context_snapshot WHERE subject_id=$1::uuid`, `DELETE FROM consent_snapshot WHERE subject_id=$1::uuid`, `DELETE FROM subject_profile WHERE id=$1::uuid`, } { exec(q, subject) } exec(`DELETE FROM device WHERE id IN ($1::uuid,$2::uuid)`, device, legacyDevice) exec(`DELETE FROM household_member WHERE household_id=$1::uuid`, household) exec(`DELETE FROM web_event WHERE household_id=$1::uuid`, household) exec(`DELETE FROM household WHERE id=$1::uuid`, household) }() now := time.Now().UTC() loc, _ := time.LoadLocation("Asia/Shanghai") local := now.In(loc) midnight := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, loc).AddDate(0, 0, -1) from := midnight.AddDate(0, 0, -1) // Use prior calendar days so fixtures remain finished even just after midnight. start := midnight.Add(10 * time.Hour) direct := func(at time.Time, duration time.Duration, state string) string { t.Helper() id := uuid.NewString() var ended any = at.Add(duration) if duration < 0 { ended = nil } exec(`INSERT INTO call_session(id,device_id,tenant_id,subject_id,boot_id,hook_event_id,call_generation,binding_generation,call_state,created_at,ended_at) VALUES($1::uuid,$2::uuid,$3::uuid,$4::uuid,$1::uuid,$1,0,0,$5,$6,$7)`, id, device, tenant, subject, state, at, ended) return id } legacy := uuid.NewString() exec(`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,$1::uuid,$2::uuid,$3::uuid,$4::uuid,$5::uuid,$6::uuid,$7::uuid,$1,'s2s_hybrid','none','COMPLETED',$8,$8,$9,$8)`, legacy, tenant, household, subject, legacyDevice, snapshot, contextID, midnight.Add(-10*time.Minute), midnight.Add(-7*time.Minute)) crossMidnight := direct(midnight.Add(-time.Minute), 2*time.Minute, "ENDED") ended := direct(start, 4*time.Minute, "ENDED") failed := direct(start.Add(5*time.Minute), 30*time.Second, "FAILED") pending := direct(start.Add(10*time.Minute), -1, "STARTING") // Multiple final utterances must not multiply a session, and no transcript // consent or retained text is needed to count the other sessions. for i := 0; i < 3; i++ { exec(`INSERT INTO call_utterance(session_id,utterance_id,source_revision,speaker,text_content,interrupted,source_at) VALUES($1::uuid,$2,1,'USER','fixture',false,$3)`, ended, uuid.NewString(), start.Add(time.Second)) } direct(from.Add(-time.Minute), time.Second, "ENDED") // outside range direct(now.Add(time.Minute), time.Second, "ENDED") // upper bound is exclusive owner := identity.Principal{Issuer: billingTestIssuer, Subject: "00000000-0000-0000-0000-000000000101"} result, err := service.FamilyUsageOverview(ctx, owner, household, "", "Asia/Shanghai", from, now) if err != nil { t.Fatal(err) } if result.ConversationCount != 5 || result.TotalDurationMS != 570000 || result.AverageDurationMS != 114000 || len(result.RecentConversations) != 5 { t.Fatalf("mixed totals: %+v", result) } if result.CostAvailable || result.Currency != "" || result.CostMicros != 0 { t.Fatalf("unrated costs: %+v", result) } statuses := map[string]string{} for _, item := range result.RecentConversations { statuses[item.ID] = item.Status } if statuses[crossMidnight] != "COMPLETED" || statuses[ended] != "COMPLETED" || statuses[failed] != "FAILED" || statuses[pending] != "STARTING_AGENT" { t.Fatalf("statuses: %v", statuses) } for i := 1; i < len(result.RecentConversations); i++ { if result.RecentConversations[i].StartedAt.After(result.RecentConversations[i-1].StartedAt) { t.Fatal("recent sessions not ordered") } } yesterday := midnight.AddDate(0, 0, -1).Format("2006-01-02") var yesterdayDuration int64 for _, day := range result.Daily { if day.Date == yesterday { yesterdayDuration = day.DurationMS } } if yesterdayDuration != 300000 || result.TodayDurationMS != 0 || result.ActiveDays != 2 { t.Fatalf("local date totals: %+v", result) } // This device has no active binding and no legacy conversations. Its new // session history must still allow a scoped device filter. filtered, err := service.FamilyUsageOverview(ctx, owner, household, device, "Asia/Shanghai", from, now) if err != nil || filtered.ConversationCount != 4 || filtered.TotalDurationMS != 390000 || filtered.Scope.DeviceID != device { t.Fatalf("device totals: %+v err=%v", filtered, err) } // Moving the current device binding does not change the original subject's // household ownership of historical sessions. exec(`INSERT INTO device_binding(tenant_id,device_id,household_id,subject_id,generation) VALUES($1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000201','10000000-0000-0000-0000-000000000401',1)`, tenant, device) filtered, err = service.FamilyUsageOverview(ctx, owner, household, device, "Asia/Shanghai", from, now) exec(`DELETE FROM device_binding WHERE device_id=$1::uuid`, device) if err != nil || filtered.ConversationCount != 4 { t.Fatalf("rebound history: %+v err=%v", filtered, err) } other := identity.Principal{Issuer: billingTestIssuer, Subject: "00000000-0000-0000-0000-000000000103"} if _, err := service.FamilyUsageOverview(ctx, other, household, "", "Asia/Shanghai", from, now); !errors.Is(err, ErrAccessDenied) { t.Fatalf("cross-tenant access: %v", err) } if _, err := service.FamilyUsageOverview(ctx, owner, "10000000-0000-0000-0000-000000000211", device, "Asia/Shanghai", from, now); !errors.Is(err, ErrAccessDenied) { t.Fatalf("cross-household device access: %v", err) } operator := identity.Principal{Issuer: billingTestIssuer, Subject: billingAdminSubject} ops, err := service.OpsUsageOverview(ctx, operator, billingTenantPublicID, household, device, "Asia/Shanghai", from, now) if err != nil || ops.ConversationCount != 4 || ops.TotalDurationMS != 390000 { t.Fatalf("ops totals: %+v err=%v", ops, err) } }