Files
ttstd-infinite-canvas/backend/internal/repository/finance_video_formula_test.go

184 lines
8.1 KiB
Go

package repository
import (
"errors"
"strings"
"testing"
"infinite-canvas/backend/internal/model"
)
func TestVideoTokenSettlementChoosesProviderOrFormulaSnapshot(t *testing.T) {
for _, restore := range []bool{false, true} {
mode := "settle"
if restore {
mode = "restore"
}
for _, test := range []struct {
name string
log *model.ApiCallLog
formula int64
cap int64
wantTokens int64
wantAmount int64
wantSource string
wantError bool
}{
{name: "missing usage", formula: 108000, wantTokens: 108000, wantAmount: 1728000, wantSource: "video_formula"},
{name: "zero usage", formula: 108000, log: &model.ApiCallLog{UsageAvailable: true}, wantTokens: 108000, wantAmount: 1728000, wantSource: "video_formula"},
{name: "negative usage", formula: 108000, log: &model.ApiCallLog{UsageAvailable: true, OutputTokens: -1}, wantTokens: 108000, wantAmount: 1728000, wantSource: "video_formula"},
{name: "unavailable usage", formula: 108000, log: &model.ApiCallLog{OutputTokens: 200000}, wantTokens: 108000, wantAmount: 1728000, wantSource: "video_formula"},
{name: "provider refund", formula: 108000, log: &model.ApiCallLog{UsageAvailable: true, OutputTokens: 100000, InputTokens: 20, CachedTokens: 10}, wantTokens: 100000, wantAmount: 1600000, wantSource: "provider"},
{name: "provider supplement", formula: 108000, log: &model.ApiCallLog{UsageAvailable: true, OutputTokens: 200000}, wantTokens: 200000, wantAmount: 3200000, wantSource: "provider"},
{name: "provider cap", formula: 108000, cap: 1000000, log: &model.ApiCallLog{UsageAvailable: true, OutputTokens: 200000}, wantTokens: 200000, wantAmount: 1000000, wantSource: "provider"},
{name: "formula cap", formula: 108000, cap: 1000000, wantTokens: 108000, wantAmount: 1000000, wantSource: "video_formula"},
{name: "legacy no usage", wantError: true},
{name: "invalid snapshot", formula: -1, wantError: true},
} {
t.Run(mode+"/"+test.name, func(t *testing.T) {
repo, db := newRefundedBillingRecoveryRepository(t)
const available = int64(10000000)
const reserved = int64(1900800)
order := model.BillingOrder{
ID: "order", UserID: "user", IdempotencyKey: "task", Capability: "video", BillingMode: "token",
Quantity: 118800, VideoFormulaTokens: test.formula, AmountMicrocredits: reserved,
ReservedAmountMicrocredits: reserved, OutputTokenPriceMicrocredits: 16000000,
MultiplierBasisPoints: 10000, ChargeLimitMicrocredits: test.cap, Status: model.BillingStatusRunning,
}
account := model.CreditAccount{UserID: "user", AvailableMicrocredits: available, ReservedMicrocredits: reserved}
settle := repo.SettleBillingOrder
if restore {
settle = repo.RestoreRefundedBillingOrder
order.Status = model.BillingStatusRefunded
order.RefundedAmountMicrocredits = reserved
account.ReservedMicrocredits = 0
account.AvailableMicrocredits += reserved
}
if err := db.Create(&account).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&order).Error; err != nil {
t.Fatal(err)
}
if test.log != nil {
log := *test.log
log.ID, log.BillingOrderID, log.Status = "log", "order", model.ApiCallStatusSucceeded
if err := db.Create(&log).Error; err != nil {
t.Fatal(err)
}
}
if err := settle("order", "provider-id"); test.wantError {
if !errors.Is(err, ErrBillingUsageUnavailable) {
t.Fatalf("expected unavailable usage, got %v", err)
}
var after model.CreditAccount
if err := db.First(&after, "user_id = ?", "user").Error; err != nil {
t.Fatal(err)
}
if after.AvailableMicrocredits != account.AvailableMicrocredits || after.ReservedMicrocredits != account.ReservedMicrocredits {
t.Fatalf("rejected settlement changed balance: %+v", after)
}
return
} else if err != nil {
t.Fatal(err)
}
if err := settle("order", "provider-id"); err != nil {
t.Fatalf("repeat settlement: %v", err)
}
if err := db.First(&order, "id = ?", "order").Error; err != nil {
t.Fatal(err)
}
if order.Status != model.BillingStatusSettled || order.ActualAmountMicrocredits != test.wantAmount || order.OutputTokens != test.wantTokens || order.UsageSource != test.wantSource || order.UsageAvailable != (test.wantSource == "provider") || order.InputTokens != 0 || order.CachedTokens != 0 {
t.Fatalf("unexpected settled order: %+v", order)
}
if order.VideoFormulaTokens != test.formula || order.Quantity != 118800 || order.RefundedAmountMicrocredits != max(reserved-test.wantAmount, 0) {
t.Fatalf("snapshot or refund changed: %+v", order)
}
if err := db.First(&account, "user_id = ?", "user").Error; err != nil {
t.Fatal(err)
}
if account.AvailableMicrocredits != available+reserved-test.wantAmount || account.ReservedMicrocredits != 0 {
t.Fatalf("unexpected account after settlement: %+v", account)
}
var entries []model.CreditLedgerEntry
if err := db.Where("billing_order_id = ? AND type = ?", "order", model.CreditLedgerConsume).Find(&entries).Error; err != nil {
t.Fatal(err)
}
if len(entries) != 1 || entries[0].AmountMicrocredits != -test.wantAmount {
t.Fatalf("settlement not idempotent: %+v", entries)
}
if test.wantSource == "video_formula" && !strings.Contains(entries[0].Note, "公式") {
t.Fatalf("formula source not recorded: %+v", entries[0])
}
if test.cap > 0 && !strings.Contains(entries[0].Note, "硬上限") {
t.Fatalf("charge cap not recorded: %+v", entries[0])
}
})
}
}
}
func TestVideoTokenSettlementDoesNotHideDatabaseErrors(t *testing.T) {
for _, restore := range []bool{false, true} {
repo, db := newRefundedBillingRecoveryRepository(t)
if err := db.Migrator().DropTable(&model.ApiCallLog{}); err != nil {
t.Fatal(err)
}
order := model.BillingOrder{ID: "order", UserID: "user", IdempotencyKey: "task", Capability: "video", BillingMode: "token", VideoFormulaTokens: 108000, AmountMicrocredits: 1900800, ReservedAmountMicrocredits: 1900800, OutputTokenPriceMicrocredits: 16000000, MultiplierBasisPoints: 10000, Status: model.BillingStatusRunning}
settle := repo.SettleBillingOrder
if restore {
order.Status = model.BillingStatusRefunded
settle = repo.RestoreRefundedBillingOrder
}
if err := db.Create(&order).Error; err != nil {
t.Fatal(err)
}
if err := settle("order", ""); err == nil || errors.Is(err, ErrBillingUsageUnavailable) {
t.Fatalf("database failure was hidden: %v", err)
}
if err := db.First(&order, "id = ?", "order").Error; err != nil {
t.Fatal(err)
}
if order.Status == model.BillingStatusSettled || order.UsageSource != "" || order.ActualAmountMicrocredits != 0 {
t.Fatalf("database failure changed settlement: %+v", order)
}
}
}
func TestFreeVideoTokenSettlementDoesNotRequireUsage(t *testing.T) {
for _, restore := range []bool{false, true} {
repo, db := newRefundedBillingRecoveryRepository(t)
if err := db.Migrator().DropTable(&model.ApiCallLog{}); err != nil {
t.Fatal(err)
}
order := model.BillingOrder{ID: "order", UserID: "user", IdempotencyKey: "task", Capability: "video", BillingMode: "token", VideoFormulaTokens: 108000, Status: model.BillingStatusRunning}
settle := repo.SettleBillingOrder
if restore {
order.Status = model.BillingStatusRefunded
settle = repo.RestoreRefundedBillingOrder
}
if err := db.Create(&order).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.CreditAccount{UserID: "user", AvailableMicrocredits: 123}).Error; err != nil {
t.Fatal(err)
}
if err := settle("order", ""); err != nil {
t.Fatal(err)
}
if err := db.First(&order, "id = ?", "order").Error; err != nil {
t.Fatal(err)
}
if order.Status != model.BillingStatusSettled || order.ActualAmountMicrocredits != 0 || order.UsageAvailable {
t.Fatalf("unexpected free settlement: %+v", order)
}
var account model.CreditAccount
if err := db.First(&account, "user_id = ?", "user").Error; err != nil {
t.Fatal(err)
}
if account.AvailableMicrocredits != 123 || account.ReservedMicrocredits != 0 {
t.Fatalf("free order charged account: %+v", account)
}
}
}