feat: user service
This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
// Package userservice implements the authenticated Gateway -> User Service
|
||||
// self-service downstream adapter.
|
||||
package userservice
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"galaxy/gateway/internal/downstream"
|
||||
usermodel "galaxy/model/user"
|
||||
"galaxy/transcoder"
|
||||
)
|
||||
|
||||
const (
|
||||
getMyAccountResultCodeOK = "ok"
|
||||
|
||||
userServiceAccountPathSuffix = "/account"
|
||||
userServiceProfilePathSuffix = "/profile"
|
||||
userServiceSettingsPathSuffix = "/settings"
|
||||
)
|
||||
|
||||
var stableErrorMessages = map[string]string{
|
||||
"invalid_request": "request is invalid",
|
||||
"subject_not_found": "subject not found",
|
||||
"conflict": "request conflicts with current state",
|
||||
"internal_error": "internal server error",
|
||||
}
|
||||
|
||||
// HTTPClient implements downstream.Client against the trusted internal User
|
||||
// Service REST API while preserving FlatBuffers at the external authenticated
|
||||
// gateway boundary.
|
||||
type HTTPClient struct {
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewHTTPClient constructs one User Service downstream client backed by the
|
||||
// trusted internal REST API at baseURL.
|
||||
func NewHTTPClient(baseURL string) (*HTTPClient, error) {
|
||||
transport, ok := http.DefaultTransport.(*http.Transport)
|
||||
if !ok {
|
||||
return nil, errors.New("new user service HTTP client: default transport is not *http.Transport")
|
||||
}
|
||||
|
||||
return newHTTPClient(baseURL, &http.Client{
|
||||
Transport: transport.Clone(),
|
||||
})
|
||||
}
|
||||
|
||||
func newHTTPClient(baseURL string, httpClient *http.Client) (*HTTPClient, error) {
|
||||
if httpClient == nil {
|
||||
return nil, errors.New("new user service HTTP client: http client must not be nil")
|
||||
}
|
||||
|
||||
trimmedBaseURL := strings.TrimSpace(baseURL)
|
||||
if trimmedBaseURL == "" {
|
||||
return nil, errors.New("new user service HTTP client: base URL must not be empty")
|
||||
}
|
||||
|
||||
parsedBaseURL, err := url.Parse(strings.TrimRight(trimmedBaseURL, "/"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new user service HTTP client: parse base URL: %w", err)
|
||||
}
|
||||
if parsedBaseURL.Scheme == "" || parsedBaseURL.Host == "" {
|
||||
return nil, errors.New("new user service HTTP client: base URL must be absolute")
|
||||
}
|
||||
|
||||
return &HTTPClient{
|
||||
baseURL: parsedBaseURL.String(),
|
||||
httpClient: httpClient,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Close releases idle HTTP connections owned by the client transport.
|
||||
func (c *HTTPClient) Close() error {
|
||||
if c == nil || c.httpClient == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
type idleCloser interface {
|
||||
CloseIdleConnections()
|
||||
}
|
||||
|
||||
if transport, ok := c.httpClient.Transport.(idleCloser); ok {
|
||||
transport.CloseIdleConnections()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExecuteCommand routes one authenticated gateway command to the matching
|
||||
// trusted internal User Service self-service route.
|
||||
func (c *HTTPClient) ExecuteCommand(ctx context.Context, command downstream.AuthenticatedCommand) (downstream.UnaryResult, error) {
|
||||
if c == nil || c.httpClient == nil {
|
||||
return downstream.UnaryResult{}, errors.New("execute user service command: nil client")
|
||||
}
|
||||
if ctx == nil {
|
||||
return downstream.UnaryResult{}, errors.New("execute user service command: nil context")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return downstream.UnaryResult{}, err
|
||||
}
|
||||
if strings.TrimSpace(command.UserID) == "" {
|
||||
return downstream.UnaryResult{}, errors.New("execute user service command: user_id must not be empty")
|
||||
}
|
||||
|
||||
switch command.MessageType {
|
||||
case usermodel.MessageTypeGetMyAccount:
|
||||
if _, err := transcoder.PayloadToGetMyAccountRequest(command.PayloadBytes); err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("execute user service command %q: %w", command.MessageType, err)
|
||||
}
|
||||
return c.executeGetMyAccount(ctx, command.UserID)
|
||||
case usermodel.MessageTypeUpdateMyProfile:
|
||||
request, err := transcoder.PayloadToUpdateMyProfileRequest(command.PayloadBytes)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("execute user service command %q: %w", command.MessageType, err)
|
||||
}
|
||||
return c.executeUpdateMyProfile(ctx, command.UserID, request)
|
||||
case usermodel.MessageTypeUpdateMySettings:
|
||||
request, err := transcoder.PayloadToUpdateMySettingsRequest(command.PayloadBytes)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("execute user service command %q: %w", command.MessageType, err)
|
||||
}
|
||||
return c.executeUpdateMySettings(ctx, command.UserID, request)
|
||||
default:
|
||||
return downstream.UnaryResult{}, fmt.Errorf("execute user service command: unsupported message type %q", command.MessageType)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *HTTPClient) executeGetMyAccount(ctx context.Context, userID string) (downstream.UnaryResult, error) {
|
||||
payload, statusCode, err := c.doRequest(ctx, http.MethodGet, c.userPath(userID, userServiceAccountPathSuffix), nil)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("execute get my account: %w", err)
|
||||
}
|
||||
|
||||
return projectResponse(statusCode, payload)
|
||||
}
|
||||
|
||||
func (c *HTTPClient) executeUpdateMyProfile(ctx context.Context, userID string, request *usermodel.UpdateMyProfileRequest) (downstream.UnaryResult, error) {
|
||||
payload, statusCode, err := c.doRequest(ctx, http.MethodPost, c.userPath(userID, userServiceProfilePathSuffix), request)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("execute update my profile: %w", err)
|
||||
}
|
||||
|
||||
return projectResponse(statusCode, payload)
|
||||
}
|
||||
|
||||
func (c *HTTPClient) executeUpdateMySettings(ctx context.Context, userID string, request *usermodel.UpdateMySettingsRequest) (downstream.UnaryResult, error) {
|
||||
payload, statusCode, err := c.doRequest(ctx, http.MethodPost, c.userPath(userID, userServiceSettingsPathSuffix), request)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("execute update my settings: %w", err)
|
||||
}
|
||||
|
||||
return projectResponse(statusCode, payload)
|
||||
}
|
||||
|
||||
func (c *HTTPClient) doRequest(ctx context.Context, method string, targetURL string, requestBody any) ([]byte, int, error) {
|
||||
if c == nil || c.httpClient == nil {
|
||||
return nil, 0, errors.New("nil client")
|
||||
}
|
||||
|
||||
var bodyReader io.Reader
|
||||
if requestBody != nil {
|
||||
payload, err := json.Marshal(requestBody)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("marshal request body: %w", err)
|
||||
}
|
||||
bodyReader = bytes.NewReader(payload)
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, method, targetURL, bodyReader)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
if requestBody != nil {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
response, err := c.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
payload, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("read response body: %w", err)
|
||||
}
|
||||
|
||||
return payload, response.StatusCode, nil
|
||||
}
|
||||
|
||||
func (c *HTTPClient) userPath(userID string, suffix string) string {
|
||||
return c.baseURL + "/api/v1/internal/users/" + url.PathEscape(userID) + suffix
|
||||
}
|
||||
|
||||
func projectResponse(statusCode int, payload []byte) (downstream.UnaryResult, error) {
|
||||
switch {
|
||||
case statusCode == http.StatusOK:
|
||||
var response usermodel.AccountResponse
|
||||
if err := decodeStrictJSONPayload(payload, &response); err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("decode success response: %w", err)
|
||||
}
|
||||
|
||||
payloadBytes, err := transcoder.AccountResponseToPayload(&response)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("encode success response payload: %w", err)
|
||||
}
|
||||
|
||||
return downstream.UnaryResult{
|
||||
ResultCode: getMyAccountResultCodeOK,
|
||||
PayloadBytes: payloadBytes,
|
||||
}, nil
|
||||
case statusCode == http.StatusServiceUnavailable:
|
||||
return downstream.UnaryResult{}, downstream.ErrDownstreamUnavailable
|
||||
case statusCode >= 400 && statusCode <= 599:
|
||||
errorResponse, err := decodeUserServiceError(statusCode, payload)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("decode error response: %w", err)
|
||||
}
|
||||
|
||||
payloadBytes, err := transcoder.ErrorResponseToPayload(errorResponse)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("encode error response payload: %w", err)
|
||||
}
|
||||
|
||||
return downstream.UnaryResult{
|
||||
ResultCode: errorResponse.Error.Code,
|
||||
PayloadBytes: payloadBytes,
|
||||
}, nil
|
||||
default:
|
||||
return downstream.UnaryResult{}, fmt.Errorf("unexpected HTTP status %d", statusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func decodeUserServiceError(statusCode int, payload []byte) (*usermodel.ErrorResponse, error) {
|
||||
var response usermodel.ErrorResponse
|
||||
if err := decodeStrictJSONPayload(payload, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response.Error.Code = normalizeErrorCode(statusCode, response.Error.Code)
|
||||
response.Error.Message = normalizeErrorMessage(response.Error.Code, response.Error.Message)
|
||||
|
||||
if strings.TrimSpace(response.Error.Code) == "" {
|
||||
return nil, errors.New("missing error code")
|
||||
}
|
||||
if strings.TrimSpace(response.Error.Message) == "" {
|
||||
return nil, errors.New("missing error message")
|
||||
}
|
||||
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
func normalizeErrorCode(statusCode int, code string) string {
|
||||
trimmed := strings.TrimSpace(code)
|
||||
if trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
switch statusCode {
|
||||
case http.StatusBadRequest:
|
||||
return "invalid_request"
|
||||
case http.StatusNotFound:
|
||||
return "subject_not_found"
|
||||
case http.StatusConflict:
|
||||
return "conflict"
|
||||
default:
|
||||
return "internal_error"
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeErrorMessage(code string, message string) string {
|
||||
trimmed := strings.TrimSpace(message)
|
||||
if trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
if stable, ok := stableErrorMessages[code]; ok {
|
||||
return stable
|
||||
}
|
||||
|
||||
return stableErrorMessages["internal_error"]
|
||||
}
|
||||
|
||||
func decodeStrictJSONPayload(payload []byte, target any) error {
|
||||
decoder := json.NewDecoder(bytes.NewReader(payload))
|
||||
decoder.DisallowUnknownFields()
|
||||
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
if err == nil {
|
||||
return errors.New("unexpected trailing JSON input")
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ downstream.Client = (*HTTPClient)(nil)
|
||||
@@ -0,0 +1,399 @@
|
||||
package userservice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/gateway/internal/downstream"
|
||||
usermodel "galaxy/model/user"
|
||||
"galaxy/transcoder"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewHTTPClient(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
baseURL string
|
||||
wantURL string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "absolute URL is normalized",
|
||||
baseURL: " http://127.0.0.1:8081/ ",
|
||||
wantURL: "http://127.0.0.1:8081",
|
||||
},
|
||||
{
|
||||
name: "empty base URL is rejected",
|
||||
baseURL: " ",
|
||||
wantErr: "base URL must not be empty",
|
||||
},
|
||||
{
|
||||
name: "relative base URL is rejected",
|
||||
baseURL: "/relative",
|
||||
wantErr: "base URL must be absolute",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, err := NewHTTPClient(tt.baseURL)
|
||||
if tt.wantErr != "" {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.wantURL, client.baseURL)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPClientExecuteGetMyAccountSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wantResponse := sampleAccountResponse()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
require.Equal(t, http.MethodGet, request.Method)
|
||||
require.Equal(t, "/api/v1/internal/users/user-123/account", request.URL.Path)
|
||||
require.NoError(t, json.NewEncoder(writer).Encode(wantResponse))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := newTestHTTPClient(t, server)
|
||||
payload, err := transcoder.GetMyAccountRequestToPayload(&usermodel.GetMyAccountRequest{})
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := client.ExecuteCommand(context.Background(), downstream.AuthenticatedCommand{
|
||||
UserID: "user-123",
|
||||
MessageType: usermodel.MessageTypeGetMyAccount,
|
||||
PayloadBytes: payload,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, getMyAccountResultCodeOK, result.ResultCode)
|
||||
|
||||
decoded, err := transcoder.PayloadToAccountResponse(result.PayloadBytes)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, wantResponse, decoded)
|
||||
}
|
||||
|
||||
func TestHTTPClientExecuteUpdateMyProfileProjectsConflict(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
require.Equal(t, http.MethodPost, request.Method)
|
||||
require.Equal(t, "/api/v1/internal/users/user-123/profile", request.URL.Path)
|
||||
|
||||
body, err := io.ReadAll(request.Body)
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, `{"race_name":"Nova Prime"}`, string(body))
|
||||
|
||||
writer.WriteHeader(http.StatusConflict)
|
||||
require.NoError(t, json.NewEncoder(writer).Encode(&usermodel.ErrorResponse{
|
||||
Error: usermodel.ErrorBody{
|
||||
Code: "conflict",
|
||||
Message: "request conflicts with current state",
|
||||
},
|
||||
}))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := newTestHTTPClient(t, server)
|
||||
payload, err := transcoder.UpdateMyProfileRequestToPayload(&usermodel.UpdateMyProfileRequest{RaceName: "Nova Prime"})
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := client.ExecuteCommand(context.Background(), downstream.AuthenticatedCommand{
|
||||
UserID: "user-123",
|
||||
MessageType: usermodel.MessageTypeUpdateMyProfile,
|
||||
PayloadBytes: payload,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "conflict", result.ResultCode)
|
||||
|
||||
decoded, err := transcoder.PayloadToErrorResponse(result.PayloadBytes)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, &usermodel.ErrorResponse{
|
||||
Error: usermodel.ErrorBody{
|
||||
Code: "conflict",
|
||||
Message: "request conflicts with current state",
|
||||
},
|
||||
}, decoded)
|
||||
}
|
||||
|
||||
func TestHTTPClientExecuteUpdateMySettingsProjectsInvalidRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
require.Equal(t, http.MethodPost, request.Method)
|
||||
require.Equal(t, "/api/v1/internal/users/user-123/settings", request.URL.Path)
|
||||
|
||||
body, err := io.ReadAll(request.Body)
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, `{"preferred_language":"bad","time_zone":"Mars/Base"}`, string(body))
|
||||
|
||||
writer.WriteHeader(http.StatusBadRequest)
|
||||
require.NoError(t, json.NewEncoder(writer).Encode(&usermodel.ErrorResponse{
|
||||
Error: usermodel.ErrorBody{
|
||||
Code: "invalid_request",
|
||||
Message: "request is invalid",
|
||||
},
|
||||
}))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := newTestHTTPClient(t, server)
|
||||
payload, err := transcoder.UpdateMySettingsRequestToPayload(&usermodel.UpdateMySettingsRequest{
|
||||
PreferredLanguage: "bad",
|
||||
TimeZone: "Mars/Base",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := client.ExecuteCommand(context.Background(), downstream.AuthenticatedCommand{
|
||||
UserID: "user-123",
|
||||
MessageType: usermodel.MessageTypeUpdateMySettings,
|
||||
PayloadBytes: payload,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "invalid_request", result.ResultCode)
|
||||
|
||||
decoded, err := transcoder.PayloadToErrorResponse(result.PayloadBytes)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "invalid_request", decoded.Error.Code)
|
||||
}
|
||||
|
||||
func TestHTTPClientExecuteCommandProjectsSubjectNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.WriteHeader(http.StatusNotFound)
|
||||
require.NoError(t, json.NewEncoder(writer).Encode(&usermodel.ErrorResponse{
|
||||
Error: usermodel.ErrorBody{
|
||||
Code: "subject_not_found",
|
||||
Message: "subject not found",
|
||||
},
|
||||
}))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := newTestHTTPClient(t, server)
|
||||
payload, err := transcoder.GetMyAccountRequestToPayload(&usermodel.GetMyAccountRequest{})
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := client.ExecuteCommand(context.Background(), downstream.AuthenticatedCommand{
|
||||
UserID: "user-missing",
|
||||
MessageType: usermodel.MessageTypeGetMyAccount,
|
||||
PayloadBytes: payload,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "subject_not_found", result.ResultCode)
|
||||
}
|
||||
|
||||
func TestHTTPClientExecuteCommandMaps503ToUnavailable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.WriteHeader(http.StatusServiceUnavailable)
|
||||
require.NoError(t, json.NewEncoder(writer).Encode(&usermodel.ErrorResponse{
|
||||
Error: usermodel.ErrorBody{
|
||||
Code: "service_unavailable",
|
||||
Message: "service is unavailable",
|
||||
},
|
||||
}))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := newTestHTTPClient(t, server)
|
||||
payload, err := transcoder.GetMyAccountRequestToPayload(&usermodel.GetMyAccountRequest{})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = client.ExecuteCommand(context.Background(), downstream.AuthenticatedCommand{
|
||||
UserID: "user-123",
|
||||
MessageType: usermodel.MessageTypeGetMyAccount,
|
||||
PayloadBytes: payload,
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, downstream.ErrDownstreamUnavailable)
|
||||
}
|
||||
|
||||
func TestHTTPClientExecuteCommandUsesCallerContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
<-request.Context().Done()
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := newTestHTTPClient(t, server)
|
||||
payload, err := transcoder.GetMyAccountRequestToPayload(&usermodel.GetMyAccountRequest{})
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
_, err = client.ExecuteCommand(ctx, downstream.AuthenticatedCommand{
|
||||
UserID: "user-123",
|
||||
MessageType: usermodel.MessageTypeGetMyAccount,
|
||||
PayloadBytes: payload,
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, context.DeadlineExceeded)
|
||||
}
|
||||
|
||||
func TestHTTPClientExecuteCommandRejectsMalformedSuccessPayload(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
_, _ = writer.Write([]byte(`{"account":{"user_id":"user-123","unexpected":true}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := newTestHTTPClient(t, server)
|
||||
payload, err := transcoder.GetMyAccountRequestToPayload(&usermodel.GetMyAccountRequest{})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = client.ExecuteCommand(context.Background(), downstream.AuthenticatedCommand{
|
||||
UserID: "user-123",
|
||||
MessageType: usermodel.MessageTypeGetMyAccount,
|
||||
PayloadBytes: payload,
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "decode success response")
|
||||
}
|
||||
|
||||
func TestHTTPClientExecuteCommandRejectsUnsupportedMessageType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.NotFoundHandler())
|
||||
defer server.Close()
|
||||
|
||||
client := newTestHTTPClient(t, server)
|
||||
|
||||
_, err := client.ExecuteCommand(context.Background(), downstream.AuthenticatedCommand{
|
||||
UserID: "user-123",
|
||||
MessageType: "user.unsupported",
|
||||
PayloadBytes: []byte("payload"),
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "unsupported message type")
|
||||
}
|
||||
|
||||
func TestNewRoutesReserveUserMessageTypesWhenUnconfigured(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
routes, closeFn, err := NewRoutes("")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, closeFn())
|
||||
|
||||
router := downstream.NewStaticRouter(routes)
|
||||
for _, messageType := range []string{
|
||||
usermodel.MessageTypeGetMyAccount,
|
||||
usermodel.MessageTypeUpdateMyProfile,
|
||||
usermodel.MessageTypeUpdateMySettings,
|
||||
} {
|
||||
client, routeErr := router.Route(messageType)
|
||||
require.NoError(t, routeErr)
|
||||
|
||||
_, execErr := client.ExecuteCommand(context.Background(), downstream.AuthenticatedCommand{
|
||||
UserID: "user-123",
|
||||
MessageType: messageType,
|
||||
})
|
||||
require.Error(t, execErr)
|
||||
assert.ErrorIs(t, execErr, downstream.ErrDownstreamUnavailable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnavailableClientReturnsDownstreamUnavailable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := unavailableClient{}.ExecuteCommand(context.Background(), downstream.AuthenticatedCommand{})
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, downstream.ErrDownstreamUnavailable)
|
||||
}
|
||||
|
||||
func newTestHTTPClient(t *testing.T, server *httptest.Server) *HTTPClient {
|
||||
t.Helper()
|
||||
|
||||
client, err := newHTTPClient(server.URL, server.Client())
|
||||
require.NoError(t, err)
|
||||
return client
|
||||
}
|
||||
|
||||
func sampleAccountResponse() *usermodel.AccountResponse {
|
||||
now := time.Date(2026, time.April, 9, 10, 0, 0, 0, time.UTC)
|
||||
expiresAt := now.Add(30 * 24 * time.Hour)
|
||||
|
||||
return &usermodel.AccountResponse{
|
||||
Account: usermodel.Account{
|
||||
UserID: "user-123",
|
||||
Email: "pilot@example.com",
|
||||
RaceName: "Pilot Nova",
|
||||
PreferredLanguage: "en",
|
||||
TimeZone: "Europe/Kaliningrad",
|
||||
DeclaredCountry: "DE",
|
||||
Entitlement: usermodel.EntitlementSnapshot{
|
||||
PlanCode: "free",
|
||||
IsPaid: false,
|
||||
Source: "auth_registration",
|
||||
Actor: usermodel.ActorRef{Type: "service", ID: "user-service"},
|
||||
ReasonCode: "initial_free_entitlement",
|
||||
StartsAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
ActiveSanctions: []usermodel.ActiveSanction{
|
||||
{
|
||||
SanctionCode: "profile_update_block",
|
||||
Scope: "lobby",
|
||||
ReasonCode: "manual_block",
|
||||
Actor: usermodel.ActorRef{Type: "admin", ID: "admin-1"},
|
||||
AppliedAt: now,
|
||||
ExpiresAt: &expiresAt,
|
||||
},
|
||||
},
|
||||
ActiveLimits: []usermodel.ActiveLimit{
|
||||
{
|
||||
LimitCode: "max_owned_private_games",
|
||||
Value: 3,
|
||||
ReasonCode: "manual_override",
|
||||
Actor: usermodel.ActorRef{Type: "admin", ID: "admin-1"},
|
||||
AppliedAt: now,
|
||||
},
|
||||
},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeUserServiceErrorNormalizesBlankFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
response, err := decodeUserServiceError(http.StatusBadRequest, []byte(`{"error":{"code":" ","message":" "}}`))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "invalid_request", response.Error.Code)
|
||||
assert.Equal(t, "request is invalid", response.Error.Message)
|
||||
}
|
||||
|
||||
func TestHTTPClientExecuteCommandRejectsNilContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.NotFoundHandler())
|
||||
defer server.Close()
|
||||
|
||||
client := newTestHTTPClient(t, server)
|
||||
|
||||
_, err := client.ExecuteCommand(nil, downstream.AuthenticatedCommand{})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "nil context")
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package userservice
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"galaxy/gateway/internal/downstream"
|
||||
usermodel "galaxy/model/user"
|
||||
)
|
||||
|
||||
var noOpClose = func() error { return nil }
|
||||
|
||||
// NewRoutes returns the reserved authenticated gateway routes owned by the
|
||||
// Gateway -> User self-service boundary.
|
||||
//
|
||||
// When baseURL is empty, the returned routes still reserve the stable
|
||||
// `user.*` message types but resolve them to a dependency-unavailable client
|
||||
// so callers receive the transport-level unavailable outcome instead of a
|
||||
// route-miss error.
|
||||
func NewRoutes(baseURL string) (map[string]downstream.Client, func() error, error) {
|
||||
client := downstream.Client(unavailableClient{})
|
||||
closeFn := noOpClose
|
||||
|
||||
if baseURL != "" {
|
||||
httpClient, err := NewHTTPClient(baseURL)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
client = httpClient
|
||||
closeFn = httpClient.Close
|
||||
}
|
||||
|
||||
return map[string]downstream.Client{
|
||||
usermodel.MessageTypeGetMyAccount: client,
|
||||
usermodel.MessageTypeUpdateMyProfile: client,
|
||||
usermodel.MessageTypeUpdateMySettings: client,
|
||||
}, closeFn, nil
|
||||
}
|
||||
|
||||
type unavailableClient struct{}
|
||||
|
||||
func (unavailableClient) ExecuteCommand(context.Context, downstream.AuthenticatedCommand) (downstream.UnaryResult, error) {
|
||||
return downstream.UnaryResult{}, downstream.ErrDownstreamUnavailable
|
||||
}
|
||||
|
||||
var _ downstream.Client = unavailableClient{}
|
||||
Reference in New Issue
Block a user