Files

155 lines
8.5 KiB
Go
Raw Permalink Normal View History

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