package billing import ( "context" "errors" "net/url" "os" "strconv" "strings" "testing" "time" "data-terminal-cloud/internal/audit" "data-terminal-cloud/internal/database" "data-terminal-cloud/internal/identity" "github.com/google/uuid" ) // Run actual overview SQL and aggregation against an explicitly supplied, // disposable loopback database. This never uses TEST_DATABASE_URL or app config. // The minimal schema exercises the query, not the application's migrations. func TestUsageOverviewSyntheticBoundaries(t *testing.T) { raw := os.Getenv("TEST_USAGE_BOUNDARY_DATABASE_URL") if raw == "" { t.Skip("isolated TEST_USAGE_BOUNDARY_DATABASE_URL is not set") } u, err := url.Parse(raw) if err != nil || u.Hostname() != "127.0.0.1" || u.Path != "/story_usage_boundary" { t.Fatal("boundary tests require the disposable loopback story_usage_boundary database") } ctx := context.Background() base, err := database.Open(ctx, raw, "usage-boundary-schema") if err != nil { t.Fatal(err) } defer base.Close() schema := "usage_boundary_" + strings.ReplaceAll(uuid.NewString(), "-", "") if _, err = base.Exec(ctx, `CREATE SCHEMA `+schema); err != nil { t.Fatal(err) } defer func() { _, _ = base.Exec(ctx, `DROP SCHEMA `+schema+` CASCADE`) }() q := u.Query() q.Set("search_path", schema) u.RawQuery = q.Encode() pool, err := database.Open(ctx, u.String(), "usage-boundary-test") if err != nil { t.Fatal(err) } defer pool.Close() exec := func(query string, args ...any) { t.Helper() if _, err := pool.Exec(ctx, query, args...); err != nil { t.Fatal(err) } } exec(` CREATE TABLE tenant(id uuid PRIMARY KEY,public_id uuid,name text,status text); CREATE TABLE user_account(id uuid PRIMARY KEY,tenant_id uuid,oidc_issuer text,oidc_subject text,status text); CREATE TABLE household(id uuid PRIMARY KEY,public_id uuid,tenant_id uuid,name text,status text); CREATE TABLE household_member(tenant_id uuid,household_id uuid,user_id uuid,role text,status text); CREATE TABLE subject_profile(id uuid PRIMARY KEY,tenant_id uuid,household_id uuid,preferred_name text); CREATE TABLE device(id uuid PRIMARY KEY,public_id uuid,tenant_id uuid,model text); CREATE TABLE device_binding(tenant_id uuid,device_id uuid,household_id uuid,unbound_at timestamptz); CREATE TABLE conversation(id uuid PRIMARY KEY,public_id uuid,tenant_id uuid,household_id uuid,subject_id uuid,device_id uuid,status text,started_at timestamptz,ended_at timestamptz); CREATE TABLE conversation_cost_line(conversation_id uuid,cost_micros bigint,currency text); CREATE TABLE call_session(id uuid PRIMARY KEY,tenant_id uuid,subject_id uuid,device_id uuid,call_state text,created_at timestamptz,ended_at timestamptz); `) tenant, user, familyA, familyB, subjectA, subjectB, deviceA, deviceB := uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString() exec(`INSERT INTO tenant VALUES($1,$1,'synthetic tenant','active')`, tenant) exec(`INSERT INTO user_account VALUES($1,$2,'synthetic issuer','synthetic owner','active')`, user, tenant) for _, family := range []string{familyA, familyB} { exec(`INSERT INTO household VALUES($1,$1,$2,'synthetic family','active')`, family, tenant) exec(`INSERT INTO household_member VALUES($1,$2,$3,'owner','active')`, tenant, family, user) } exec(`INSERT INTO subject_profile VALUES($1,$2,$3,'synthetic A'),($4,$2,$5,'synthetic B')`, subjectA, tenant, familyA, subjectB, familyB) for _, device := range []string{deviceA, deviceB} { exec(`INSERT INTO device VALUES($1,$1,$2,'synthetic device')`, device, tenant) exec(`INSERT INTO device_binding VALUES($1,$2,$3,NULL)`, tenant, device, familyA) } service, err := NewService(pool, audit.NewRepository()) if err != nil { t.Fatal(err) } owner := identity.Principal{Issuer: "synthetic issuer", Subject: "synthetic owner"} add := func(device string, at time.Time, duration time.Duration, legacy bool) { t.Helper() id, end := uuid.NewString(), at.Add(duration) if legacy { exec(`INSERT INTO conversation VALUES($1,$1,$2,$3,$4,$5,'COMPLETED',$6,$7)`, id, tenant, familyA, subjectA, device, at, end) } else { exec(`INSERT INTO call_session VALUES($1,$2,$3,$4,'ENDED',$5,$6)`, id, tenant, subjectA, device, at, end) } } read := func(family, device string, from, to time.Time) UsageOverview { t.Helper() result, err := service.FamilyUsageOverview(ctx, owner, family, device, "Asia/Shanghai", from, to) if err != nil { t.Fatal(err) } return result } loc, _ := time.LoadLocation("Asia/Shanghai") midnight := time.Date(2026, 10, 6, 0, 0, 0, 0, loc) for _, to := range []time.Time{midnight.Add(-time.Millisecond), midnight, midnight.Add(time.Millisecond)} { for _, days := range []int{30, 90, 365} { t.Run(to.Format("15:04:05.000")+"/"+strconv.Itoa(days), func(t *testing.T) { exec(`TRUNCATE conversation,call_session,conversation_cost_line`) local := to.In(loc) from := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, loc).AddDate(0, 0, -(days - 1)) add(deviceA, from.Add(-time.Millisecond), time.Minute, false) add(deviceA, from, time.Minute, false) add(deviceB, from.Add(time.Millisecond), 2*time.Minute, false) add(deviceA, from.Add(2*time.Millisecond), 30*time.Second, true) add(deviceB, to.Add(-time.Millisecond), time.Millisecond, false) add(deviceA, to, time.Minute, false) add(deviceB, to, time.Minute, true) add(deviceB, to.Add(time.Millisecond), time.Minute, false) all := read(familyA, "", from, to) if all.ConversationCount != 4 || all.TotalDurationMS != 210001 || all.AverageDurationMS != 52500 || len(all.RecentConversations) != 4 { t.Fatalf("[from,to) aggregation: %+v", all) } a, b := read(familyA, deviceA, from, to), read(familyA, deviceB, from, to) if a.ConversationCount != 2 || b.ConversationCount != 2 || a.TotalDurationMS != 90000 || b.TotalDurationMS != 120001 || all.TotalDurationMS != a.TotalDurationMS+b.TotalDurationMS { t.Fatalf("independent devices: A=%+v B=%+v all=%+v", a, b, all) } if len(all.Devices) != 2 || len(all.Daily) != 2 || all.Daily[0].Date != from.Format("2006-01-02") || all.Daily[0].ConversationCount != 3 || all.Daily[1].Date != to.Add(-time.Millisecond).In(loc).Format("2006-01-02") { t.Fatalf("Beijing daily grouping: %+v", all.Daily) } }) } } t.Run("midnight_attribution_and_recent_cap", func(t *testing.T) { exec(`TRUNCATE conversation,call_session,conversation_cost_line`) from, to := midnight.AddDate(0, 0, -1), midnight.Add(3*time.Minute) add(deviceA, midnight.Add(-time.Millisecond), time.Minute, false) add(deviceB, midnight, 2*time.Minute, false) add(deviceA, midnight.Add(time.Millisecond), 30*time.Second, true) all := read(familyA, "", from, to) if len(all.Daily) != 2 || all.Daily[0].Date != "2026-10-05" || all.Daily[0].DurationMS != 60000 || all.Daily[1].Date != "2026-10-06" || all.Daily[1].DurationMS != 150000 { t.Fatalf("cross-midnight duration belongs to its start date: %+v", all.Daily) } for i := 0; i < 22; i++ { add(deviceA, midnight.Add(-time.Duration(i+2)*time.Minute), time.Minute, false) } all = read(familyA, "", from, to) if all.ConversationCount != 25 || all.TotalDurationMS != 1530000 || len(all.RecentConversations) != 20 { t.Fatalf("recent cap must not cap totals: %+v", all) } for i := 1; i < len(all.RecentConversations); i++ { if all.RecentConversations[i].StartedAt.After(all.RecentConversations[i-1].StartedAt) { t.Fatal("recent records are not ordered") } } empty := read(familyB, "", from, to) if empty.ConversationCount != 0 || empty.TotalDurationMS != 0 || len(empty.Devices) != 0 || len(empty.Daily) != 0 || len(empty.RecentConversations) != 0 { t.Fatalf("empty family retained A data: %+v", empty) } if _, err := service.FamilyUsageOverview(ctx, owner, familyB, deviceA, "Asia/Shanghai", from, to); !errors.Is(err, ErrAccessDenied) { t.Fatalf("foreign device in empty family: %v", err) } }) }