2026-09-17 21:22:09 +08:00
|
|
|
package admin
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bytes"
|
|
|
|
|
"context"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/http/httptest"
|
|
|
|
|
"testing"
|
|
|
|
|
|
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
|
"github.com/stretchr/testify/require"
|
|
|
|
|
)
|
|
|
|
|
|
2026-09-23 01:24:59 +08:00
|
|
|
const testWithdrawIdempotencyKey = "affiliate-withdraw-1-5d0c7a2e"
|
|
|
|
|
|
2026-09-17 21:22:09 +08:00
|
|
|
type withdrawQuotaCall struct {
|
2026-09-23 01:24:59 +08:00
|
|
|
userID int64
|
|
|
|
|
amount float64
|
|
|
|
|
operationID string
|
2026-09-17 21:22:09 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type withdrawQuotaRepoStub struct {
|
|
|
|
|
service.AffiliateRepository
|
|
|
|
|
calls []withdrawQuotaCall
|
|
|
|
|
result *service.AffiliateWithdrawResult
|
|
|
|
|
err error
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-23 01:24:59 +08:00
|
|
|
func (s *withdrawQuotaRepoStub) WithdrawQuota(_ context.Context, userID int64, amount float64, operationID string) (*service.AffiliateWithdrawResult, error) {
|
|
|
|
|
s.calls = append(s.calls, withdrawQuotaCall{userID: userID, amount: amount, operationID: operationID})
|
2026-09-17 21:22:09 +08:00
|
|
|
return s.result, s.err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type withdrawQuotaResponse struct {
|
2026-09-23 01:24:59 +08:00
|
|
|
Code int `json:"code"`
|
|
|
|
|
Reason string `json:"reason"`
|
|
|
|
|
Data json.RawMessage `json:"data"`
|
2026-09-17 21:22:09 +08:00
|
|
|
}
|
|
|
|
|
|
2026-09-23 01:24:59 +08:00
|
|
|
func performWithdrawQuota(t *testing.T, repo *withdrawQuotaRepoStub, userID, idempotencyKey, body string) (*httptest.ResponseRecorder, withdrawQuotaResponse) {
|
2026-09-17 21:22:09 +08:00
|
|
|
t.Helper()
|
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
|
router := gin.New()
|
|
|
|
|
handler := NewAffiliateHandler(service.NewAffiliateService(repo, nil, nil, nil), nil)
|
|
|
|
|
router.POST("/api/v1/admin/affiliates/users/:user_id/withdraw", handler.WithdrawQuota)
|
|
|
|
|
|
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/affiliates/users/"+userID+"/withdraw", bytes.NewBufferString(body))
|
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
2026-09-23 01:24:59 +08:00
|
|
|
if idempotencyKey != "" {
|
|
|
|
|
req.Header.Set("Idempotency-Key", idempotencyKey)
|
|
|
|
|
}
|
2026-09-17 21:22:09 +08:00
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
router.ServeHTTP(rec, req)
|
|
|
|
|
|
|
|
|
|
var resp withdrawQuotaResponse
|
|
|
|
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
2026-09-23 01:24:59 +08:00
|
|
|
return rec, resp
|
2026-09-17 21:22:09 +08:00
|
|
|
}
|
|
|
|
|
|
2026-09-23 01:24:59 +08:00
|
|
|
// TestAffiliateHandlerWithdrawQuota_Success 验证登记线下提现把路径中的用户、
|
|
|
|
|
// 舍入后的金额与幂等键派生的 operation_id 交给仓储,并返回登记结果。
|
2026-09-17 21:22:09 +08:00
|
|
|
func TestAffiliateHandlerWithdrawQuota_Success(t *testing.T) {
|
|
|
|
|
repo := &withdrawQuotaRepoStub{result: &service.AffiliateWithdrawResult{LedgerID: 9, UserID: 42, Amount: 12.34567891}}
|
2026-09-23 01:24:59 +08:00
|
|
|
rec, resp := performWithdrawQuota(t, repo, "42", testWithdrawIdempotencyKey, `{"amount":12.3456789149}`)
|
2026-09-17 21:22:09 +08:00
|
|
|
|
2026-09-23 01:24:59 +08:00
|
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
2026-09-17 21:22:09 +08:00
|
|
|
require.Equal(t, 0, resp.Code)
|
2026-09-23 01:24:59 +08:00
|
|
|
var data service.AffiliateWithdrawResult
|
|
|
|
|
require.NoError(t, json.Unmarshal(resp.Data, &data))
|
|
|
|
|
require.Equal(t, int64(9), data.LedgerID)
|
|
|
|
|
require.Empty(t, rec.Header().Get("X-Idempotency-Replayed"))
|
|
|
|
|
|
|
|
|
|
require.Len(t, repo.calls, 1)
|
|
|
|
|
require.Equal(t, int64(42), repo.calls[0].userID)
|
|
|
|
|
require.Equal(t, 12.34567891, repo.calls[0].amount)
|
|
|
|
|
require.Regexp(t, `^[0-9a-f]{64}$`, repo.calls[0].operationID)
|
2026-09-17 21:22:09 +08:00
|
|
|
}
|
|
|
|
|
|
2026-09-23 01:24:59 +08:00
|
|
|
// TestAffiliateHandlerWithdrawQuota_SameKeyMapsToSameOperation 验证重试时带同一个
|
|
|
|
|
// Idempotency-Key 的请求落到同一个 operation_id,换一个键则是另一笔登记。
|
|
|
|
|
func TestAffiliateHandlerWithdrawQuota_SameKeyMapsToSameOperation(t *testing.T) {
|
|
|
|
|
repo := &withdrawQuotaRepoStub{result: &service.AffiliateWithdrawResult{LedgerID: 9, UserID: 42, Amount: 10}}
|
|
|
|
|
for _, key := range []string{testWithdrawIdempotencyKey, testWithdrawIdempotencyKey, testWithdrawIdempotencyKey + "-next"} {
|
|
|
|
|
rec, _ := performWithdrawQuota(t, repo, "42", key, `{"amount":10}`)
|
|
|
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
require.Len(t, repo.calls, 3)
|
|
|
|
|
require.Equal(t, repo.calls[0].operationID, repo.calls[1].operationID)
|
|
|
|
|
require.NotEqual(t, repo.calls[0].operationID, repo.calls[2].operationID)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestAffiliateHandlerWithdrawQuota_ReplayReturnsFirstResult 验证命中已有登记时
|
|
|
|
|
// 原样返回首次登记的结果,并以 X-Idempotency-Replayed 标明是重放。
|
|
|
|
|
func TestAffiliateHandlerWithdrawQuota_ReplayReturnsFirstResult(t *testing.T) {
|
|
|
|
|
repo := &withdrawQuotaRepoStub{result: &service.AffiliateWithdrawResult{
|
|
|
|
|
LedgerID: 9,
|
|
|
|
|
UserID: 42,
|
|
|
|
|
Amount: 10,
|
|
|
|
|
AvailableQuotaAfter: 90,
|
|
|
|
|
HistoryQuotaAfter: 100,
|
|
|
|
|
Replayed: true,
|
|
|
|
|
}}
|
|
|
|
|
rec, resp := performWithdrawQuota(t, repo, "42", testWithdrawIdempotencyKey, `{"amount":10}`)
|
|
|
|
|
|
|
|
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
|
require.Equal(t, "true", rec.Header().Get("X-Idempotency-Replayed"))
|
|
|
|
|
require.JSONEq(t, `{
|
|
|
|
|
"ledger_id": 9,
|
|
|
|
|
"user_id": 42,
|
|
|
|
|
"amount": 10,
|
|
|
|
|
"available_quota_after": 90,
|
|
|
|
|
"frozen_quota_after": 0,
|
|
|
|
|
"history_quota_after": 100
|
|
|
|
|
}`, string(resp.Data))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestAffiliateHandlerWithdrawQuota_RejectsBadRequests 验证非法用户 ID、非法 JSON、
|
|
|
|
|
// 缺少或非法的幂等键与非法金额在进入仓储前以 400 拒绝。
|
2026-09-17 21:22:09 +08:00
|
|
|
func TestAffiliateHandlerWithdrawQuota_RejectsBadRequests(t *testing.T) {
|
|
|
|
|
cases := []struct {
|
|
|
|
|
name string
|
|
|
|
|
userID string
|
2026-09-23 01:24:59 +08:00
|
|
|
key string
|
2026-09-17 21:22:09 +08:00
|
|
|
body string
|
|
|
|
|
wantReason string
|
|
|
|
|
}{
|
2026-09-23 01:24:59 +08:00
|
|
|
{name: "non-numeric user id", userID: "abc", key: testWithdrawIdempotencyKey, body: `{"amount":1}`},
|
|
|
|
|
{name: "non-positive user id", userID: "0", key: testWithdrawIdempotencyKey, body: `{"amount":1}`},
|
|
|
|
|
{name: "malformed json", userID: "42", key: testWithdrawIdempotencyKey, body: `{"amount":`},
|
|
|
|
|
{name: "missing idempotency key", userID: "42", body: `{"amount":1}`, wantReason: "IDEMPOTENCY_KEY_REQUIRED"},
|
|
|
|
|
{name: "invalid idempotency key", userID: "42", key: "has space", body: `{"amount":1}`, wantReason: "IDEMPOTENCY_KEY_INVALID"},
|
|
|
|
|
{name: "zero amount", userID: "42", key: testWithdrawIdempotencyKey, body: `{"amount":0}`, wantReason: "AFFILIATE_WITHDRAW_AMOUNT_INVALID"},
|
|
|
|
|
{name: "missing amount", userID: "42", key: testWithdrawIdempotencyKey, body: `{}`, wantReason: "AFFILIATE_WITHDRAW_AMOUNT_INVALID"},
|
2026-09-17 21:22:09 +08:00
|
|
|
}
|
|
|
|
|
for _, tc := range cases {
|
|
|
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
|
|
|
repo := &withdrawQuotaRepoStub{}
|
2026-09-23 01:24:59 +08:00
|
|
|
rec, resp := performWithdrawQuota(t, repo, tc.userID, tc.key, tc.body)
|
2026-09-17 21:22:09 +08:00
|
|
|
|
2026-09-23 01:24:59 +08:00
|
|
|
require.Equal(t, http.StatusBadRequest, rec.Code)
|
2026-09-17 21:22:09 +08:00
|
|
|
require.Equal(t, tc.wantReason, resp.Reason)
|
|
|
|
|
require.Empty(t, repo.calls)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-23 01:24:59 +08:00
|
|
|
// TestAffiliateHandlerWithdrawQuota_RepositoryRejections 验证额度不足以 400、
|
|
|
|
|
// 同一幂等键换了用户或金额以 409 返回,前端据错误码提示。
|
|
|
|
|
func TestAffiliateHandlerWithdrawQuota_RepositoryRejections(t *testing.T) {
|
|
|
|
|
cases := []struct {
|
|
|
|
|
err error
|
|
|
|
|
wantStatus int
|
|
|
|
|
wantReason string
|
|
|
|
|
}{
|
|
|
|
|
{err: service.ErrAffiliateQuotaInsufficient, wantStatus: http.StatusBadRequest, wantReason: "AFFILIATE_QUOTA_INSUFFICIENT"},
|
|
|
|
|
{err: service.ErrIdempotencyKeyConflict, wantStatus: http.StatusConflict, wantReason: "IDEMPOTENCY_KEY_CONFLICT"},
|
|
|
|
|
}
|
|
|
|
|
for _, tc := range cases {
|
|
|
|
|
t.Run(tc.wantReason, func(t *testing.T) {
|
|
|
|
|
repo := &withdrawQuotaRepoStub{err: tc.err}
|
|
|
|
|
rec, resp := performWithdrawQuota(t, repo, "42", testWithdrawIdempotencyKey, `{"amount":5}`)
|
2026-09-17 21:22:09 +08:00
|
|
|
|
2026-09-23 01:24:59 +08:00
|
|
|
require.Equal(t, tc.wantStatus, rec.Code)
|
|
|
|
|
require.Equal(t, tc.wantReason, resp.Reason)
|
|
|
|
|
require.Empty(t, rec.Header().Get("X-Idempotency-Replayed"))
|
|
|
|
|
require.Len(t, repo.calls, 1)
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-09-17 21:22:09 +08:00
|
|
|
}
|