222 lines
9.6 KiB
Go
222 lines
9.6 KiB
Go
package app
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"reflect"
|
|
"testing"
|
|
|
|
"yingce/backend/internal/model"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
func TestVideoTokenChannelQuoteAgreesWithTaskBilling(t *testing.T) {
|
|
for _, test := range []struct {
|
|
name string
|
|
audio any
|
|
free bool
|
|
wantTier string
|
|
wantPrice int64
|
|
wantCost int64
|
|
}{
|
|
{name: "audio default", wantTier: "video-audio", wantPrice: 16_000_000, wantCost: 1_900_800},
|
|
{name: "explicit audio", audio: true, wantTier: "video-audio", wantPrice: 16_000_000, wantCost: 1_900_800},
|
|
{name: "silent", audio: false, wantTier: "video-silent", wantPrice: 8_000_000, wantCost: 950_400},
|
|
{name: "configured free", audio: true, free: true, wantTier: "video-audio"},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
svc, db, channel, channelModel, _ := newVideoTokenQuoteFixture(t)
|
|
if test.free {
|
|
if err := db.Model(&model.ChannelModelPriceTier{}).Where("id = ?", test.wantTier).Update("output_token_price_microcredits", 0).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
intent := ModelRequestIntent{Capability: "video", Options: map[string]any{
|
|
"vquality": "720p", "size": "16:9", "videoSeconds": 5,
|
|
}}
|
|
if test.audio != nil {
|
|
intent.Options["videoGenerateAudio"] = test.audio
|
|
}
|
|
quote, err := svc.QuoteChannelModel(ChannelModelQuoteRequest{ChannelID: channel.ID, ModelKey: channelModel.ModelKey, Intent: intent})
|
|
if err != nil {
|
|
t.Fatalf("QuoteChannelModel() error = %v", err)
|
|
}
|
|
assertVideoTokenQuote(t, quote, test.wantCost)
|
|
assertVideoQuoteHasNoFinancialWrites(t, db)
|
|
|
|
input := quoteInput(intent, channelModel.ModelKey)
|
|
input["config"].(map[string]any)["channelId"] = channel.ID
|
|
resolved, err := svc.resolveSystemChannelModelSelection(input, "canvas_video", "")
|
|
if err != nil {
|
|
t.Fatalf("resolveSystemChannelModelSelection() error = %v", err)
|
|
}
|
|
order, err := svc.taskBillingOrder("quote-user", &model.Task{ID: "video-task", Type: "canvas_video"}, resolved)
|
|
if err != nil {
|
|
t.Fatalf("taskBillingOrder() error = %v", err)
|
|
}
|
|
if order == nil || order.PriceTierID != test.wantTier || order.OutputTokenPriceMicrocredits != test.wantPrice || order.InputTokenPriceMicrocredits != 0 || order.CachedTokenPriceMicrocredits != 0 {
|
|
t.Fatalf("wrong video price tier: %#v", order)
|
|
}
|
|
if order.AmountMicrocredits != quote.AmountMicrocredits || order.ReservedAmountMicrocredits != quote.AmountMicrocredits || order.Quantity != quote.Quantity {
|
|
t.Fatalf("quote and task reservation disagree: quote=%#v order=%#v", quote, order)
|
|
}
|
|
assertVideoQuoteHasNoFinancialWrites(t, db)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestVideoTokenLogicalQuoteAgreesWithSystemChannel(t *testing.T) {
|
|
for _, policy := range []string{"channel", "unified"} {
|
|
t.Run(policy, func(t *testing.T) {
|
|
svc, db, channel, channelModel, profile := newVideoTokenQuoteFixture(t)
|
|
spec, err := CapabilitySpecFromModelCapabilityConfig(profile, "video")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
specJSON, err := json.Marshal(spec)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
logical := model.LogicalModel{
|
|
ID: "logical-video", Code: "seedance-video", Capability: "video", Enabled: true,
|
|
ActiveRevisionID: "video-revision", PricePolicy: policy, BillingMode: "token", OutputPriceMicrocredits: 16_000_000,
|
|
}
|
|
revision := model.LogicalModelRevision{
|
|
ID: logical.ActiveRevisionID, LogicalModelID: logical.ID, Version: 1,
|
|
CapabilitySpecJSON: string(specJSON),
|
|
DefaultOptionsJSON: `{"vquality":"720p","size":"16:9","videoSeconds":5,"videoGenerateAudio":true}`,
|
|
}
|
|
route := model.LogicalModelRoute{
|
|
ID: "video-route", LogicalModelRevisionID: revision.ID, ChannelModelID: channelModel.ID, Enabled: true, Weight: 1,
|
|
}
|
|
for _, row := range []any{&logical, &revision, &route} {
|
|
if err := db.Create(row).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
intent := ModelRequestIntent{Capability: "video"}
|
|
logicalQuote, err := svc.QuoteLogicalModel(logical.ID, intent)
|
|
if err != nil {
|
|
t.Fatalf("QuoteLogicalModel() error = %v", err)
|
|
}
|
|
assertVideoTokenQuote(t, logicalQuote, 1_900_800)
|
|
if logicalQuote.LogicalModelID != logical.ID {
|
|
t.Fatalf("logicalModelId = %q, want %q", logicalQuote.LogicalModelID, logical.ID)
|
|
}
|
|
channelQuote, err := svc.QuoteChannelModel(ChannelModelQuoteRequest{ChannelID: channel.ID, ModelKey: channelModel.ModelKey, Intent: intent})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if logicalQuote.AmountMicrocredits != channelQuote.AmountMicrocredits || logicalQuote.Quantity != channelQuote.Quantity || !reflect.DeepEqual(logicalQuote.VideoTokenEstimate, channelQuote.VideoTokenEstimate) {
|
|
t.Fatalf("logical and channel quote disagree: logical=%#v channel=%#v", logicalQuote, channelQuote)
|
|
}
|
|
assertVideoQuoteHasNoFinancialWrites(t, db)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestVideoTokenChannelQuoteRejectsUnavailableChannel(t *testing.T) {
|
|
for _, test := range []struct {
|
|
name string
|
|
field string
|
|
value any
|
|
}{
|
|
{name: "disabled channel", field: "enabled", value: false},
|
|
{name: "private channel", field: "scope", value: model.ChannelScopeUser},
|
|
{name: "disabled model", field: "enabled", value: false},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
svc, db, channel, channelModel, _ := newVideoTokenQuoteFixture(t)
|
|
row, id := any(&model.ModelChannel{}), channel.ID
|
|
if test.name == "disabled model" {
|
|
row, id = &model.ChannelModel{}, channelModel.ID
|
|
}
|
|
if err := db.Model(row).Where("id = ?", id).Update(test.field, test.value).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
quote, err := svc.QuoteChannelModel(ChannelModelQuoteRequest{ChannelID: channel.ID, ModelKey: channelModel.ModelKey, Intent: ModelRequestIntent{Capability: "video"}})
|
|
if err == nil || quote != nil {
|
|
t.Fatalf("unavailable channel/model must be rejected: quote=%#v error=%v", quote, err)
|
|
}
|
|
assertVideoQuoteHasNoFinancialWrites(t, db)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestVideoTokenQuoteRejectsInvalidReferenceCounts(t *testing.T) {
|
|
for _, count := range []int{-1, 129, int(^uint(0) >> 1)} {
|
|
for _, kind := range []string{"image", "video", "audio"} {
|
|
t.Run(fmt.Sprintf("%s/%d", kind, count), func(t *testing.T) {
|
|
intent := ModelRequestIntent{Capability: "video", Inputs: map[string]int{kind: count}}
|
|
// A nil repository verifies counts fail before allocation, lookup, or routing.
|
|
svc := &Service{}
|
|
if quote, err := svc.QuoteChannelModel(ChannelModelQuoteRequest{ChannelID: "system-video", ModelKey: "video", Intent: intent}); err == nil || quote != nil {
|
|
t.Fatalf("channel quote must reject invalid count: quote=%#v error=%v", quote, err)
|
|
}
|
|
if quote, err := svc.QuoteLogicalModel("logical-video", intent); err == nil || quote != nil {
|
|
t.Fatalf("logical quote must reject invalid count: quote=%#v error=%v", quote, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func newVideoTokenQuoteFixture(t *testing.T) (*Service, *gorm.DB, model.ModelChannel, model.ChannelModel, *ModelCapabilityConfig) {
|
|
t.Helper()
|
|
const providerModel = "doubao-seedance-1-5-pro-251215"
|
|
profile := DefaultModelCapabilityConfigForModel(string(model.ChannelInterfaceVolcengineArkVideo), providerModel)
|
|
profile.Video.Duration.Default = 5
|
|
tiers := []model.ChannelModelPriceTier{
|
|
{ID: "video-audio", SelectorKey: `{"videoGenerateAudio":"true"}`, SelectorJSON: `{"videoGenerateAudio":"true"}`, ProviderModelKey: providerModel, BillingMode: "token", OutputTokenPriceMicrocredits: 16_000_000, PriceConfigured: true, Enabled: true},
|
|
{ID: "video-silent", SelectorKey: `{"videoGenerateAudio":"false"}`, SelectorJSON: `{"videoGenerateAudio":"false"}`, ProviderModelKey: providerModel, BillingMode: "token", OutputTokenPriceMicrocredits: 8_000_000, PriceConfigured: true, Enabled: true},
|
|
}
|
|
svc, db, channel, channelModel := createSystemChannelSelectionFixture(t, "video", model.ChannelInterfaceVolcengineArkVideo, profile, tiers)
|
|
channelModel.ProviderModelKey = providerModel
|
|
if err := db.Model(&model.ChannelModel{}).Where("id = ?", channelModel.ID).Update("provider_model_key", providerModel).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.AutoMigrate(&model.SystemSetting{}, &model.CreditAccount{}, &model.CreditLedgerEntry{}, &model.BillingOrder{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
account := model.CreditAccount{UserID: "quote-user", AvailableMicrocredits: 10_000_000, ReservedMicrocredits: 250_000, Version: 7}
|
|
if err := db.Create(&account).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return svc, db, channel, channelModel, profile
|
|
}
|
|
|
|
func assertVideoTokenQuote(t *testing.T, quote *LogicalModelQuote, amount int64) {
|
|
t.Helper()
|
|
if quote == nil || quote.BillingMode != "token" || !quote.Estimated || quote.Quantity != 118_800 || quote.AmountMicrocredits != amount {
|
|
t.Fatalf("unexpected video quote: %#v, want 118800 tokens and %d microcredits", quote, amount)
|
|
}
|
|
want := VideoTokenEstimate{
|
|
FormulaTokens: 108_000, ReservedTokens: 118_800, OutputWidth: 1280, OutputHeight: 720,
|
|
FramesPerSecond: 24, OutputSeconds: 5, ReservationMarginPercent: 10,
|
|
}
|
|
if quote.VideoTokenEstimate == nil || *quote.VideoTokenEstimate != want {
|
|
t.Fatalf("video estimate = %#v, want %#v", quote.VideoTokenEstimate, want)
|
|
}
|
|
}
|
|
|
|
func assertVideoQuoteHasNoFinancialWrites(t *testing.T, db *gorm.DB) {
|
|
t.Helper()
|
|
var account model.CreditAccount
|
|
if err := db.First(&account, "user_id = ?", "quote-user").Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if account.AvailableMicrocredits != 10_000_000 || account.ReservedMicrocredits != 250_000 || account.Version != 7 {
|
|
t.Fatalf("read-only quote changed the credit account: %#v", account)
|
|
}
|
|
for _, row := range []any{&model.BillingOrder{}, &model.CreditLedgerEntry{}, &model.Task{}} {
|
|
var count int64
|
|
if err := db.Model(row).Count(&count).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if count != 0 {
|
|
t.Fatalf("read-only quote created %d records of %T", count, row)
|
|
}
|
|
}
|
|
}
|