feat: expand companion platform, device controls, and memoir workflows
ci / verify (push) Canceled after 0s
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.
This commit is contained in:
@@ -88,6 +88,7 @@ type usageOverviewRow struct {
|
||||
costMicros int64
|
||||
currency string
|
||||
hasCost bool
|
||||
isCallSession bool
|
||||
}
|
||||
|
||||
func (s *Service) FamilyUsageOverview(ctx context.Context, principal identity.Principal, householdPublicID, devicePublicID, timezone string, from, to time.Time) (UsageOverview, error) {
|
||||
@@ -158,6 +159,8 @@ func (s *Service) resolveUsageOverviewDevice(ctx context.Context, target *usageO
|
||||
query += ` AND (
|
||||
EXISTS(SELECT 1 FROM device_binding b WHERE b.tenant_id=d.tenant_id AND b.device_id=d.id AND b.household_id=$3::uuid AND b.unbound_at IS NULL)
|
||||
OR EXISTS(SELECT 1 FROM conversation c WHERE c.tenant_id=d.tenant_id AND c.device_id=d.id AND c.household_id=$3::uuid)
|
||||
OR EXISTS(SELECT 1 FROM call_session c JOIN subject_profile sp ON sp.id=c.subject_id AND sp.tenant_id=c.tenant_id
|
||||
WHERE c.tenant_id=d.tenant_id AND c.device_id=d.id AND sp.household_id=$3::uuid)
|
||||
)`
|
||||
args = append(args, target.householdID)
|
||||
}
|
||||
@@ -179,8 +182,8 @@ func (s *Service) buildUsageOverview(ctx context.Context, target usageOverviewTa
|
||||
from = from.UTC()
|
||||
to = to.UTC()
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT c.public_id::text,h.public_id::text,h.name,d.public_id::text,d.model,sp.preferred_name,c.status,c.started_at,c.ended_at,
|
||||
COALESCE(cost.cost_micros,0)::bigint,COALESCE(cost.currency,''),COALESCE(cost.has_cost,false)
|
||||
SELECT c.public_id::text AS conversation_id,h.public_id::text,h.name,d.public_id::text,d.model,sp.preferred_name,c.status,c.started_at,c.ended_at,
|
||||
COALESCE(cost.cost_micros,0)::bigint,COALESCE(cost.currency,''),COALESCE(cost.has_cost,false),false AS is_call_session
|
||||
FROM conversation c
|
||||
JOIN household h ON h.id=c.household_id AND h.tenant_id=c.tenant_id
|
||||
JOIN device d ON d.id=c.device_id AND d.tenant_id=c.tenant_id
|
||||
@@ -192,7 +195,19 @@ func (s *Service) buildUsageOverview(ctx context.Context, target usageOverviewTa
|
||||
WHERE c.tenant_id=$1::uuid AND c.started_at IS NOT NULL AND c.started_at>=$2 AND c.started_at<$3
|
||||
AND ($4::uuid IS NULL OR c.household_id=$4::uuid)
|
||||
AND ($5::uuid IS NULL OR c.device_id=$5::uuid)
|
||||
ORDER BY c.started_at DESC,c.public_id`, target.tenantID, from, to, nullableUUID(target.householdID), nullableUUID(target.deviceID))
|
||||
UNION ALL
|
||||
SELECT c.id::text,h.public_id::text,h.name,d.public_id::text,d.model,sp.preferred_name,
|
||||
CASE c.call_state WHEN 'ENDED' THEN 'COMPLETED' WHEN 'TALKING' THEN 'ACTIVE'
|
||||
WHEN 'CANCELLED' THEN 'FAILED' WHEN 'STARTING' THEN 'STARTING_AGENT' ELSE c.call_state END,
|
||||
c.created_at,c.ended_at,0::bigint,''::text,false,true
|
||||
FROM call_session c
|
||||
JOIN subject_profile sp ON sp.id=c.subject_id AND sp.tenant_id=c.tenant_id
|
||||
JOIN household h ON h.id=sp.household_id AND h.tenant_id=c.tenant_id
|
||||
JOIN device d ON d.id=c.device_id AND d.tenant_id=c.tenant_id
|
||||
WHERE c.tenant_id=$1::uuid AND c.created_at>=$2 AND c.created_at<$3
|
||||
AND ($4::uuid IS NULL OR sp.household_id=$4::uuid)
|
||||
AND ($5::uuid IS NULL OR c.device_id=$5::uuid)
|
||||
ORDER BY started_at DESC,conversation_id`, target.tenantID, from, to, nullableUUID(target.householdID), nullableUUID(target.deviceID))
|
||||
if err != nil {
|
||||
return UsageOverview{}, err
|
||||
}
|
||||
@@ -210,10 +225,15 @@ func (s *Service) buildUsageOverview(ctx context.Context, target usageOverviewTa
|
||||
today := time.Now().In(location).Format("2006-01-02")
|
||||
for rows.Next() {
|
||||
var row usageOverviewRow
|
||||
if err = rows.Scan(&row.conversationID, &row.householdID, &row.householdName, &row.deviceID, &row.deviceName, &row.subjectName, &row.status, &row.startedAt, &row.endedAt, &row.costMicros, &row.currency, &row.hasCost); err != nil {
|
||||
if err = rows.Scan(&row.conversationID, &row.householdID, &row.householdName, &row.deviceID, &row.deviceName, &row.subjectName, &row.status, &row.startedAt, &row.endedAt, &row.costMicros, &row.currency, &row.hasCost, &row.isCallSession); err != nil {
|
||||
return UsageOverview{}, err
|
||||
}
|
||||
duration := conversationDurationMS(row.startedAt, row.endedAt, to)
|
||||
// Match calls/query: a new session has no finalized duration until it ends.
|
||||
// Do not inflate abandoned preparations or missing end reports up to now.
|
||||
if row.isCallSession && row.endedAt == nil {
|
||||
duration = 0
|
||||
}
|
||||
date := row.startedAt.In(location).Format("2006-01-02")
|
||||
day := daily[date]
|
||||
if day == nil {
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -195,6 +195,10 @@ type quotaRow struct {
|
||||
}
|
||||
|
||||
func CheckQuotaTx(ctx context.Context, tx pgx.Tx, identity deviceauth.Identity, now time.Time) (*BudgetSnapshot, error) {
|
||||
// New and legacy call admissions share the same tenant concurrency boundary.
|
||||
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,19))`, identity.TenantID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tenantPolicy, err := readActiveQuota(ctx, tx, identity.TenantID, "TENANT", nil)
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, err
|
||||
@@ -256,7 +260,7 @@ func CheckQuotaTx(ctx context.Context, tx pgx.Tx, identity deviceauth.Identity,
|
||||
}
|
||||
for _, policy := range policies {
|
||||
var active int
|
||||
query := `SELECT count(*) FROM conversation WHERE tenant_id=$1::uuid AND status IN ('PREPARING','ROOM_ISSUED','STARTING_AGENT','ACTIVE','STOPPING')`
|
||||
query := `SELECT count(*) FROM (SELECT tenant_id,device_id FROM conversation WHERE status IN ('PREPARING','ROOM_ISSUED','STARTING_AGENT','ACTIVE','STOPPING') UNION ALL SELECT tenant_id,device_id FROM call_session WHERE manifest_status IN ('ISSUED','REVOKED') AND NOT(local_audio_off AND sdk_stopped AND remote_state IN ('NOT_STARTED','STOPPED_OBSERVED'))) admitted WHERE tenant_id=$1::uuid`
|
||||
args := []any{identity.TenantID}
|
||||
if hasDevicePolicy && policy.id == devicePolicy.id {
|
||||
query += ` AND device_id=$2::uuid`
|
||||
|
||||
Reference in New Issue
Block a user