usage_billing_repo_unit_test.go 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261
  1. //go:build unit
  2. package repository
  3. import (
  4. "context"
  5. "database/sql"
  6. "testing"
  7. "github.com/DATA-DOG/go-sqlmock"
  8. "github.com/stretchr/testify/require"
  9. "github.com/Wei-Shaw/sub2api/internal/service"
  10. )
  11. const (
  12. conditionalBalanceDeductSQL = `(?s)UPDATE users\s+SET balance = balance - \$1,\s+updated_at = NOW\(\)\s+WHERE id = \$2 AND deleted_at IS NULL AND balance >= \$1\s+RETURNING balance`
  13. overdraftBalanceDeductSQL = `(?s)UPDATE users\s+SET balance = balance - \$1,\s+updated_at = NOW\(\)\s+WHERE id = \$2 AND deleted_at IS NULL\s+RETURNING balance`
  14. reserveBatchImageHoldSQL = `(?s)UPDATE users\s+SET balance = balance - \$1,\s+frozen_balance = COALESCE\(frozen_balance, 0\) \+ \$1,\s+updated_at = NOW\(\)\s+WHERE id = \$2 AND deleted_at IS NULL AND balance >= \$1\s+RETURNING balance, frozen_balance`
  15. captureBatchImageHoldSQL = `(?s)UPDATE users\s+SET balance = balance\s+\+ CASE WHEN \$1 > \$2 THEN \$1 - \$2 ELSE 0 END\s+- CASE WHEN \$2 > \$1 THEN \$2 - \$1 ELSE 0 END,\s+frozen_balance = COALESCE\(frozen_balance, 0\) - \$1,\s+updated_at = NOW\(\)\s+WHERE id = \$3 AND deleted_at IS NULL AND COALESCE\(frozen_balance, 0\) >= \$1\s+RETURNING balance, frozen_balance`
  16. releaseBatchImageHoldSQL = `(?s)UPDATE users\s+SET balance = balance \+ \$1,\s+frozen_balance = COALESCE\(frozen_balance, 0\) - \$1,\s+updated_at = NOW\(\)\s+WHERE id = \$2 AND deleted_at IS NULL AND COALESCE\(frozen_balance, 0\) >= \$1\s+RETURNING balance, frozen_balance`
  17. userExistsForBillingSQL = `(?s)SELECT 1\s+FROM users\s+WHERE id = \$1 AND deleted_at IS NULL`
  18. )
  19. func TestDeductUsageBillingBalance_UsesSufficientBalanceGuard(t *testing.T) {
  20. ctx := context.Background()
  21. db, mock, err := sqlmock.New()
  22. require.NoError(t, err)
  23. defer func() { _ = db.Close() }()
  24. mock.ExpectBegin()
  25. tx, err := db.BeginTx(ctx, nil)
  26. require.NoError(t, err)
  27. mock.ExpectQuery(conditionalBalanceDeductSQL).
  28. WithArgs(2.5, int64(42)).
  29. WillReturnRows(sqlmock.NewRows([]string{"balance"}).AddRow(7.5))
  30. mock.ExpectCommit()
  31. newBalance, sufficient, err := deductUsageBillingBalance(ctx, tx, 42, 2.5)
  32. require.NoError(t, err)
  33. require.True(t, sufficient)
  34. require.InDelta(t, 7.5, newBalance, 0.000001)
  35. require.NoError(t, tx.Commit())
  36. require.NoError(t, mock.ExpectationsWereMet())
  37. }
  38. func TestDeductUsageBillingBalance_RecordsOverdraftWhenGuardMisses(t *testing.T) {
  39. ctx := context.Background()
  40. db, mock, err := sqlmock.New()
  41. require.NoError(t, err)
  42. defer func() { _ = db.Close() }()
  43. mock.ExpectBegin()
  44. tx, err := db.BeginTx(ctx, nil)
  45. require.NoError(t, err)
  46. mock.ExpectQuery(conditionalBalanceDeductSQL).
  47. WithArgs(10.0, int64(42)).
  48. WillReturnError(sql.ErrNoRows)
  49. mock.ExpectQuery(overdraftBalanceDeductSQL).
  50. WithArgs(10.0, int64(42)).
  51. WillReturnRows(sqlmock.NewRows([]string{"balance"}).AddRow(-5.0))
  52. mock.ExpectCommit()
  53. newBalance, sufficient, err := deductUsageBillingBalance(ctx, tx, 42, 10)
  54. require.NoError(t, err)
  55. require.False(t, sufficient)
  56. require.InDelta(t, -5.0, newBalance, 0.000001)
  57. require.NoError(t, tx.Commit())
  58. require.NoError(t, mock.ExpectationsWereMet())
  59. }
  60. func TestApplyUsageBillingEffects_FlagsBalanceOverdraft(t *testing.T) {
  61. ctx := context.Background()
  62. db, mock, err := sqlmock.New()
  63. require.NoError(t, err)
  64. defer func() { _ = db.Close() }()
  65. mock.ExpectBegin()
  66. tx, err := db.BeginTx(ctx, nil)
  67. require.NoError(t, err)
  68. mock.ExpectQuery(conditionalBalanceDeductSQL).
  69. WithArgs(10.0, int64(42)).
  70. WillReturnError(sql.ErrNoRows)
  71. mock.ExpectQuery(overdraftBalanceDeductSQL).
  72. WithArgs(10.0, int64(42)).
  73. WillReturnRows(sqlmock.NewRows([]string{"balance"}).AddRow(-5.0))
  74. mock.ExpectCommit()
  75. result := &service.UsageBillingApplyResult{Applied: true}
  76. err = (&usageBillingRepository{}).applyUsageBillingEffects(ctx, tx, &service.UsageBillingCommand{
  77. UserID: 42,
  78. BalanceCost: 10,
  79. }, result)
  80. require.NoError(t, err)
  81. require.NotNil(t, result.NewBalance)
  82. require.InDelta(t, -5.0, *result.NewBalance, 0.000001)
  83. require.True(t, result.BalanceOverdrafted)
  84. require.NoError(t, tx.Commit())
  85. require.NoError(t, mock.ExpectationsWereMet())
  86. }
  87. func TestDeductUsageBillingBalance_ReturnsUserNotFoundWhenNoUserUpdated(t *testing.T) {
  88. ctx := context.Background()
  89. db, mock, err := sqlmock.New()
  90. require.NoError(t, err)
  91. defer func() { _ = db.Close() }()
  92. mock.ExpectBegin()
  93. tx, err := db.BeginTx(ctx, nil)
  94. require.NoError(t, err)
  95. mock.ExpectQuery(conditionalBalanceDeductSQL).
  96. WithArgs(10.0, int64(42)).
  97. WillReturnError(sql.ErrNoRows)
  98. mock.ExpectQuery(overdraftBalanceDeductSQL).
  99. WithArgs(10.0, int64(42)).
  100. WillReturnError(sql.ErrNoRows)
  101. mock.ExpectRollback()
  102. _, _, err = deductUsageBillingBalance(ctx, tx, 42, 10)
  103. require.ErrorIs(t, err, service.ErrUserNotFound)
  104. require.NoError(t, tx.Rollback())
  105. require.NoError(t, mock.ExpectationsWereMet())
  106. }
  107. func TestReserveUsageBillingBatchImageBalance_MovesAvailableToFrozen(t *testing.T) {
  108. ctx := context.Background()
  109. db, mock, err := sqlmock.New()
  110. require.NoError(t, err)
  111. defer func() { _ = db.Close() }()
  112. mock.ExpectBegin()
  113. tx, err := db.BeginTx(ctx, nil)
  114. require.NoError(t, err)
  115. mock.ExpectQuery(reserveBatchImageHoldSQL).
  116. WithArgs(2.5, int64(42)).
  117. WillReturnRows(sqlmock.NewRows([]string{"balance", "frozen_balance"}).AddRow(7.5, 2.5))
  118. mock.ExpectCommit()
  119. result, err := reserveUsageBillingBatchImageBalance(ctx, tx, &service.BatchImageBalanceHoldCommand{UserID: 42, HoldAmount: 2.5})
  120. require.NoError(t, err)
  121. require.NotNil(t, result.NewBalance)
  122. require.NotNil(t, result.FrozenBalance)
  123. require.InDelta(t, 7.5, *result.NewBalance, 0.000001)
  124. require.InDelta(t, 2.5, *result.FrozenBalance, 0.000001)
  125. require.NoError(t, tx.Commit())
  126. require.NoError(t, mock.ExpectationsWereMet())
  127. }
  128. func TestReserveUsageBillingBatchImageBalance_InsufficientBalance(t *testing.T) {
  129. ctx := context.Background()
  130. db, mock, err := sqlmock.New()
  131. require.NoError(t, err)
  132. defer func() { _ = db.Close() }()
  133. mock.ExpectBegin()
  134. tx, err := db.BeginTx(ctx, nil)
  135. require.NoError(t, err)
  136. mock.ExpectQuery(reserveBatchImageHoldSQL).
  137. WithArgs(10.0, int64(42)).
  138. WillReturnError(sql.ErrNoRows)
  139. mock.ExpectQuery(userExistsForBillingSQL).
  140. WithArgs(int64(42)).
  141. WillReturnRows(sqlmock.NewRows([]string{"?column?"}).AddRow(1))
  142. mock.ExpectRollback()
  143. _, err = reserveUsageBillingBatchImageBalance(ctx, tx, &service.BatchImageBalanceHoldCommand{UserID: 42, HoldAmount: 10})
  144. require.ErrorIs(t, err, service.ErrBatchImageInsufficientBalance)
  145. require.NoError(t, tx.Rollback())
  146. require.NoError(t, mock.ExpectationsWereMet())
  147. }
  148. func TestCaptureUsageBillingBatchImageBalance_ReleasesRemainder(t *testing.T) {
  149. ctx := context.Background()
  150. db, mock, err := sqlmock.New()
  151. require.NoError(t, err)
  152. defer func() { _ = db.Close() }()
  153. mock.ExpectBegin()
  154. tx, err := db.BeginTx(ctx, nil)
  155. require.NoError(t, err)
  156. mock.ExpectQuery(captureBatchImageHoldSQL).
  157. WithArgs(1.0, 0.25, int64(42)).
  158. WillReturnRows(sqlmock.NewRows([]string{"balance", "frozen_balance"}).AddRow(9.75, 0.0))
  159. mock.ExpectCommit()
  160. result, err := captureUsageBillingBatchImageBalance(ctx, tx, &service.BatchImageBalanceHoldCommand{UserID: 42, HoldAmount: 1, ActualAmount: 0.25})
  161. require.NoError(t, err)
  162. require.InDelta(t, 9.75, *result.NewBalance, 0.000001)
  163. require.InDelta(t, 0.0, *result.FrozenBalance, 0.000001)
  164. require.NoError(t, tx.Commit())
  165. require.NoError(t, mock.ExpectationsWereMet())
  166. }
  167. func TestCaptureUsageBillingBatchImageBalance_RejectsActualCostOverHold(t *testing.T) {
  168. ctx := context.Background()
  169. db, mock, err := sqlmock.New()
  170. require.NoError(t, err)
  171. defer func() { _ = db.Close() }()
  172. mock.ExpectBegin()
  173. tx, err := db.BeginTx(ctx, nil)
  174. require.NoError(t, err)
  175. mock.ExpectRollback()
  176. _, err = captureUsageBillingBatchImageBalance(ctx, tx, &service.BatchImageBalanceHoldCommand{UserID: 42, HoldAmount: 0.5, ActualAmount: 1})
  177. require.ErrorIs(t, err, service.ErrBatchImageSettlementCostExceedsHold)
  178. require.NoError(t, tx.Rollback())
  179. require.NoError(t, mock.ExpectationsWereMet())
  180. }
  181. func TestReleaseUsageBillingBatchImageBalance_ReturnsFrozenToAvailable(t *testing.T) {
  182. ctx := context.Background()
  183. db, mock, err := sqlmock.New()
  184. require.NoError(t, err)
  185. defer func() { _ = db.Close() }()
  186. mock.ExpectBegin()
  187. tx, err := db.BeginTx(ctx, nil)
  188. require.NoError(t, err)
  189. mock.ExpectQuery(`SELECT 1\s+FROM usage_billing_dedup\s+WHERE request_id = \$1 AND api_key_id = \$2`).
  190. WithArgs(service.BatchImageHoldRequestID("imgbatch_release"), int64(7)).
  191. WillReturnRows(sqlmock.NewRows([]string{"?column?"}).AddRow(1))
  192. mock.ExpectQuery(releaseBatchImageHoldSQL).
  193. WithArgs(1.0, int64(42)).
  194. WillReturnRows(sqlmock.NewRows([]string{"balance", "frozen_balance"}).AddRow(10.0, 0.0))
  195. mock.ExpectCommit()
  196. result, err := releaseUsageBillingBatchImageBalance(ctx, tx, &service.BatchImageBalanceHoldCommand{UserID: 42, APIKeyID: 7, BatchID: "imgbatch_release", HoldAmount: 1})
  197. require.NoError(t, err)
  198. require.InDelta(t, 10.0, *result.NewBalance, 0.000001)
  199. require.InDelta(t, 0.0, *result.FrozenBalance, 0.000001)
  200. require.NoError(t, tx.Commit())
  201. require.NoError(t, mock.ExpectationsWereMet())
  202. }
  203. func TestReleaseUsageBillingBatchImageBalance_SkipsWhenHoldNeverReserved(t *testing.T) {
  204. ctx := context.Background()
  205. db, mock, err := sqlmock.New()
  206. require.NoError(t, err)
  207. defer func() { _ = db.Close() }()
  208. mock.ExpectBegin()
  209. tx, err := db.BeginTx(ctx, nil)
  210. require.NoError(t, err)
  211. // dedup 与归档表均无 hold claim:说明该 job 从未成功冻结,
  212. // 释放必须跳过,不得从他人冻结资金池中凭空生成余额。
  213. mock.ExpectQuery(`SELECT 1\s+FROM usage_billing_dedup\s+WHERE request_id = \$1 AND api_key_id = \$2`).
  214. WithArgs(service.BatchImageHoldRequestID("imgbatch_phantom"), int64(7)).
  215. WillReturnError(sql.ErrNoRows)
  216. mock.ExpectQuery(`SELECT 1\s+FROM usage_billing_dedup_archive\s+WHERE request_id = \$1 AND api_key_id = \$2`).
  217. WithArgs(service.BatchImageHoldRequestID("imgbatch_phantom"), int64(7)).
  218. WillReturnError(sql.ErrNoRows)
  219. mock.ExpectCommit()
  220. result, err := releaseUsageBillingBatchImageBalance(ctx, tx, &service.BatchImageBalanceHoldCommand{UserID: 42, APIKeyID: 7, BatchID: "imgbatch_phantom", HoldAmount: 1})
  221. require.NoError(t, err)
  222. require.Nil(t, result.NewBalance)
  223. require.Nil(t, result.FrozenBalance)
  224. require.NoError(t, tx.Commit())
  225. require.NoError(t, mock.ExpectationsWereMet())
  226. }