package httpapi import ( "context" "encoding/json" "net/http" "net/http/httptest" "net/url" "os" "strings" "testing" "time" "data-terminal-cloud/internal/audit" "data-terminal-cloud/internal/billing" "data-terminal-cloud/internal/concurrency" "data-terminal-cloud/internal/identity" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" ) func TestBillingHandlersPriceQuotaAndSummary(t *testing.T) { databaseURL := os.Getenv("TEST_DATABASE_URL") if databaseURL == "" { t.Skip("TEST_DATABASE_URL is not set") } ctx := context.Background() pool, err := pgxpool.New(ctx, databaseURL) if err != nil { t.Fatal(err) } defer pool.Close() service, err := billing.NewService(pool, audit.NewRepository()) if err != nil { t.Fatal(err) } admin := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000101"} approver := identity.Principal{Issuer: admin.Issuer, Subject: "00000000-0000-0000-0000-000000000102"} tenantID := "10000000-0000-0000-0000-000000000010" priceKey, quotaKey := "http-billing-price-"+uuid.NewString(), "http-billing-quota-"+uuid.NewString() sourceRef := "integration://http-billing/" + uuid.NewString() changePrefix := "HTTP费用集成-" + uuid.NewString() defer func() { _, _ = pool.Exec(ctx, `DELETE FROM audit_event WHERE request_id LIKE 'http-billing-%'`) _, _ = pool.Exec(ctx, `DELETE FROM idempotency_record WHERE idempotency_key=ANY($1::text[])`, []string{priceKey, quotaKey}) _, _ = pool.Exec(ctx, `DELETE FROM usage_quota_policy WHERE change_summary LIKE $1`, changePrefix+"%") _, _ = pool.Exec(ctx, `DELETE FROM billing_price_book WHERE source_ref=$1`, sourceRef) }() setHeaders := func(request *http.Request, key string) { request.Header.Set("X-Tenant-Id", tenantID) if key != "" { request.Header.Set("Idempotency-Key", key) } } withID := func(request *http.Request, id string) *http.Request { return request.WithContext(context.WithValue(request.Context(), requestIDKey, id)) } priceBody, _ := json.Marshal(map[string]any{"provider": "mock-http", "currency": "CNY", "source_ref": sourceRef, "change_summary": changePrefix + "价格", "effective_from": time.Now().UTC().Add(-time.Hour), "rates": []map[string]any{{"usage_category": "llm", "metric_code": "input_tokens", "unit": "token", "unit_price_micros": 2}}}) request := withID(authenticatedRequest(http.MethodPost, "/api/v1/ops/price-books", string(priceBody), admin), "http-billing-price-create") setHeaders(request, priceKey) recorder := httptest.NewRecorder() CreateOpsPriceBookHandler(service).ServeHTTP(recorder, request) if recorder.Code != http.StatusCreated || recorder.Header().Get("ETag") != concurrency.FormatETag(1) { t.Fatalf("create price status=%d body=%s", recorder.Code, recorder.Body.String()) } var priceResponse struct { Data billing.PriceBook `json:"data"` } if err := json.Unmarshal(recorder.Body.Bytes(), &priceResponse); err != nil { t.Fatal(err) } price := priceResponse.Data request = authenticatedRequest(http.MethodGet, "/api/v1/ops/price-books", "", admin) setHeaders(request, "") recorder = httptest.NewRecorder() ListOpsPriceBooksHandler(service).ServeHTTP(recorder, request) if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), price.ID) { t.Fatalf("list prices status=%d body=%s", recorder.Code, recorder.Body.String()) } request = withID(authenticatedRequest(http.MethodPost, "/api/v1/ops/price-books/"+price.ID+"/publish", `{"reason":"本人不得发布"}`, admin), "http-billing-self-publish") request.SetPathValue("id", price.ID) setHeaders(request, "") request.Header.Set("If-Match", concurrency.FormatETag(1)) recorder = httptest.NewRecorder() PublishOpsPriceBookHandler(service).ServeHTTP(recorder, request) if recorder.Code != http.StatusConflict || !strings.Contains(recorder.Body.String(), "SELF_APPROVAL_FORBIDDEN") { t.Fatalf("self publish status=%d body=%s", recorder.Code, recorder.Body.String()) } request = withID(authenticatedRequest(http.MethodPost, "/api/v1/ops/price-books/"+price.ID+"/publish", `{"reason":"另一审批人复核发布"}`, approver), "http-billing-price-publish") request.SetPathValue("id", price.ID) setHeaders(request, "") request.Header.Set("If-Match", concurrency.FormatETag(1)) recorder = httptest.NewRecorder() PublishOpsPriceBookHandler(service).ServeHTTP(recorder, request) if recorder.Code != http.StatusOK || recorder.Header().Get("ETag") != concurrency.FormatETag(2) { t.Fatalf("publish price status=%d body=%s", recorder.Code, recorder.Body.String()) } quotaBody, _ := json.Marshal(map[string]any{"scope_type": "TENANT", "billing_timezone": "Asia/Shanghai", "currency": "CNY", "daily_limit_micros": 10000000, "monthly_limit_micros": 100000000, "max_concurrent_calls": 10, "max_call_duration_sec": 3600, "in_progress_action": "COMPLETE_CURRENT_TURN", "change_summary": changePrefix + "额度"}) request = withID(authenticatedRequest(http.MethodPost, "/api/v1/ops/quota-policies", string(quotaBody), approver), "http-billing-quota-create") setHeaders(request, quotaKey) recorder = httptest.NewRecorder() CreateOpsQuotaPolicyHandler(service).ServeHTTP(recorder, request) if recorder.Code != http.StatusCreated || recorder.Header().Get("ETag") != concurrency.FormatETag(1) { t.Fatalf("create quota status=%d body=%s", recorder.Code, recorder.Body.String()) } request = authenticatedRequest(http.MethodGet, "/api/v1/ops/quota-policies", "", admin) setHeaders(request, "") recorder = httptest.NewRecorder() ListOpsQuotaPoliciesHandler(service).ServeHTTP(recorder, request) if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), changePrefix) { t.Fatalf("list quotas status=%d body=%s", recorder.Code, recorder.Body.String()) } request = authenticatedRequest(http.MethodGet, "/api/v1/ops/cost-summary?from=bad&to=bad", "", admin) setHeaders(request, "") recorder = httptest.NewRecorder() GetOpsCostSummaryHandler(service).ServeHTTP(recorder, request) if recorder.Code != http.StatusBadRequest { t.Fatalf("invalid summary range status=%d body=%s", recorder.Code, recorder.Body.String()) } from, to := time.Now().UTC().Add(-time.Hour).Format(time.RFC3339), time.Now().UTC().Format(time.RFC3339) request = authenticatedRequest(http.MethodGet, "/api/v1/ops/cost-summary?from="+url.QueryEscape(from)+"&to="+url.QueryEscape(to), "", admin) setHeaders(request, "") recorder = httptest.NewRecorder() GetOpsCostSummaryHandler(service).ServeHTTP(recorder, request) if recorder.Code != http.StatusOK || recorder.Header().Get("Cache-Control") != "no-store" { t.Fatalf("cost summary status=%d body=%s", recorder.Code, recorder.Body.String()) } }