Phase 28 (Step 3): gateway translators for user.games.mail.*
Tests · Integration / integration (pull_request) Successful in 1m55s
Tests · Go / test (push) Successful in 2m10s
Tests · Go / test (pull_request) Successful in 2m11s
Tests · UI / test (pull_request) Waiting to run

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:
Ilia Denisov
2026-05-15 22:32:50 +02:00
parent 57d2286f5e
commit 4cb03736de
4 changed files with 812 additions and 1 deletions
@@ -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
}