feat: game lobby service
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
// Package userservice provides the HTTP adapter for the
|
||||
// ports.UserService eligibility port. It wraps the trusted
|
||||
// User Service internal endpoint
|
||||
// `GET /api/v1/internal/users/{user_id}/eligibility` and decodes the
|
||||
// response into the lobby-side ports.Eligibility shape.
|
||||
package userservice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/ports"
|
||||
|
||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||
)
|
||||
|
||||
// permanentBlockSanctionCode mirrors policy.SanctionCodePermanentBlock in
|
||||
// galaxy/user. The lobby adapter inspects the active_sanctions array for
|
||||
// this string to populate Eligibility.PermanentBlocked without taking a
|
||||
// build-time dependency on the user module.
|
||||
const permanentBlockSanctionCode = "permanent_block"
|
||||
|
||||
// maxRegisteredRaceNamesLimitCode mirrors
|
||||
// policy.LimitCodeMaxRegisteredRaceNames in galaxy/user. A snapshot value
|
||||
// of 0 denotes unlimited per the lifetime tariff.
|
||||
const maxRegisteredRaceNamesLimitCode = "max_registered_race_names"
|
||||
|
||||
// Client implements ports.UserService against the trusted internal HTTP
|
||||
// surface of User Service.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// Config groups the construction parameters of Client.
|
||||
type Config struct {
|
||||
// BaseURL is the absolute root URL of User Service (no trailing slash
|
||||
// required). The eligibility path is appended on every call.
|
||||
BaseURL string
|
||||
|
||||
// Timeout bounds one round trip including TLS handshake. It must be
|
||||
// positive.
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// Validate reports whether cfg stores a usable Client configuration.
|
||||
func (cfg Config) Validate() error {
|
||||
switch {
|
||||
case strings.TrimSpace(cfg.BaseURL) == "":
|
||||
return errors.New("user service base url must not be empty")
|
||||
case cfg.Timeout <= 0:
|
||||
return errors.New("user service timeout must be positive")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// NewClient constructs a Client from cfg. Transport is wrapped with
|
||||
// otelhttp.NewTransport so traces propagate to User Service.
|
||||
func NewClient(cfg Config) (*Client, error) {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("new user service client: %w", err)
|
||||
}
|
||||
httpClient := &http.Client{
|
||||
Timeout: cfg.Timeout,
|
||||
Transport: otelhttp.NewTransport(http.DefaultTransport),
|
||||
}
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(cfg.BaseURL, "/"),
|
||||
httpClient: httpClient,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// rawEligibility mirrors the lobby-relevant subset of
|
||||
// lobbyeligibility.GetUserEligibilityResult. Unknown JSON fields are
|
||||
// ignored intentionally so future user-side additions do not break the
|
||||
// lobby decoder; see the decision record for context.
|
||||
type rawEligibility struct {
|
||||
Exists bool `json:"exists"`
|
||||
Markers rawMarkers `json:"markers"`
|
||||
ActiveSanctions []rawSanction `json:"active_sanctions"`
|
||||
EffectiveLimits []rawLimit `json:"effective_limits"`
|
||||
}
|
||||
|
||||
type rawMarkers struct {
|
||||
CanLogin bool `json:"can_login"`
|
||||
CanCreatePrivateGame bool `json:"can_create_private_game"`
|
||||
CanManagePrivateGame bool `json:"can_manage_private_game"`
|
||||
CanJoinGame bool `json:"can_join_game"`
|
||||
CanUpdateProfile bool `json:"can_update_profile"`
|
||||
}
|
||||
|
||||
type rawSanction struct {
|
||||
SanctionCode string `json:"sanction_code"`
|
||||
}
|
||||
|
||||
type rawLimit struct {
|
||||
LimitCode string `json:"limit_code"`
|
||||
Value int `json:"value"`
|
||||
}
|
||||
|
||||
// GetEligibility issues GET /api/v1/internal/users/{user_id}/eligibility
|
||||
// and decodes the response into a ports.Eligibility value. HTTP 404 is
|
||||
// treated as a present-but-missing user (Exists=false). Transport errors,
|
||||
// timeouts, and unexpected statuses surface as ports.ErrUserServiceUnavailable.
|
||||
func (client *Client) GetEligibility(ctx context.Context, userID string) (ports.Eligibility, error) {
|
||||
if client == nil || client.httpClient == nil {
|
||||
return ports.Eligibility{}, errors.New("get eligibility: nil client")
|
||||
}
|
||||
if ctx == nil {
|
||||
return ports.Eligibility{}, errors.New("get eligibility: nil context")
|
||||
}
|
||||
trimmed := strings.TrimSpace(userID)
|
||||
if trimmed == "" {
|
||||
return ports.Eligibility{}, errors.New("get eligibility: user id must not be empty")
|
||||
}
|
||||
|
||||
endpoint := client.baseURL + "/api/v1/internal/users/" + url.PathEscape(trimmed) + "/eligibility"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return ports.Eligibility{}, fmt.Errorf("get eligibility: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := client.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return ports.Eligibility{}, fmt.Errorf("get eligibility: %w", errors.Join(ports.ErrUserServiceUnavailable, err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusNotFound:
|
||||
return ports.Eligibility{Exists: false}, nil
|
||||
case resp.StatusCode < 200 || resp.StatusCode >= 300:
|
||||
return ports.Eligibility{}, fmt.Errorf(
|
||||
"get eligibility: unexpected status %d: %w",
|
||||
resp.StatusCode, ports.ErrUserServiceUnavailable,
|
||||
)
|
||||
}
|
||||
|
||||
var raw rawEligibility
|
||||
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
|
||||
return ports.Eligibility{}, fmt.Errorf("get eligibility: decode body: %w", err)
|
||||
}
|
||||
|
||||
return ports.Eligibility{
|
||||
Exists: raw.Exists,
|
||||
CanLogin: raw.Markers.CanLogin,
|
||||
CanCreatePrivateGame: raw.Markers.CanCreatePrivateGame,
|
||||
CanManagePrivateGame: raw.Markers.CanManagePrivateGame,
|
||||
CanJoinGame: raw.Markers.CanJoinGame,
|
||||
CanUpdateProfile: raw.Markers.CanUpdateProfile,
|
||||
PermanentBlocked: containsSanction(raw.ActiveSanctions, permanentBlockSanctionCode),
|
||||
MaxRegisteredRaceNames: lookupLimit(raw.EffectiveLimits, maxRegisteredRaceNamesLimitCode),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func containsSanction(records []rawSanction, code string) bool {
|
||||
for _, record := range records {
|
||||
if record.SanctionCode == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func lookupLimit(records []rawLimit, code string) int {
|
||||
for _, record := range records {
|
||||
if record.LimitCode == code {
|
||||
return record.Value
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Compile-time interface assertion.
|
||||
var _ ports.UserService = (*Client)(nil)
|
||||
@@ -0,0 +1,167 @@
|
||||
package userservice_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/userservice"
|
||||
"galaxy/lobby/internal/ports"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestClientNewRejectsInvalidConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := userservice.NewClient(userservice.Config{})
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = userservice.NewClient(userservice.Config{BaseURL: "http://x", Timeout: 0})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestGetEligibilityHappyPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := `{
|
||||
"exists": true,
|
||||
"user_id": "user-1",
|
||||
"markers": {
|
||||
"can_login": true,
|
||||
"can_create_private_game": true,
|
||||
"can_manage_private_game": true,
|
||||
"can_join_game": true,
|
||||
"can_update_profile": true
|
||||
},
|
||||
"active_sanctions": [],
|
||||
"effective_limits": [
|
||||
{"limit_code": "max_registered_race_names", "value": 6},
|
||||
{"limit_code": "max_active_game_memberships", "value": 10}
|
||||
]
|
||||
}`
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, http.MethodGet, r.Method)
|
||||
require.Equal(t, "/api/v1/internal/users/user-1/eligibility", r.URL.Path)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, err := userservice.NewClient(userservice.Config{BaseURL: server.URL, Timeout: 2 * time.Second})
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := client.GetEligibility(context.Background(), "user-1")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, got.Exists)
|
||||
assert.True(t, got.CanLogin)
|
||||
assert.True(t, got.CanJoinGame)
|
||||
assert.True(t, got.CanCreatePrivateGame)
|
||||
assert.True(t, got.CanManagePrivateGame)
|
||||
assert.True(t, got.CanUpdateProfile)
|
||||
assert.False(t, got.PermanentBlocked)
|
||||
assert.Equal(t, 6, got.MaxRegisteredRaceNames)
|
||||
}
|
||||
|
||||
func TestGetEligibilityPermanentBlockSurfaces(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := `{
|
||||
"exists": true,
|
||||
"markers": {"can_login": false, "can_join_game": false},
|
||||
"active_sanctions": [{"sanction_code": "permanent_block"}],
|
||||
"effective_limits": []
|
||||
}`
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, err := userservice.NewClient(userservice.Config{BaseURL: server.URL, Timeout: time.Second})
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := client.GetEligibility(context.Background(), "user-blocked")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, got.Exists)
|
||||
assert.False(t, got.CanJoinGame)
|
||||
assert.True(t, got.PermanentBlocked)
|
||||
}
|
||||
|
||||
func TestGetEligibilityNotFoundExistsFalse(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, err := userservice.NewClient(userservice.Config{BaseURL: server.URL, Timeout: time.Second})
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := client.GetEligibility(context.Background(), "user-missing")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, got.Exists)
|
||||
}
|
||||
|
||||
func TestGetEligibilityUnexpectedStatusUnavailable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, err := userservice.NewClient(userservice.Config{BaseURL: server.URL, Timeout: time.Second})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = client.GetEligibility(context.Background(), "user-1")
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, ports.ErrUserServiceUnavailable))
|
||||
}
|
||||
|
||||
func TestGetEligibilityTransportErrorUnavailable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, err := userservice.NewClient(userservice.Config{BaseURL: "http://127.0.0.1:1", Timeout: 100 * time.Millisecond})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = client.GetEligibility(context.Background(), "user-1")
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, ports.ErrUserServiceUnavailable))
|
||||
}
|
||||
|
||||
func TestGetEligibilityMalformedBodyError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte("not-json"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, err := userservice.NewClient(userservice.Config{BaseURL: server.URL, Timeout: time.Second})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = client.GetEligibility(context.Background(), "user-1")
|
||||
require.Error(t, err)
|
||||
require.False(t, errors.Is(err, ports.ErrUserServiceUnavailable))
|
||||
}
|
||||
|
||||
func TestGetEligibilityRejectsEmptyUserID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, err := userservice.NewClient(userservice.Config{BaseURL: "http://x", Timeout: time.Second})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = client.GetEligibility(context.Background(), " ")
|
||||
require.Error(t, err)
|
||||
}
|
||||
Reference in New Issue
Block a user