Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
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
|