Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.
 
 
 

990 строки
31 KiB

  1. package service
  2. import (
  3. "context"
  4. "encoding/json"
  5. "io"
  6. "net/http"
  7. "net/http/httptest"
  8. "os"
  9. "strings"
  10. "testing"
  11. "time"
  12. "github.com/QuantumNous/new-api/common"
  13. "github.com/QuantumNous/new-api/constant"
  14. "github.com/QuantumNous/new-api/dto"
  15. "github.com/QuantumNous/new-api/model"
  16. relaycommon "github.com/QuantumNous/new-api/relay/common"
  17. "github.com/QuantumNous/new-api/types"
  18. "github.com/gin-gonic/gin"
  19. "github.com/glebarez/sqlite"
  20. "github.com/stretchr/testify/assert"
  21. "github.com/stretchr/testify/require"
  22. "gorm.io/gorm"
  23. )
  24. func TestMain(m *testing.M) {
  25. db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
  26. if err != nil {
  27. panic("failed to open test db: " + err.Error())
  28. }
  29. sqlDB, err := db.DB()
  30. if err != nil {
  31. panic("failed to get sql.DB: " + err.Error())
  32. }
  33. sqlDB.SetMaxOpenConns(1)
  34. model.DB = db
  35. model.LOG_DB = db
  36. common.UsingSQLite = true
  37. common.RedisEnabled = false
  38. common.BatchUpdateEnabled = false
  39. common.LogConsumeEnabled = true
  40. if err := db.AutoMigrate(
  41. &model.Task{},
  42. &model.User{},
  43. &model.Token{},
  44. &model.Log{},
  45. &model.Channel{},
  46. &model.UserSubscription{},
  47. ); err != nil {
  48. panic("failed to migrate: " + err.Error())
  49. }
  50. os.Exit(m.Run())
  51. }
  52. // ---------------------------------------------------------------------------
  53. // Seed helpers
  54. // ---------------------------------------------------------------------------
  55. func truncate(t *testing.T) {
  56. t.Helper()
  57. t.Cleanup(func() {
  58. model.DB.Exec("DELETE FROM tasks")
  59. model.DB.Exec("DELETE FROM users")
  60. model.DB.Exec("DELETE FROM tokens")
  61. model.DB.Exec("DELETE FROM logs")
  62. model.DB.Exec("DELETE FROM channels")
  63. model.DB.Exec("DELETE FROM user_subscriptions")
  64. })
  65. }
  66. func seedUser(t *testing.T, id int, quota int) {
  67. t.Helper()
  68. user := &model.User{Id: id, Username: "test_user", Quota: quota, Status: common.UserStatusEnabled}
  69. require.NoError(t, model.DB.Create(user).Error)
  70. }
  71. func seedToken(t *testing.T, id int, userId int, key string, remainQuota int) {
  72. t.Helper()
  73. token := &model.Token{
  74. Id: id,
  75. UserId: userId,
  76. Key: key,
  77. Name: "test_token",
  78. Status: common.TokenStatusEnabled,
  79. RemainQuota: remainQuota,
  80. UsedQuota: 0,
  81. }
  82. require.NoError(t, model.DB.Create(token).Error)
  83. }
  84. func seedSubscription(t *testing.T, id int, userId int, amountTotal int64, amountUsed int64) {
  85. t.Helper()
  86. sub := &model.UserSubscription{
  87. Id: id,
  88. UserId: userId,
  89. AmountTotal: amountTotal,
  90. AmountUsed: amountUsed,
  91. Status: "active",
  92. StartTime: time.Now().Unix(),
  93. EndTime: time.Now().Add(30 * 24 * time.Hour).Unix(),
  94. }
  95. require.NoError(t, model.DB.Create(sub).Error)
  96. }
  97. func seedChannel(t *testing.T, id int) {
  98. t.Helper()
  99. ch := &model.Channel{Id: id, Name: "test_channel", Key: "sk-test", Status: common.ChannelStatusEnabled}
  100. require.NoError(t, model.DB.Create(ch).Error)
  101. }
  102. func makeTask(userId, channelId, quota, tokenId int, billingSource string, subscriptionId int) *model.Task {
  103. return &model.Task{
  104. TaskID: "task_" + time.Now().Format("150405.000"),
  105. UserId: userId,
  106. ChannelId: channelId,
  107. Quota: quota,
  108. Status: model.TaskStatus(model.TaskStatusInProgress),
  109. Group: "default",
  110. Data: json.RawMessage(`{}`),
  111. CreatedAt: time.Now().Unix(),
  112. UpdatedAt: time.Now().Unix(),
  113. Properties: model.Properties{
  114. OriginModelName: "test-model",
  115. },
  116. PrivateData: model.TaskPrivateData{
  117. BillingSource: billingSource,
  118. SubscriptionId: subscriptionId,
  119. TokenId: tokenId,
  120. BillingContext: &model.TaskBillingContext{
  121. ModelPrice: 0.02,
  122. GroupRatio: 1.0,
  123. OriginModelName: "test-model",
  124. },
  125. },
  126. }
  127. }
  128. // ---------------------------------------------------------------------------
  129. // Read-back helpers
  130. // ---------------------------------------------------------------------------
  131. func getUserQuota(t *testing.T, id int) int {
  132. t.Helper()
  133. var user model.User
  134. require.NoError(t, model.DB.Select("quota").Where("id = ?", id).First(&user).Error)
  135. return user.Quota
  136. }
  137. func getTokenRemainQuota(t *testing.T, id int) int {
  138. t.Helper()
  139. var token model.Token
  140. require.NoError(t, model.DB.Select("remain_quota").Where("id = ?", id).First(&token).Error)
  141. return token.RemainQuota
  142. }
  143. func getTokenUsedQuota(t *testing.T, id int) int {
  144. t.Helper()
  145. var token model.Token
  146. require.NoError(t, model.DB.Select("used_quota").Where("id = ?", id).First(&token).Error)
  147. return token.UsedQuota
  148. }
  149. func getSubscriptionUsed(t *testing.T, id int) int64 {
  150. t.Helper()
  151. var sub model.UserSubscription
  152. require.NoError(t, model.DB.Select("amount_used").Where("id = ?", id).First(&sub).Error)
  153. return sub.AmountUsed
  154. }
  155. func getLastLog(t *testing.T) *model.Log {
  156. t.Helper()
  157. var log model.Log
  158. err := model.LOG_DB.Order("id desc").First(&log).Error
  159. if err != nil {
  160. return nil
  161. }
  162. return &log
  163. }
  164. func getLogOtherMap(t *testing.T, log *model.Log) map[string]any {
  165. t.Helper()
  166. require.NotNil(t, log)
  167. other := map[string]any{}
  168. require.NoError(t, common.UnmarshalJsonStr(log.Other, &other))
  169. return other
  170. }
  171. func countLogs(t *testing.T) int64 {
  172. t.Helper()
  173. var count int64
  174. model.LOG_DB.Model(&model.Log{}).Count(&count)
  175. return count
  176. }
  177. // ===========================================================================
  178. // RefundTaskQuota tests
  179. // ===========================================================================
  180. func TestRefundTaskQuota_Wallet(t *testing.T) {
  181. truncate(t)
  182. ctx := context.Background()
  183. const userID, tokenID, channelID = 1, 1, 1
  184. const initQuota, preConsumed = 10000, 3000
  185. const tokenRemain = 5000
  186. seedUser(t, userID, initQuota)
  187. seedToken(t, tokenID, userID, "sk-test-key", tokenRemain)
  188. seedChannel(t, channelID)
  189. task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0)
  190. RefundTaskQuota(ctx, task, "task failed: upstream error")
  191. // User quota should increase by preConsumed
  192. assert.Equal(t, initQuota+preConsumed, getUserQuota(t, userID))
  193. // Token remain_quota should increase, used_quota should decrease
  194. assert.Equal(t, tokenRemain+preConsumed, getTokenRemainQuota(t, tokenID))
  195. assert.Equal(t, -preConsumed, getTokenUsedQuota(t, tokenID))
  196. // A refund log should be created
  197. log := getLastLog(t)
  198. require.NotNil(t, log)
  199. assert.Equal(t, model.LogTypeRefund, log.Type)
  200. assert.Equal(t, preConsumed, log.Quota)
  201. assert.Equal(t, "test-model", log.ModelName)
  202. }
  203. func TestLogTaskConsumption_MatrixPreconsumeIncludesTaskBillingMetadata(t *testing.T) {
  204. truncate(t)
  205. gin.SetMode(gin.TestMode)
  206. ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
  207. ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/videos", nil)
  208. ctx.Set("token_name", "task-token")
  209. const userID, channelID = 40, 40
  210. seedUser(t, userID, 10000)
  211. seedChannel(t, channelID)
  212. info := &relaycommon.RelayInfo{
  213. UserId: userID,
  214. ChannelMeta: &relaycommon.ChannelMeta{
  215. ChannelId: channelID,
  216. },
  217. TokenId: 400,
  218. UsingGroup: "default",
  219. OriginModelName: "matrix-model",
  220. TaskRelayInfo: &relaycommon.TaskRelayInfo{Action: "submit", PublicTaskID: "task_public_123"},
  221. PriceData: types.PriceData{
  222. ModelPrice: 46,
  223. Quota: 1,
  224. GroupRatioInfo: types.GroupRatioInfo{
  225. GroupRatio: 1,
  226. },
  227. },
  228. PricingDecisionFrozen: &types.PricingDecision{
  229. BillingMode: types.BillingModeMatrix,
  230. BillingUnit: types.BillingUnitPer1MTokens,
  231. TokenUnitPriceUSD: 46,
  232. Snapshot: map[string]any{
  233. "billing_unit": types.BillingUnitPer1MTokens,
  234. "resolution": "1080p",
  235. },
  236. },
  237. }
  238. LogTaskConsumption(ctx, info)
  239. log := getLastLog(t)
  240. require.NotNil(t, log)
  241. assert.Equal(t, model.LogTypeConsume, log.Type)
  242. assert.Equal(t, 1, log.Quota)
  243. other := getLogOtherMap(t, log)
  244. assert.Equal(t, "preconsume", other["billing_phase"])
  245. assert.Equal(t, "task_public_123", other["task_id"])
  246. assert.Equal(t, float64(1), other["pre_consumed_quota"])
  247. assert.Equal(t, types.BillingModeMatrix, other["billing_mode"])
  248. assert.Equal(t, types.BillingUnitPer1MTokens, other["billing_unit"])
  249. assert.Equal(t, float64(46), other["token_unit_price_usd"])
  250. require.IsType(t, map[string]any{}, other["pricing_snapshot"])
  251. }
  252. func TestRefundTaskQuota_Subscription(t *testing.T) {
  253. truncate(t)
  254. ctx := context.Background()
  255. const userID, tokenID, channelID, subID = 2, 2, 2, 1
  256. const preConsumed = 2000
  257. const subTotal, subUsed int64 = 100000, 50000
  258. const tokenRemain = 8000
  259. seedUser(t, userID, 0)
  260. seedToken(t, tokenID, userID, "sk-sub-key", tokenRemain)
  261. seedChannel(t, channelID)
  262. seedSubscription(t, subID, userID, subTotal, subUsed)
  263. task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceSubscription, subID)
  264. RefundTaskQuota(ctx, task, "subscription task failed")
  265. // Subscription used should decrease by preConsumed
  266. assert.Equal(t, subUsed-int64(preConsumed), getSubscriptionUsed(t, subID))
  267. // Token should also be refunded
  268. assert.Equal(t, tokenRemain+preConsumed, getTokenRemainQuota(t, tokenID))
  269. log := getLastLog(t)
  270. require.NotNil(t, log)
  271. assert.Equal(t, model.LogTypeRefund, log.Type)
  272. other := getLogOtherMap(t, log)
  273. assert.Equal(t, "refund", other["billing_phase"])
  274. assert.Equal(t, task.TaskID, other["task_id"])
  275. assert.Equal(t, float64(preConsumed), other["pre_consumed_quota"])
  276. }
  277. func TestRefundTaskQuota_ZeroQuota(t *testing.T) {
  278. truncate(t)
  279. ctx := context.Background()
  280. const userID = 3
  281. seedUser(t, userID, 5000)
  282. task := makeTask(userID, 0, 0, 0, BillingSourceWallet, 0)
  283. RefundTaskQuota(ctx, task, "zero quota task")
  284. // No change to user quota
  285. assert.Equal(t, 5000, getUserQuota(t, userID))
  286. // No log created
  287. assert.Equal(t, int64(0), countLogs(t))
  288. }
  289. func TestRefundTaskQuota_NoToken(t *testing.T) {
  290. truncate(t)
  291. ctx := context.Background()
  292. const userID, channelID = 4, 4
  293. const initQuota, preConsumed = 10000, 1500
  294. seedUser(t, userID, initQuota)
  295. seedChannel(t, channelID)
  296. task := makeTask(userID, channelID, preConsumed, 0, BillingSourceWallet, 0) // TokenId=0
  297. RefundTaskQuota(ctx, task, "no token task failed")
  298. // User quota refunded
  299. assert.Equal(t, initQuota+preConsumed, getUserQuota(t, userID))
  300. // Log created
  301. log := getLastLog(t)
  302. require.NotNil(t, log)
  303. assert.Equal(t, model.LogTypeRefund, log.Type)
  304. }
  305. // ===========================================================================
  306. // RecalculateTaskQuota tests
  307. // ===========================================================================
  308. func TestRecalculate_PositiveDelta(t *testing.T) {
  309. truncate(t)
  310. ctx := context.Background()
  311. const userID, tokenID, channelID = 10, 10, 10
  312. const initQuota, preConsumed = 10000, 2000
  313. const actualQuota = 3000 // under-charged by 1000
  314. const tokenRemain = 5000
  315. seedUser(t, userID, initQuota)
  316. seedToken(t, tokenID, userID, "sk-recalc-pos", tokenRemain)
  317. seedChannel(t, channelID)
  318. task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0)
  319. require.NoError(t, model.DB.Create(task).Error)
  320. RecalculateTaskQuota(ctx, task, actualQuota, "adaptor adjustment")
  321. // User quota should decrease by the delta (1000 additional charge)
  322. assert.Equal(t, initQuota-(actualQuota-preConsumed), getUserQuota(t, userID))
  323. // Token should also be charged the delta
  324. assert.Equal(t, tokenRemain-(actualQuota-preConsumed), getTokenRemainQuota(t, tokenID))
  325. // task.Quota should be updated to actualQuota
  326. assert.Equal(t, actualQuota, task.Quota)
  327. var reloaded model.Task
  328. require.NoError(t, model.DB.First(&reloaded, task.ID).Error)
  329. assert.Equal(t, actualQuota, reloaded.Quota)
  330. // Log type should be Consume (additional charge)
  331. log := getLastLog(t)
  332. require.NotNil(t, log)
  333. assert.Equal(t, model.LogTypeConsume, log.Type)
  334. assert.Equal(t, actualQuota-preConsumed, log.Quota)
  335. other := getLogOtherMap(t, log)
  336. assert.Equal(t, "settlement", other["billing_phase"])
  337. assert.Equal(t, task.TaskID, other["task_id"])
  338. assert.Equal(t, float64(preConsumed), other["pre_consumed_quota"])
  339. assert.Equal(t, float64(actualQuota), other["actual_quota"])
  340. assert.Equal(t, float64(actualQuota-preConsumed), other["quota_delta"])
  341. }
  342. func TestRecalculate_NegativeDelta(t *testing.T) {
  343. truncate(t)
  344. ctx := context.Background()
  345. const userID, tokenID, channelID = 11, 11, 11
  346. const initQuota, preConsumed = 10000, 5000
  347. const actualQuota = 3000 // over-charged by 2000
  348. const tokenRemain = 5000
  349. seedUser(t, userID, initQuota)
  350. seedToken(t, tokenID, userID, "sk-recalc-neg", tokenRemain)
  351. seedChannel(t, channelID)
  352. task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0)
  353. RecalculateTaskQuota(ctx, task, actualQuota, "adaptor adjustment")
  354. // User quota should increase by abs(delta) = 2000 (refund overpayment)
  355. assert.Equal(t, initQuota+(preConsumed-actualQuota), getUserQuota(t, userID))
  356. // Token should be refunded the difference
  357. assert.Equal(t, tokenRemain+(preConsumed-actualQuota), getTokenRemainQuota(t, tokenID))
  358. // task.Quota updated
  359. assert.Equal(t, actualQuota, task.Quota)
  360. // Log type should be Refund
  361. log := getLastLog(t)
  362. require.NotNil(t, log)
  363. assert.Equal(t, model.LogTypeRefund, log.Type)
  364. assert.Equal(t, preConsumed-actualQuota, log.Quota)
  365. }
  366. func TestRecalculate_ZeroDelta(t *testing.T) {
  367. truncate(t)
  368. ctx := context.Background()
  369. const userID = 12
  370. const initQuota, preConsumed = 10000, 3000
  371. const totalTokens = 48400
  372. seedUser(t, userID, initQuota)
  373. task := makeTask(userID, 0, preConsumed, 0, BillingSourceWallet, 0)
  374. task.PrivateData.BillingContext.PricingSnapshot = map[string]any{
  375. "total_tokens": float64(totalTokens),
  376. }
  377. RecalculateTaskQuota(ctx, task, preConsumed, "exact match")
  378. // No change to user quota
  379. assert.Equal(t, initQuota, getUserQuota(t, userID))
  380. log := getLastLog(t)
  381. require.NotNil(t, log)
  382. assert.Equal(t, model.LogTypeConsume, log.Type)
  383. assert.Equal(t, 0, log.Quota)
  384. assert.Equal(t, totalTokens, log.CompletionTokens)
  385. other := getLogOtherMap(t, log)
  386. assert.Equal(t, "settlement", other["billing_phase"])
  387. assert.Equal(t, float64(0), other["quota_delta"])
  388. }
  389. func TestRecalculate_ActualQuotaZero(t *testing.T) {
  390. truncate(t)
  391. ctx := context.Background()
  392. const userID = 13
  393. const initQuota = 10000
  394. seedUser(t, userID, initQuota)
  395. task := makeTask(userID, 0, 5000, 0, BillingSourceWallet, 0)
  396. RecalculateTaskQuota(ctx, task, 0, "zero actual")
  397. // No change (early return)
  398. assert.Equal(t, initQuota, getUserQuota(t, userID))
  399. assert.Equal(t, int64(0), countLogs(t))
  400. }
  401. func TestRecalculate_Subscription_NegativeDelta(t *testing.T) {
  402. truncate(t)
  403. ctx := context.Background()
  404. const userID, tokenID, channelID, subID = 14, 14, 14, 2
  405. const preConsumed = 5000
  406. const actualQuota = 2000 // over-charged by 3000
  407. const subTotal, subUsed int64 = 100000, 50000
  408. const tokenRemain = 8000
  409. seedUser(t, userID, 0)
  410. seedToken(t, tokenID, userID, "sk-sub-recalc", tokenRemain)
  411. seedChannel(t, channelID)
  412. seedSubscription(t, subID, userID, subTotal, subUsed)
  413. task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceSubscription, subID)
  414. RecalculateTaskQuota(ctx, task, actualQuota, "subscription over-charge")
  415. // Subscription used should decrease by delta (refund 3000)
  416. assert.Equal(t, subUsed-int64(preConsumed-actualQuota), getSubscriptionUsed(t, subID))
  417. // Token refunded
  418. assert.Equal(t, tokenRemain+(preConsumed-actualQuota), getTokenRemainQuota(t, tokenID))
  419. assert.Equal(t, actualQuota, task.Quota)
  420. log := getLastLog(t)
  421. require.NotNil(t, log)
  422. assert.Equal(t, model.LogTypeRefund, log.Type)
  423. }
  424. // ===========================================================================
  425. // CAS + Billing integration tests
  426. // Simulates the flow in updateVideoSingleTask (service/task_polling.go)
  427. // ===========================================================================
  428. // simulatePollBilling reproduces the CAS + billing logic from updateVideoSingleTask.
  429. // It takes a persisted task (already in DB), applies the new status, and performs
  430. // the conditional update + billing exactly as the polling loop does.
  431. func simulatePollBilling(ctx context.Context, task *model.Task, newStatus model.TaskStatus, actualQuota int) {
  432. snap := task.Snapshot()
  433. shouldRefund := false
  434. shouldSettle := false
  435. quota := task.Quota
  436. task.Status = newStatus
  437. switch string(newStatus) {
  438. case model.TaskStatusSuccess:
  439. task.Progress = "100%"
  440. task.FinishTime = 9999
  441. shouldSettle = true
  442. case model.TaskStatusFailure:
  443. task.Progress = "100%"
  444. task.FinishTime = 9999
  445. task.FailReason = "upstream error"
  446. if quota != 0 {
  447. shouldRefund = true
  448. }
  449. default:
  450. task.Progress = "50%"
  451. }
  452. isDone := task.Status == model.TaskStatus(model.TaskStatusSuccess) || task.Status == model.TaskStatus(model.TaskStatusFailure)
  453. if isDone && snap.Status != task.Status {
  454. won, err := task.UpdateWithStatus(snap.Status)
  455. if err != nil {
  456. shouldRefund = false
  457. shouldSettle = false
  458. } else if !won {
  459. shouldRefund = false
  460. shouldSettle = false
  461. }
  462. } else if !snap.Equal(task.Snapshot()) {
  463. _, _ = task.UpdateWithStatus(snap.Status)
  464. }
  465. if shouldSettle && actualQuota > 0 {
  466. RecalculateTaskQuota(ctx, task, actualQuota, "test settle")
  467. }
  468. if shouldRefund {
  469. RefundTaskQuota(ctx, task, task.FailReason)
  470. }
  471. }
  472. func TestCASGuardedRefund_Win(t *testing.T) {
  473. truncate(t)
  474. ctx := context.Background()
  475. const userID, tokenID, channelID = 20, 20, 20
  476. const initQuota, preConsumed = 10000, 4000
  477. const tokenRemain = 6000
  478. seedUser(t, userID, initQuota)
  479. seedToken(t, tokenID, userID, "sk-cas-refund-win", tokenRemain)
  480. seedChannel(t, channelID)
  481. task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0)
  482. task.Status = model.TaskStatus(model.TaskStatusInProgress)
  483. require.NoError(t, model.DB.Create(task).Error)
  484. simulatePollBilling(ctx, task, model.TaskStatus(model.TaskStatusFailure), 0)
  485. // CAS wins: task in DB should now be FAILURE
  486. var reloaded model.Task
  487. require.NoError(t, model.DB.First(&reloaded, task.ID).Error)
  488. assert.EqualValues(t, model.TaskStatusFailure, reloaded.Status)
  489. // Refund should have happened
  490. assert.Equal(t, initQuota+preConsumed, getUserQuota(t, userID))
  491. assert.Equal(t, tokenRemain+preConsumed, getTokenRemainQuota(t, tokenID))
  492. log := getLastLog(t)
  493. require.NotNil(t, log)
  494. assert.Equal(t, model.LogTypeRefund, log.Type)
  495. }
  496. func TestCASGuardedRefund_Lose(t *testing.T) {
  497. truncate(t)
  498. ctx := context.Background()
  499. const userID, tokenID, channelID = 21, 21, 21
  500. const initQuota, preConsumed = 10000, 4000
  501. const tokenRemain = 6000
  502. seedUser(t, userID, initQuota)
  503. seedToken(t, tokenID, userID, "sk-cas-refund-lose", tokenRemain)
  504. seedChannel(t, channelID)
  505. // Create task with IN_PROGRESS in DB
  506. task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0)
  507. task.Status = model.TaskStatus(model.TaskStatusInProgress)
  508. require.NoError(t, model.DB.Create(task).Error)
  509. // Simulate another process already transitioning to FAILURE
  510. model.DB.Model(&model.Task{}).Where("id = ?", task.ID).Update("status", model.TaskStatusFailure)
  511. // Our process still has the old in-memory state (IN_PROGRESS) and tries to transition
  512. // task.Status is still IN_PROGRESS in the snapshot
  513. simulatePollBilling(ctx, task, model.TaskStatus(model.TaskStatusFailure), 0)
  514. // CAS lost: user quota should NOT change (no double refund)
  515. assert.Equal(t, initQuota, getUserQuota(t, userID))
  516. assert.Equal(t, tokenRemain, getTokenRemainQuota(t, tokenID))
  517. // No billing log should be created
  518. assert.Equal(t, int64(0), countLogs(t))
  519. }
  520. func TestCASGuardedSettle_Win(t *testing.T) {
  521. truncate(t)
  522. ctx := context.Background()
  523. const userID, tokenID, channelID = 22, 22, 22
  524. const initQuota, preConsumed = 10000, 5000
  525. const actualQuota = 3000 // over-charged, should get partial refund
  526. const tokenRemain = 8000
  527. seedUser(t, userID, initQuota)
  528. seedToken(t, tokenID, userID, "sk-cas-settle-win", tokenRemain)
  529. seedChannel(t, channelID)
  530. task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0)
  531. task.Status = model.TaskStatus(model.TaskStatusInProgress)
  532. require.NoError(t, model.DB.Create(task).Error)
  533. simulatePollBilling(ctx, task, model.TaskStatus(model.TaskStatusSuccess), actualQuota)
  534. // CAS wins: task should be SUCCESS
  535. var reloaded model.Task
  536. require.NoError(t, model.DB.First(&reloaded, task.ID).Error)
  537. assert.EqualValues(t, model.TaskStatusSuccess, reloaded.Status)
  538. // Settlement should refund the over-charge (5000 - 3000 = 2000 back to user)
  539. assert.Equal(t, initQuota+(preConsumed-actualQuota), getUserQuota(t, userID))
  540. assert.Equal(t, tokenRemain+(preConsumed-actualQuota), getTokenRemainQuota(t, tokenID))
  541. // task.Quota should be updated to actualQuota
  542. assert.Equal(t, actualQuota, task.Quota)
  543. }
  544. func TestUpdateVideoTasks_ChannelMissingRefundsPreConsumedQuota(t *testing.T) {
  545. truncate(t)
  546. ctx := context.Background()
  547. const userID, tokenID, channelID = 24, 24, 424242
  548. const initQuota, preConsumed = 10000, 3500
  549. const tokenRemain = 7000
  550. seedUser(t, userID, initQuota)
  551. seedToken(t, tokenID, userID, "sk-missing-channel-refund", tokenRemain)
  552. task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0)
  553. task.Status = model.TaskStatus(model.TaskStatusInProgress)
  554. task.Progress = "50%"
  555. require.NoError(t, model.DB.Create(task).Error)
  556. err := UpdateVideoTasks(ctx, "missing-channel-platform", map[int][]string{
  557. channelID: {task.GetUpstreamTaskID()},
  558. }, map[string]*model.Task{
  559. task.GetUpstreamTaskID(): task,
  560. })
  561. require.NoError(t, err)
  562. var reloaded model.Task
  563. require.NoError(t, model.DB.First(&reloaded, task.ID).Error)
  564. assert.EqualValues(t, model.TaskStatusFailure, reloaded.Status)
  565. assert.Equal(t, "100%", reloaded.Progress)
  566. assert.Equal(t, initQuota+preConsumed, getUserQuota(t, userID))
  567. assert.Equal(t, tokenRemain+preConsumed, getTokenRemainQuota(t, tokenID))
  568. log := getLastLog(t)
  569. require.NotNil(t, log)
  570. assert.Equal(t, model.LogTypeRefund, log.Type)
  571. }
  572. func TestUpdateSunoTasks_ChannelMissingRefundsPreConsumedQuota(t *testing.T) {
  573. truncate(t)
  574. ctx := context.Background()
  575. const userID, tokenID, channelID = 25, 25, 525252
  576. const initQuota, preConsumed = 10000, 2500
  577. const tokenRemain = 6000
  578. seedUser(t, userID, initQuota)
  579. seedToken(t, tokenID, userID, "sk-suno-missing-channel-refund", tokenRemain)
  580. task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0)
  581. task.Status = model.TaskStatus(model.TaskStatusInProgress)
  582. task.Progress = "50%"
  583. require.NoError(t, model.DB.Create(task).Error)
  584. err := UpdateSunoTasks(ctx, map[int][]string{
  585. channelID: {task.GetUpstreamTaskID()},
  586. }, map[string]*model.Task{
  587. task.GetUpstreamTaskID(): task,
  588. })
  589. require.NoError(t, err)
  590. var reloaded model.Task
  591. require.NoError(t, model.DB.First(&reloaded, task.ID).Error)
  592. assert.EqualValues(t, model.TaskStatusFailure, reloaded.Status)
  593. assert.Equal(t, "100%", reloaded.Progress)
  594. assert.Equal(t, initQuota+preConsumed, getUserQuota(t, userID))
  595. assert.Equal(t, tokenRemain+preConsumed, getTokenRemainQuota(t, tokenID))
  596. log := getLastLog(t)
  597. require.NotNil(t, log)
  598. assert.Equal(t, model.LogTypeRefund, log.Type)
  599. }
  600. func TestNonTerminalUpdate_NoBilling(t *testing.T) {
  601. truncate(t)
  602. ctx := context.Background()
  603. const userID, channelID = 23, 23
  604. const initQuota, preConsumed = 10000, 3000
  605. seedUser(t, userID, initQuota)
  606. seedChannel(t, channelID)
  607. task := makeTask(userID, channelID, preConsumed, 0, BillingSourceWallet, 0)
  608. task.Status = model.TaskStatus(model.TaskStatusInProgress)
  609. task.Progress = "20%"
  610. require.NoError(t, model.DB.Create(task).Error)
  611. // Simulate a non-terminal poll update (still IN_PROGRESS, progress changed)
  612. simulatePollBilling(ctx, task, model.TaskStatus(model.TaskStatusInProgress), 0)
  613. // User quota should NOT change
  614. assert.Equal(t, initQuota, getUserQuota(t, userID))
  615. // No billing log
  616. assert.Equal(t, int64(0), countLogs(t))
  617. // Task progress should be updated in DB
  618. var reloaded model.Task
  619. require.NoError(t, model.DB.First(&reloaded, task.ID).Error)
  620. assert.Equal(t, "50%", reloaded.Progress)
  621. }
  622. // ===========================================================================
  623. // Mock adaptor for settleTaskBillingOnComplete tests
  624. // ===========================================================================
  625. type mockAdaptor struct {
  626. adjustReturn int
  627. }
  628. func (m *mockAdaptor) Init(_ *relaycommon.RelayInfo) {}
  629. func (m *mockAdaptor) FetchTask(string, string, map[string]any, string) (*http.Response, error) {
  630. return nil, nil
  631. }
  632. func (m *mockAdaptor) ParseTaskResult([]byte) (*relaycommon.TaskInfo, error) { return nil, nil }
  633. func (m *mockAdaptor) AdjustBillingOnComplete(_ *model.Task, _ *relaycommon.TaskInfo) int {
  634. return m.adjustReturn
  635. }
  636. type mockSunoPollingAdaptor struct {
  637. mockAdaptor
  638. body string
  639. }
  640. func (m *mockSunoPollingAdaptor) FetchTask(string, string, map[string]any, string) (*http.Response, error) {
  641. return &http.Response{
  642. StatusCode: http.StatusOK,
  643. Body: io.NopCloser(strings.NewReader(m.body)),
  644. }, nil
  645. }
  646. func TestUpdateSunoTasks_CASLostDoesNotRefundAgain(t *testing.T) {
  647. truncate(t)
  648. ctx := context.Background()
  649. const userID, tokenID, channelID = 26, 26, 26
  650. const initQuota, preConsumed = 10000, 2000
  651. const tokenRemain = 5000
  652. seedUser(t, userID, initQuota)
  653. seedToken(t, tokenID, userID, "sk-suno-cas-lost", tokenRemain)
  654. seedChannel(t, channelID)
  655. task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0)
  656. task.TaskID = "task_suno_cas_lost"
  657. task.PrivateData.UpstreamTaskID = "suno_upstream_cas_lost"
  658. task.Status = model.TaskStatus(model.TaskStatusInProgress)
  659. task.Progress = "50%"
  660. require.NoError(t, model.DB.Create(task).Error)
  661. // Another poller already transitioned the task. This stale in-memory task must not refund again.
  662. require.NoError(t, model.DB.Model(&model.Task{}).Where("id = ?", task.ID).Updates(map[string]any{
  663. "status": model.TaskStatusFailure,
  664. "progress": "100%",
  665. "fail_reason": "already failed",
  666. }).Error)
  667. body, err := common.Marshal(dto.TaskResponse[[]dto.SunoDataResponse]{
  668. Code: dto.TaskSuccessCode,
  669. Data: []dto.SunoDataResponse{{
  670. TaskID: task.GetUpstreamTaskID(),
  671. Status: model.TaskStatusFailure,
  672. FailReason: "upstream failed",
  673. Data: json.RawMessage(`{}`),
  674. }},
  675. })
  676. require.NoError(t, err)
  677. oldGetTaskAdaptor := GetTaskAdaptorFunc
  678. GetTaskAdaptorFunc = func(platform constant.TaskPlatform) TaskPollingAdaptor {
  679. require.Equal(t, constant.TaskPlatformSuno, platform)
  680. return &mockSunoPollingAdaptor{body: string(body)}
  681. }
  682. t.Cleanup(func() { GetTaskAdaptorFunc = oldGetTaskAdaptor })
  683. err = UpdateSunoTasks(ctx, map[int][]string{
  684. channelID: {task.GetUpstreamTaskID()},
  685. }, map[string]*model.Task{
  686. task.GetUpstreamTaskID(): task,
  687. })
  688. require.NoError(t, err)
  689. assert.Equal(t, initQuota, getUserQuota(t, userID))
  690. assert.Equal(t, tokenRemain, getTokenRemainQuota(t, tokenID))
  691. assert.Equal(t, int64(0), countLogs(t))
  692. }
  693. // ===========================================================================
  694. // PerCallBilling tests — settleTaskBillingOnComplete
  695. // ===========================================================================
  696. func TestSettle_PerCallBilling_SkipsAdaptorAdjust(t *testing.T) {
  697. truncate(t)
  698. ctx := context.Background()
  699. const userID, tokenID, channelID = 30, 30, 30
  700. const initQuota, preConsumed = 10000, 5000
  701. const tokenRemain = 8000
  702. seedUser(t, userID, initQuota)
  703. seedToken(t, tokenID, userID, "sk-percall-adaptor", tokenRemain)
  704. seedChannel(t, channelID)
  705. task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0)
  706. task.PrivateData.BillingContext.PerCallBilling = true
  707. adaptor := &mockAdaptor{adjustReturn: 2000}
  708. taskResult := &relaycommon.TaskInfo{Status: model.TaskStatusSuccess}
  709. settleTaskBillingOnComplete(ctx, adaptor, task, taskResult)
  710. // Per-call: no adjustment despite adaptor returning 2000
  711. assert.Equal(t, initQuota, getUserQuota(t, userID))
  712. assert.Equal(t, tokenRemain, getTokenRemainQuota(t, tokenID))
  713. assert.Equal(t, preConsumed, task.Quota)
  714. assert.Equal(t, int64(0), countLogs(t))
  715. }
  716. func TestSettle_PerCallBilling_SkipsTotalTokens(t *testing.T) {
  717. truncate(t)
  718. ctx := context.Background()
  719. const userID, tokenID, channelID = 31, 31, 31
  720. const initQuota, preConsumed = 10000, 4000
  721. const tokenRemain = 7000
  722. seedUser(t, userID, initQuota)
  723. seedToken(t, tokenID, userID, "sk-percall-tokens", tokenRemain)
  724. seedChannel(t, channelID)
  725. task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0)
  726. task.PrivateData.BillingContext.PerCallBilling = true
  727. adaptor := &mockAdaptor{adjustReturn: 0}
  728. taskResult := &relaycommon.TaskInfo{Status: model.TaskStatusSuccess, TotalTokens: 9999}
  729. settleTaskBillingOnComplete(ctx, adaptor, task, taskResult)
  730. // Per-call: no recalculation by tokens
  731. assert.Equal(t, initQuota, getUserQuota(t, userID))
  732. assert.Equal(t, tokenRemain, getTokenRemainQuota(t, tokenID))
  733. assert.Equal(t, preConsumed, task.Quota)
  734. assert.Equal(t, int64(0), countLogs(t))
  735. }
  736. func TestSettle_MatrixPer1MTokensUsesTaskResultTotalTokens(t *testing.T) {
  737. truncate(t)
  738. ctx := context.Background()
  739. const userID, tokenID, channelID = 32, 32, 32
  740. const initQuota, preConsumed = 10000, 1000
  741. const totalTokens = 1234567
  742. seedUser(t, userID, initQuota)
  743. seedToken(t, tokenID, userID, "sk-matrix-tokens", 8000)
  744. seedChannel(t, channelID)
  745. task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0)
  746. task.PrivateData.BillingContext.BillingMode = types.BillingModeMatrix
  747. task.PrivateData.BillingContext.BillingUnit = types.BillingUnitPer1MTokens
  748. task.PrivateData.BillingContext.TokenUnitPriceUSD = 2
  749. task.PrivateData.BillingContext.GroupRatio = 1
  750. task.PrivateData.BillingContext.PricingSnapshot = map[string]any{
  751. "billing_unit": types.BillingUnitPer1MTokens,
  752. "price": float64(2),
  753. "pricing_mode": string(types.PricingModeMatched),
  754. }
  755. settleTaskBillingOnComplete(ctx, &mockAdaptor{}, task, &relaycommon.TaskInfo{
  756. Status: model.TaskStatusSuccess,
  757. CompletionTokens: totalTokens,
  758. TotalTokens: totalTokens,
  759. })
  760. expectedQuota := int(float64(totalTokens) * 2 / 1_000_000 * common.QuotaPerUnit)
  761. assert.Equal(t, expectedQuota, task.Quota)
  762. log := getLastLog(t)
  763. require.NotNil(t, log)
  764. assert.Equal(t, totalTokens, log.CompletionTokens)
  765. other := getLogOtherMap(t, log)
  766. pricingSnapshot := other["pricing_snapshot"].(map[string]any)
  767. assert.Equal(t, float64(totalTokens), pricingSnapshot["total_tokens"])
  768. }
  769. func TestSettle_NonPerCall_AdaptorAdjustWorks(t *testing.T) {
  770. truncate(t)
  771. ctx := context.Background()
  772. const userID, tokenID, channelID = 32, 32, 32
  773. const initQuota, preConsumed = 10000, 5000
  774. const adaptorQuota = 3000
  775. const tokenRemain = 8000
  776. seedUser(t, userID, initQuota)
  777. seedToken(t, tokenID, userID, "sk-nonpercall-adj", tokenRemain)
  778. seedChannel(t, channelID)
  779. task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0)
  780. // PerCallBilling defaults to false
  781. adaptor := &mockAdaptor{adjustReturn: adaptorQuota}
  782. taskResult := &relaycommon.TaskInfo{Status: model.TaskStatusSuccess}
  783. settleTaskBillingOnComplete(ctx, adaptor, task, taskResult)
  784. // Non-per-call: adaptor adjustment applies (refund 2000)
  785. assert.Equal(t, initQuota+(preConsumed-adaptorQuota), getUserQuota(t, userID))
  786. assert.Equal(t, tokenRemain+(preConsumed-adaptorQuota), getTokenRemainQuota(t, tokenID))
  787. assert.Equal(t, adaptorQuota, task.Quota)
  788. log := getLastLog(t)
  789. require.NotNil(t, log)
  790. assert.Equal(t, model.LogTypeRefund, log.Type)
  791. }