493 lines
23 KiB
Go
493 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 giftTestHostAuthority(handler *Handler) *Handler {
|
||
// 成功路径显式装配 Host owner,并让空映射表示这些测试 target 不是 Host;生产不允许缺依赖时降级。
|
||
handler.SetUserHostClient(&fakeUserHostClient{})
|
||
return handler
|
||
}
|
||
|
||
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 := giftTestHostAuthority(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 := giftTestHostAuthority(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 := giftTestHostAuthority(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 := giftTestHostAuthority(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 := giftTestHostAuthority(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 := giftTestHostAuthority(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 := giftTestHostAuthority(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 := giftTestHostAuthority(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 := giftTestHostAuthority(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 := giftTestHostAuthority(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 := giftTestHostAuthority(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 := giftTestHostAuthority(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 := giftTestHostAuthority(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 := giftTestHostAuthority(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 := giftTestHostAuthority(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)
|
||
}
|
||
}
|