2026-07-22 13:55:55 +08:00

493 lines
23 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}
}