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

804 строки
24 KiB

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