Files
story/internal/billing/overview_boundaries_test.go
T

164 lines
7.9 KiB
Go
Raw Normal View History

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