514 lines
22 KiB
Go
514 lines
22 KiB
Go
//go:build unit
|
||||
|
|
|
|||
|
|
package service
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"context"
|
|||
|
|
"errors"
|
|||
|
|
"fmt"
|
|||
|
|
"strings"
|
|||
|
|
"testing"
|
|||
|
|
|
|||
|
|
"github.com/stretchr/testify/require"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
func TestBatchImageSettlementService_SettlesAndChargesSuccessfulImagesOnly(t *testing.T) {
|
|||
|
|
repo := newFakeBatchImageRepository()
|
|||
|
|
job := testSettlingBatchImageJob("imgbatch_settle")
|
|||
|
|
job.SuccessCount = 3
|
|||
|
|
job.FailCount = 2
|
|||
|
|
job.ItemCount = 5
|
|||
|
|
job.SessionID = batchImageStringPtr("batch-settlement-session")
|
|||
|
|
repo.jobs[job.BatchID] = job
|
|||
|
|
billing := &fakeBatchImageBillingRepo{}
|
|||
|
|
usageLogs := &openAIRecordUsageLogRepoStub{}
|
|||
|
|
svc := &BatchImageSettlementService{
|
|||
|
|
Repo: repo, BillingRepo: billing, Pricing: &fakeBatchImagePricingResolver{unitPrice: 0.25},
|
|||
|
|
UsageLogRepo: usageLogs,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
result, err := svc.Settle(context.Background(), job.BatchID)
|
|||
|
|
require.NoError(t, err)
|
|||
|
|
require.Equal(t, 0.75, result.ActualCost)
|
|||
|
|
require.Equal(t, BatchImageCaptureRequestID(job.BatchID), result.RequestID)
|
|||
|
|
require.False(t, result.AlreadySettled)
|
|||
|
|
require.Equal(t, BatchImageJobStatusCompleted, repo.jobs[job.BatchID].Status)
|
|||
|
|
require.NotNil(t, repo.jobs[job.BatchID].ActualCost)
|
|||
|
|
require.Equal(t, 0.75, *repo.jobs[job.BatchID].ActualCost)
|
|||
|
|
require.NotEmpty(t, batchImageDerefString(repo.jobs[job.BatchID].ManifestHash))
|
|||
|
|
require.NotNil(t, repo.jobs[job.BatchID].SettledAt)
|
|||
|
|
require.Equal(t, "batch-settlement-session", batchImageDerefString(usageLogs.lastLog.SessionID))
|
|||
|
|
require.Len(t, billing.captures, 1)
|
|||
|
|
require.Equal(t, int64(321), billing.captures[0].APIKeyID)
|
|||
|
|
require.Equal(t, job.UserID, billing.captures[0].UserID)
|
|||
|
|
require.Equal(t, job.BatchID, billing.captures[0].BatchID)
|
|||
|
|
require.Equal(t, 0.75, billing.captures[0].ActualAmount)
|
|||
|
|
require.Equal(t, 1.25, billing.captures[0].HoldAmount)
|
|||
|
|
require.NotContains(t, fmt.Sprintf("%+v", billing.captures[0]), batchImageTestData)
|
|||
|
|
require.NotContains(t, fmt.Sprintf("%+v", billing.captures[0]), "gs://")
|
|||
|
|
require.NotContains(t, fmt.Sprintf("%+v", billing.captures[0]), "prompt")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestBatchImageSettlementService_ZeroSuccessCanComplete(t *testing.T) {
|
|||
|
|
repo := newFakeBatchImageRepository()
|
|||
|
|
job := testSettlingBatchImageJob("imgbatch_zero")
|
|||
|
|
job.SuccessCount = 0
|
|||
|
|
job.FailCount = 4
|
|||
|
|
job.ItemCount = 4
|
|||
|
|
repo.jobs[job.BatchID] = job
|
|||
|
|
billing := &fakeBatchImageBillingRepo{}
|
|||
|
|
svc := &BatchImageSettlementService{Repo: repo, BillingRepo: billing, Pricing: &fakeBatchImagePricingResolver{unitPrice: 0.25}}
|
|||
|
|
|
|||
|
|
result, err := svc.Settle(context.Background(), job.BatchID)
|
|||
|
|
require.NoError(t, err)
|
|||
|
|
require.Equal(t, 0.0, result.ActualCost)
|
|||
|
|
require.Equal(t, BatchImageJobStatusCompleted, repo.jobs[job.BatchID].Status)
|
|||
|
|
require.Len(t, billing.captures, 1)
|
|||
|
|
require.Equal(t, 0.0, billing.captures[0].ActualAmount)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestBatchImageSettlementService_CompletedJobReturnsAlreadySettledWithoutBilling(t *testing.T) {
|
|||
|
|
repo := newFakeBatchImageRepository()
|
|||
|
|
job := testSettlingBatchImageJob("imgbatch_done")
|
|||
|
|
job.Status = BatchImageJobStatusCompleted
|
|||
|
|
cost := 0.5
|
|||
|
|
job.ActualCost = &cost
|
|||
|
|
repo.jobs[job.BatchID] = job
|
|||
|
|
billing := &fakeBatchImageBillingRepo{}
|
|||
|
|
svc := &BatchImageSettlementService{Repo: repo, BillingRepo: billing, Pricing: &fakeBatchImagePricingResolver{unitPrice: 0.25}}
|
|||
|
|
|
|||
|
|
result, err := svc.Settle(context.Background(), job.BatchID)
|
|||
|
|
require.NoError(t, err)
|
|||
|
|
require.True(t, result.AlreadySettled)
|
|||
|
|
require.Equal(t, 0.5, result.ActualCost)
|
|||
|
|
require.Empty(t, billing.captures)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestBatchImageSettlementService_IdempotentAfterBillingCrash(t *testing.T) {
|
|||
|
|
repo := newFakeBatchImageRepository()
|
|||
|
|
job := testSettlingBatchImageJob("imgbatch_crash")
|
|||
|
|
repo.jobs[job.BatchID] = job
|
|||
|
|
billing := &fakeBatchImageBillingRepo{alreadyApplied: map[string]bool{BatchImageCaptureRequestID(job.BatchID): true}}
|
|||
|
|
svc := &BatchImageSettlementService{Repo: repo, BillingRepo: billing, Pricing: &fakeBatchImagePricingResolver{unitPrice: 0.25}}
|
|||
|
|
|
|||
|
|
result, err := svc.Settle(context.Background(), job.BatchID)
|
|||
|
|
require.NoError(t, err)
|
|||
|
|
require.Equal(t, 0.5, result.ActualCost)
|
|||
|
|
require.Equal(t, BatchImageJobStatusCompleted, repo.jobs[job.BatchID].Status)
|
|||
|
|
require.Len(t, billing.captures, 1)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestBatchImageSettlementService_ValidationErrors(t *testing.T) {
|
|||
|
|
tests := []struct {
|
|||
|
|
name string
|
|||
|
|
mutate func(*BatchImageJob)
|
|||
|
|
pricing BatchImagePricingResolver
|
|||
|
|
want error
|
|||
|
|
}{
|
|||
|
|
{name: "invalid_status", mutate: func(j *BatchImageJob) { j.Status = BatchImageJobStatusRunning }, want: ErrBatchImageSettlementInvalidStatus},
|
|||
|
|
{name: "negative_success_count", mutate: func(j *BatchImageJob) { j.SuccessCount = -1 }, want: ErrBatchImageSettlementInvalidCounts},
|
|||
|
|
{name: "negative_fail_count", mutate: func(j *BatchImageJob) { j.FailCount = -1 }, want: ErrBatchImageSettlementInvalidCounts},
|
|||
|
|
{name: "counts_exceed_item_count", mutate: func(j *BatchImageJob) { j.SuccessCount = 2; j.FailCount = 2; j.ItemCount = 3 }, want: ErrBatchImageSettlementInvalidCounts},
|
|||
|
|
{name: "missing_api_key", mutate: func(j *BatchImageJob) { j.APIKeyID = nil }, want: ErrBatchImageSettlementMissingAPIKeyID},
|
|||
|
|
{name: "missing_account", mutate: func(j *BatchImageJob) { j.AccountID = nil }, want: ErrBatchImageSettlementMissingAccountID},
|
|||
|
|
{name: "pricing_missing", pricing: &fakeBatchImagePricingResolver{err: ErrBatchImageSettlementPricingMissing}, want: ErrBatchImageSettlementPricingMissing},
|
|||
|
|
{name: "manifest_conflict", mutate: func(j *BatchImageJob) { v := "different"; j.ManifestHash = &v }, want: ErrBatchImageSettlementManifestConflict},
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
for _, tt := range tests {
|
|||
|
|
t.Run(tt.name, func(t *testing.T) {
|
|||
|
|
repo := newFakeBatchImageRepository()
|
|||
|
|
job := testSettlingBatchImageJob("imgbatch_" + tt.name)
|
|||
|
|
if tt.mutate != nil {
|
|||
|
|
tt.mutate(job)
|
|||
|
|
}
|
|||
|
|
repo.jobs[job.BatchID] = job
|
|||
|
|
pricing := tt.pricing
|
|||
|
|
if pricing == nil {
|
|||
|
|
pricing = &fakeBatchImagePricingResolver{unitPrice: 0.25}
|
|||
|
|
}
|
|||
|
|
billing := &fakeBatchImageBillingRepo{}
|
|||
|
|
svc := &BatchImageSettlementService{Repo: repo, BillingRepo: billing, Pricing: pricing}
|
|||
|
|
|
|||
|
|
_, err := svc.Settle(context.Background(), job.BatchID)
|
|||
|
|
require.ErrorIs(t, err, tt.want)
|
|||
|
|
require.Empty(t, billing.captures)
|
|||
|
|
require.NotEqual(t, BatchImageJobStatusCompleted, repo.jobs[job.BatchID].Status)
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestBatchImageSettlementService_CostExceedingHoldDoesNotCharge(t *testing.T) {
|
|||
|
|
repo := newFakeBatchImageRepository()
|
|||
|
|
job := testSettlingBatchImageJob("imgbatch_cost_over_hold")
|
|||
|
|
job.SuccessCount = 2
|
|||
|
|
job.FailCount = 0
|
|||
|
|
job.ItemCount = 2
|
|||
|
|
holdAmount := 0.5
|
|||
|
|
job.HoldAmount = &holdAmount
|
|||
|
|
job.EstimatedCost = holdAmount
|
|||
|
|
repo.jobs[job.BatchID] = job
|
|||
|
|
billing := &fakeBatchImageBillingRepo{}
|
|||
|
|
svc := &BatchImageSettlementService{Repo: repo, BillingRepo: billing, Pricing: &fakeBatchImagePricingResolver{unitPrice: 0.50}}
|
|||
|
|
|
|||
|
|
_, err := svc.Settle(context.Background(), job.BatchID)
|
|||
|
|
require.ErrorIs(t, err, ErrBatchImageSettlementCostExceedsHold)
|
|||
|
|
require.Empty(t, billing.captures)
|
|||
|
|
require.Equal(t, BatchImageJobStatusSettling, repo.jobs[job.BatchID].Status)
|
|||
|
|
require.Equal(t, "SETTLEMENT_COST_EXCEEDS_HOLD", batchImageDerefString(repo.jobs[job.BatchID].LastErrorCode))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestBatchImageSettlementService_UsesSubmittedPricingSnapshot(t *testing.T) {
|
|||
|
|
repo := newFakeBatchImageRepository()
|
|||
|
|
job := testSettlingBatchImageJob("imgbatch_snapshot")
|
|||
|
|
job.SuccessCount = 2
|
|||
|
|
job.FailCount = 0
|
|||
|
|
job.ItemCount = 2
|
|||
|
|
job.PricingSnapshotVersion = 1
|
|||
|
|
job.BaseUnitPrice = 0.25
|
|||
|
|
job.GroupRateMultiplier = 1
|
|||
|
|
job.AccountRateMultiplier = 1
|
|||
|
|
job.BatchDiscountMultiplier = 1
|
|||
|
|
job.HoldMultiplier = 1.1
|
|||
|
|
job.BillableUnitPrice = 0.25
|
|||
|
|
job.HoldUnitPrice = 0.275
|
|||
|
|
holdAmount := 0.55
|
|||
|
|
job.HoldAmount = &holdAmount
|
|||
|
|
job.EstimatedCost = 0.5
|
|||
|
|
repo.jobs[job.BatchID] = job
|
|||
|
|
billing := &fakeBatchImageBillingRepo{}
|
|||
|
|
svc := &BatchImageSettlementService{Repo: repo, BillingRepo: billing, Pricing: &fakeBatchImagePricingResolver{unitPrice: 0.50}}
|
|||
|
|
|
|||
|
|
result, err := svc.Settle(context.Background(), job.BatchID)
|
|||
|
|
require.NoError(t, err)
|
|||
|
|
require.InDelta(t, 0.5, result.ActualCost, 1e-12)
|
|||
|
|
require.Len(t, billing.captures, 1)
|
|||
|
|
require.InDelta(t, 0.5, billing.captures[0].ActualAmount, 1e-12)
|
|||
|
|
require.InDelta(t, 0.55, billing.captures[0].HoldAmount, 1e-12)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestBatchImageSettlementService_BillingFailureLeavesSettlingAndRecordsError(t *testing.T) {
|
|||
|
|
repo := newFakeBatchImageRepository()
|
|||
|
|
job := testSettlingBatchImageJob("imgbatch_billing_fail")
|
|||
|
|
repo.jobs[job.BatchID] = job
|
|||
|
|
billing := &fakeBatchImageBillingRepo{err: errors.New("temporary billing timeout with gs://hidden-output")}
|
|||
|
|
svc := &BatchImageSettlementService{Repo: repo, BillingRepo: billing, Pricing: &fakeBatchImagePricingResolver{unitPrice: 0.25}}
|
|||
|
|
|
|||
|
|
_, err := svc.Settle(context.Background(), job.BatchID)
|
|||
|
|
require.ErrorIs(t, err, ErrBatchImageSettlementBillingFailed)
|
|||
|
|
require.Equal(t, BatchImageJobStatusSettling, repo.jobs[job.BatchID].Status)
|
|||
|
|
require.Equal(t, "SETTLEMENT_BILLING_FAILED", batchImageDerefString(repo.jobs[job.BatchID].LastErrorCode))
|
|||
|
|
require.Contains(t, batchImageDerefString(repo.jobs[job.BatchID].LastErrorMessage), "temporary billing timeout")
|
|||
|
|
require.NotNil(t, billing.captures[0])
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestBatchImagePipelineProcessor_SettlesQueuedSettlingJob(t *testing.T) {
|
|||
|
|
repo := newFakeBatchImageRepository()
|
|||
|
|
job := testSettlingBatchImageJob("imgbatch_pipeline")
|
|||
|
|
repo.jobs[job.BatchID] = job
|
|||
|
|
billing := &fakeBatchImageBillingRepo{}
|
|||
|
|
settlement := &BatchImageSettlementService{Repo: repo, BillingRepo: billing, Pricing: &fakeBatchImagePricingResolver{unitPrice: 0.25}}
|
|||
|
|
processor := &BatchImagePipelineProcessor{
|
|||
|
|
ProviderProcessor: &BatchImageProviderProcessor{Repo: repo, ProviderRegistry: NewBatchImageProviderRegistry(&fakeProcessorProvider{}), AccountResolver: &fakeBatchImageAccountResolver{account: &Account{}}},
|
|||
|
|
SettlementService: settlement,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
result, err := processor.Process(context.Background(), job.BatchID)
|
|||
|
|
require.NoError(t, err)
|
|||
|
|
require.True(t, result.Terminal)
|
|||
|
|
require.Equal(t, BatchImageJobStatusCompleted, repo.jobs[job.BatchID].Status)
|
|||
|
|
require.Len(t, billing.captures, 1)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestBatchImagePipelineProcessor_RequeuesTransientSettlementFailure(t *testing.T) {
|
|||
|
|
repo := newFakeBatchImageRepository()
|
|||
|
|
job := testSettlingBatchImageJob("imgbatch_pipeline_retry")
|
|||
|
|
repo.jobs[job.BatchID] = job
|
|||
|
|
settlement := &BatchImageSettlementService{Repo: repo, BillingRepo: &fakeBatchImageBillingRepo{err: errors.New("temporary")}, Pricing: &fakeBatchImagePricingResolver{unitPrice: 0.25}}
|
|||
|
|
processor := &BatchImagePipelineProcessor{
|
|||
|
|
ProviderProcessor: &BatchImageProviderProcessor{Repo: repo, ProviderRegistry: NewBatchImageProviderRegistry(&fakeProcessorProvider{}), AccountResolver: &fakeBatchImageAccountResolver{account: &Account{}}},
|
|||
|
|
SettlementService: settlement,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
result, err := processor.Process(context.Background(), job.BatchID)
|
|||
|
|
require.NoError(t, err)
|
|||
|
|
require.False(t, result.Terminal)
|
|||
|
|
require.Equal(t, batchImageSettlementRetryDelay, result.RequeueAfter)
|
|||
|
|
require.Equal(t, BatchImageJobStatusSettling, repo.jobs[job.BatchID].Status)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestBatchImagePipelineProcessor_FailsAndReleasesAfterSettlementRetryLimit(t *testing.T) {
|
|||
|
|
repo := newFakeBatchImageRepository()
|
|||
|
|
job := testSettlingBatchImageJob("imgbatch_pipeline_retry_exhausted")
|
|||
|
|
job.RetryCount = batchImageSettlementMaxRetries - 1
|
|||
|
|
repo.jobs[job.BatchID] = job
|
|||
|
|
billing := &fakeBatchImageBillingRepo{captureErr: errors.New("temporary billing timeout")}
|
|||
|
|
settlement := &BatchImageSettlementService{Repo: repo, BillingRepo: billing, Pricing: &fakeBatchImagePricingResolver{unitPrice: 0.25}}
|
|||
|
|
processor := &BatchImagePipelineProcessor{
|
|||
|
|
ProviderProcessor: &BatchImageProviderProcessor{Repo: repo, ProviderRegistry: NewBatchImageProviderRegistry(&fakeProcessorProvider{}), AccountResolver: &fakeBatchImageAccountResolver{account: &Account{}}},
|
|||
|
|
SettlementService: settlement,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
result, err := processor.Process(context.Background(), job.BatchID)
|
|||
|
|
require.NoError(t, err)
|
|||
|
|
require.True(t, result.Terminal)
|
|||
|
|
require.Equal(t, BatchImageJobStatusFailed, repo.jobs[job.BatchID].Status)
|
|||
|
|
require.Equal(t, "SETTLEMENT_BILLING_RETRY_EXHAUSTED", batchImageDerefString(repo.jobs[job.BatchID].LastErrorCode))
|
|||
|
|
require.Len(t, billing.captures, 1)
|
|||
|
|
require.Len(t, billing.releases, 1)
|
|||
|
|
require.Equal(t, BatchImageReleaseRequestID(job.BatchID), billing.releases[0].RequestID)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestBatchImageSettlementRetryExhaustedReleaseIsIdempotentAfterTransitionFailure(t *testing.T) {
|
|||
|
|
repo := newFakeBatchImageRepository()
|
|||
|
|
job := testSettlingBatchImageJob("imgbatch_retry_exhausted_transition_fail")
|
|||
|
|
job.RetryCount = batchImageSettlementMaxRetries
|
|||
|
|
job.LastErrorCode = batchImageStringPtr("SETTLEMENT_BILLING_FAILED")
|
|||
|
|
repo.jobs[job.BatchID] = job
|
|||
|
|
repo.transitionErr = errors.New("temporary transition failure")
|
|||
|
|
billing := &fakeBatchImageBillingRepo{}
|
|||
|
|
svc := &BatchImageSettlementService{Repo: repo, BillingRepo: billing, Pricing: &fakeBatchImagePricingResolver{unitPrice: 0.25}}
|
|||
|
|
|
|||
|
|
_, err := svc.Settle(context.Background(), job.BatchID)
|
|||
|
|
require.ErrorContains(t, err, "temporary transition failure")
|
|||
|
|
require.Equal(t, BatchImageJobStatusSettling, repo.jobs[job.BatchID].Status)
|
|||
|
|
require.Len(t, billing.releases, 1)
|
|||
|
|
require.Len(t, billing.seen, 1)
|
|||
|
|
|
|||
|
|
repo.transitionErr = nil
|
|||
|
|
_, err = svc.Settle(context.Background(), job.BatchID)
|
|||
|
|
require.ErrorIs(t, err, ErrBatchImageSettlementBillingFailed)
|
|||
|
|
require.Equal(t, BatchImageJobStatusFailed, repo.jobs[job.BatchID].Status)
|
|||
|
|
require.Len(t, billing.releases, 2)
|
|||
|
|
require.Equal(t, billing.releases[0].RequestID, billing.releases[1].RequestID)
|
|||
|
|
require.Len(t, billing.seen, 1)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestBatchImageSettlementService_CostExceedsHoldExhaustsAndReleases(t *testing.T) {
|
|||
|
|
repo := newFakeBatchImageRepository()
|
|||
|
|
job := testSettlingBatchImageJob("imgbatch_over_hold_exhausted")
|
|||
|
|
job.SuccessCount = 2
|
|||
|
|
job.FailCount = 0
|
|||
|
|
job.ItemCount = 2
|
|||
|
|
holdAmount := 0.5
|
|||
|
|
job.HoldAmount = &holdAmount
|
|||
|
|
job.EstimatedCost = holdAmount
|
|||
|
|
requestHash := "request-hash-over-hold"
|
|||
|
|
job.RequestHash = &requestHash
|
|||
|
|
repo.jobs[job.BatchID] = job
|
|||
|
|
billing := &fakeBatchImageBillingRepo{}
|
|||
|
|
svc := &BatchImageSettlementService{Repo: repo, BillingRepo: billing, Pricing: &fakeBatchImagePricingResolver{unitPrice: 0.50}}
|
|||
|
|
|
|||
|
|
// 前 N-1 次:记录失败并返回错误(等待 worker 重试)。
|
|||
|
|
for i := 0; i < batchImageSettlementMaxRetries-1; i++ {
|
|||
|
|
_, err := svc.Settle(context.Background(), job.BatchID)
|
|||
|
|
require.ErrorIs(t, err, ErrBatchImageSettlementCostExceedsHold)
|
|||
|
|
require.Equal(t, BatchImageJobStatusSettling, repo.jobs[job.BatchID].Status)
|
|||
|
|
}
|
|||
|
|
// 达到上限:必须走耗尽出口释放冻结并转 failed,而不是无限 requeue。
|
|||
|
|
_, err := svc.Settle(context.Background(), job.BatchID)
|
|||
|
|
require.ErrorIs(t, err, ErrBatchImageSettlementBillingFailed)
|
|||
|
|
require.Equal(t, BatchImageJobStatusFailed, repo.jobs[job.BatchID].Status)
|
|||
|
|
require.Empty(t, billing.captures)
|
|||
|
|
require.Len(t, billing.releases, 1)
|
|||
|
|
require.Equal(t, BatchImageReleaseRequestID(job.BatchID), billing.releases[0].RequestID)
|
|||
|
|
// 释放指纹必须与 processor/Cancel/recovery 一致地使用 RequestHash,
|
|||
|
|
// 否则共享同一 request id 的后续释放会命中指纹冲突(毒消息)。
|
|||
|
|
require.Equal(t, requestHash, billing.releases[0].RequestPayloadHash)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestBatchImageSettlementService_InvalidCountsExhaustsAndReleases(t *testing.T) {
|
|||
|
|
repo := newFakeBatchImageRepository()
|
|||
|
|
job := testSettlingBatchImageJob("imgbatch_bad_counts_exhausted")
|
|||
|
|
job.SuccessCount = 2
|
|||
|
|
job.FailCount = 2
|
|||
|
|
job.ItemCount = 3
|
|||
|
|
requestHash := "request-hash-bad-counts"
|
|||
|
|
job.RequestHash = &requestHash
|
|||
|
|
repo.jobs[job.BatchID] = job
|
|||
|
|
billing := &fakeBatchImageBillingRepo{}
|
|||
|
|
svc := &BatchImageSettlementService{Repo: repo, BillingRepo: billing, Pricing: &fakeBatchImagePricingResolver{unitPrice: 0.25}}
|
|||
|
|
|
|||
|
|
for i := 0; i < batchImageSettlementMaxRetries-1; i++ {
|
|||
|
|
_, err := svc.Settle(context.Background(), job.BatchID)
|
|||
|
|
require.ErrorIs(t, err, ErrBatchImageSettlementInvalidCounts)
|
|||
|
|
}
|
|||
|
|
_, err := svc.Settle(context.Background(), job.BatchID)
|
|||
|
|
require.ErrorIs(t, err, ErrBatchImageSettlementBillingFailed)
|
|||
|
|
require.Equal(t, BatchImageJobStatusFailed, repo.jobs[job.BatchID].Status)
|
|||
|
|
require.Empty(t, billing.captures)
|
|||
|
|
require.Len(t, billing.releases, 1)
|
|||
|
|
require.Equal(t, requestHash, billing.releases[0].RequestPayloadHash)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestReleaseBatchImageBalanceHold_TreatsFingerprintConflictAsReleased(t *testing.T) {
|
|||
|
|
job := testSettlingBatchImageJob("imgbatch_release_conflict")
|
|||
|
|
// 历史版本用 manifestHash 释放过一次:同一 request id 再以 RequestHash
|
|||
|
|
// 释放会命中指纹冲突。资金已归还,必须视为幂等成功而非毒消息。
|
|||
|
|
billing := &fakeBatchImageBillingRepo{releaseErr: ErrUsageBillingRequestConflict}
|
|||
|
|
err := releaseBatchImageBalanceHold(context.Background(), billing, job, "request-hash")
|
|||
|
|
require.NoError(t, err)
|
|||
|
|
require.Len(t, billing.releases, 1)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestBatchImageSettlementManifestHash(t *testing.T) {
|
|||
|
|
job := testSettlingBatchImageJob("imgbatch_hash")
|
|||
|
|
first := BuildBatchImageSettlementManifestHash(job)
|
|||
|
|
job.CreatedAt = job.CreatedAt.AddDate(0, 0, 1)
|
|||
|
|
job.UpdatedAt = job.UpdatedAt.AddDate(0, 0, 1)
|
|||
|
|
require.Equal(t, first, BuildBatchImageSettlementManifestHash(job))
|
|||
|
|
|
|||
|
|
job.SuccessCount++
|
|||
|
|
require.NotEqual(t, first, BuildBatchImageSettlementManifestHash(job))
|
|||
|
|
|
|||
|
|
job.SuccessCount--
|
|||
|
|
promptOrBase64 := first + " prompt " + batchImageTestData
|
|||
|
|
require.NotContains(t, BuildBatchImageSettlementManifestHash(job), promptOrBase64)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestBatchImageSettlementBillingRequestIDs(t *testing.T) {
|
|||
|
|
repo := newFakeBatchImageRepository()
|
|||
|
|
first := testSettlingBatchImageJob("imgbatch_unique_1")
|
|||
|
|
second := testSettlingBatchImageJob("imgbatch_unique_2")
|
|||
|
|
repo.jobs[first.BatchID] = first
|
|||
|
|
repo.jobs[second.BatchID] = second
|
|||
|
|
billing := &fakeBatchImageBillingRepo{}
|
|||
|
|
svc := &BatchImageSettlementService{Repo: repo, BillingRepo: billing, Pricing: &fakeBatchImagePricingResolver{unitPrice: 0.25}}
|
|||
|
|
|
|||
|
|
_, err := svc.Settle(context.Background(), first.BatchID)
|
|||
|
|
require.NoError(t, err)
|
|||
|
|
_, err = svc.Settle(context.Background(), first.BatchID)
|
|||
|
|
require.NoError(t, err)
|
|||
|
|
_, err = svc.Settle(context.Background(), second.BatchID)
|
|||
|
|
require.NoError(t, err)
|
|||
|
|
|
|||
|
|
require.Len(t, billing.captures, 2)
|
|||
|
|
require.Equal(t, BatchImageCaptureRequestID(first.BatchID), billing.captures[0].RequestID)
|
|||
|
|
require.Equal(t, BatchImageCaptureRequestID(second.BatchID), billing.captures[1].RequestID)
|
|||
|
|
require.NotEqual(t, billing.captures[0].RequestID, billing.captures[1].RequestID)
|
|||
|
|
require.Len(t, billing.seen, 2)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func testSettlingBatchImageJob(batchID string) *BatchImageJob {
|
|||
|
|
apiKeyID := int64(321)
|
|||
|
|
accountID := int64(654)
|
|||
|
|
providerJobName := "providers/job"
|
|||
|
|
outputRef := "files/output"
|
|||
|
|
holdAmount := 1.25
|
|||
|
|
holdID := BatchImageHoldRequestID(batchID)
|
|||
|
|
return &BatchImageJob{
|
|||
|
|
BatchID: batchID,
|
|||
|
|
UserID: 123,
|
|||
|
|
APIKeyID: &apiKeyID,
|
|||
|
|
AccountID: &accountID,
|
|||
|
|
Provider: BatchImageProviderGeminiAPI,
|
|||
|
|
Model: "gemini-image",
|
|||
|
|
Status: BatchImageJobStatusSettling,
|
|||
|
|
ProviderJobName: &providerJobName,
|
|||
|
|
ProviderOutputRef: &outputRef,
|
|||
|
|
ItemCount: 3,
|
|||
|
|
SuccessCount: 2,
|
|||
|
|
FailCount: 1,
|
|||
|
|
EstimatedCost: holdAmount,
|
|||
|
|
HoldAmount: &holdAmount,
|
|||
|
|
HoldID: &holdID,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type fakeBatchImagePricingResolver struct {
|
|||
|
|
unitPrice float64
|
|||
|
|
missingModels map[string]bool
|
|||
|
|
err error
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (r *fakeBatchImagePricingResolver) BatchImageUnitPrice(_ context.Context, job *BatchImageJob) (float64, error) {
|
|||
|
|
if r.err != nil {
|
|||
|
|
return 0, r.err
|
|||
|
|
}
|
|||
|
|
if job != nil && r.missingModels[job.Model] {
|
|||
|
|
return 0, ErrBatchImageSettlementPricingMissing
|
|||
|
|
}
|
|||
|
|
return r.unitPrice, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type fakeBatchImageBillingRepo struct {
|
|||
|
|
commands []*UsageBillingCommand
|
|||
|
|
reserves []*BatchImageBalanceHoldCommand
|
|||
|
|
captures []*BatchImageBalanceHoldCommand
|
|||
|
|
releases []*BatchImageBalanceHoldCommand
|
|||
|
|
seen map[string]struct{}
|
|||
|
|
alreadyApplied map[string]bool
|
|||
|
|
err error
|
|||
|
|
reserveErr error
|
|||
|
|
captureErr error
|
|||
|
|
releaseErr error
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (r *fakeBatchImageBillingRepo) Apply(_ context.Context, cmd *UsageBillingCommand) (*UsageBillingApplyResult, error) {
|
|||
|
|
if r.seen == nil {
|
|||
|
|
r.seen = make(map[string]struct{})
|
|||
|
|
}
|
|||
|
|
if r.err != nil {
|
|||
|
|
r.commands = append(r.commands, cmd)
|
|||
|
|
return nil, r.err
|
|||
|
|
}
|
|||
|
|
if cmd != nil {
|
|||
|
|
cmd.Normalize()
|
|||
|
|
if _, ok := r.seen[cmd.RequestID]; ok || r.alreadyApplied[cmd.RequestID] {
|
|||
|
|
r.commands = append(r.commands, cmd)
|
|||
|
|
return &UsageBillingApplyResult{Applied: false}, nil
|
|||
|
|
}
|
|||
|
|
r.seen[cmd.RequestID] = struct{}{}
|
|||
|
|
}
|
|||
|
|
r.commands = append(r.commands, cmd)
|
|||
|
|
return &UsageBillingApplyResult{Applied: true}, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (r *fakeBatchImageBillingRepo) ReserveBatchImageBalance(_ context.Context, cmd *BatchImageBalanceHoldCommand) (*BatchImageBalanceHoldResult, error) {
|
|||
|
|
if r.reserveErr != nil {
|
|||
|
|
r.reserves = append(r.reserves, cmd)
|
|||
|
|
return nil, r.reserveErr
|
|||
|
|
}
|
|||
|
|
return r.applyHold(cmd, &r.reserves)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (r *fakeBatchImageBillingRepo) CaptureBatchImageBalance(_ context.Context, cmd *BatchImageBalanceHoldCommand) (*BatchImageBalanceHoldResult, error) {
|
|||
|
|
if r.captureErr != nil {
|
|||
|
|
r.captures = append(r.captures, cmd)
|
|||
|
|
return nil, r.captureErr
|
|||
|
|
}
|
|||
|
|
return r.applyHold(cmd, &r.captures)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (r *fakeBatchImageBillingRepo) ReleaseBatchImageBalance(_ context.Context, cmd *BatchImageBalanceHoldCommand) (*BatchImageBalanceHoldResult, error) {
|
|||
|
|
if r.releaseErr != nil {
|
|||
|
|
r.releases = append(r.releases, cmd)
|
|||
|
|
return nil, r.releaseErr
|
|||
|
|
}
|
|||
|
|
return r.applyHold(cmd, &r.releases)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (r *fakeBatchImageBillingRepo) applyHold(cmd *BatchImageBalanceHoldCommand, calls *[]*BatchImageBalanceHoldCommand) (*BatchImageBalanceHoldResult, error) {
|
|||
|
|
if r.seen == nil {
|
|||
|
|
r.seen = make(map[string]struct{})
|
|||
|
|
}
|
|||
|
|
if r.err != nil {
|
|||
|
|
*calls = append(*calls, cmd)
|
|||
|
|
return nil, r.err
|
|||
|
|
}
|
|||
|
|
if cmd != nil {
|
|||
|
|
cmd.Normalize()
|
|||
|
|
if _, ok := r.seen[cmd.RequestID]; ok || r.alreadyApplied[cmd.RequestID] {
|
|||
|
|
*calls = append(*calls, cmd)
|
|||
|
|
return &BatchImageBalanceHoldResult{Applied: false}, nil
|
|||
|
|
}
|
|||
|
|
r.seen[cmd.RequestID] = struct{}{}
|
|||
|
|
}
|
|||
|
|
*calls = append(*calls, cmd)
|
|||
|
|
return &BatchImageBalanceHoldResult{Applied: true}, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var _ UsageBillingRepository = (*fakeBatchImageBillingRepo)(nil)
|
|||
|
|
var _ BatchImagePricingResolver = (*fakeBatchImagePricingResolver)(nil)
|
|||
|
|
var _ = strings.TrimSpace
|