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:
@@ -186,7 +186,8 @@ func newAuthenticatedGRPCDependencies(ctx context.Context, cfg config.Config, lo
|
|||||||
userRoutes := backendclient.UserRoutes(backend.REST())
|
userRoutes := backendclient.UserRoutes(backend.REST())
|
||||||
lobbyRoutes := backendclient.LobbyRoutes(backend.REST())
|
lobbyRoutes := backendclient.LobbyRoutes(backend.REST())
|
||||||
gameRoutes := backendclient.GameRoutes(backend.REST())
|
gameRoutes := backendclient.GameRoutes(backend.REST())
|
||||||
allRoutes := make(map[string]downstream.Client, len(userRoutes)+len(lobbyRoutes)+len(gameRoutes))
|
mailRoutes := backendclient.MailRoutes(backend.REST())
|
||||||
|
allRoutes := make(map[string]downstream.Client, len(userRoutes)+len(lobbyRoutes)+len(gameRoutes)+len(mailRoutes))
|
||||||
for k, v := range userRoutes {
|
for k, v := range userRoutes {
|
||||||
allRoutes[k] = v
|
allRoutes[k] = v
|
||||||
}
|
}
|
||||||
@@ -196,6 +197,9 @@ func newAuthenticatedGRPCDependencies(ctx context.Context, cfg config.Config, lo
|
|||||||
for k, v := range gameRoutes {
|
for k, v := range gameRoutes {
|
||||||
allRoutes[k] = v
|
allRoutes[k] = v
|
||||||
}
|
}
|
||||||
|
for k, v := range mailRoutes {
|
||||||
|
allRoutes[k] = v
|
||||||
|
}
|
||||||
|
|
||||||
cleanup := func() error {
|
cleanup := func() error {
|
||||||
return closeRedisClient()
|
return closeRedisClient()
|
||||||
|
|||||||
@@ -0,0 +1,567 @@
|
|||||||
|
package backendclient
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"galaxy/gateway/internal/downstream"
|
||||||
|
diplomailmodel "galaxy/model/diplomail"
|
||||||
|
commonfbs "galaxy/schema/fbs/common"
|
||||||
|
fbs "galaxy/schema/fbs/diplomail"
|
||||||
|
|
||||||
|
flatbuffers "github.com/google/flatbuffers/go"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExecuteMailCommand routes one authenticated `user.games.mail.*`
|
||||||
|
// command into the matching `/api/v1/user/games/{game_id}/mail/...`
|
||||||
|
// backend REST endpoint. Each command decodes a FlatBuffers request
|
||||||
|
// payload, issues the REST call, decodes the JSON response, and
|
||||||
|
// re-encodes the result as a typed FlatBuffers envelope.
|
||||||
|
func (c *RESTClient) ExecuteMailCommand(ctx context.Context, command downstream.AuthenticatedCommand) (downstream.UnaryResult, error) {
|
||||||
|
if c == nil || c.httpClient == nil {
|
||||||
|
return downstream.UnaryResult{}, errors.New("backendclient: execute mail command: nil client")
|
||||||
|
}
|
||||||
|
if ctx == nil {
|
||||||
|
return downstream.UnaryResult{}, errors.New("backendclient: execute mail command: nil context")
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return downstream.UnaryResult{}, err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(command.UserID) == "" {
|
||||||
|
return downstream.UnaryResult{}, errors.New("backendclient: execute mail command: user_id must not be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch command.MessageType {
|
||||||
|
case diplomailmodel.MessageTypeUserGamesMailInbox:
|
||||||
|
return c.executeMailInbox(ctx, command.UserID, command.PayloadBytes)
|
||||||
|
case diplomailmodel.MessageTypeUserGamesMailSent:
|
||||||
|
return c.executeMailSent(ctx, command.UserID, command.PayloadBytes)
|
||||||
|
case diplomailmodel.MessageTypeUserGamesMailMessageGet:
|
||||||
|
return c.executeMailMessageGet(ctx, command.UserID, command.PayloadBytes)
|
||||||
|
case diplomailmodel.MessageTypeUserGamesMailSend:
|
||||||
|
return c.executeMailSend(ctx, command.UserID, command.PayloadBytes)
|
||||||
|
case diplomailmodel.MessageTypeUserGamesMailBroadcast:
|
||||||
|
return c.executeMailBroadcast(ctx, command.UserID, command.PayloadBytes)
|
||||||
|
case diplomailmodel.MessageTypeUserGamesMailAdmin:
|
||||||
|
return c.executeMailAdmin(ctx, command.UserID, command.PayloadBytes)
|
||||||
|
case diplomailmodel.MessageTypeUserGamesMailRead:
|
||||||
|
return c.executeMailRead(ctx, command.UserID, command.PayloadBytes)
|
||||||
|
case diplomailmodel.MessageTypeUserGamesMailDelete:
|
||||||
|
return c.executeMailDelete(ctx, command.UserID, command.PayloadBytes)
|
||||||
|
default:
|
||||||
|
return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute mail command: unsupported message type %q", command.MessageType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mailMessageJSON mirrors the backend's `UserMailMessageDetail` wire
|
||||||
|
// shape from `backend/openapi.yaml`. Pointer fields are nullable in
|
||||||
|
// the OpenAPI spec; the encoder treats empty strings as "absent".
|
||||||
|
type mailMessageJSON struct {
|
||||||
|
MessageID string `json:"message_id"`
|
||||||
|
GameID string `json:"game_id"`
|
||||||
|
GameName string `json:"game_name,omitempty"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
SenderKind string `json:"sender_kind"`
|
||||||
|
SenderUserID *string `json:"sender_user_id,omitempty"`
|
||||||
|
SenderUsername *string `json:"sender_username,omitempty"`
|
||||||
|
SenderRaceName *string `json:"sender_race_name,omitempty"`
|
||||||
|
Subject string `json:"subject,omitempty"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
BodyLang string `json:"body_lang"`
|
||||||
|
BroadcastScope string `json:"broadcast_scope"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
RecipientUserID string `json:"recipient_user_id"`
|
||||||
|
RecipientUserName string `json:"recipient_user_name,omitempty"`
|
||||||
|
RecipientRaceName *string `json:"recipient_race_name,omitempty"`
|
||||||
|
ReadAt *string `json:"read_at,omitempty"`
|
||||||
|
DeletedAt *string `json:"deleted_at,omitempty"`
|
||||||
|
TranslatedSubject *string `json:"translated_subject,omitempty"`
|
||||||
|
TranslatedBody *string `json:"translated_body,omitempty"`
|
||||||
|
TranslationLang *string `json:"translation_lang,omitempty"`
|
||||||
|
Translator *string `json:"translator,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// mailRecipientStateJSON mirrors `UserMailRecipientState`.
|
||||||
|
type mailRecipientStateJSON struct {
|
||||||
|
MessageID string `json:"message_id"`
|
||||||
|
ReadAt *string `json:"read_at,omitempty"`
|
||||||
|
DeletedAt *string `json:"deleted_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// mailBroadcastReceiptJSON mirrors `UserMailBroadcastReceipt`.
|
||||||
|
type mailBroadcastReceiptJSON struct {
|
||||||
|
MessageID string `json:"message_id"`
|
||||||
|
GameID string `json:"game_id"`
|
||||||
|
GameName string `json:"game_name,omitempty"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
SenderKind string `json:"sender_kind"`
|
||||||
|
Subject string `json:"subject,omitempty"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
BodyLang string `json:"body_lang"`
|
||||||
|
BroadcastScope string `json:"broadcast_scope"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
RecipientCount int `json:"recipient_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type mailInboxJSON struct {
|
||||||
|
Items []mailMessageJSON `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RESTClient) executeMailInbox(ctx context.Context, userID string, payload []byte) (downstream.UnaryResult, error) {
|
||||||
|
if len(payload) == 0 {
|
||||||
|
return downstream.UnaryResult{}, errors.New("execute user.games.mail.inbox: payload is empty")
|
||||||
|
}
|
||||||
|
flat := fbs.GetRootAsInboxRequest(payload, 0)
|
||||||
|
gameID := readUUID(flat.GameId(nil))
|
||||||
|
if gameID == uuid.Nil {
|
||||||
|
return downstream.UnaryResult{}, errors.New("execute user.games.mail.inbox: game_id is missing")
|
||||||
|
}
|
||||||
|
target := c.baseURL + "/api/v1/user/games/" + url.PathEscape(gameID.String()) + "/mail/inbox"
|
||||||
|
respBody, status, err := c.do(ctx, http.MethodGet, target, userID, nil)
|
||||||
|
if err != nil {
|
||||||
|
return downstream.UnaryResult{}, fmt.Errorf("execute user.games.mail.inbox: %w", err)
|
||||||
|
}
|
||||||
|
if status < 200 || status >= 300 {
|
||||||
|
return projectMailErrorResponse(status, respBody)
|
||||||
|
}
|
||||||
|
var resp mailInboxJSON
|
||||||
|
if err := json.Unmarshal(respBody, &resp); err != nil {
|
||||||
|
return downstream.UnaryResult{}, fmt.Errorf("decode mail inbox response: %w", err)
|
||||||
|
}
|
||||||
|
out := encodeMailMessageList(resp.Items, fbs.InboxResponseStart, fbs.InboxResponseAddItems, fbs.InboxResponseEnd, fbs.FinishInboxResponseBuffer)
|
||||||
|
return downstream.UnaryResult{ResultCode: userCommandResultCodeOK, PayloadBytes: out}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RESTClient) executeMailSent(ctx context.Context, userID string, payload []byte) (downstream.UnaryResult, error) {
|
||||||
|
if len(payload) == 0 {
|
||||||
|
return downstream.UnaryResult{}, errors.New("execute user.games.mail.sent: payload is empty")
|
||||||
|
}
|
||||||
|
flat := fbs.GetRootAsSentRequest(payload, 0)
|
||||||
|
gameID := readUUID(flat.GameId(nil))
|
||||||
|
if gameID == uuid.Nil {
|
||||||
|
return downstream.UnaryResult{}, errors.New("execute user.games.mail.sent: game_id is missing")
|
||||||
|
}
|
||||||
|
target := c.baseURL + "/api/v1/user/games/" + url.PathEscape(gameID.String()) + "/mail/sent"
|
||||||
|
respBody, status, err := c.do(ctx, http.MethodGet, target, userID, nil)
|
||||||
|
if err != nil {
|
||||||
|
return downstream.UnaryResult{}, fmt.Errorf("execute user.games.mail.sent: %w", err)
|
||||||
|
}
|
||||||
|
if status < 200 || status >= 300 {
|
||||||
|
return projectMailErrorResponse(status, respBody)
|
||||||
|
}
|
||||||
|
var resp mailInboxJSON
|
||||||
|
if err := json.Unmarshal(respBody, &resp); err != nil {
|
||||||
|
return downstream.UnaryResult{}, fmt.Errorf("decode mail sent response: %w", err)
|
||||||
|
}
|
||||||
|
out := encodeMailMessageList(resp.Items, fbs.SentResponseStart, fbs.SentResponseAddItems, fbs.SentResponseEnd, fbs.FinishSentResponseBuffer)
|
||||||
|
return downstream.UnaryResult{ResultCode: userCommandResultCodeOK, PayloadBytes: out}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RESTClient) executeMailMessageGet(ctx context.Context, userID string, payload []byte) (downstream.UnaryResult, error) {
|
||||||
|
if len(payload) == 0 {
|
||||||
|
return downstream.UnaryResult{}, errors.New("execute user.games.mail.message.get: payload is empty")
|
||||||
|
}
|
||||||
|
flat := fbs.GetRootAsMessageGetRequest(payload, 0)
|
||||||
|
gameID := readUUID(flat.GameId(nil))
|
||||||
|
messageID := readUUID(flat.MessageId(nil))
|
||||||
|
if gameID == uuid.Nil || messageID == uuid.Nil {
|
||||||
|
return downstream.UnaryResult{}, errors.New("execute user.games.mail.message.get: game_id and message_id are required")
|
||||||
|
}
|
||||||
|
target := c.baseURL + "/api/v1/user/games/" + url.PathEscape(gameID.String()) + "/mail/messages/" + url.PathEscape(messageID.String())
|
||||||
|
respBody, status, err := c.do(ctx, http.MethodGet, target, userID, nil)
|
||||||
|
if err != nil {
|
||||||
|
return downstream.UnaryResult{}, fmt.Errorf("execute user.games.mail.message.get: %w", err)
|
||||||
|
}
|
||||||
|
if status < 200 || status >= 300 {
|
||||||
|
return projectMailErrorResponse(status, respBody)
|
||||||
|
}
|
||||||
|
var msg mailMessageJSON
|
||||||
|
if err := json.Unmarshal(respBody, &msg); err != nil {
|
||||||
|
return downstream.UnaryResult{}, fmt.Errorf("decode mail message response: %w", err)
|
||||||
|
}
|
||||||
|
builder := flatbuffers.NewBuilder(512)
|
||||||
|
msgOff := encodeMailMessage(builder, &msg)
|
||||||
|
fbs.MessageGetResponseStart(builder)
|
||||||
|
fbs.MessageGetResponseAddMessage(builder, msgOff)
|
||||||
|
root := fbs.MessageGetResponseEnd(builder)
|
||||||
|
fbs.FinishMessageGetResponseBuffer(builder, root)
|
||||||
|
return downstream.UnaryResult{ResultCode: userCommandResultCodeOK, PayloadBytes: builder.FinishedBytes()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RESTClient) executeMailSend(ctx context.Context, userID string, payload []byte) (downstream.UnaryResult, error) {
|
||||||
|
if len(payload) == 0 {
|
||||||
|
return downstream.UnaryResult{}, errors.New("execute user.games.mail.send: payload is empty")
|
||||||
|
}
|
||||||
|
flat := fbs.GetRootAsSendRequest(payload, 0)
|
||||||
|
gameID := readUUID(flat.GameId(nil))
|
||||||
|
if gameID == uuid.Nil {
|
||||||
|
return downstream.UnaryResult{}, errors.New("execute user.games.mail.send: game_id is missing")
|
||||||
|
}
|
||||||
|
body := struct {
|
||||||
|
RecipientUserID string `json:"recipient_user_id,omitempty"`
|
||||||
|
RecipientRaceName string `json:"recipient_race_name,omitempty"`
|
||||||
|
Subject string `json:"subject,omitempty"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
}{
|
||||||
|
RecipientUserID: string(flat.RecipientUserId()),
|
||||||
|
RecipientRaceName: string(flat.RecipientRaceName()),
|
||||||
|
Subject: string(flat.Subject()),
|
||||||
|
Body: string(flat.Body()),
|
||||||
|
}
|
||||||
|
target := c.baseURL + "/api/v1/user/games/" + url.PathEscape(gameID.String()) + "/mail/messages"
|
||||||
|
respBody, status, err := c.do(ctx, http.MethodPost, target, userID, body)
|
||||||
|
if err != nil {
|
||||||
|
return downstream.UnaryResult{}, fmt.Errorf("execute user.games.mail.send: %w", err)
|
||||||
|
}
|
||||||
|
if status < 200 || status >= 300 {
|
||||||
|
return projectMailErrorResponse(status, respBody)
|
||||||
|
}
|
||||||
|
var msg mailMessageJSON
|
||||||
|
if err := json.Unmarshal(respBody, &msg); err != nil {
|
||||||
|
return downstream.UnaryResult{}, fmt.Errorf("decode mail send response: %w", err)
|
||||||
|
}
|
||||||
|
builder := flatbuffers.NewBuilder(512)
|
||||||
|
msgOff := encodeMailMessage(builder, &msg)
|
||||||
|
fbs.SendResponseStart(builder)
|
||||||
|
fbs.SendResponseAddMessage(builder, msgOff)
|
||||||
|
root := fbs.SendResponseEnd(builder)
|
||||||
|
fbs.FinishSendResponseBuffer(builder, root)
|
||||||
|
return downstream.UnaryResult{ResultCode: userCommandResultCodeOK, PayloadBytes: builder.FinishedBytes()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RESTClient) executeMailBroadcast(ctx context.Context, userID string, payload []byte) (downstream.UnaryResult, error) {
|
||||||
|
if len(payload) == 0 {
|
||||||
|
return downstream.UnaryResult{}, errors.New("execute user.games.mail.broadcast: payload is empty")
|
||||||
|
}
|
||||||
|
flat := fbs.GetRootAsBroadcastRequest(payload, 0)
|
||||||
|
gameID := readUUID(flat.GameId(nil))
|
||||||
|
if gameID == uuid.Nil {
|
||||||
|
return downstream.UnaryResult{}, errors.New("execute user.games.mail.broadcast: game_id is missing")
|
||||||
|
}
|
||||||
|
body := struct {
|
||||||
|
Subject string `json:"subject,omitempty"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
}{
|
||||||
|
Subject: string(flat.Subject()),
|
||||||
|
Body: string(flat.Body()),
|
||||||
|
}
|
||||||
|
target := c.baseURL + "/api/v1/user/games/" + url.PathEscape(gameID.String()) + "/mail/broadcast"
|
||||||
|
respBody, status, err := c.do(ctx, http.MethodPost, target, userID, body)
|
||||||
|
if err != nil {
|
||||||
|
return downstream.UnaryResult{}, fmt.Errorf("execute user.games.mail.broadcast: %w", err)
|
||||||
|
}
|
||||||
|
if status < 200 || status >= 300 {
|
||||||
|
return projectMailErrorResponse(status, respBody)
|
||||||
|
}
|
||||||
|
var receipt mailBroadcastReceiptJSON
|
||||||
|
if err := json.Unmarshal(respBody, &receipt); err != nil {
|
||||||
|
return downstream.UnaryResult{}, fmt.Errorf("decode mail broadcast response: %w", err)
|
||||||
|
}
|
||||||
|
builder := flatbuffers.NewBuilder(256)
|
||||||
|
recOff := encodeMailBroadcastReceipt(builder, &receipt)
|
||||||
|
fbs.BroadcastResponseStart(builder)
|
||||||
|
fbs.BroadcastResponseAddReceipt(builder, recOff)
|
||||||
|
root := fbs.BroadcastResponseEnd(builder)
|
||||||
|
fbs.FinishBroadcastResponseBuffer(builder, root)
|
||||||
|
return downstream.UnaryResult{ResultCode: userCommandResultCodeOK, PayloadBytes: builder.FinishedBytes()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RESTClient) executeMailAdmin(ctx context.Context, userID string, payload []byte) (downstream.UnaryResult, error) {
|
||||||
|
if len(payload) == 0 {
|
||||||
|
return downstream.UnaryResult{}, errors.New("execute user.games.mail.admin: payload is empty")
|
||||||
|
}
|
||||||
|
flat := fbs.GetRootAsAdminRequest(payload, 0)
|
||||||
|
gameID := readUUID(flat.GameId(nil))
|
||||||
|
if gameID == uuid.Nil {
|
||||||
|
return downstream.UnaryResult{}, errors.New("execute user.games.mail.admin: game_id is missing")
|
||||||
|
}
|
||||||
|
target := string(flat.Target())
|
||||||
|
body := struct {
|
||||||
|
Target string `json:"target"`
|
||||||
|
RecipientUserID string `json:"recipient_user_id,omitempty"`
|
||||||
|
RecipientRaceName string `json:"recipient_race_name,omitempty"`
|
||||||
|
Recipients string `json:"recipients,omitempty"`
|
||||||
|
Subject string `json:"subject,omitempty"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
}{
|
||||||
|
Target: target,
|
||||||
|
RecipientUserID: string(flat.RecipientUserId()),
|
||||||
|
RecipientRaceName: string(flat.RecipientRaceName()),
|
||||||
|
Recipients: string(flat.Recipients()),
|
||||||
|
Subject: string(flat.Subject()),
|
||||||
|
Body: string(flat.Body()),
|
||||||
|
}
|
||||||
|
url := c.baseURL + "/api/v1/user/games/" + url.PathEscape(gameID.String()) + "/mail/admin"
|
||||||
|
respBody, status, err := c.do(ctx, http.MethodPost, url, userID, body)
|
||||||
|
if err != nil {
|
||||||
|
return downstream.UnaryResult{}, fmt.Errorf("execute user.games.mail.admin: %w", err)
|
||||||
|
}
|
||||||
|
if status < 200 || status >= 300 {
|
||||||
|
return projectMailErrorResponse(status, respBody)
|
||||||
|
}
|
||||||
|
builder := flatbuffers.NewBuilder(512)
|
||||||
|
if target == "all" {
|
||||||
|
var receipt mailBroadcastReceiptJSON
|
||||||
|
if err := json.Unmarshal(respBody, &receipt); err != nil {
|
||||||
|
return downstream.UnaryResult{}, fmt.Errorf("decode mail admin broadcast response: %w", err)
|
||||||
|
}
|
||||||
|
recOff := encodeMailBroadcastReceipt(builder, &receipt)
|
||||||
|
fbs.AdminResponseStart(builder)
|
||||||
|
fbs.AdminResponseAddReceipt(builder, recOff)
|
||||||
|
root := fbs.AdminResponseEnd(builder)
|
||||||
|
fbs.FinishAdminResponseBuffer(builder, root)
|
||||||
|
} else {
|
||||||
|
var msg mailMessageJSON
|
||||||
|
if err := json.Unmarshal(respBody, &msg); err != nil {
|
||||||
|
return downstream.UnaryResult{}, fmt.Errorf("decode mail admin send response: %w", err)
|
||||||
|
}
|
||||||
|
msgOff := encodeMailMessage(builder, &msg)
|
||||||
|
fbs.AdminResponseStart(builder)
|
||||||
|
fbs.AdminResponseAddMessage(builder, msgOff)
|
||||||
|
root := fbs.AdminResponseEnd(builder)
|
||||||
|
fbs.FinishAdminResponseBuffer(builder, root)
|
||||||
|
}
|
||||||
|
return downstream.UnaryResult{ResultCode: userCommandResultCodeOK, PayloadBytes: builder.FinishedBytes()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RESTClient) executeMailRead(ctx context.Context, userID string, payload []byte) (downstream.UnaryResult, error) {
|
||||||
|
if len(payload) == 0 {
|
||||||
|
return downstream.UnaryResult{}, errors.New("execute user.games.mail.read: payload is empty")
|
||||||
|
}
|
||||||
|
flat := fbs.GetRootAsReadRequest(payload, 0)
|
||||||
|
gameID := readUUID(flat.GameId(nil))
|
||||||
|
messageID := readUUID(flat.MessageId(nil))
|
||||||
|
if gameID == uuid.Nil || messageID == uuid.Nil {
|
||||||
|
return downstream.UnaryResult{}, errors.New("execute user.games.mail.read: game_id and message_id are required")
|
||||||
|
}
|
||||||
|
target := c.baseURL + "/api/v1/user/games/" + url.PathEscape(gameID.String()) + "/mail/messages/" + url.PathEscape(messageID.String()) + "/read"
|
||||||
|
respBody, status, err := c.do(ctx, http.MethodPost, target, userID, struct{}{})
|
||||||
|
if err != nil {
|
||||||
|
return downstream.UnaryResult{}, fmt.Errorf("execute user.games.mail.read: %w", err)
|
||||||
|
}
|
||||||
|
if status < 200 || status >= 300 {
|
||||||
|
return projectMailErrorResponse(status, respBody)
|
||||||
|
}
|
||||||
|
return encodeRecipientStateResponse(respBody, fbs.ReadResponseStart, fbs.ReadResponseAddState, fbs.ReadResponseEnd, fbs.FinishReadResponseBuffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RESTClient) executeMailDelete(ctx context.Context, userID string, payload []byte) (downstream.UnaryResult, error) {
|
||||||
|
if len(payload) == 0 {
|
||||||
|
return downstream.UnaryResult{}, errors.New("execute user.games.mail.delete: payload is empty")
|
||||||
|
}
|
||||||
|
flat := fbs.GetRootAsDeleteRequest(payload, 0)
|
||||||
|
gameID := readUUID(flat.GameId(nil))
|
||||||
|
messageID := readUUID(flat.MessageId(nil))
|
||||||
|
if gameID == uuid.Nil || messageID == uuid.Nil {
|
||||||
|
return downstream.UnaryResult{}, errors.New("execute user.games.mail.delete: game_id and message_id are required")
|
||||||
|
}
|
||||||
|
target := c.baseURL + "/api/v1/user/games/" + url.PathEscape(gameID.String()) + "/mail/messages/" + url.PathEscape(messageID.String())
|
||||||
|
respBody, status, err := c.do(ctx, http.MethodDelete, target, userID, nil)
|
||||||
|
if err != nil {
|
||||||
|
return downstream.UnaryResult{}, fmt.Errorf("execute user.games.mail.delete: %w", err)
|
||||||
|
}
|
||||||
|
if status < 200 || status >= 300 {
|
||||||
|
return projectMailErrorResponse(status, respBody)
|
||||||
|
}
|
||||||
|
return encodeRecipientStateResponse(respBody, fbs.DeleteResponseStart, fbs.DeleteResponseAddState, fbs.DeleteResponseEnd, fbs.FinishDeleteResponseBuffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodeRecipientStateResponse decodes the JSON recipient-state body
|
||||||
|
// and emits the corresponding FlatBuffers Read/Delete envelope. The
|
||||||
|
// caller supplies the trio of envelope start / add-state / end / finish
|
||||||
|
// functions so this helper covers both endpoints with the same shape.
|
||||||
|
func encodeRecipientStateResponse(respBody []byte,
|
||||||
|
startFn func(*flatbuffers.Builder),
|
||||||
|
addStateFn func(*flatbuffers.Builder, flatbuffers.UOffsetT),
|
||||||
|
endFn func(*flatbuffers.Builder) flatbuffers.UOffsetT,
|
||||||
|
finishFn func(*flatbuffers.Builder, flatbuffers.UOffsetT),
|
||||||
|
) (downstream.UnaryResult, error) {
|
||||||
|
var state mailRecipientStateJSON
|
||||||
|
if err := json.Unmarshal(respBody, &state); err != nil {
|
||||||
|
return downstream.UnaryResult{}, fmt.Errorf("decode mail recipient state: %w", err)
|
||||||
|
}
|
||||||
|
builder := flatbuffers.NewBuilder(128)
|
||||||
|
stateOff := encodeMailRecipientState(builder, &state)
|
||||||
|
startFn(builder)
|
||||||
|
addStateFn(builder, stateOff)
|
||||||
|
root := endFn(builder)
|
||||||
|
finishFn(builder, root)
|
||||||
|
return downstream.UnaryResult{ResultCode: userCommandResultCodeOK, PayloadBytes: builder.FinishedBytes()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodeMailMessageList is a shared helper that encodes a slice of
|
||||||
|
// mailMessageJSON items into either an InboxResponse or a
|
||||||
|
// SentResponse FlatBuffers envelope. The two envelopes have the same
|
||||||
|
// shape (just a `items` vector of MailMessage) so the trio of
|
||||||
|
// constructor functions parameterises the helper.
|
||||||
|
func encodeMailMessageList(items []mailMessageJSON,
|
||||||
|
startFn func(*flatbuffers.Builder),
|
||||||
|
addItemsFn func(*flatbuffers.Builder, flatbuffers.UOffsetT),
|
||||||
|
endFn func(*flatbuffers.Builder) flatbuffers.UOffsetT,
|
||||||
|
finishFn func(*flatbuffers.Builder, flatbuffers.UOffsetT),
|
||||||
|
) []byte {
|
||||||
|
builder := flatbuffers.NewBuilder(1024)
|
||||||
|
offsets := make([]flatbuffers.UOffsetT, 0, len(items))
|
||||||
|
for i := range items {
|
||||||
|
offsets = append(offsets, encodeMailMessage(builder, &items[i]))
|
||||||
|
}
|
||||||
|
// FlatBuffers vectors are built in reverse: prepend each offset.
|
||||||
|
builder.StartVector(4, len(offsets), 4)
|
||||||
|
for i := len(offsets) - 1; i >= 0; i-- {
|
||||||
|
builder.PrependUOffsetT(offsets[i])
|
||||||
|
}
|
||||||
|
itemsVec := builder.EndVector(len(offsets))
|
||||||
|
startFn(builder)
|
||||||
|
addItemsFn(builder, itemsVec)
|
||||||
|
root := endFn(builder)
|
||||||
|
finishFn(builder, root)
|
||||||
|
return builder.FinishedBytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodeMailMessage builds a MailMessage table inside builder. Returns
|
||||||
|
// the offset of the finished table. Strings are interned through the
|
||||||
|
// builder; missing JSON fields (nil pointers, empty strings) yield
|
||||||
|
// empty FB strings which the readers treat as absent.
|
||||||
|
func encodeMailMessage(builder *flatbuffers.Builder, m *mailMessageJSON) flatbuffers.UOffsetT {
|
||||||
|
messageIDOff := builder.CreateString(m.MessageID)
|
||||||
|
gameIDOff := builder.CreateString(m.GameID)
|
||||||
|
gameNameOff := builder.CreateString(m.GameName)
|
||||||
|
kindOff := builder.CreateString(m.Kind)
|
||||||
|
senderKindOff := builder.CreateString(m.SenderKind)
|
||||||
|
senderUserIDOff := builder.CreateString(stringPtrValue(m.SenderUserID))
|
||||||
|
senderUsernameOff := builder.CreateString(stringPtrValue(m.SenderUsername))
|
||||||
|
senderRaceNameOff := builder.CreateString(stringPtrValue(m.SenderRaceName))
|
||||||
|
subjectOff := builder.CreateString(m.Subject)
|
||||||
|
bodyOff := builder.CreateString(m.Body)
|
||||||
|
bodyLangOff := builder.CreateString(m.BodyLang)
|
||||||
|
broadcastScopeOff := builder.CreateString(m.BroadcastScope)
|
||||||
|
recipientUserIDOff := builder.CreateString(m.RecipientUserID)
|
||||||
|
recipientUserNameOff := builder.CreateString(m.RecipientUserName)
|
||||||
|
recipientRaceNameOff := builder.CreateString(stringPtrValue(m.RecipientRaceName))
|
||||||
|
translatedSubjectOff := builder.CreateString(stringPtrValue(m.TranslatedSubject))
|
||||||
|
translatedBodyOff := builder.CreateString(stringPtrValue(m.TranslatedBody))
|
||||||
|
translationLangOff := builder.CreateString(stringPtrValue(m.TranslationLang))
|
||||||
|
translatorOff := builder.CreateString(stringPtrValue(m.Translator))
|
||||||
|
|
||||||
|
fbs.MailMessageStart(builder)
|
||||||
|
fbs.MailMessageAddMessageId(builder, messageIDOff)
|
||||||
|
fbs.MailMessageAddGameId(builder, gameIDOff)
|
||||||
|
fbs.MailMessageAddGameName(builder, gameNameOff)
|
||||||
|
fbs.MailMessageAddKind(builder, kindOff)
|
||||||
|
fbs.MailMessageAddSenderKind(builder, senderKindOff)
|
||||||
|
fbs.MailMessageAddSenderUserId(builder, senderUserIDOff)
|
||||||
|
fbs.MailMessageAddSenderUsername(builder, senderUsernameOff)
|
||||||
|
fbs.MailMessageAddSenderRaceName(builder, senderRaceNameOff)
|
||||||
|
fbs.MailMessageAddSubject(builder, subjectOff)
|
||||||
|
fbs.MailMessageAddBody(builder, bodyOff)
|
||||||
|
fbs.MailMessageAddBodyLang(builder, bodyLangOff)
|
||||||
|
fbs.MailMessageAddBroadcastScope(builder, broadcastScopeOff)
|
||||||
|
fbs.MailMessageAddCreatedAtMs(builder, parseRFC3339Millis(m.CreatedAt))
|
||||||
|
fbs.MailMessageAddRecipientUserId(builder, recipientUserIDOff)
|
||||||
|
fbs.MailMessageAddRecipientUserName(builder, recipientUserNameOff)
|
||||||
|
fbs.MailMessageAddRecipientRaceName(builder, recipientRaceNameOff)
|
||||||
|
fbs.MailMessageAddReadAtMs(builder, parseRFC3339Millis(stringPtrValue(m.ReadAt)))
|
||||||
|
fbs.MailMessageAddDeletedAtMs(builder, parseRFC3339Millis(stringPtrValue(m.DeletedAt)))
|
||||||
|
fbs.MailMessageAddTranslatedSubject(builder, translatedSubjectOff)
|
||||||
|
fbs.MailMessageAddTranslatedBody(builder, translatedBodyOff)
|
||||||
|
fbs.MailMessageAddTranslationLang(builder, translationLangOff)
|
||||||
|
fbs.MailMessageAddTranslator(builder, translatorOff)
|
||||||
|
return fbs.MailMessageEnd(builder)
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodeMailRecipientState builds a MailRecipientState table.
|
||||||
|
func encodeMailRecipientState(builder *flatbuffers.Builder, s *mailRecipientStateJSON) flatbuffers.UOffsetT {
|
||||||
|
messageIDOff := builder.CreateString(s.MessageID)
|
||||||
|
fbs.MailRecipientStateStart(builder)
|
||||||
|
fbs.MailRecipientStateAddMessageId(builder, messageIDOff)
|
||||||
|
fbs.MailRecipientStateAddReadAtMs(builder, parseRFC3339Millis(stringPtrValue(s.ReadAt)))
|
||||||
|
fbs.MailRecipientStateAddDeletedAtMs(builder, parseRFC3339Millis(stringPtrValue(s.DeletedAt)))
|
||||||
|
return fbs.MailRecipientStateEnd(builder)
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodeMailBroadcastReceipt builds a MailBroadcastReceipt table.
|
||||||
|
func encodeMailBroadcastReceipt(builder *flatbuffers.Builder, r *mailBroadcastReceiptJSON) flatbuffers.UOffsetT {
|
||||||
|
messageIDOff := builder.CreateString(r.MessageID)
|
||||||
|
gameIDOff := builder.CreateString(r.GameID)
|
||||||
|
gameNameOff := builder.CreateString(r.GameName)
|
||||||
|
kindOff := builder.CreateString(r.Kind)
|
||||||
|
senderKindOff := builder.CreateString(r.SenderKind)
|
||||||
|
subjectOff := builder.CreateString(r.Subject)
|
||||||
|
bodyOff := builder.CreateString(r.Body)
|
||||||
|
bodyLangOff := builder.CreateString(r.BodyLang)
|
||||||
|
broadcastScopeOff := builder.CreateString(r.BroadcastScope)
|
||||||
|
fbs.MailBroadcastReceiptStart(builder)
|
||||||
|
fbs.MailBroadcastReceiptAddMessageId(builder, messageIDOff)
|
||||||
|
fbs.MailBroadcastReceiptAddGameId(builder, gameIDOff)
|
||||||
|
fbs.MailBroadcastReceiptAddGameName(builder, gameNameOff)
|
||||||
|
fbs.MailBroadcastReceiptAddKind(builder, kindOff)
|
||||||
|
fbs.MailBroadcastReceiptAddSenderKind(builder, senderKindOff)
|
||||||
|
fbs.MailBroadcastReceiptAddSubject(builder, subjectOff)
|
||||||
|
fbs.MailBroadcastReceiptAddBody(builder, bodyOff)
|
||||||
|
fbs.MailBroadcastReceiptAddBodyLang(builder, bodyLangOff)
|
||||||
|
fbs.MailBroadcastReceiptAddBroadcastScope(builder, broadcastScopeOff)
|
||||||
|
fbs.MailBroadcastReceiptAddCreatedAtMs(builder, parseRFC3339Millis(r.CreatedAt))
|
||||||
|
fbs.MailBroadcastReceiptAddRecipientCount(builder, int32(r.RecipientCount))
|
||||||
|
return fbs.MailBroadcastReceiptEnd(builder)
|
||||||
|
}
|
||||||
|
|
||||||
|
// projectMailErrorResponse maps a non-2xx response into a UnaryResult
|
||||||
|
// carrying the backend error envelope, reusing the shared user-mail
|
||||||
|
// error-projection. 503 is bubbled as ErrDownstreamUnavailable.
|
||||||
|
func projectMailErrorResponse(statusCode int, payload []byte) (downstream.UnaryResult, error) {
|
||||||
|
if statusCode == http.StatusServiceUnavailable {
|
||||||
|
return downstream.UnaryResult{}, downstream.ErrDownstreamUnavailable
|
||||||
|
}
|
||||||
|
if statusCode >= 400 && statusCode <= 599 {
|
||||||
|
return projectUserBackendError(statusCode, payload)
|
||||||
|
}
|
||||||
|
return downstream.UnaryResult{}, fmt.Errorf("unexpected HTTP status %d", statusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// readUUID converts the common.UUID struct (or its absence) into a
|
||||||
|
// google/uuid.UUID. Returns uuid.Nil when the input is nil.
|
||||||
|
func readUUID(u *commonfbs.UUID) uuid.UUID {
|
||||||
|
if u == nil {
|
||||||
|
return uuid.Nil
|
||||||
|
}
|
||||||
|
var out uuid.UUID
|
||||||
|
hi := u.Hi()
|
||||||
|
lo := u.Lo()
|
||||||
|
for i := 0; i < 8; i++ {
|
||||||
|
out[i] = byte(hi >> (56 - 8*i))
|
||||||
|
out[i+8] = byte(lo >> (56 - 8*i))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// stringPtrValue returns "" for nil and the dereferenced value
|
||||||
|
// otherwise. Used to flatten nullable JSON strings into the
|
||||||
|
// always-present FlatBuffers string slot.
|
||||||
|
func stringPtrValue(p *string) string {
|
||||||
|
if p == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *p
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseRFC3339Millis parses an RFC 3339 timestamp string (the format
|
||||||
|
// the backend mail handler emits) into Unix milliseconds. Returns 0
|
||||||
|
// when the input is empty or unparseable, matching the "absent"
|
||||||
|
// convention for the *_at_ms wire fields.
|
||||||
|
func parseRFC3339Millis(s string) int64 {
|
||||||
|
if s == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
t, err := time.Parse(time.RFC3339Nano, s)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return t.UnixMilli()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
|
|
||||||
"galaxy/gateway/internal/downstream"
|
"galaxy/gateway/internal/downstream"
|
||||||
|
diplomailmodel "galaxy/model/diplomail"
|
||||||
lobbymodel "galaxy/model/lobby"
|
lobbymodel "galaxy/model/lobby"
|
||||||
ordermodel "galaxy/model/order"
|
ordermodel "galaxy/model/order"
|
||||||
reportmodel "galaxy/model/report"
|
reportmodel "galaxy/model/report"
|
||||||
@@ -67,6 +68,27 @@ func GameRoutes(client *RESTClient) map[string]downstream.Client {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MailRoutes returns the authenticated `user.games.mail.*` downstream
|
||||||
|
// routes served by backend's diplomail subsystem. When client is nil
|
||||||
|
// every route resolves to a dependency-unavailable client so the
|
||||||
|
// static router still recognises the message types.
|
||||||
|
func MailRoutes(client *RESTClient) map[string]downstream.Client {
|
||||||
|
target := downstream.Client(unavailableClient{})
|
||||||
|
if client != nil {
|
||||||
|
target = mailCommandClient{rest: client}
|
||||||
|
}
|
||||||
|
return map[string]downstream.Client{
|
||||||
|
diplomailmodel.MessageTypeUserGamesMailInbox: target,
|
||||||
|
diplomailmodel.MessageTypeUserGamesMailSent: target,
|
||||||
|
diplomailmodel.MessageTypeUserGamesMailMessageGet: target,
|
||||||
|
diplomailmodel.MessageTypeUserGamesMailSend: target,
|
||||||
|
diplomailmodel.MessageTypeUserGamesMailBroadcast: target,
|
||||||
|
diplomailmodel.MessageTypeUserGamesMailAdmin: target,
|
||||||
|
diplomailmodel.MessageTypeUserGamesMailRead: target,
|
||||||
|
diplomailmodel.MessageTypeUserGamesMailDelete: target,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type unavailableClient struct{}
|
type unavailableClient struct{}
|
||||||
|
|
||||||
func (unavailableClient) ExecuteCommand(context.Context, downstream.AuthenticatedCommand) (downstream.UnaryResult, error) {
|
func (unavailableClient) ExecuteCommand(context.Context, downstream.AuthenticatedCommand) (downstream.UnaryResult, error) {
|
||||||
@@ -97,9 +119,18 @@ func (c gameCommandClient) ExecuteCommand(ctx context.Context, command downstrea
|
|||||||
return c.rest.ExecuteGameCommand(ctx, command)
|
return c.rest.ExecuteGameCommand(ctx, command)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type mailCommandClient struct {
|
||||||
|
rest *RESTClient
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c mailCommandClient) ExecuteCommand(ctx context.Context, command downstream.AuthenticatedCommand) (downstream.UnaryResult, error) {
|
||||||
|
return c.rest.ExecuteMailCommand(ctx, command)
|
||||||
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
_ downstream.Client = unavailableClient{}
|
_ downstream.Client = unavailableClient{}
|
||||||
_ downstream.Client = userCommandClient{}
|
_ downstream.Client = userCommandClient{}
|
||||||
_ downstream.Client = lobbyCommandClient{}
|
_ downstream.Client = lobbyCommandClient{}
|
||||||
_ downstream.Client = gameCommandClient{}
|
_ downstream.Client = gameCommandClient{}
|
||||||
|
_ downstream.Client = mailCommandClient{}
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user