Files
lijiaoqiao/supply-api/e2e/e2e_test.go
Your Name a2f042f1c2 test(supply-api): expand e2e coverage and support unix socket dsn
Add broader e2e coverage for account, package, billing, tracing, and reliability scenarios.\nSupport Unix socket DSN formatting in config and cover it with unit tests.\nIgnore local assistant metadata and generated gate artifacts to reduce workspace noise.
2026-04-13 18:53:35 +08:00

935 lines
34 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//go:build e2e
// +build e2e
package e2e
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"lijiaoqiao/supply-api/internal/adapter"
"lijiaoqiao/supply-api/internal/audit"
"lijiaoqiao/supply-api/internal/domain"
"lijiaoqiao/supply-api/internal/httpapi"
"lijiaoqiao/supply-api/internal/middleware"
"lijiaoqiao/supply-api/internal/pkg/logging"
)
type e2eOptions struct {
withdrawEnabled bool
}
type e2eSystem struct {
handler http.Handler
accountSvc *e2eAccountService
auditStore *audit.MemoryAuditStore
secretKey string
tokenIssuer string
}
type e2eAccountService struct {
verifyResult *domain.VerifyResult
lastVerifySupplierID int64
}
func (s *e2eAccountService) Verify(ctx context.Context, supplierID int64, provider domain.Provider, accountType domain.AccountType, credential string) (*domain.VerifyResult, error) {
s.lastVerifySupplierID = supplierID
return s.verifyResult, nil
}
func (s *e2eAccountService) Create(ctx context.Context, req *domain.CreateAccountRequest) (*domain.Account, error) {
return &domain.Account{ID: 1, SupplierID: req.SupplierID, Provider: req.Provider, AccountType: req.AccountType, Status: domain.AccountStatusActive}, nil
}
func (s *e2eAccountService) Activate(ctx context.Context, supplierID, accountID int64) (*domain.Account, error) {
return &domain.Account{ID: accountID, SupplierID: supplierID, Status: domain.AccountStatusActive}, nil
}
func (s *e2eAccountService) Suspend(ctx context.Context, supplierID, accountID int64) (*domain.Account, error) {
return &domain.Account{ID: accountID, SupplierID: supplierID, Status: domain.AccountStatusSuspended}, nil
}
func (s *e2eAccountService) Delete(ctx context.Context, supplierID, accountID int64) error {
return nil
}
func (s *e2eAccountService) GetByID(ctx context.Context, supplierID, accountID int64) (*domain.Account, error) {
return &domain.Account{ID: accountID, SupplierID: supplierID, Status: domain.AccountStatusActive}, nil
}
type e2ePackageService struct{}
func (s *e2ePackageService) CreateDraft(ctx context.Context, supplierID int64, req *domain.CreatePackageDraftRequest) (*domain.Package, error) {
return &domain.Package{ID: 1, SupplierID: supplierID, Model: req.Model, Status: domain.PackageStatusDraft}, nil
}
func (s *e2ePackageService) Publish(ctx context.Context, supplierID, packageID int64) (*domain.Package, error) {
return &domain.Package{ID: packageID, SupplierID: supplierID, Status: domain.PackageStatusActive}, nil
}
func (s *e2ePackageService) Pause(ctx context.Context, supplierID, packageID int64) (*domain.Package, error) {
return &domain.Package{ID: packageID, SupplierID: supplierID, Status: domain.PackageStatusPaused}, nil
}
func (s *e2ePackageService) Unlist(ctx context.Context, supplierID, packageID int64) (*domain.Package, error) {
return &domain.Package{ID: packageID, SupplierID: supplierID, Status: domain.PackageStatusExpired}, nil
}
func (s *e2ePackageService) Clone(ctx context.Context, supplierID, packageID int64) (*domain.Package, error) {
return &domain.Package{ID: packageID + 1, SupplierID: supplierID, Status: domain.PackageStatusDraft}, nil
}
func (s *e2ePackageService) BatchUpdatePrice(ctx context.Context, supplierID int64, req *domain.BatchUpdatePriceRequest) (*domain.BatchUpdatePriceResponse, error) {
return &domain.BatchUpdatePriceResponse{Total: len(req.Items), SuccessCount: len(req.Items)}, nil
}
func (s *e2ePackageService) GetByID(ctx context.Context, supplierID, packageID int64) (*domain.Package, error) {
return &domain.Package{ID: packageID, SupplierID: supplierID, Status: domain.PackageStatusActive}, nil
}
type e2eSettlementService struct{}
func (s *e2eSettlementService) Withdraw(ctx context.Context, supplierID int64, req *domain.WithdrawRequest) (*domain.Settlement, error) {
now := time.Now().UTC()
return &domain.Settlement{
ID: 1,
SupplierID: supplierID,
SettlementNo: "SET-001",
Status: domain.SettlementStatusPending,
TotalAmount: req.Amount,
NetAmount: req.Amount,
CreatedAt: now,
UpdatedAt: now,
}, nil
}
func (s *e2eSettlementService) Cancel(ctx context.Context, supplierID, settlementID int64) (*domain.Settlement, error) {
now := time.Now().UTC()
return &domain.Settlement{ID: settlementID, SupplierID: supplierID, Status: domain.SettlementStatusFailed, CreatedAt: now, UpdatedAt: now}, nil
}
func (s *e2eSettlementService) GetByID(ctx context.Context, supplierID, settlementID int64) (*domain.Settlement, error) {
now := time.Now().UTC()
return &domain.Settlement{ID: settlementID, SupplierID: supplierID, Status: domain.SettlementStatusPending, CreatedAt: now, UpdatedAt: now}, nil
}
func (s *e2eSettlementService) List(ctx context.Context, supplierID int64) ([]*domain.Settlement, error) {
now := time.Now().UTC()
return []*domain.Settlement{{ID: 1, SupplierID: supplierID, Status: domain.SettlementStatusPending, CreatedAt: now, UpdatedAt: now}}, nil
}
func (s *e2eSettlementService) GetBillingSummary(ctx context.Context, supplierID int64, startDate, endDate string) (*domain.BillingSummary, error) {
return &domain.BillingSummary{
Period: domain.BillingPeriod{Start: startDate, End: endDate},
Summary: domain.BillingTotal{TotalRevenue: 100, TotalOrders: 1, TotalUsage: 1000, TotalRequests: 10, AvgSuccessRate: 1, NetEarnings: 95},
}, nil
}
type e2eEarningService struct{}
func (s *e2eEarningService) ListRecords(ctx context.Context, supplierID int64, startDate, endDate string, page, pageSize int) ([]*domain.EarningRecord, int, error) {
return []*domain.EarningRecord{
{ID: 1, SupplierID: supplierID, EarningsType: "usage", Amount: 100, Status: "available", EarnedAt: time.Now().UTC()},
}, 1, nil
}
func (s *e2eEarningService) GetBillingSummary(ctx context.Context, supplierID int64, startDate, endDate string) (*domain.BillingSummary, error) {
return &domain.BillingSummary{
Period: domain.BillingPeriod{Start: startDate, End: endDate},
Summary: domain.BillingTotal{TotalRevenue: 100, TotalOrders: 1, TotalUsage: 1000, TotalRequests: 10, AvgSuccessRate: 1, NetEarnings: 95},
}, nil
}
type staticTokenBackend struct {
statusByTokenID map[string]string
}
func (b *staticTokenBackend) CheckTokenStatus(ctx context.Context, tokenID string) (string, error) {
if status, ok := b.statusByTokenID[tokenID]; ok {
return status, nil
}
return "active", nil
}
func newE2ESystem(t *testing.T, opts e2eOptions) *e2eSystem {
t.Helper()
accountSvc := &e2eAccountService{
verifyResult: &domain.VerifyResult{
VerifyStatus: "pass",
AvailableQuota: 2048,
RiskScore: 0,
CheckItems: []domain.CheckItem{
{Item: "credential_format", Result: "pass", Message: "ok"},
},
},
}
auditStore := audit.NewMemoryAuditStore()
api := httpapi.NewSupplyAPI(
accountSvc,
&e2ePackageService{},
&e2eSettlementService{},
&e2eEarningService{},
nil,
auditStore,
nil,
0,
"https://statements.example.com",
func() time.Time { return time.Unix(1712800000, 0).UTC() },
)
api.SetWithdrawEnabled(opts.withdrawEnabled)
mux := http.NewServeMux()
healthHandler := httpapi.NewHealthHandlerWithDefaults(nil, nil)
mux.HandleFunc("/actuator/health", healthHandler.ServeHealth)
mux.HandleFunc("/actuator/health/live", healthHandler.ServeLiveness)
mux.HandleFunc("/actuator/health/ready", healthHandler.ServeReadiness)
api.Register(mux)
authMiddleware := middleware.NewAuthMiddleware(
middleware.AuthConfig{
SecretKey: "e2e-secret-key-should-be-long",
Algorithm: "HS256",
Issuer: "supply-api-e2e",
Enabled: true,
},
middleware.NewTokenCache(),
&staticTokenBackend{statusByTokenID: map[string]string{}},
adapter.NewAuditEmitterAdapter(auditStore),
)
logger := logging.NewLogger("supply-api-e2e", logging.LogLevelError)
var handler http.Handler = mux
handler = middleware.RequestID(handler)
handler = middleware.Recovery(handler)
handler = middleware.Logging(handler, logger)
handler = middleware.TracingMiddleware(handler)
handler = authMiddleware.TokenVerifyMiddleware(handler)
handler = authMiddleware.BearerExtractMiddleware(handler)
handler = authMiddleware.QueryKeyRejectMiddleware(handler)
return &e2eSystem{
handler: handler,
accountSvc: accountSvc,
auditStore: auditStore,
secretKey: "e2e-secret-key-should-be-long",
tokenIssuer: "supply-api-e2e",
}
}
func (s *e2eSystem) tokenForTenant(t *testing.T, tokenID string, tenantID int64) string {
t.Helper()
claims := &middleware.TokenClaims{
RegisteredClaims: jwt.RegisteredClaims{
ID: tokenID,
Issuer: s.tokenIssuer,
Subject: "subject-42",
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
SubjectID: "42",
Role: "org_admin",
Scope: []string{"supply:write", "supply:read"},
TenantID: tenantID,
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString([]byte(s.secretKey))
if err != nil {
t.Fatalf("failed to sign token: %v", err)
}
return tokenString
}
func decodeJSONBody(t *testing.T, recorder *httptest.ResponseRecorder) map[string]any {
t.Helper()
var payload map[string]any
if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil {
t.Fatalf("failed to decode response: %v, body=%s", err, recorder.Body.String())
}
return payload
}
func TestE2E_HealthProbe_IsPublicAndHealthy(t *testing.T) {
system := newE2ESystem(t, e2eOptions{withdrawEnabled: false})
req := httptest.NewRequest(http.MethodGet, "/actuator/health", nil)
req.Header.Set("X-Request-Id", "health-req-001")
recorder := httptest.NewRecorder()
system.handler.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d, body=%s", recorder.Code, recorder.Body.String())
}
if recorder.Header().Get("X-Request-Id") != "health-req-001" {
t.Fatalf("expected X-Request-Id response header to round-trip")
}
payload := decodeJSONBody(t, recorder)
if payload["status"] != "healthy" {
t.Fatalf("expected healthy status, got %v", payload["status"])
}
}
func TestE2E_ProtectedRoute_RejectsMissingBearer(t *testing.T) {
system := newE2ESystem(t, e2eOptions{withdrawEnabled: false})
req := httptest.NewRequest(http.MethodGet, "/api/v1/supply/billing?start_date=2026-04-01&end_date=2026-04-11", nil)
recorder := httptest.NewRecorder()
system.handler.ServeHTTP(recorder, req)
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("expected status 401, got %d, body=%s", recorder.Code, recorder.Body.String())
}
payload := decodeJSONBody(t, recorder)
errBody, ok := payload["error"].(map[string]any)
if !ok {
t.Fatalf("expected error body, got %v", payload["error"])
}
if errBody["code"] != "AUTH_MISSING_BEARER" {
t.Fatalf("expected AUTH_MISSING_BEARER, got %v", errBody["code"])
}
}
func TestE2E_ProtectedRoute_RejectsQueryCredentialLeak(t *testing.T) {
system := newE2ESystem(t, e2eOptions{withdrawEnabled: false})
req := httptest.NewRequest(http.MethodGet, "/api/v1/supply/billing?api_key=abcdefghijklmnopqrstuvwxyz123456", nil)
recorder := httptest.NewRecorder()
system.handler.ServeHTTP(recorder, req)
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("expected status 401, got %d, body=%s", recorder.Code, recorder.Body.String())
}
payload := decodeJSONBody(t, recorder)
errBody, ok := payload["error"].(map[string]any)
if !ok {
t.Fatalf("expected error body, got %v", payload["error"])
}
if errBody["code"] != "QUERY_KEY_NOT_ALLOWED" {
t.Fatalf("expected QUERY_KEY_NOT_ALLOWED, got %v", errBody["code"])
}
}
func TestE2E_VerifyAccount_UsesTenantIDFromVerifiedToken(t *testing.T) {
system := newE2ESystem(t, e2eOptions{withdrawEnabled: false})
token := system.tokenForTenant(t, "tok-e2e-verify", 2001)
body := `{"provider":"openai","account_type":"resource","credential_input":"sk-test"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/supply/accounts/verify", strings.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Request-Id", "verify-req-001")
recorder := httptest.NewRecorder()
system.handler.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d, body=%s", recorder.Code, recorder.Body.String())
}
if system.accountSvc.lastVerifySupplierID != 2001 {
t.Fatalf("expected supplierID 2001 from token tenant, got %d", system.accountSvc.lastVerifySupplierID)
}
payload := decodeJSONBody(t, recorder)
if payload["request_id"] != "verify-req-001" {
t.Fatalf("expected request_id verify-req-001, got %v", payload["request_id"])
}
}
func TestE2E_Withdraw_DisabledBeforeSMSIntegration(t *testing.T) {
system := newE2ESystem(t, e2eOptions{withdrawEnabled: false})
token := system.tokenForTenant(t, "tok-e2e-withdraw", 2002)
body := `{"withdraw_amount":100,"payment_method":"bank","payment_account":"13800000000","sms_code":"123456"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/supply/settlements/withdraw", strings.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
system.handler.ServeHTTP(recorder, req)
if recorder.Code != http.StatusServiceUnavailable {
t.Fatalf("expected status 503, got %d, body=%s", recorder.Code, recorder.Body.String())
}
payload := decodeJSONBody(t, recorder)
errBody, ok := payload["error"].(map[string]any)
if !ok {
t.Fatalf("expected error body, got %v", payload["error"])
}
// 验证功能禁用错误码SUP_HTTP_5030 = FEATURE_DISABLED
if errBody["code"] != httpapi.CodeFeatureDisabled {
t.Fatalf("expected %s, got %v", httpapi.CodeFeatureDisabled, errBody["code"])
}
}
func TestE2E_AuditEvent_CanBeReadBackThroughAPI(t *testing.T) {
system := newE2ESystem(t, e2eOptions{withdrawEnabled: false})
token := system.tokenForTenant(t, "tok-e2e-audit", 3001)
if err := system.auditStore.Emit(context.Background(), audit.Event{
TenantID: 3001,
ObjectType: "supply_account",
ObjectID: 77,
Action: "verify",
RequestID: "audit-req-001",
ResultCode: "OK",
SourceIP: "127.0.0.1",
}); err != nil {
t.Fatalf("failed to seed audit event: %v", err)
}
events, err := system.auditStore.Query(context.Background(), audit.EventFilter{TenantID: 3001, Limit: 1})
if err != nil {
t.Fatalf("failed to query seeded audit event: %v", err)
}
if len(events) != 1 {
t.Fatalf("expected 1 audit event, got %d", len(events))
}
req := httptest.NewRequest(http.MethodGet, "/api/v1/audit/events/"+events[0].EventID, nil)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("X-Request-Id", "audit-read-001")
recorder := httptest.NewRecorder()
system.handler.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d, body=%s", recorder.Code, recorder.Body.String())
}
payload := decodeJSONBody(t, recorder)
data, ok := payload["data"].(map[string]any)
if !ok {
t.Fatalf("expected data object, got %v", payload["data"])
}
if data["event_id"] != events[0].EventID {
t.Fatalf("expected event_id %s, got %v", events[0].EventID, data["event_id"])
}
if data["request_id"] != "audit-req-001" {
t.Fatalf("expected seeded request_id audit-req-001, got %v", data["request_id"])
}
}
// ============================================================================
// 增强 E2E 测试:账户管理流程
// ============================================================================
func TestE2E_Account_Create_Success(t *testing.T) {
system := newE2ESystem(t, e2eOptions{withdrawEnabled: false})
token := system.tokenForTenant(t, "tok-account-create", 4001)
body := `{"provider":"openai","account_type":"resource","credential_input":"sk-test12345678901234567890"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/supply/accounts", strings.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Request-Id", "account-create-001")
recorder := httptest.NewRecorder()
system.handler.ServeHTTP(recorder, req)
if recorder.Code != http.StatusCreated {
t.Fatalf("expected status 201, got %d, body=%s", recorder.Code, recorder.Body.String())
}
payload := decodeJSONBody(t, recorder)
data, ok := payload["data"].(map[string]any)
if !ok {
t.Fatalf("expected data object, got %v", payload["data"])
}
// 验证账户创建成功mock 服务返回固定的 account_id
if data["account_id"] == nil || data["account_id"].(float64) != 1 {
t.Fatalf("expected account_id 1, got %v", data["account_id"])
}
}
func TestE2E_Account_Activate_Success(t *testing.T) {
system := newE2ESystem(t, e2eOptions{withdrawEnabled: false})
token := system.tokenForTenant(t, "tok-account-activate", 4002)
req := httptest.NewRequest(http.MethodPost, "/api/v1/supply/accounts/123/activate", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("X-Request-Id", "account-activate-001")
recorder := httptest.NewRecorder()
system.handler.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d, body=%s", recorder.Code, recorder.Body.String())
}
payload := decodeJSONBody(t, recorder)
data, ok := payload["data"].(map[string]any)
if !ok {
t.Fatalf("expected data object, got %v", payload["data"])
}
if data["status"] != "active" {
t.Fatalf("expected status active, got %v", data["status"])
}
}
func TestE2E_Account_Suspend_Success(t *testing.T) {
system := newE2ESystem(t, e2eOptions{withdrawEnabled: false})
token := system.tokenForTenant(t, "tok-account-suspend", 4003)
req := httptest.NewRequest(http.MethodPost, "/api/v1/supply/accounts/456/suspend", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("X-Request-Id", "account-suspend-001")
recorder := httptest.NewRecorder()
system.handler.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d, body=%s", recorder.Code, recorder.Body.String())
}
payload := decodeJSONBody(t, recorder)
data, ok := payload["data"].(map[string]any)
if !ok {
t.Fatalf("expected data object, got %v", payload["data"])
}
if data["status"] != "suspended" {
t.Fatalf("expected status suspended, got %v", data["status"])
}
}
func TestE2E_Account_Delete_Success(t *testing.T) {
system := newE2ESystem(t, e2eOptions{withdrawEnabled: false})
token := system.tokenForTenant(t, "tok-account-delete", 4004)
// 删除账户的正确路由: DELETE /api/v1/supply/accounts/{id}/delete
req := httptest.NewRequest(http.MethodDelete, "/api/v1/supply/accounts/789/delete", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("X-Request-Id", "account-delete-001")
recorder := httptest.NewRecorder()
system.handler.ServeHTTP(recorder, req)
if recorder.Code != http.StatusNoContent {
t.Fatalf("expected status 204, got %d, body=%s", recorder.Code, recorder.Body.String())
}
}
// ============================================================================
// 增强 E2E 测试:套餐管理流程
// ============================================================================
func TestE2E_Package_CreateDraft_Success(t *testing.T) {
system := newE2ESystem(t, e2eOptions{withdrawEnabled: false})
token := system.tokenForTenant(t, "tok-package-create", 5001)
body := `{"model":"gpt-4-turbo","display_name":"GPT-4 Turbo","price_input":0.03,"quota_per_batch":1000}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/supply/packages/draft", strings.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Request-Id", "package-create-001")
recorder := httptest.NewRecorder()
system.handler.ServeHTTP(recorder, req)
if recorder.Code != http.StatusCreated {
t.Fatalf("expected status 201, got %d, body=%s", recorder.Code, recorder.Body.String())
}
payload := decodeJSONBody(t, recorder)
data, ok := payload["data"].(map[string]any)
if !ok {
t.Fatalf("expected data object, got %v", payload["data"])
}
if data["status"] != "draft" {
t.Fatalf("expected status draft, got %v", data["status"])
}
}
func TestE2E_Package_Publish_Success(t *testing.T) {
system := newE2ESystem(t, e2eOptions{withdrawEnabled: false})
token := system.tokenForTenant(t, "tok-package-publish", 5002)
req := httptest.NewRequest(http.MethodPost, "/api/v1/supply/packages/1001/publish", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("X-Request-Id", "package-publish-001")
recorder := httptest.NewRecorder()
system.handler.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d, body=%s", recorder.Code, recorder.Body.String())
}
payload := decodeJSONBody(t, recorder)
data, ok := payload["data"].(map[string]any)
if !ok {
t.Fatalf("expected data object, got %v", payload["data"])
}
if data["status"] != "active" {
t.Fatalf("expected status active, got %v", data["status"])
}
}
func TestE2E_Package_Pause_Success(t *testing.T) {
system := newE2ESystem(t, e2eOptions{withdrawEnabled: false})
token := system.tokenForTenant(t, "tok-package-pause", 5003)
req := httptest.NewRequest(http.MethodPost, "/api/v1/supply/packages/1002/pause", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("X-Request-Id", "package-pause-001")
recorder := httptest.NewRecorder()
system.handler.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d, body=%s", recorder.Code, recorder.Body.String())
}
payload := decodeJSONBody(t, recorder)
data, ok := payload["data"].(map[string]any)
if !ok {
t.Fatalf("expected data object, got %v", payload["data"])
}
if data["status"] != "paused" {
t.Fatalf("expected status paused, got %v", data["status"])
}
}
// ============================================================================
// 增强 E2E 测试:结算与账单
// ============================================================================
func TestE2E_Settlement_List_Success(t *testing.T) {
system := newE2ESystem(t, e2eOptions{withdrawEnabled: false})
token := system.tokenForTenant(t, "tok-settlement-list", 6001)
// 结算列表通过账单接口获取: /api/v1/supply/billing
req := httptest.NewRequest(http.MethodGet, "/api/v1/supply/billing?start_date=2026-04-01&end_date=2026-04-30", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("X-Request-Id", "settlement-list-001")
recorder := httptest.NewRecorder()
system.handler.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d, body=%s", recorder.Code, recorder.Body.String())
}
payload := decodeJSONBody(t, recorder)
data, ok := payload["data"].(map[string]any)
if !ok {
t.Fatalf("expected data object, got %v", payload["data"])
}
if data["summary"] == nil {
t.Fatalf("expected summary field in billing data, got %v", data)
}
}
func TestE2E_Billing_GetSummary_Success(t *testing.T) {
system := newE2ESystem(t, e2eOptions{withdrawEnabled: false})
token := system.tokenForTenant(t, "tok-billing-summary", 6002)
req := httptest.NewRequest(http.MethodGet, "/api/v1/supply/billing?start_date=2026-04-01&end_date=2026-04-30", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("X-Request-Id", "billing-summary-001")
recorder := httptest.NewRecorder()
system.handler.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d, body=%s", recorder.Code, recorder.Body.String())
}
payload := decodeJSONBody(t, recorder)
data, ok := payload["data"].(map[string]any)
if !ok {
t.Fatalf("expected data object, got %v", payload["data"])
}
summary, ok := data["summary"].(map[string]any)
if !ok {
t.Fatalf("expected summary object, got %v", data["summary"])
}
if summary["total_revenue"] == nil {
t.Fatalf("expected total_revenue field, got %v", summary)
}
}
// ============================================================================
// 增强 E2E 测试:收益记录
// ============================================================================
func TestE2E_Earnings_ListRecords_Success(t *testing.T) {
system := newE2ESystem(t, e2eOptions{withdrawEnabled: false})
token := system.tokenForTenant(t, "tok-earnings-list", 7001)
req := httptest.NewRequest(http.MethodGet, "/api/v1/supply/earnings/records?start_date=2026-04-01&end_date=2026-04-30&page=1&page_size=10", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("X-Request-Id", "earnings-list-001")
recorder := httptest.NewRecorder()
system.handler.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d, body=%s", recorder.Code, recorder.Body.String())
}
payload := decodeJSONBody(t, recorder)
// earnings/records 直接返回数组
data, ok := payload["data"].([]any)
if !ok {
t.Fatalf("expected data array, got %v", payload["data"])
}
if len(data) == 0 {
t.Fatalf("expected at least 1 earning record, got 0")
}
firstRecord, ok := data[0].(map[string]any)
if !ok {
t.Fatalf("expected first record to be map, got %v", data[0])
}
if firstRecord["amount"] == nil {
t.Fatalf("expected amount field in record, got %v", firstRecord)
}
}
// ============================================================================
// 增强 E2E 测试:错误处理
// ============================================================================
func TestE2E_Error_InvalidJSON(t *testing.T) {
system := newE2ESystem(t, e2eOptions{withdrawEnabled: false})
token := system.tokenForTenant(t, "tok-invalid-json", 8001)
body := `{"provider":"openai", invalid json}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/supply/accounts", strings.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
system.handler.ServeHTTP(recorder, req)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d, body=%s", recorder.Code, recorder.Body.String())
}
payload := decodeJSONBody(t, recorder)
errBody, ok := payload["error"].(map[string]any)
if !ok {
t.Fatalf("expected error object, got %v", payload["error"])
}
if errBody["code"] != httpapi.CodeBadRequest {
t.Fatalf("expected %s, got %v", httpapi.CodeBadRequest, errBody["code"])
}
}
func TestE2E_Error_EmptyBody(t *testing.T) {
system := newE2ESystem(t, e2eOptions{withdrawEnabled: false})
token := system.tokenForTenant(t, "tok-empty-body", 8002)
// 空请求体
req := httptest.NewRequest(http.MethodPost, "/api/v1/supply/accounts", strings.NewReader(""))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
system.handler.ServeHTTP(recorder, req)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d, body=%s", recorder.Code, recorder.Body.String())
}
}
func TestE2E_Error_ExpiredToken(t *testing.T) {
system := newE2ESystem(t, e2eOptions{withdrawEnabled: false})
// 创建一个已过期的 token
claims := &middleware.TokenClaims{
RegisteredClaims: jwt.RegisteredClaims{
ID: "tok-expired",
Issuer: system.tokenIssuer,
Subject: "subject-42",
ExpiresAt: jwt.NewNumericDate(time.Now().Add(-1 * time.Hour)), // 1小时前过期
IssuedAt: jwt.NewNumericDate(time.Now().Add(-2 * time.Hour)),
},
SubjectID: "42",
Role: "org_admin",
Scope: []string{"supply:write", "supply:read"},
TenantID: 8003,
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, _ := token.SignedString([]byte(system.secretKey))
req := httptest.NewRequest(http.MethodGet, "/api/v1/supply/accounts", nil)
req.Header.Set("Authorization", "Bearer "+tokenString)
recorder := httptest.NewRecorder()
system.handler.ServeHTTP(recorder, req)
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("expected status 401, got %d, body=%s", recorder.Code, recorder.Body.String())
}
}
func TestE2E_Error_InvalidPathAccount(t *testing.T) {
system := newE2ESystem(t, e2eOptions{withdrawEnabled: false})
token := system.tokenForTenant(t, "tok-invalid-path", 8004)
// 无效的账户 ID
req := httptest.NewRequest(http.MethodGet, "/api/v1/supply/accounts/invalid-id", nil)
req.Header.Set("Authorization", "Bearer "+token)
recorder := httptest.NewRecorder()
system.handler.ServeHTTP(recorder, req)
if recorder.Code != http.StatusNotFound {
t.Fatalf("expected status 404, got %d, body=%s", recorder.Code, recorder.Body.String())
}
payload := decodeJSONBody(t, recorder)
errBody, ok := payload["error"].(map[string]any)
if !ok {
t.Fatalf("expected error object, got %v", payload["error"])
}
if errBody["code"] != httpapi.CodeNotFound {
t.Fatalf("expected %s, got %v", httpapi.CodeNotFound, errBody["code"])
}
}
// ============================================================================
// 增强 E2E 测试:审计日志敏感数据脱敏
// ============================================================================
func TestE2E_AuditEvent_SensitiveDataSanitized(t *testing.T) {
system := newE2ESystem(t, e2eOptions{withdrawEnabled: false})
token := system.tokenForTenant(t, "tok-audit-sanitize", 9001)
// 触发一个包含敏感信息的操作
body := `{"provider":"openai","account_type":"resource","credential_input":"sk-1234567890abcdef"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/supply/accounts/verify", strings.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Request-Id", "audit-sanitize-001")
recorder := httptest.NewRecorder()
system.handler.ServeHTTP(recorder, req)
// 查询审计事件
events, err := system.auditStore.Query(context.Background(), audit.EventFilter{
TenantID: 9001,
ObjectType: "supply_account",
Action: "verify",
Limit: 1,
})
if err != nil {
t.Fatalf("failed to query audit events: %v", err)
}
// 验证事件包含敏感信息(应该脱敏)
for _, event := range events {
afterStateStr, ok := event.AfterState["credential_input"]
if ok {
// 验证凭证已脱敏
credStr, ok := afterStateStr.(string)
if ok && (credStr == "sk-1234567890abcdef" || strings.Contains(credStr, "sk-")) {
t.Fatalf("credential_input should be sanitized, got %v", afterStateStr)
}
}
}
}
// ============================================================================
// 增强 E2E 测试W3C Trace Context 追踪
// ============================================================================
func TestE2E_Tracing_W3CTraceContext(t *testing.T) {
system := newE2ESystem(t, e2eOptions{withdrawEnabled: false})
token := system.tokenForTenant(t, "tok-trace", 10001)
// 模拟 W3C Trace Context 头
// 格式: 00-{trace-id}-{span-id}-{trace-flags}
traceParent := "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"
// 使用 POST /api/v1/supply/accounts/verify 来测试追踪
body := `{"provider":"openai","account_type":"resource","credential_input":"sk-test"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/supply/accounts/verify", strings.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("traceparent", traceParent)
req.Header.Set("X-Request-Id", "trace-001")
recorder := httptest.NewRecorder()
system.handler.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d, body=%s", recorder.Code, recorder.Body.String())
}
// 验证追踪中间件正确处理了 traceparent 头
// 检查 X-Request-Id 是否正确传递
if recorder.Header().Get("X-Request-Id") != "trace-001" {
t.Fatalf("expected X-Request-Id trace-001, got %s", recorder.Header().Get("X-Request-Id"))
}
}
// ============================================================================
// 增强 E2E 测试:并发请求处理
// ============================================================================
func TestE2E_ConcurrentRequests_SameIdempotencyKey(t *testing.T) {
system := newE2ESystem(t, e2eOptions{withdrawEnabled: false})
token := system.tokenForTenant(t, "tok-idempotent", 11001)
// 使用相同的 Idempotency-Key 发送多个请求
idempotencyKey := "idem-key-12345"
body := `{"provider":"openai","account_type":"resource","credential_input":"sk-concurrent-test"}`
// 发送第一个请求
req1 := httptest.NewRequest(http.MethodPost, "/api/v1/supply/accounts", strings.NewReader(body))
req1.Header.Set("Authorization", "Bearer "+token)
req1.Header.Set("Content-Type", "application/json")
req1.Header.Set("Idempotency-Key", idempotencyKey)
req1.Header.Set("X-Request-Id", "idem-req-001")
recorder1 := httptest.NewRecorder()
system.handler.ServeHTTP(recorder1, req1)
if recorder1.Code != http.StatusCreated {
t.Fatalf("first request failed: expected 201, got %d, body=%s", recorder1.Code, recorder1.Body.String())
}
// 发送第二个相同 Idempotency-Key 的请求
req2 := httptest.NewRequest(http.MethodPost, "/api/v1/supply/accounts", strings.NewReader(body))
req2.Header.Set("Authorization", "Bearer "+token)
req2.Header.Set("Content-Type", "application/json")
req2.Header.Set("Idempotency-Key", idempotencyKey)
req2.Header.Set("X-Request-Id", "idem-req-002")
recorder2 := httptest.NewRecorder()
system.handler.ServeHTTP(recorder2, req2)
// 第二个请求应该返回相同的响应(幂等性)
if recorder2.Code != http.StatusCreated {
t.Fatalf("second request failed: expected 201, got %d, body=%s", recorder2.Code, recorder2.Body.String())
}
// 验证响应一致
payload1 := decodeJSONBody(t, recorder1)
payload2 := decodeJSONBody(t, recorder2)
if payload1["request_id"] == payload2["request_id"] {
t.Log("Idempotent requests returned same request_id (expected behavior)")
}
}