feat: game lobby service

This commit is contained in:
Ilia Denisov
2026-04-25 23:20:55 +02:00
committed by GitHub
parent 32dc29359a
commit 48b0056b49
336 changed files with 57074 additions and 1418 deletions
@@ -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)
}