487 lines
23 KiB
Go
487 lines
23 KiB
Go
package http
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/redis/go-redis/v9"
|
|
roomv1 "hyapp.local/api/proto/room/v1"
|
|
"hyapp/services/gateway-service/internal/auth"
|
|
"hyapp/services/gateway-service/internal/transport/http/giftlimit"
|
|
)
|
|
|
|
type fakeGiftCapacityLimiter struct {
|
|
decision giftlimit.Decision
|
|
err error
|
|
inputs []giftlimit.Input
|
|
releases int
|
|
}
|
|
|
|
func (f *fakeGiftCapacityLimiter) Acquire(_ context.Context, input giftlimit.Input) (giftlimit.Decision, error) {
|
|
f.inputs = append(f.inputs, input)
|
|
return f.decision, f.err
|
|
}
|
|
|
|
func (f *fakeGiftCapacityLimiter) Release(context.Context, giftlimit.Lease) error {
|
|
f.releases++
|
|
return nil
|
|
}
|
|
|
|
func TestGiftCapacityLimitWeightsUniqueTargetsAndReleasesInFlightSlot(t *testing.T) {
|
|
roomClient := &fakeRoomClient{}
|
|
limiter := &fakeGiftCapacityLimiter{decision: giftlimit.Decision{Allowed: true}}
|
|
handler := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001})
|
|
handler.SetGiftCapacityProtection(true, 5*time.Second, 30, limiter)
|
|
router := handler.Routes(auth.NewVerifier("secret"))
|
|
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/batch-send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-weighted","target_user_ids":[43,44,43,0],"gift_id":"rose","gift_count":1}`)))
|
|
request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithAppCode(t, "secret", 42, "huwaa"))
|
|
recorder := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(recorder, request)
|
|
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
if len(limiter.inputs) != 1 {
|
|
t.Fatalf("limiter acquire count mismatch: %d", len(limiter.inputs))
|
|
}
|
|
input := limiter.inputs[0]
|
|
if input.AppCode != "huwaa" || input.UserID != 42 || input.RoomID != "room-1" || input.Weight != 2 || input.RequestID == "" {
|
|
t.Fatalf("weighted limiter input mismatch: %+v", input)
|
|
}
|
|
if limiter.releases != 1 {
|
|
t.Fatalf("successful acquire must release in-flight slot exactly once: %d", limiter.releases)
|
|
}
|
|
if roomClient.lastGift == nil || len(roomClient.lastGift.GetTargetUserIds()) != 2 {
|
|
t.Fatalf("normal batch-send behavior changed: %+v", roomClient.lastGift)
|
|
}
|
|
}
|
|
|
|
func TestGiftCapacityLimitPreservesSingleTargetWeight(t *testing.T) {
|
|
limiter := &fakeGiftCapacityLimiter{decision: giftlimit.Decision{Allowed: true}}
|
|
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{regionID: 1001})
|
|
handler.SetGiftCapacityProtection(true, 5*time.Second, 30, limiter)
|
|
router := handler.Routes(auth.NewVerifier("secret"))
|
|
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-single","target_user_id":43,"gift_id":"rose","gift_count":1}`)))
|
|
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
|
recorder := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(recorder, request)
|
|
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
if len(limiter.inputs) != 1 || limiter.inputs[0].Weight != 1 {
|
|
t.Fatalf("legacy single-target request must consume one token: %+v", limiter.inputs)
|
|
}
|
|
}
|
|
|
|
func TestGiftCapacityLimitWeightsGiftCountTimesUniqueTargets(t *testing.T) {
|
|
limiter := &fakeGiftCapacityLimiter{decision: giftlimit.Decision{Allowed: true}}
|
|
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{regionID: 1001})
|
|
handler.SetGiftCapacityProtection(true, 5*time.Second, 30, limiter)
|
|
router := handler.Routes(auth.NewVerifier("secret"))
|
|
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/batch-send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-units","target_user_ids":[43,44,43],"gift_id":"rose","gift_count":99}`)))
|
|
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
|
recorder := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(recorder, request)
|
|
|
|
if recorder.Code != http.StatusOK || len(limiter.inputs) != 1 || limiter.inputs[0].Weight != 198 {
|
|
t.Fatalf("gift work units must be count*deduplicated targets: status=%d inputs=%+v body=%s", recorder.Code, limiter.inputs, recorder.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestGiftCapacityLimitRejectsExplosiveGiftUnitsBeforeRedis(t *testing.T) {
|
|
targets := make([]int64, 0, 6)
|
|
for userID := int64(43); userID < 49; userID++ {
|
|
targets = append(targets, userID)
|
|
}
|
|
body, err := json.Marshal(map[string]any{
|
|
"room_id": "room-1",
|
|
"command_id": "cmd-too-many-units",
|
|
"target_user_ids": targets,
|
|
"gift_id": "rose",
|
|
"gift_count": 999,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("marshal body: %v", err)
|
|
}
|
|
roomClient := &fakeRoomClient{}
|
|
limiter := &fakeGiftCapacityLimiter{decision: giftlimit.Decision{Allowed: true}}
|
|
handler := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001})
|
|
handler.SetGiftCapacityProtection(true, 5*time.Second, 30, limiter)
|
|
router := handler.Routes(auth.NewVerifier("secret"))
|
|
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/batch-send", bytes.NewReader(body))
|
|
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
|
recorder := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(recorder, request)
|
|
|
|
if recorder.Code != http.StatusBadRequest || len(limiter.inputs) != 0 || roomClient.lastGift != nil {
|
|
t.Fatalf("gift_count*targets above hard cap reached Redis/downstream: status=%d inputs=%+v gift=%+v", recorder.Code, limiter.inputs, roomClient.lastGift)
|
|
}
|
|
}
|
|
|
|
func TestGiftCapacityLimitRejectsGiftCountAboveV2Contract(t *testing.T) {
|
|
roomClient := &fakeRoomClient{}
|
|
limiter := &fakeGiftCapacityLimiter{decision: giftlimit.Decision{Allowed: true}}
|
|
handler := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001})
|
|
handler.SetGiftCapacityProtection(true, 5*time.Second, 30, limiter)
|
|
router := handler.Routes(auth.NewVerifier("secret"))
|
|
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-count-overflow","target_user_id":43,"gift_id":"rose","gift_count":1000}`)))
|
|
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
|
recorder := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(recorder, request)
|
|
|
|
if recorder.Code != http.StatusBadRequest || len(limiter.inputs) != 0 || roomClient.lastGift != nil {
|
|
t.Fatalf("gift_count above 999 reached Redis/downstream: status=%d inputs=%+v gift=%+v", recorder.Code, limiter.inputs, roomClient.lastGift)
|
|
}
|
|
}
|
|
|
|
func TestSendGiftV2PreservesV1FieldsAndForwardsStableBatchIdentity(t *testing.T) {
|
|
roomClient := &fakeRoomClient{sendGiftResp: &roomv1.SendGiftResponse{
|
|
Result: &roomv1.CommandResult{Applied: true, RoomVersion: 14},
|
|
BillingReceiptId: "receipt-v2",
|
|
CoinBalanceAfter: 900,
|
|
V2: &roomv1.SendGiftResultV2{
|
|
ApiVersion: 2, CommandId: "combo-1_b1", ComboSessionId: "combo-1", BatchSeq: 1,
|
|
CommittedRoomVersion: 14, BillingReceiptId: "receipt-v2", CoinBalanceAfter: 900,
|
|
SettledGiftCount: 9, SettledTargetUserIds: []int64{43}, OperationStatus: "committed",
|
|
},
|
|
}}
|
|
handler := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001})
|
|
router := handler.Routes(auth.NewVerifier("secret"))
|
|
request := httptest.NewRequest(http.MethodPost, "/api/v2/rooms/gift/send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"combo-1_b1","combo_session_id":"combo-1","batch_seq":1,"target_user_id":43,"gift_id":"rose","gift_count":9}`)))
|
|
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
|
recorder := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(recorder, request)
|
|
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("V2 status mismatch: got=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
if roomClient.lastGift == nil || roomClient.lastGift.GetComboSessionId() != "combo-1" || roomClient.lastGift.GetBatchSeq() != 1 || roomClient.lastGift.GetMeta().GetCommandId() != "combo-1_b1" || roomClient.lastGift.GetDisplayMode() != "" {
|
|
t.Fatalf("V2 stable batch identity was not forwarded: %+v", roomClient.lastGift)
|
|
}
|
|
var envelope struct {
|
|
Data struct {
|
|
BillingReceiptID string `json:"billing_receipt_id"`
|
|
CoinBalanceAfter int64 `json:"coin_balance_after"`
|
|
V2 struct {
|
|
CommandID string `json:"command_id"`
|
|
CommittedRoomVersion int64 `json:"committed_room_version"`
|
|
} `json:"v2"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
|
|
t.Fatalf("decode V2 response: %v", err)
|
|
}
|
|
if envelope.Data.BillingReceiptID != "receipt-v2" || envelope.Data.CoinBalanceAfter != 900 || envelope.Data.V2.CommandID != "combo-1_b1" || envelope.Data.V2.CommittedRoomVersion != 14 {
|
|
t.Fatalf("V1 compatibility or nested V2 result missing: %+v body=%s", envelope, recorder.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestSendGiftV1RoutesPreserveTopLevelFieldsAndStripNestedV2(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
path string
|
|
body string
|
|
displayMode string
|
|
}{
|
|
{
|
|
name: "single",
|
|
path: "/api/v1/rooms/gift/send",
|
|
body: `{"room_id":"room-1","command_id":"legacy-single","target_user_id":43,"gift_id":"rose","gift_count":1}`,
|
|
},
|
|
{
|
|
name: "batch",
|
|
path: "/api/v1/rooms/gift/batch-send",
|
|
body: `{"room_id":"room-1","command_id":"legacy-batch","target_user_ids":[43,44],"gift_id":"rose","gift_count":2}`,
|
|
displayMode: "batch",
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
roomClient := &fakeRoomClient{sendGiftResp: &roomv1.SendGiftResponse{
|
|
Result: &roomv1.CommandResult{Applied: true, RoomVersion: 14},
|
|
BillingReceiptId: "legacy-receipt",
|
|
RoomHeat: 81,
|
|
CoinBalanceAfter: 900,
|
|
GiftIncomeBalanceAfter: 700,
|
|
V2: &roomv1.SendGiftResultV2{
|
|
ApiVersion: 2, CommandId: "internal-result", OperationStatus: "committed",
|
|
},
|
|
}}
|
|
router := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001}).Routes(auth.NewVerifier("secret"))
|
|
request := httptest.NewRequest(http.MethodPost, test.path, bytes.NewReader([]byte(test.body)))
|
|
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
|
recorder := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(recorder, request)
|
|
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("V1 status mismatch: got=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
if roomClient.lastGift == nil || roomClient.lastGift.GetDisplayMode() != test.displayMode {
|
|
t.Fatalf("V1 request behavior changed: %+v", roomClient.lastGift)
|
|
}
|
|
var envelope struct {
|
|
Data map[string]json.RawMessage `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
|
|
t.Fatalf("decode V1 response: %v", err)
|
|
}
|
|
if _, exists := envelope.Data["v2"]; exists {
|
|
t.Fatalf("legacy V1 response exposed nested v2: %s", recorder.Body.String())
|
|
}
|
|
var receipt string
|
|
var balance int64
|
|
var roomHeat int64
|
|
if err := json.Unmarshal(envelope.Data["billing_receipt_id"], &receipt); err != nil {
|
|
t.Fatalf("decode billing_receipt_id: %v", err)
|
|
}
|
|
if err := json.Unmarshal(envelope.Data["coin_balance_after"], &balance); err != nil {
|
|
t.Fatalf("decode coin_balance_after: %v", err)
|
|
}
|
|
if err := json.Unmarshal(envelope.Data["room_heat"], &roomHeat); err != nil {
|
|
t.Fatalf("decode room_heat: %v", err)
|
|
}
|
|
if receipt != "legacy-receipt" || balance != 900 || roomHeat != 81 {
|
|
t.Fatalf("V1 top-level fields changed: receipt=%q balance=%d heat=%d body=%s", receipt, balance, roomHeat, recorder.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSendGiftV2RequiresComboIdentityBeforeDownstream(t *testing.T) {
|
|
roomClient := &fakeRoomClient{}
|
|
handler := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001})
|
|
router := handler.Routes(auth.NewVerifier("secret"))
|
|
request := httptest.NewRequest(http.MethodPost, "/api/v2/rooms/gift/send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-no-combo","target_user_id":43,"gift_id":"rose","gift_count":1}`)))
|
|
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
|
recorder := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(recorder, request)
|
|
|
|
if recorder.Code != http.StatusBadRequest || roomClient.lastGift != nil {
|
|
t.Fatalf("V2 without combo identity reached downstream: status=%d gift=%+v body=%s", recorder.Code, roomClient.lastGift, recorder.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestGiftCapacityLimitReturns429EnvelopeBeforeDownstreamCalls(t *testing.T) {
|
|
for _, endpoint := range []struct {
|
|
name string
|
|
path string
|
|
body string
|
|
}{
|
|
{name: "single", path: "/api/v1/rooms/gift/send", body: `{"room_id":"room-1","command_id":"cmd-denied-single","target_user_id":43,"gift_id":"rose","gift_count":1}`},
|
|
{name: "batch", path: "/api/v1/rooms/gift/batch-send", body: `{"room_id":"room-1","command_id":"cmd-denied-batch","target_user_ids":[43,44],"gift_id":"rose","gift_count":1}`},
|
|
} {
|
|
t.Run(endpoint.name, func(t *testing.T) {
|
|
roomClient := &fakeRoomClient{}
|
|
profileClient := &fakeUserProfileClient{regionID: 1001}
|
|
limiter := &fakeGiftCapacityLimiter{decision: giftlimit.Decision{RetryAfter: 275 * time.Millisecond}}
|
|
handler := NewHandlerWithClients(roomClient, nil, nil, profileClient)
|
|
handler.SetGiftCapacityProtection(true, 5*time.Second, 30, limiter)
|
|
router := handler.Routes(auth.NewVerifier("secret"))
|
|
request := httptest.NewRequest(http.MethodPost, endpoint.path, bytes.NewReader([]byte(endpoint.body)))
|
|
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
|
recorder := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(recorder, request)
|
|
|
|
if recorder.Code != http.StatusTooManyRequests {
|
|
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
if recorder.Header().Get("Retry-After") != "1" {
|
|
t.Fatalf("Retry-After mismatch: %q", recorder.Header().Get("Retry-After"))
|
|
}
|
|
var envelope struct {
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
RequestID string `json:"request_id"`
|
|
Data struct {
|
|
RetryAfterMS int64 `json:"retry_after_ms"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
|
|
t.Fatalf("decode response failed: %v body=%s", err, recorder.Body.String())
|
|
}
|
|
if envelope.Code != "RATE_LIMITED" || envelope.Message != "rate limited" || envelope.RequestID == "" || envelope.Data.RetryAfterMS != 275 {
|
|
t.Fatalf("rate-limit envelope mismatch: %+v", envelope)
|
|
}
|
|
if roomClient.lastGift != nil || profileClient.lastGet != nil || len(profileClient.batchRequests) != 0 {
|
|
t.Fatalf("denied request reached downstream: gift=%+v profile_get=%+v profile_batches=%d", roomClient.lastGift, profileClient.lastGet, len(profileClient.batchRequests))
|
|
}
|
|
if limiter.releases != 0 {
|
|
t.Fatalf("denied request must not release an unowned slot: %d", limiter.releases)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGiftCapacityLimitFailsClosedWhenRedisUnavailable(t *testing.T) {
|
|
roomClient := &fakeRoomClient{}
|
|
limiter := &fakeGiftCapacityLimiter{err: errors.New("redis unavailable")}
|
|
handler := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001})
|
|
handler.SetGiftCapacityProtection(true, 5*time.Second, 30, limiter)
|
|
router := handler.Routes(auth.NewVerifier("secret"))
|
|
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-error","target_user_id":43,"gift_id":"rose","gift_count":1}`)))
|
|
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
|
recorder := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(recorder, request)
|
|
|
|
if recorder.Code != http.StatusBadGateway || roomClient.lastGift != nil {
|
|
t.Fatalf("limiter backend failure must fail closed: status=%d body=%s gift=%+v", recorder.Code, recorder.Body.String(), roomClient.lastGift)
|
|
}
|
|
}
|
|
|
|
func TestGiftCapacityLimitBlockedRedisFailsClosedQuickly(t *testing.T) {
|
|
// net.Pipe 对 Redis 写入只读不回,模拟连接存在但服务端永久不返回 Lua 结果;真正的
|
|
// RedisLimiter 必须靠自己的 operation deadline 结束,而不是依赖测试 fake 立即报错。
|
|
redisClient := redis.NewClient(&redis.Options{
|
|
Addr: "blocked-redis",
|
|
MaxRetries: -1,
|
|
ReadTimeout: time.Second,
|
|
WriteTimeout: time.Second,
|
|
ContextTimeoutEnabled: true,
|
|
Dialer: func(context.Context, string, string) (net.Conn, error) {
|
|
clientConn, serverConn := net.Pipe()
|
|
go func() {
|
|
defer serverConn.Close()
|
|
_, _ = io.Copy(io.Discard, serverConn)
|
|
}()
|
|
return clientConn, nil
|
|
},
|
|
})
|
|
defer redisClient.Close()
|
|
limiter := giftlimit.NewRedisLimiter(redisClient, giftlimit.Config{RedisOperationTimeout: 50 * time.Millisecond})
|
|
roomClient := &fakeRoomClient{}
|
|
handler := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001})
|
|
handler.SetGiftCapacityProtection(true, 5*time.Second, 30, limiter)
|
|
router := handler.Routes(auth.NewVerifier("secret"))
|
|
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-blocked-redis","target_user_id":43,"gift_id":"rose","gift_count":1}`)))
|
|
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
|
recorder := httptest.NewRecorder()
|
|
startedAt := time.Now()
|
|
|
|
router.ServeHTTP(recorder, request)
|
|
elapsed := time.Since(startedAt)
|
|
|
|
if recorder.Code != http.StatusBadGateway || roomClient.lastGift != nil || elapsed < 35*time.Millisecond || elapsed > 500*time.Millisecond {
|
|
t.Fatalf("blocked Redis must fail closed inside short deadline: status=%d elapsed=%s body=%s gift=%+v", recorder.Code, elapsed, recorder.Body.String(), roomClient.lastGift)
|
|
}
|
|
}
|
|
|
|
func TestGiftCapacityLimitRejectsTooManyUniqueTargetsBeforeRedis(t *testing.T) {
|
|
uniqueTargets := make([]int64, 0, 31)
|
|
for userID := int64(1); userID <= 31; userID++ {
|
|
uniqueTargets = append(uniqueTargets, userID)
|
|
}
|
|
body, err := json.Marshal(map[string]any{
|
|
"room_id": "room-1",
|
|
"command_id": "cmd-too-many",
|
|
"target_user_ids": uniqueTargets,
|
|
"gift_id": "rose",
|
|
"gift_count": 1,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("marshal body: %v", err)
|
|
}
|
|
roomClient := &fakeRoomClient{}
|
|
limiter := &fakeGiftCapacityLimiter{decision: giftlimit.Decision{Allowed: true}}
|
|
handler := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001})
|
|
handler.SetGiftCapacityProtection(true, 5*time.Second, 30, limiter)
|
|
router := handler.Routes(auth.NewVerifier("secret"))
|
|
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/batch-send", bytes.NewReader(body))
|
|
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
|
recorder := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(recorder, request)
|
|
|
|
if recorder.Code != http.StatusBadRequest || len(limiter.inputs) != 0 || roomClient.lastGift != nil {
|
|
t.Fatalf("31 unique targets must fail before Redis/downstream: status=%d body=%s inputs=%+v gift=%+v", recorder.Code, recorder.Body.String(), limiter.inputs, roomClient.lastGift)
|
|
}
|
|
}
|
|
|
|
func TestGiftCapacityLimitRejectsUnsupportedTargetTypeBeforeRedis(t *testing.T) {
|
|
roomClient := &fakeRoomClient{}
|
|
limiter := &fakeGiftCapacityLimiter{decision: giftlimit.Decision{Allowed: true}}
|
|
handler := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001})
|
|
handler.SetGiftCapacityProtection(true, 5*time.Second, 30, limiter)
|
|
router := handler.Routes(auth.NewVerifier("secret"))
|
|
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-unsupported","target_type":"all_mic","gift_id":"rose","gift_count":1}`)))
|
|
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
|
recorder := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(recorder, request)
|
|
|
|
if recorder.Code != http.StatusBadRequest || len(limiter.inputs) != 0 || roomClient.lastGift != nil {
|
|
t.Fatalf("unsupported target_type must fail before Redis/downstream: status=%d body=%s inputs=%+v gift=%+v", recorder.Code, recorder.Body.String(), limiter.inputs, roomClient.lastGift)
|
|
}
|
|
}
|
|
|
|
func TestGiftCapacityLimitAppliesMaximumAfterTargetDeduplication(t *testing.T) {
|
|
targets := make([]int64, 0, 31)
|
|
for userID := int64(1); userID <= 30; userID++ {
|
|
targets = append(targets, userID)
|
|
}
|
|
targets = append(targets, 30)
|
|
body, err := json.Marshal(map[string]any{
|
|
"room_id": "room-1",
|
|
"command_id": "cmd-dedup-maximum",
|
|
"target_user_ids": targets,
|
|
"gift_id": "rose",
|
|
"gift_count": 1,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("marshal body: %v", err)
|
|
}
|
|
limiter := &fakeGiftCapacityLimiter{decision: giftlimit.Decision{RetryAfter: 10 * time.Millisecond}}
|
|
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{regionID: 1001})
|
|
handler.SetGiftCapacityProtection(true, 5*time.Second, 30, limiter)
|
|
router := handler.Routes(auth.NewVerifier("secret"))
|
|
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/batch-send", bytes.NewReader(body))
|
|
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
|
recorder := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(recorder, request)
|
|
|
|
if recorder.Code != http.StatusTooManyRequests || len(limiter.inputs) != 1 || limiter.inputs[0].Weight != 30 {
|
|
t.Fatalf("duplicate target must not trip max_targets_per_request: status=%d body=%s inputs=%+v", recorder.Code, recorder.Body.String(), limiter.inputs)
|
|
}
|
|
}
|
|
|
|
func TestGiftRequestTotalDeadlineReleasesInFlightLease(t *testing.T) {
|
|
roomClient := &fakeRoomClient{sendGiftWaitForContext: true}
|
|
limiter := &fakeGiftCapacityLimiter{decision: giftlimit.Decision{Allowed: true}}
|
|
handler := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001})
|
|
handler.SetGiftCapacityProtection(true, 40*time.Millisecond, 30, limiter)
|
|
router := handler.Routes(auth.NewVerifier("secret"))
|
|
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-timeout","target_user_id":43,"gift_id":"rose","gift_count":1}`)))
|
|
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
|
recorder := httptest.NewRecorder()
|
|
startedAt := time.Now()
|
|
|
|
router.ServeHTTP(recorder, request)
|
|
elapsed := time.Since(startedAt)
|
|
|
|
if recorder.Code == http.StatusOK || elapsed < 30*time.Millisecond || elapsed > 300*time.Millisecond {
|
|
t.Fatalf("gift orchestration must honor one total deadline: status=%d elapsed=%s body=%s", recorder.Code, elapsed, recorder.Body.String())
|
|
}
|
|
if roomClient.lastGift == nil || limiter.releases != 1 {
|
|
t.Fatalf("timed-out request must reach room once and release lease: gift=%+v releases=%d", roomClient.lastGift, limiter.releases)
|
|
}
|
|
}
|