You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

946 lines
29 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. RecalculateTaskQuota(ctx, task, actualQuota, "adaptor adjustment")
  320. // User quota should decrease by the delta (1000 additional charge)
  321. assert.Equal(t, initQuota-(actualQuota-preConsumed), getUserQuota(t, userID))
  322. // Token should also be charged the delta
  323. assert.Equal(t, tokenRemain-(actualQuota-preConsumed), getTokenRemainQuota(t, tokenID))
  324. // task.Quota should be updated to actualQuota
  325. assert.Equal(t, actualQuota, task.Quota)
  326. // Log type should be Consume (additional charge)
  327. log := getLastLog(t)
  328. require.NotNil(t, log)
  329. assert.Equal(t, model.LogTypeConsume, log.Type)
  330. assert.Equal(t, actualQuota-preConsumed, log.Quota)
  331. other := getLogOtherMap(t, log)
  332. assert.Equal(t, "settlement", other["billing_phase"])
  333. assert.Equal(t, task.TaskID, other["task_id"])
  334. assert.Equal(t, float64(preConsumed), other["pre_consumed_quota"])
  335. assert.Equal(t, float64(actualQuota), other["actual_quota"])
  336. assert.Equal(t, float64(actualQuota-preConsumed), other["quota_delta"])
  337. }
  338. func TestRecalculate_NegativeDelta(t *testing.T) {
  339. truncate(t)
  340. ctx := context.Background()
  341. const userID, tokenID, channelID = 11, 11, 11
  342. const initQuota, preConsumed = 10000, 5000
  343. const actualQuota = 3000 // over-charged by 2000
  344. const tokenRemain = 5000
  345. seedUser(t, userID, initQuota)
  346. seedToken(t, tokenID, userID, "sk-recalc-neg", tokenRemain)
  347. seedChannel(t, channelID)
  348. task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0)
  349. RecalculateTaskQuota(ctx, task, actualQuota, "adaptor adjustment")
  350. // User quota should increase by abs(delta) = 2000 (refund overpayment)
  351. assert.Equal(t, initQuota+(preConsumed-actualQuota), getUserQuota(t, userID))
  352. // Token should be refunded the difference
  353. assert.Equal(t, tokenRemain+(preConsumed-actualQuota), getTokenRemainQuota(t, tokenID))
  354. // task.Quota updated
  355. assert.Equal(t, actualQuota, task.Quota)
  356. // Log type should be Refund
  357. log := getLastLog(t)
  358. require.NotNil(t, log)
  359. assert.Equal(t, model.LogTypeRefund, log.Type)
  360. assert.Equal(t, preConsumed-actualQuota, log.Quota)
  361. }
  362. func TestRecalculate_ZeroDelta(t *testing.T) {
  363. truncate(t)
  364. ctx := context.Background()
  365. const userID = 12
  366. const initQuota, preConsumed = 10000, 3000
  367. const totalTokens = 48400
  368. seedUser(t, userID, initQuota)
  369. task := makeTask(userID, 0, preConsumed, 0, BillingSourceWallet, 0)
  370. task.PrivateData.BillingContext.PricingSnapshot = map[string]any{
  371. "total_tokens": float64(totalTokens),
  372. }
  373. RecalculateTaskQuota(ctx, task, preConsumed, "exact match")
  374. // No change to user quota
  375. assert.Equal(t, initQuota, getUserQuota(t, userID))
  376. log := getLastLog(t)
  377. require.NotNil(t, log)
  378. assert.Equal(t, model.LogTypeConsume, log.Type)
  379. assert.Equal(t, 0, log.Quota)
  380. assert.Equal(t, totalTokens, log.CompletionTokens)
  381. other := getLogOtherMap(t, log)
  382. assert.Equal(t, "settlement", other["billing_phase"])
  383. assert.Equal(t, float64(0), other["quota_delta"])
  384. }
  385. func TestRecalculate_ActualQuotaZero(t *testing.T) {
  386. truncate(t)
  387. ctx := context.Background()
  388. const userID = 13
  389. const initQuota = 10000
  390. seedUser(t, userID, initQuota)
  391. task := makeTask(userID, 0, 5000, 0, BillingSourceWallet, 0)
  392. RecalculateTaskQuota(ctx, task, 0, "zero actual")
  393. // No change (early return)
  394. assert.Equal(t, initQuota, getUserQuota(t, userID))
  395. assert.Equal(t, int64(0), countLogs(t))
  396. }
  397. func TestRecalculate_Subscription_NegativeDelta(t *testing.T) {
  398. truncate(t)
  399. ctx := context.Background()
  400. const userID, tokenID, channelID, subID = 14, 14, 14, 2
  401. const preConsumed = 5000
  402. const actualQuota = 2000 // over-charged by 3000
  403. const subTotal, subUsed int64 = 100000, 50000
  404. const tokenRemain = 8000
  405. seedUser(t, userID, 0)
  406. seedToken(t, tokenID, userID, "sk-sub-recalc", tokenRemain)
  407. seedChannel(t, channelID)
  408. seedSubscription(t, subID, userID, subTotal, subUsed)
  409. task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceSubscription, subID)
  410. RecalculateTaskQuota(ctx, task, actualQuota, "subscription over-charge")
  411. // Subscription used should decrease by delta (refund 3000)
  412. assert.Equal(t, subUsed-int64(preConsumed-actualQuota), getSubscriptionUsed(t, subID))
  413. // Token refunded
  414. assert.Equal(t, tokenRemain+(preConsumed-actualQuota), getTokenRemainQuota(t, tokenID))
  415. assert.Equal(t, actualQuota, task.Quota)
  416. log := getLastLog(t)
  417. require.NotNil(t, log)
  418. assert.Equal(t, model.LogTypeRefund, log.Type)
  419. }
  420. // ===========================================================================
  421. // CAS + Billing integration tests
  422. // Simulates the flow in updateVideoSingleTask (service/task_polling.go)
  423. // ===========================================================================
  424. // simulatePollBilling reproduces the CAS + billing logic from updateVideoSingleTask.
  425. // It takes a persisted task (already in DB), applies the new status, and performs
  426. // the conditional update + billing exactly as the polling loop does.
  427. func simulatePollBilling(ctx context.Context, task *model.Task, newStatus model.TaskStatus, actualQuota int) {
  428. snap := task.Snapshot()
  429. shouldRefund := false
  430. shouldSettle := false
  431. quota := task.Quota
  432. task.Status = newStatus
  433. switch string(newStatus) {
  434. case model.TaskStatusSuccess:
  435. task.Progress = "100%"
  436. task.FinishTime = 9999
  437. shouldSettle = true
  438. case model.TaskStatusFailure:
  439. task.Progress = "100%"
  440. task.FinishTime = 9999
  441. task.FailReason = "upstream error"
  442. if quota != 0 {
  443. shouldRefund = true
  444. }
  445. default:
  446. task.Progress = "50%"
  447. }
  448. isDone := task.Status == model.TaskStatus(model.TaskStatusSuccess) || task.Status == model.TaskStatus(model.TaskStatusFailure)
  449. if isDone && snap.Status != task.Status {
  450. won, err := task.UpdateWithStatus(snap.Status)
  451. if err != nil {
  452. shouldRefund = false
  453. shouldSettle = false
  454. } else if !won {
  455. shouldRefund = false
  456. shouldSettle = false
  457. }
  458. } else if !snap.Equal(task.Snapshot()) {
  459. _, _ = task.UpdateWithStatus(snap.Status)
  460. }
  461. if shouldSettle && actualQuota > 0 {
  462. RecalculateTaskQuota(ctx, task, actualQuota, "test settle")
  463. }
  464. if shouldRefund {
  465. RefundTaskQuota(ctx, task, task.FailReason)
  466. }
  467. }
  468. func TestCASGuardedRefund_Win(t *testing.T) {
  469. truncate(t)
  470. ctx := context.Background()
  471. const userID, tokenID, channelID = 20, 20, 20
  472. const initQuota, preConsumed = 10000, 4000
  473. const tokenRemain = 6000
  474. seedUser(t, userID, initQuota)
  475. seedToken(t, tokenID, userID, "sk-cas-refund-win", tokenRemain)
  476. seedChannel(t, channelID)
  477. task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0)
  478. task.Status = model.TaskStatus(model.TaskStatusInProgress)
  479. require.NoError(t, model.DB.Create(task).Error)
  480. simulatePollBilling(ctx, task, model.TaskStatus(model.TaskStatusFailure), 0)
  481. // CAS wins: task in DB should now be FAILURE
  482. var reloaded model.Task
  483. require.NoError(t, model.DB.First(&reloaded, task.ID).Error)
  484. assert.EqualValues(t, model.TaskStatusFailure, reloaded.Status)
  485. // Refund should have happened
  486. assert.Equal(t, initQuota+preConsumed, getUserQuota(t, userID))
  487. assert.Equal(t, tokenRemain+preConsumed, getTokenRemainQuota(t, tokenID))
  488. log := getLastLog(t)
  489. require.NotNil(t, log)
  490. assert.Equal(t, model.LogTypeRefund, log.Type)
  491. }
  492. func TestCASGuardedRefund_Lose(t *testing.T) {
  493. truncate(t)
  494. ctx := context.Background()
  495. const userID, tokenID, channelID = 21, 21, 21
  496. const initQuota, preConsumed = 10000, 4000
  497. const tokenRemain = 6000
  498. seedUser(t, userID, initQuota)
  499. seedToken(t, tokenID, userID, "sk-cas-refund-lose", tokenRemain)
  500. seedChannel(t, channelID)
  501. // Create task with IN_PROGRESS in DB
  502. task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0)
  503. task.Status = model.TaskStatus(model.TaskStatusInProgress)
  504. require.NoError(t, model.DB.Create(task).Error)
  505. // Simulate another process already transitioning to FAILURE
  506. model.DB.Model(&model.Task{}).Where("id = ?", task.ID).Update("status", model.TaskStatusFailure)
  507. // Our process still has the old in-memory state (IN_PROGRESS) and tries to transition
  508. // task.Status is still IN_PROGRESS in the snapshot
  509. simulatePollBilling(ctx, task, model.TaskStatus(model.TaskStatusFailure), 0)
  510. // CAS lost: user quota should NOT change (no double refund)
  511. assert.Equal(t, initQuota, getUserQuota(t, userID))
  512. assert.Equal(t, tokenRemain, getTokenRemainQuota(t, tokenID))
  513. // No billing log should be created
  514. assert.Equal(t, int64(0), countLogs(t))
  515. }
  516. func TestCASGuardedSettle_Win(t *testing.T) {
  517. truncate(t)
  518. ctx := context.Background()
  519. const userID, tokenID, channelID = 22, 22, 22
  520. const initQuota, preConsumed = 10000, 5000
  521. const actualQuota = 3000 // over-charged, should get partial refund
  522. const tokenRemain = 8000
  523. seedUser(t, userID, initQuota)
  524. seedToken(t, tokenID, userID, "sk-cas-settle-win", tokenRemain)
  525. seedChannel(t, channelID)
  526. task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0)
  527. task.Status = model.TaskStatus(model.TaskStatusInProgress)
  528. require.NoError(t, model.DB.Create(task).Error)
  529. simulatePollBilling(ctx, task, model.TaskStatus(model.TaskStatusSuccess), actualQuota)
  530. // CAS wins: task should be SUCCESS
  531. var reloaded model.Task
  532. require.NoError(t, model.DB.First(&reloaded, task.ID).Error)
  533. assert.EqualValues(t, model.TaskStatusSuccess, reloaded.Status)
  534. // Settlement should refund the over-charge (5000 - 3000 = 2000 back to user)
  535. assert.Equal(t, initQuota+(preConsumed-actualQuota), getUserQuota(t, userID))
  536. assert.Equal(t, tokenRemain+(preConsumed-actualQuota), getTokenRemainQuota(t, tokenID))
  537. // task.Quota should be updated to actualQuota
  538. assert.Equal(t, actualQuota, task.Quota)
  539. }
  540. func TestUpdateVideoTasks_ChannelMissingRefundsPreConsumedQuota(t *testing.T) {
  541. truncate(t)
  542. ctx := context.Background()
  543. const userID, tokenID, channelID = 24, 24, 424242
  544. const initQuota, preConsumed = 10000, 3500
  545. const tokenRemain = 7000
  546. seedUser(t, userID, initQuota)
  547. seedToken(t, tokenID, userID, "sk-missing-channel-refund", tokenRemain)
  548. task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0)
  549. task.Status = model.TaskStatus(model.TaskStatusInProgress)
  550. task.Progress = "50%"
  551. require.NoError(t, model.DB.Create(task).Error)
  552. err := UpdateVideoTasks(ctx, "missing-channel-platform", map[int][]string{
  553. channelID: {task.GetUpstreamTaskID()},
  554. }, map[string]*model.Task{
  555. task.GetUpstreamTaskID(): task,
  556. })
  557. require.NoError(t, err)
  558. var reloaded model.Task
  559. require.NoError(t, model.DB.First(&reloaded, task.ID).Error)
  560. assert.EqualValues(t, model.TaskStatusFailure, reloaded.Status)
  561. assert.Equal(t, "100%", reloaded.Progress)
  562. assert.Equal(t, initQuota+preConsumed, getUserQuota(t, userID))
  563. assert.Equal(t, tokenRemain+preConsumed, getTokenRemainQuota(t, tokenID))
  564. log := getLastLog(t)
  565. require.NotNil(t, log)
  566. assert.Equal(t, model.LogTypeRefund, log.Type)
  567. }
  568. func TestUpdateSunoTasks_ChannelMissingRefundsPreConsumedQuota(t *testing.T) {
  569. truncate(t)
  570. ctx := context.Background()
  571. const userID, tokenID, channelID = 25, 25, 525252
  572. const initQuota, preConsumed = 10000, 2500
  573. const tokenRemain = 6000
  574. seedUser(t, userID, initQuota)
  575. seedToken(t, tokenID, userID, "sk-suno-missing-channel-refund", tokenRemain)
  576. task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0)
  577. task.Status = model.TaskStatus(model.TaskStatusInProgress)
  578. task.Progress = "50%"
  579. require.NoError(t, model.DB.Create(task).Error)
  580. err := UpdateSunoTasks(ctx, map[int][]string{
  581. channelID: {task.GetUpstreamTaskID()},
  582. }, map[string]*model.Task{
  583. task.GetUpstreamTaskID(): task,
  584. })
  585. require.NoError(t, err)
  586. var reloaded model.Task
  587. require.NoError(t, model.DB.First(&reloaded, task.ID).Error)
  588. assert.EqualValues(t, model.TaskStatusFailure, reloaded.Status)
  589. assert.Equal(t, "100%", reloaded.Progress)
  590. assert.Equal(t, initQuota+preConsumed, getUserQuota(t, userID))
  591. assert.Equal(t, tokenRemain+preConsumed, getTokenRemainQuota(t, tokenID))
  592. log := getLastLog(t)
  593. require.NotNil(t, log)
  594. assert.Equal(t, model.LogTypeRefund, log.Type)
  595. }
  596. func TestNonTerminalUpdate_NoBilling(t *testing.T) {
  597. truncate(t)
  598. ctx := context.Background()
  599. const userID, channelID = 23, 23
  600. const initQuota, preConsumed = 10000, 3000
  601. seedUser(t, userID, initQuota)
  602. seedChannel(t, channelID)
  603. task := makeTask(userID, channelID, preConsumed, 0, BillingSourceWallet, 0)
  604. task.Status = model.TaskStatus(model.TaskStatusInProgress)
  605. task.Progress = "20%"
  606. require.NoError(t, model.DB.Create(task).Error)
  607. // Simulate a non-terminal poll update (still IN_PROGRESS, progress changed)
  608. simulatePollBilling(ctx, task, model.TaskStatus(model.TaskStatusInProgress), 0)
  609. // User quota should NOT change
  610. assert.Equal(t, initQuota, getUserQuota(t, userID))
  611. // No billing log
  612. assert.Equal(t, int64(0), countLogs(t))
  613. // Task progress should be updated in DB
  614. var reloaded model.Task
  615. require.NoError(t, model.DB.First(&reloaded, task.ID).Error)
  616. assert.Equal(t, "50%", reloaded.Progress)
  617. }
  618. // ===========================================================================
  619. // Mock adaptor for settleTaskBillingOnComplete tests
  620. // ===========================================================================
  621. type mockAdaptor struct {
  622. adjustReturn int
  623. }
  624. func (m *mockAdaptor) Init(_ *relaycommon.RelayInfo) {}
  625. func (m *mockAdaptor) FetchTask(string, string, map[string]any, string) (*http.Response, error) {
  626. return nil, nil
  627. }
  628. func (m *mockAdaptor) ParseTaskResult([]byte) (*relaycommon.TaskInfo, error) { return nil, nil }
  629. func (m *mockAdaptor) AdjustBillingOnComplete(_ *model.Task, _ *relaycommon.TaskInfo) int {
  630. return m.adjustReturn
  631. }
  632. type mockSunoPollingAdaptor struct {
  633. mockAdaptor
  634. body string
  635. }
  636. func (m *mockSunoPollingAdaptor) FetchTask(string, string, map[string]any, string) (*http.Response, error) {
  637. return &http.Response{
  638. StatusCode: http.StatusOK,
  639. Body: io.NopCloser(strings.NewReader(m.body)),
  640. }, nil
  641. }
  642. func TestUpdateSunoTasks_CASLostDoesNotRefundAgain(t *testing.T) {
  643. truncate(t)
  644. ctx := context.Background()
  645. const userID, tokenID, channelID = 26, 26, 26
  646. const initQuota, preConsumed = 10000, 2000
  647. const tokenRemain = 5000
  648. seedUser(t, userID, initQuota)
  649. seedToken(t, tokenID, userID, "sk-suno-cas-lost", tokenRemain)
  650. seedChannel(t, channelID)
  651. task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0)
  652. task.TaskID = "task_suno_cas_lost"
  653. task.PrivateData.UpstreamTaskID = "suno_upstream_cas_lost"
  654. task.Status = model.TaskStatus(model.TaskStatusInProgress)
  655. task.Progress = "50%"
  656. require.NoError(t, model.DB.Create(task).Error)
  657. // Another poller already transitioned the task. This stale in-memory task must not refund again.
  658. require.NoError(t, model.DB.Model(&model.Task{}).Where("id = ?", task.ID).Updates(map[string]any{
  659. "status": model.TaskStatusFailure,
  660. "progress": "100%",
  661. "fail_reason": "already failed",
  662. }).Error)
  663. body, err := common.Marshal(dto.TaskResponse[[]dto.SunoDataResponse]{
  664. Code: dto.TaskSuccessCode,
  665. Data: []dto.SunoDataResponse{{
  666. TaskID: task.GetUpstreamTaskID(),
  667. Status: model.TaskStatusFailure,
  668. FailReason: "upstream failed",
  669. Data: json.RawMessage(`{}`),
  670. }},
  671. })
  672. require.NoError(t, err)
  673. oldGetTaskAdaptor := GetTaskAdaptorFunc
  674. GetTaskAdaptorFunc = func(platform constant.TaskPlatform) TaskPollingAdaptor {
  675. require.Equal(t, constant.TaskPlatformSuno, platform)
  676. return &mockSunoPollingAdaptor{body: string(body)}
  677. }
  678. t.Cleanup(func() { GetTaskAdaptorFunc = oldGetTaskAdaptor })
  679. err = UpdateSunoTasks(ctx, map[int][]string{
  680. channelID: {task.GetUpstreamTaskID()},
  681. }, map[string]*model.Task{
  682. task.GetUpstreamTaskID(): task,
  683. })
  684. require.NoError(t, err)
  685. assert.Equal(t, initQuota, getUserQuota(t, userID))
  686. assert.Equal(t, tokenRemain, getTokenRemainQuota(t, tokenID))
  687. assert.Equal(t, int64(0), countLogs(t))
  688. }
  689. // ===========================================================================
  690. // PerCallBilling tests — settleTaskBillingOnComplete
  691. // ===========================================================================
  692. func TestSettle_PerCallBilling_SkipsAdaptorAdjust(t *testing.T) {
  693. truncate(t)
  694. ctx := context.Background()
  695. const userID, tokenID, channelID = 30, 30, 30
  696. const initQuota, preConsumed = 10000, 5000
  697. const tokenRemain = 8000
  698. seedUser(t, userID, initQuota)
  699. seedToken(t, tokenID, userID, "sk-percall-adaptor", tokenRemain)
  700. seedChannel(t, channelID)
  701. task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0)
  702. task.PrivateData.BillingContext.PerCallBilling = true
  703. adaptor := &mockAdaptor{adjustReturn: 2000}
  704. taskResult := &relaycommon.TaskInfo{Status: model.TaskStatusSuccess}
  705. settleTaskBillingOnComplete(ctx, adaptor, task, taskResult)
  706. // Per-call: no adjustment despite adaptor returning 2000
  707. assert.Equal(t, initQuota, getUserQuota(t, userID))
  708. assert.Equal(t, tokenRemain, getTokenRemainQuota(t, tokenID))
  709. assert.Equal(t, preConsumed, task.Quota)
  710. assert.Equal(t, int64(0), countLogs(t))
  711. }
  712. func TestSettle_PerCallBilling_SkipsTotalTokens(t *testing.T) {
  713. truncate(t)
  714. ctx := context.Background()
  715. const userID, tokenID, channelID = 31, 31, 31
  716. const initQuota, preConsumed = 10000, 4000
  717. const tokenRemain = 7000
  718. seedUser(t, userID, initQuota)
  719. seedToken(t, tokenID, userID, "sk-percall-tokens", tokenRemain)
  720. seedChannel(t, channelID)
  721. task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0)
  722. task.PrivateData.BillingContext.PerCallBilling = true
  723. adaptor := &mockAdaptor{adjustReturn: 0}
  724. taskResult := &relaycommon.TaskInfo{Status: model.TaskStatusSuccess, TotalTokens: 9999}
  725. settleTaskBillingOnComplete(ctx, adaptor, task, taskResult)
  726. // Per-call: no recalculation by tokens
  727. assert.Equal(t, initQuota, getUserQuota(t, userID))
  728. assert.Equal(t, tokenRemain, getTokenRemainQuota(t, tokenID))
  729. assert.Equal(t, preConsumed, task.Quota)
  730. assert.Equal(t, int64(0), countLogs(t))
  731. }
  732. func TestSettle_NonPerCall_AdaptorAdjustWorks(t *testing.T) {
  733. truncate(t)
  734. ctx := context.Background()
  735. const userID, tokenID, channelID = 32, 32, 32
  736. const initQuota, preConsumed = 10000, 5000
  737. const adaptorQuota = 3000
  738. const tokenRemain = 8000
  739. seedUser(t, userID, initQuota)
  740. seedToken(t, tokenID, userID, "sk-nonpercall-adj", tokenRemain)
  741. seedChannel(t, channelID)
  742. task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0)
  743. // PerCallBilling defaults to false
  744. adaptor := &mockAdaptor{adjustReturn: adaptorQuota}
  745. taskResult := &relaycommon.TaskInfo{Status: model.TaskStatusSuccess}
  746. settleTaskBillingOnComplete(ctx, adaptor, task, taskResult)
  747. // Non-per-call: adaptor adjustment applies (refund 2000)
  748. assert.Equal(t, initQuota+(preConsumed-adaptorQuota), getUserQuota(t, userID))
  749. assert.Equal(t, tokenRemain+(preConsumed-adaptorQuota), getTokenRemainQuota(t, tokenID))
  750. assert.Equal(t, adaptorQuota, task.Quota)
  751. log := getLastLog(t)
  752. require.NotNil(t, log)
  753. assert.Equal(t, model.LogTypeRefund, log.Type)
  754. }