test(billing): 覆盖估价只读与请求重放契约
PostgreSQL HTTP 集成测试验证估价新增字段且不创建任务、冻结余额或写流水,并验证非流式重放只执行一次、异请求冲突及流式重复冲突。
This commit is contained in:
@@ -48,6 +48,7 @@ func TestCoreLocalFlow(t *testing.T) {
|
|||||||
DatabaseURL: databaseURL,
|
DatabaseURL: databaseURL,
|
||||||
IdentityMode: "hybrid",
|
IdentityMode: "hybrid",
|
||||||
JWTSecret: "test-secret",
|
JWTSecret: "test-secret",
|
||||||
|
BillingEngineMode: "enforce",
|
||||||
TaskProgressCallbackEnabled: true,
|
TaskProgressCallbackEnabled: true,
|
||||||
TaskProgressCallbackURL: "http://callback.local/task-progress",
|
TaskProgressCallbackURL: "http://callback.local/task-progress",
|
||||||
CORSAllowedOrigin: "*",
|
CORSAllowedOrigin: "*",
|
||||||
@@ -814,6 +815,7 @@ LIMIT 1`).Scan(&gptImageModelTypesRaw); err != nil {
|
|||||||
"currency": "resource",
|
"currency": "resource",
|
||||||
"rules": []map[string]any{
|
"rules": []map[string]any{
|
||||||
{"ruleKey": "text_input", "displayName": "Text Input", "resourceType": "text_input", "unit": "1k_tokens", "basePrice": 1},
|
{"ruleKey": "text_input", "displayName": "Text Input", "resourceType": "text_input", "unit": "1k_tokens", "basePrice": 1},
|
||||||
|
{"ruleKey": "text_cached_input", "displayName": "Text Cached Input", "resourceType": "text_cached_input", "unit": "1k_tokens", "basePrice": 0.5},
|
||||||
{"ruleKey": "text_output", "displayName": "Text Output", "resourceType": "text_output", "unit": "1k_tokens", "basePrice": 2},
|
{"ruleKey": "text_output", "displayName": "Text Output", "resourceType": "text_output", "unit": "1k_tokens", "basePrice": 2},
|
||||||
{"ruleKey": "image", "displayName": "Image", "resourceType": "image", "unit": "image", "basePrice": 7},
|
{"ruleKey": "image", "displayName": "Image", "resourceType": "image", "unit": "image", "basePrice": 7},
|
||||||
{"ruleKey": "image_edit", "displayName": "Image Edit", "resourceType": "image_edit", "unit": "image", "basePrice": 11},
|
{"ruleKey": "image_edit", "displayName": "Image Edit", "resourceType": "image_edit", "unit": "image", "basePrice": 11},
|
||||||
@@ -845,6 +847,101 @@ WHERE gateway_user_id = $1::uuid
|
|||||||
AND currency = 'resource'`, smokeGatewayUserID).Scan(&walletBalanceBefore); err != nil {
|
AND currency = 'resource'`, smokeGatewayUserID).Scan(&walletBalanceBefore); err != nil {
|
||||||
t.Fatalf("read wallet balance before pricing task: %v", err)
|
t.Fatalf("read wallet balance before pricing task: %v", err)
|
||||||
}
|
}
|
||||||
|
var estimateSideEffectsBefore struct {
|
||||||
|
Tasks int
|
||||||
|
Transactions int
|
||||||
|
Frozen string
|
||||||
|
}
|
||||||
|
if err := testPool.QueryRow(ctx, `
|
||||||
|
SELECT (SELECT count(*) FROM gateway_tasks),
|
||||||
|
(SELECT count(*) FROM gateway_wallet_transactions WHERE gateway_user_id=$1::uuid),
|
||||||
|
(SELECT frozen_balance::text FROM gateway_wallet_accounts WHERE gateway_user_id=$1::uuid AND currency='resource')`,
|
||||||
|
smokeGatewayUserID,
|
||||||
|
).Scan(&estimateSideEffectsBefore.Tasks, &estimateSideEffectsBefore.Transactions, &estimateSideEffectsBefore.Frozen); err != nil {
|
||||||
|
t.Fatalf("read estimate side effects before request: %v", err)
|
||||||
|
}
|
||||||
|
var pricingEstimate struct {
|
||||||
|
TotalAmount float64 `json:"totalAmount"`
|
||||||
|
ReservationAmount float64 `json:"reservationAmount"`
|
||||||
|
CandidateCount int `json:"candidateCount"`
|
||||||
|
PricingVersion string `json:"pricingVersion"`
|
||||||
|
RequestFingerprint string `json:"requestFingerprint"`
|
||||||
|
}
|
||||||
|
doJSON(t, server.URL, http.MethodPost, "/api/v1/pricing/estimate", apiKeyResponse.Secret, map[string]any{
|
||||||
|
"kind": "chat.completions", "model": pricingModel, "max_completion_tokens": 32,
|
||||||
|
"messages": []map[string]any{{"role": "user", "content": "estimate only"}},
|
||||||
|
}, http.StatusOK, &pricingEstimate)
|
||||||
|
if pricingEstimate.TotalAmount <= 0 || pricingEstimate.ReservationAmount < pricingEstimate.TotalAmount ||
|
||||||
|
pricingEstimate.CandidateCount != 1 || pricingEstimate.PricingVersion != "effective-pricing-v2" || pricingEstimate.RequestFingerprint == "" {
|
||||||
|
t.Fatalf("unexpected effective pricing estimate: %+v", pricingEstimate)
|
||||||
|
}
|
||||||
|
var estimateSideEffectsAfter struct {
|
||||||
|
Tasks int
|
||||||
|
Transactions int
|
||||||
|
Frozen string
|
||||||
|
}
|
||||||
|
if err := testPool.QueryRow(ctx, `
|
||||||
|
SELECT (SELECT count(*) FROM gateway_tasks),
|
||||||
|
(SELECT count(*) FROM gateway_wallet_transactions WHERE gateway_user_id=$1::uuid),
|
||||||
|
(SELECT frozen_balance::text FROM gateway_wallet_accounts WHERE gateway_user_id=$1::uuid AND currency='resource')`,
|
||||||
|
smokeGatewayUserID,
|
||||||
|
).Scan(&estimateSideEffectsAfter.Tasks, &estimateSideEffectsAfter.Transactions, &estimateSideEffectsAfter.Frozen); err != nil {
|
||||||
|
t.Fatalf("read estimate side effects after request: %v", err)
|
||||||
|
}
|
||||||
|
if estimateSideEffectsAfter != estimateSideEffectsBefore {
|
||||||
|
t.Fatalf("pricing estimate must be read-only, before=%+v after=%+v", estimateSideEffectsBefore, estimateSideEffectsAfter)
|
||||||
|
}
|
||||||
|
|
||||||
|
idempotencyPayload := map[string]any{
|
||||||
|
"model": pricingModel, "runMode": "simulation", "simulation": true, "simulationDurationMs": 5,
|
||||||
|
"messages": []map[string]any{{"role": "user", "content": "idempotent replay"}},
|
||||||
|
}
|
||||||
|
idempotencyHeaders := map[string]string{"Idempotency-Key": "chat-replay-" + suffixText}
|
||||||
|
var idempotentFirst map[string]any
|
||||||
|
firstReplayHeaders := doJSONWithHeaders(t, server.URL, http.MethodPost, "/api/v1/chat/completions", apiKeyResponse.Secret, idempotencyPayload, idempotencyHeaders, http.StatusOK, &idempotentFirst)
|
||||||
|
var idempotentSecond map[string]any
|
||||||
|
secondReplayHeaders := doJSONWithHeaders(t, server.URL, http.MethodPost, "/api/v1/chat/completions", apiKeyResponse.Secret, idempotencyPayload, idempotencyHeaders, http.StatusOK, &idempotentSecond)
|
||||||
|
idempotentTaskID := firstReplayHeaders.Get("X-Gateway-Task-Id")
|
||||||
|
if idempotentTaskID == "" || secondReplayHeaders.Get("X-Gateway-Task-Id") != idempotentTaskID || secondReplayHeaders.Get("Idempotent-Replayed") != "true" {
|
||||||
|
t.Fatalf("non-stream replay headers first=%v second=%v", firstReplayHeaders, secondReplayHeaders)
|
||||||
|
}
|
||||||
|
var idempotentAttempts int
|
||||||
|
if err := testPool.QueryRow(ctx, `SELECT count(*) FROM gateway_task_attempts WHERE task_id=$1::uuid`, idempotentTaskID).Scan(&idempotentAttempts); err != nil {
|
||||||
|
t.Fatalf("count idempotent task attempts: %v", err)
|
||||||
|
}
|
||||||
|
if idempotentAttempts != 1 {
|
||||||
|
t.Fatalf("idempotent request called upstream %d times, want 1", idempotentAttempts)
|
||||||
|
}
|
||||||
|
idempotencyPayload["messages"] = []map[string]any{{"role": "user", "content": "different request"}}
|
||||||
|
doJSONWithHeaders(t, server.URL, http.MethodPost, "/api/v1/chat/completions", apiKeyResponse.Secret, idempotencyPayload, idempotencyHeaders, http.StatusConflict, nil)
|
||||||
|
|
||||||
|
streamPayload := map[string]any{
|
||||||
|
"model": pricingModel, "runMode": "simulation", "simulation": true, "simulationDurationMs": 5, "stream": true,
|
||||||
|
"messages": []map[string]any{{"role": "user", "content": "stream replay"}},
|
||||||
|
}
|
||||||
|
streamRaw, err := json.Marshal(streamPayload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
streamKey := "stream-replay-" + suffixText
|
||||||
|
streamRequest, err := http.NewRequest(http.MethodPost, server.URL+"/api/v1/chat/completions", bytes.NewReader(streamRaw))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
streamRequest.Header.Set("Authorization", "Bearer "+apiKeyResponse.Secret)
|
||||||
|
streamRequest.Header.Set("Content-Type", "application/json")
|
||||||
|
streamRequest.Header.Set("Idempotency-Key", streamKey)
|
||||||
|
streamResponse, err := http.DefaultClient.Do(streamRequest)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("execute first idempotent stream: %v", err)
|
||||||
|
}
|
||||||
|
_, _ = io.ReadAll(streamResponse.Body)
|
||||||
|
_ = streamResponse.Body.Close()
|
||||||
|
if streamResponse.StatusCode != http.StatusOK || streamResponse.Header.Get("X-Gateway-Task-Id") == "" {
|
||||||
|
t.Fatalf("first idempotent stream status=%d headers=%v", streamResponse.StatusCode, streamResponse.Header)
|
||||||
|
}
|
||||||
|
doJSONWithHeaders(t, server.URL, http.MethodPost, "/api/v1/chat/completions", apiKeyResponse.Secret, streamPayload,
|
||||||
|
map[string]string{"Idempotency-Key": streamKey}, http.StatusConflict, nil)
|
||||||
var pricingTask struct {
|
var pricingTask struct {
|
||||||
Task struct {
|
Task struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -1588,6 +1685,7 @@ WHERE m.platform_id = $1::uuid
|
|||||||
DatabaseURL: databaseURL,
|
DatabaseURL: databaseURL,
|
||||||
IdentityMode: "hybrid",
|
IdentityMode: "hybrid",
|
||||||
JWTSecret: "test-secret",
|
JWTSecret: "test-secret",
|
||||||
|
BillingEngineMode: "enforce",
|
||||||
TaskProgressCallbackEnabled: true,
|
TaskProgressCallbackEnabled: true,
|
||||||
TaskProgressCallbackURL: "http://callback.local/task-progress",
|
TaskProgressCallbackURL: "http://callback.local/task-progress",
|
||||||
CORSAllowedOrigin: "*",
|
CORSAllowedOrigin: "*",
|
||||||
|
|||||||
Reference in New Issue
Block a user