redeem_handler_test.go 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172
  1. package admin
  2. import (
  3. "bytes"
  4. "encoding/json"
  5. "net/http"
  6. "net/http/httptest"
  7. "testing"
  8. "time"
  9. "github.com/Wei-Shaw/sub2api/internal/service"
  10. "github.com/gin-gonic/gin"
  11. "github.com/stretchr/testify/assert"
  12. "github.com/stretchr/testify/require"
  13. )
  14. // newCreateAndRedeemHandler creates a RedeemHandler with a non-nil (but minimal)
  15. // RedeemService so that CreateAndRedeem's nil guard passes and we can test the
  16. // parameter-validation layer that runs before any service call.
  17. func newCreateAndRedeemHandler() *RedeemHandler {
  18. return &RedeemHandler{
  19. adminService: newStubAdminService(),
  20. redeemService: &service.RedeemService{}, // non-nil to pass nil guard
  21. }
  22. }
  23. // postCreateAndRedeemValidation calls CreateAndRedeem and returns the response
  24. // status code. For cases that pass validation and proceed into the service layer,
  25. // a panic may occur (because RedeemService internals are nil); this is expected
  26. // and treated as "validation passed" (returns 0 to indicate panic).
  27. func postCreateAndRedeemValidation(t *testing.T, handler *RedeemHandler, body any) (code int) {
  28. t.Helper()
  29. gin.SetMode(gin.TestMode)
  30. w := httptest.NewRecorder()
  31. c, _ := gin.CreateTestContext(w)
  32. jsonBytes, err := json.Marshal(body)
  33. require.NoError(t, err)
  34. c.Request, _ = http.NewRequest(http.MethodPost, "/api/v1/admin/redeem-codes/create-and-redeem", bytes.NewReader(jsonBytes))
  35. c.Request.Header.Set("Content-Type", "application/json")
  36. defer func() {
  37. if r := recover(); r != nil {
  38. // Panic means we passed validation and entered service layer (expected for minimal stub).
  39. code = 0
  40. }
  41. }()
  42. handler.CreateAndRedeem(c)
  43. return w.Code
  44. }
  45. func TestCreateAndRedeem_TypeDefaultsToBalance(t *testing.T) {
  46. // 不传 type 字段时应默认 balance,不触发 subscription 校验。
  47. // 验证通过后进入 service 层会 panic(返回 0),说明默认值生效。
  48. h := newCreateAndRedeemHandler()
  49. code := postCreateAndRedeemValidation(t, h, map[string]any{
  50. "code": "test-balance-default",
  51. "value": 10.0,
  52. "user_id": 1,
  53. })
  54. assert.NotEqual(t, http.StatusBadRequest, code,
  55. "omitting type should default to balance and pass validation")
  56. }
  57. func TestCreateAndRedeem_SubscriptionRequiresGroupID(t *testing.T) {
  58. h := newCreateAndRedeemHandler()
  59. code := postCreateAndRedeemValidation(t, h, map[string]any{
  60. "code": "test-sub-no-group",
  61. "type": "subscription",
  62. "value": 29.9,
  63. "user_id": 1,
  64. "validity_days": 30,
  65. // group_id 缺失
  66. })
  67. assert.Equal(t, http.StatusBadRequest, code)
  68. }
  69. func TestCreateAndRedeem_SubscriptionRequiresNonZeroValidityDays(t *testing.T) {
  70. groupID := int64(5)
  71. h := newCreateAndRedeemHandler()
  72. // zero should be rejected
  73. t.Run("zero", func(t *testing.T) {
  74. code := postCreateAndRedeemValidation(t, h, map[string]any{
  75. "code": "test-sub-bad-days-zero",
  76. "type": "subscription",
  77. "value": 29.9,
  78. "user_id": 1,
  79. "group_id": groupID,
  80. "validity_days": 0,
  81. })
  82. assert.Equal(t, http.StatusBadRequest, code)
  83. })
  84. // negative should pass validation (used for refund/reduction)
  85. t.Run("negative_passes_validation", func(t *testing.T) {
  86. code := postCreateAndRedeemValidation(t, h, map[string]any{
  87. "code": "test-sub-negative-days",
  88. "type": "subscription",
  89. "value": 29.9,
  90. "user_id": 1,
  91. "group_id": groupID,
  92. "validity_days": -7,
  93. })
  94. assert.NotEqual(t, http.StatusBadRequest, code,
  95. "negative validity_days should pass validation for refund")
  96. })
  97. }
  98. func TestCreateAndRedeem_SubscriptionValidParamsPassValidation(t *testing.T) {
  99. groupID := int64(5)
  100. h := newCreateAndRedeemHandler()
  101. code := postCreateAndRedeemValidation(t, h, map[string]any{
  102. "code": "test-sub-valid",
  103. "type": "subscription",
  104. "value": 29.9,
  105. "user_id": 1,
  106. "group_id": groupID,
  107. "validity_days": 31,
  108. })
  109. assert.NotEqual(t, http.StatusBadRequest, code,
  110. "valid subscription params should pass validation")
  111. }
  112. func TestCreateAndRedeem_BalanceIgnoresSubscriptionFields(t *testing.T) {
  113. h := newCreateAndRedeemHandler()
  114. // balance 类型不传 group_id 和 validity_days,不应报 400
  115. code := postCreateAndRedeemValidation(t, h, map[string]any{
  116. "code": "test-balance-no-extras",
  117. "type": "balance",
  118. "value": 50.0,
  119. "user_id": 1,
  120. })
  121. assert.NotEqual(t, http.StatusBadRequest, code,
  122. "balance type should not require group_id or validity_days")
  123. }
  124. func TestResolveRedeemCodeExpiresAt_FromDays(t *testing.T) {
  125. days := 3
  126. expiresAt, err := resolveRedeemCodeExpiresAt(nil, &days)
  127. require.NoError(t, err)
  128. require.NotNil(t, expiresAt)
  129. require.WithinDuration(t, time.Now().UTC().AddDate(0, 0, days), *expiresAt, 2*time.Second)
  130. }
  131. func TestResolveRedeemCodeExpiresAt_RejectsPastAbsoluteTime(t *testing.T) {
  132. past := time.Now().UTC().Add(-time.Minute)
  133. expiresAt, err := resolveRedeemCodeExpiresAt(&past, nil)
  134. require.Error(t, err)
  135. require.Nil(t, expiresAt)
  136. }
  137. func TestResolveRedeemCodeExpiresAt_RejectsNonPositiveDays(t *testing.T) {
  138. days := 0
  139. expiresAt, err := resolveRedeemCodeExpiresAt(nil, &days)
  140. require.Error(t, err)
  141. require.Nil(t, expiresAt)
  142. }
  143. func TestResolveRedeemCodeExpiresAt_RejectsConflictingInputs(t *testing.T) {
  144. future := time.Now().UTC().Add(time.Hour)
  145. days := 3
  146. expiresAt, err := resolveRedeemCodeExpiresAt(&future, &days)
  147. require.Error(t, err)
  148. require.Nil(t, expiresAt)
  149. }