168 lines
8.2 KiB
Go
168 lines
8.2 KiB
Go
package billing
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"data-terminal-cloud/internal/audit"
|
|
"data-terminal-cloud/internal/database"
|
|
"data-terminal-cloud/internal/deviceauth"
|
|
"data-terminal-cloud/internal/identity"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
const (
|
|
billingTestIssuer = "http://localhost:58180/realms/voice-local"
|
|
billingTenantPublicID = "10000000-0000-0000-0000-000000000010"
|
|
billingOtherTenantID = "20000000-0000-0000-0000-000000000010"
|
|
billingAdminSubject = "00000000-0000-0000-0000-000000000104"
|
|
billingApproverSubject = "00000000-0000-0000-0000-000000000105"
|
|
billingDevicePublicID = "10000000-0000-0000-0000-000000000511"
|
|
billingTestSource = "integration://billing-service"
|
|
billingTestChangePrefix = "费用集成测试"
|
|
)
|
|
|
|
func TestServicePriceAndQuotaLifecycle(t *testing.T) {
|
|
databaseURL := os.Getenv("TEST_DATABASE_URL")
|
|
if databaseURL == "" {
|
|
t.Skip("TEST_DATABASE_URL is not set")
|
|
}
|
|
ctx := context.Background()
|
|
pool, err := database.Open(ctx, databaseURL, "billing-service-test")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer pool.Close()
|
|
cleanupBillingFixture(t, ctx, pool)
|
|
defer cleanupBillingFixture(t, context.Background(), pool)
|
|
|
|
service, err := NewService(pool, audit.NewRepository())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
admin := identity.Principal{Issuer: billingTestIssuer, Subject: billingAdminSubject}
|
|
approver := identity.Principal{Issuer: billingTestIssuer, Subject: billingApproverSubject}
|
|
priceInput := CreatePriceBookInput{
|
|
TenantID: billingTenantPublicID, Provider: " MOCK ", Currency: " cny ",
|
|
SourceRef: billingTestSource, ChangeSummary: billingTestChangePrefix + "价格版本",
|
|
EffectiveFrom: time.Now().UTC().Add(-time.Hour),
|
|
Rates: []RateInput{
|
|
{UsageCategory: " llm ", MetricCode: " INPUT_TOKENS ", Unit: " TOKEN ", UnitPriceMicros: 2},
|
|
{UsageCategory: " tts ", MetricCode: " AUDIO_SECONDS ", Unit: " SECOND ", UnitPriceMicros: 5},
|
|
},
|
|
}
|
|
createdPrice, created, err := service.CreatePriceBook(ctx, admin, "billing-integration-price-1", "billing-price-create", priceInput)
|
|
if err != nil || !created || createdPrice.Status != "DRAFT" || createdPrice.Provider != "mock" || createdPrice.Currency != "CNY" || len(createdPrice.Rates) != 2 {
|
|
t.Fatalf("created price=%#v created=%v err=%v", createdPrice, created, err)
|
|
}
|
|
replayedPrice, created, err := service.CreatePriceBook(ctx, admin, "billing-integration-price-1", "billing-price-replay", priceInput)
|
|
if err != nil || created || replayedPrice.ID != createdPrice.ID {
|
|
t.Fatalf("replayed price=%#v created=%v err=%v", replayedPrice, created, err)
|
|
}
|
|
if _, err := service.PublishPriceBook(ctx, admin, billingTenantPublicID, createdPrice.ID, "billing-price-self", "本人不得审批", createdPrice.RowVersion); !errors.Is(err, ErrSelfApproval) {
|
|
t.Fatalf("self approval error=%v", err)
|
|
}
|
|
publishedPrice, err := service.PublishPriceBook(ctx, approver, billingTenantPublicID, createdPrice.ID, "billing-price-publish", "另一运营人员复核发布", createdPrice.RowVersion)
|
|
if err != nil || publishedPrice.Status != "PUBLISHED" || publishedPrice.PublishedBy == nil || publishedPrice.RowVersion != createdPrice.RowVersion+1 {
|
|
t.Fatalf("published price=%#v err=%v", publishedPrice, err)
|
|
}
|
|
if _, err := service.PublishPriceBook(ctx, approver, billingTenantPublicID, createdPrice.ID, "billing-price-repeat", "重复发布应拒绝", publishedPrice.RowVersion); !errors.Is(err, ErrStateConflict) {
|
|
t.Fatalf("repeat publish error=%v", err)
|
|
}
|
|
prices, err := service.ListPriceBooks(ctx, admin, billingTenantPublicID)
|
|
if err != nil || len(prices) != 1 || prices[0].ID != createdPrice.ID {
|
|
t.Fatalf("prices=%#v err=%v", prices, err)
|
|
}
|
|
|
|
tenantQuotaInput := QuotaInput{
|
|
ScopeType: " tenant ", BillingTimezone: "Asia/Shanghai", Currency: " cny ",
|
|
DailyLimitMicros: 10_000_000, MonthlyLimitMicros: 100_000_000,
|
|
MaxConcurrentCalls: 10, MaxCallDurationSec: 3600,
|
|
InProgressAction: " complete_current_turn ", ChangeSummary: billingTestChangePrefix + "客户组织额度V1",
|
|
}
|
|
tenantQuota, created, err := service.CreateQuotaPolicy(ctx, approver, billingTenantPublicID, "billing-integration-quota-1", "billing-quota-create", tenantQuotaInput)
|
|
if err != nil || !created || tenantQuota.Status != "ACTIVE" || tenantQuota.ScopeType != "TENANT" || tenantQuota.VersionNo != 1 {
|
|
t.Fatalf("tenant quota=%#v created=%v err=%v", tenantQuota, created, err)
|
|
}
|
|
replayedQuota, created, err := service.CreateQuotaPolicy(ctx, approver, billingTenantPublicID, "billing-integration-quota-1", "billing-quota-replay", tenantQuotaInput)
|
|
if err != nil || created || replayedQuota.ID != tenantQuota.ID {
|
|
t.Fatalf("replayed quota=%#v created=%v err=%v", replayedQuota, created, err)
|
|
}
|
|
deviceQuotaInput := tenantQuotaInput
|
|
deviceQuotaInput.ScopeType = "DEVICE"
|
|
deviceQuotaInput.ScopeID = billingDevicePublicID
|
|
deviceQuotaInput.DailyLimitMicros = 5_000_000
|
|
deviceQuotaInput.MonthlyLimitMicros = 50_000_000
|
|
deviceQuotaInput.MaxCallDurationSec = 1800
|
|
deviceQuotaInput.InProgressAction = "STOP_IMMEDIATELY"
|
|
deviceQuotaInput.ChangeSummary = billingTestChangePrefix + "设备额度V1"
|
|
deviceQuota, created, err := service.CreateQuotaPolicy(ctx, approver, billingTenantPublicID, "billing-integration-quota-device", "billing-device-quota-create", deviceQuotaInput)
|
|
if err != nil || !created || deviceQuota.ScopeID == nil || *deviceQuota.ScopeID != billingDevicePublicID {
|
|
t.Fatalf("device quota=%#v created=%v err=%v", deviceQuota, created, err)
|
|
}
|
|
tenantQuotaInput.DailyLimitMicros = 20_000_000
|
|
tenantQuotaInput.MonthlyLimitMicros = 200_000_000
|
|
tenantQuotaInput.ChangeSummary = billingTestChangePrefix + "客户组织额度V2"
|
|
replacementQuota, created, err := service.CreateQuotaPolicy(ctx, approver, billingTenantPublicID, "billing-integration-quota-2", "billing-quota-replace", tenantQuotaInput)
|
|
if err != nil || !created || replacementQuota.VersionNo != 2 || replacementQuota.Status != "ACTIVE" {
|
|
t.Fatalf("replacement quota=%#v created=%v err=%v", replacementQuota, created, err)
|
|
}
|
|
quotas, err := service.ListQuotaPolicies(ctx, admin, billingTenantPublicID)
|
|
if err != nil || len(quotas) != 3 {
|
|
t.Fatalf("quotas=%#v err=%v", quotas, err)
|
|
}
|
|
if _, err := service.ListQuotaPolicies(ctx, approver, billingOtherTenantID); !errors.Is(err, ErrAccessDenied) {
|
|
t.Fatalf("cross-tenant quota access error=%v", err)
|
|
}
|
|
tx, err := pool.Begin(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
deviceIdentity := deviceauth.Identity{
|
|
ID: "10000000-0000-0000-0000-000000000501", TenantID: "10000000-0000-0000-0000-000000000001",
|
|
}
|
|
budget, err := CheckQuotaTx(ctx, tx, deviceIdentity, time.Now().UTC())
|
|
if err != nil || budget == nil || budget.TenantPolicyID == nil || budget.DevicePolicyID == nil || budget.DailyLimitMicros != 5_000_000 || budget.MaxCallDurationSec != 1800 || budget.InProgressAction != "STOP_IMMEDIATELY" {
|
|
_ = tx.Rollback(ctx)
|
|
t.Fatalf("effective budget=%#v err=%v", budget, err)
|
|
}
|
|
if err := FreezeBudgetTx(ctx, tx, "", deviceIdentity.TenantID, nil); err != nil {
|
|
_ = tx.Rollback(ctx)
|
|
t.Fatal(err)
|
|
}
|
|
noPolicy, err := CheckQuotaTx(ctx, tx, deviceauth.Identity{ID: "20000000-0000-0000-0000-000000000501", TenantID: "20000000-0000-0000-0000-000000000001"}, time.Now().UTC())
|
|
if err != nil || noPolicy != nil {
|
|
_ = tx.Rollback(ctx)
|
|
t.Fatalf("no-policy budget=%#v err=%v", noPolicy, err)
|
|
}
|
|
if err := tx.Rollback(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
summary, err := service.CostSummary(ctx, admin, billingTenantPublicID, time.Now().UTC().Add(-24*time.Hour), time.Now().UTC())
|
|
if err != nil || summary.CostMicros != 0 || len(summary.Lines) != 0 || summary.Currency != "" {
|
|
t.Fatalf("empty cost summary=%#v err=%v", summary, err)
|
|
}
|
|
}
|
|
|
|
func cleanupBillingFixture(t *testing.T, ctx context.Context, pool *pgxpool.Pool) {
|
|
t.Helper()
|
|
statements := []struct {
|
|
query string
|
|
args []any
|
|
}{
|
|
{`DELETE FROM idempotency_record WHERE idempotency_key LIKE 'billing-integration-%'`, nil},
|
|
{`DELETE FROM audit_event WHERE request_id LIKE 'billing-%'`, nil},
|
|
{`DELETE FROM usage_quota_policy WHERE change_summary LIKE $1`, []any{billingTestChangePrefix + "%"}},
|
|
{`DELETE FROM billing_price_book WHERE source_ref=$1`, []any{billingTestSource}},
|
|
}
|
|
for _, statement := range statements {
|
|
if _, err := pool.Exec(ctx, statement.query, statement.args...); err != nil {
|
|
t.Fatalf("cleanup billing fixture: %v", err)
|
|
}
|
|
}
|
|
}
|