Phase 28 (Step 3): gateway translators for user.games.mail.*
Adds the gateway-side translation layer that maps the eight new
ConnectRPC mail commands onto backend's
`/api/v1/user/games/{game_id}/mail/*` REST endpoints.
- `gateway/internal/backendclient/mail_commands.go` defines
`ExecuteMailCommand` and one helper per command (inbox, sent,
message.get, send, broadcast, admin, read, delete). Each helper
decodes the FlatBuffers request envelope, issues the REST call
via the existing `*RESTClient.do`, decodes the JSON body, and
re-encodes a typed FlatBuffers response. Recipient identifiers
travel through unchanged so the new `recipient_race_name`
shortcut introduced in Step 1 reaches backend untouched.
- `routes.go` exposes a `MailRoutes` constructor and a matching
`mailCommandClient` implementing `downstream.Client`.
- `cmd/gateway/main.go` registers the new routes alongside the
existing user / lobby / game-engine routes.
- `mail_commands_test.go` covers the inbox, send-by-race-name, and
read-state paths end-to-end against an `httptest.Server`,
asserting request shapes (path, body, X-User-ID) and the
decoded FlatBuffers response.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
package backendclient_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"galaxy/gateway/internal/backendclient"
|
||||
diplomailmodel "galaxy/model/diplomail"
|
||||
commonfbs "galaxy/schema/fbs/common"
|
||||
fbs "galaxy/schema/fbs/diplomail"
|
||||
|
||||
flatbuffers "github.com/google/flatbuffers/go"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestExecuteMailInboxDecodesItems(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
gameID := uuid.MustParse("11111111-2222-3333-4444-555555555555")
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, http.MethodGet, r.Method)
|
||||
require.Equal(t, "/api/v1/user/games/"+gameID.String()+"/mail/inbox", r.URL.Path)
|
||||
require.Equal(t, "user-1", r.Header.Get(backendclient.HeaderUserID))
|
||||
writeJSON(t, w, http.StatusOK, map[string]any{
|
||||
"items": []map[string]any{
|
||||
{
|
||||
"message_id": "00000000-0000-0000-0000-000000000001",
|
||||
"game_id": gameID.String(),
|
||||
"kind": "personal",
|
||||
"sender_kind": "player",
|
||||
"sender_user_id": "00000000-0000-0000-0000-000000000010",
|
||||
"sender_username": "alice",
|
||||
"sender_race_name": "AliceRace",
|
||||
"subject": "hi",
|
||||
"body": "hello there",
|
||||
"body_lang": "en",
|
||||
"broadcast_scope": "single",
|
||||
"created_at": "2026-05-15T12:00:00Z",
|
||||
"recipient_user_id": "00000000-0000-0000-0000-000000000020",
|
||||
"recipient_user_name": "bob",
|
||||
"recipient_race_name": "BobRace",
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
client := newRESTClient(t, server)
|
||||
payload := buildInboxRequest(gameID)
|
||||
cmd := newAuthCommand(t, diplomailmodel.MessageTypeUserGamesMailInbox, payload)
|
||||
result, err := client.ExecuteMailCommand(context.Background(), cmd)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "ok", result.ResultCode)
|
||||
|
||||
resp := fbs.GetRootAsInboxResponse(result.PayloadBytes, 0)
|
||||
require.Equal(t, 1, resp.ItemsLength())
|
||||
var item fbs.MailMessage
|
||||
require.True(t, resp.Items(&item, 0))
|
||||
assert.Equal(t, "00000000-0000-0000-0000-000000000001", string(item.MessageId()))
|
||||
assert.Equal(t, "AliceRace", string(item.SenderRaceName()))
|
||||
assert.Equal(t, "BobRace", string(item.RecipientRaceName()))
|
||||
}
|
||||
|
||||
func TestExecuteMailSendForwardsRaceName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
gameID := uuid.MustParse("22222222-3333-4444-5555-666666666666")
|
||||
var captured struct {
|
||||
Body string
|
||||
RecipientUserID string
|
||||
RecipientRaceName string
|
||||
}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, http.MethodPost, r.Method)
|
||||
require.Equal(t, "/api/v1/user/games/"+gameID.String()+"/mail/messages", r.URL.Path)
|
||||
var req map[string]any
|
||||
raw, err := io.ReadAll(r.Body)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, json.Unmarshal(raw, &req))
|
||||
if v, ok := req["body"].(string); ok {
|
||||
captured.Body = v
|
||||
}
|
||||
if v, ok := req["recipient_user_id"].(string); ok {
|
||||
captured.RecipientUserID = v
|
||||
}
|
||||
if v, ok := req["recipient_race_name"].(string); ok {
|
||||
captured.RecipientRaceName = v
|
||||
}
|
||||
writeJSON(t, w, http.StatusCreated, map[string]any{
|
||||
"message_id": "00000000-0000-0000-0000-000000000099",
|
||||
"game_id": gameID.String(),
|
||||
"kind": "personal",
|
||||
"sender_kind": "player",
|
||||
"sender_user_id": "00000000-0000-0000-0000-000000000010",
|
||||
"sender_race_name": "Senders",
|
||||
"body": captured.Body,
|
||||
"body_lang": "en",
|
||||
"broadcast_scope": "single",
|
||||
"created_at": "2026-05-15T12:00:00Z",
|
||||
"recipient_user_id": "00000000-0000-0000-0000-000000000020",
|
||||
"recipient_race_name": "Receivers",
|
||||
})
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
client := newRESTClient(t, server)
|
||||
payload := buildSendRequestByRaceName(gameID, "Receivers", "let us talk")
|
||||
cmd := newAuthCommand(t, diplomailmodel.MessageTypeUserGamesMailSend, payload)
|
||||
result, err := client.ExecuteMailCommand(context.Background(), cmd)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "ok", result.ResultCode)
|
||||
|
||||
resp := fbs.GetRootAsSendResponse(result.PayloadBytes, 0)
|
||||
require.NotNil(t, resp.Message(nil))
|
||||
msg := resp.Message(nil)
|
||||
assert.Equal(t, "let us talk", string(msg.Body()))
|
||||
assert.Equal(t, "Senders", string(msg.SenderRaceName()))
|
||||
assert.Equal(t, "Receivers", string(msg.RecipientRaceName()))
|
||||
|
||||
assert.Empty(t, captured.RecipientUserID)
|
||||
assert.Equal(t, "Receivers", captured.RecipientRaceName)
|
||||
assert.Equal(t, "let us talk", captured.Body)
|
||||
}
|
||||
|
||||
func TestExecuteMailReadReturnsState(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
gameID := uuid.MustParse("33333333-4444-5555-6666-777777777777")
|
||||
messageID := uuid.MustParse("00000000-0000-0000-0000-0000000000aa")
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, http.MethodPost, r.Method)
|
||||
require.Equal(t, "/api/v1/user/games/"+gameID.String()+"/mail/messages/"+messageID.String()+"/read", r.URL.Path)
|
||||
writeJSON(t, w, http.StatusOK, map[string]any{
|
||||
"message_id": messageID.String(),
|
||||
"read_at": "2026-05-15T12:34:56Z",
|
||||
})
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
client := newRESTClient(t, server)
|
||||
payload := buildReadRequest(gameID, messageID)
|
||||
cmd := newAuthCommand(t, diplomailmodel.MessageTypeUserGamesMailRead, payload)
|
||||
result, err := client.ExecuteMailCommand(context.Background(), cmd)
|
||||
require.NoError(t, err)
|
||||
|
||||
resp := fbs.GetRootAsReadResponse(result.PayloadBytes, 0)
|
||||
state := resp.State(nil)
|
||||
require.NotNil(t, state)
|
||||
assert.Equal(t, messageID.String(), string(state.MessageId()))
|
||||
assert.NotZero(t, state.ReadAtMs())
|
||||
}
|
||||
|
||||
// buildInboxRequest emits a FlatBuffers InboxRequest envelope with
|
||||
// the supplied game_id.
|
||||
func buildInboxRequest(gameID uuid.UUID) []byte {
|
||||
builder := flatbuffers.NewBuilder(64)
|
||||
hi, lo := uuidToHiLo(gameID)
|
||||
fbs.InboxRequestStart(builder)
|
||||
fbs.InboxRequestAddGameId(builder, commonfbs.CreateUUID(builder, hi, lo))
|
||||
root := fbs.InboxRequestEnd(builder)
|
||||
fbs.FinishInboxRequestBuffer(builder, root)
|
||||
return builder.FinishedBytes()
|
||||
}
|
||||
|
||||
// buildSendRequestByRaceName emits a FlatBuffers SendRequest that
|
||||
// addresses the recipient by race name rather than user_id.
|
||||
func buildSendRequestByRaceName(gameID uuid.UUID, raceName, body string) []byte {
|
||||
builder := flatbuffers.NewBuilder(128)
|
||||
raceOff := builder.CreateString(raceName)
|
||||
bodyOff := builder.CreateString(body)
|
||||
hi, lo := uuidToHiLo(gameID)
|
||||
fbs.SendRequestStart(builder)
|
||||
fbs.SendRequestAddGameId(builder, commonfbs.CreateUUID(builder, hi, lo))
|
||||
fbs.SendRequestAddRecipientRaceName(builder, raceOff)
|
||||
fbs.SendRequestAddBody(builder, bodyOff)
|
||||
root := fbs.SendRequestEnd(builder)
|
||||
fbs.FinishSendRequestBuffer(builder, root)
|
||||
return builder.FinishedBytes()
|
||||
}
|
||||
|
||||
// buildReadRequest emits a FlatBuffers ReadRequest envelope.
|
||||
func buildReadRequest(gameID, messageID uuid.UUID) []byte {
|
||||
builder := flatbuffers.NewBuilder(64)
|
||||
gameHi, gameLo := uuidToHiLo(gameID)
|
||||
msgHi, msgLo := uuidToHiLo(messageID)
|
||||
fbs.ReadRequestStart(builder)
|
||||
fbs.ReadRequestAddGameId(builder, commonfbs.CreateUUID(builder, gameHi, gameLo))
|
||||
fbs.ReadRequestAddMessageId(builder, commonfbs.CreateUUID(builder, msgHi, msgLo))
|
||||
root := fbs.ReadRequestEnd(builder)
|
||||
fbs.FinishReadRequestBuffer(builder, root)
|
||||
return builder.FinishedBytes()
|
||||
}
|
||||
|
||||
// uuidToHiLo splits a 16-byte UUID into the two big-endian uint64
|
||||
// halves the common.UUID struct uses.
|
||||
func uuidToHiLo(u uuid.UUID) (uint64, uint64) {
|
||||
var hi, lo uint64
|
||||
for i := 0; i < 8; i++ {
|
||||
hi = (hi << 8) | uint64(u[i])
|
||||
lo = (lo << 8) | uint64(u[i+8])
|
||||
}
|
||||
return hi, lo
|
||||
}
|
||||
Reference in New Issue
Block a user