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