ci / verify (push) Canceled after 0s
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.
164 lines
7.9 KiB
Go
164 lines
7.9 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|