feat: game lobby service
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/domain/invite"
|
||||
"galaxy/lobby/internal/ports"
|
||||
"galaxy/lobby/internal/telemetry"
|
||||
"galaxy/notificationintent"
|
||||
)
|
||||
|
||||
// CloseEnrollmentDeps groups the collaborators that CloseEnrollment
|
||||
// requires. The struct is reused by manualreadytostart and the
|
||||
// enrollmentautomation worker so the enrollment-close pipeline lives in a
|
||||
// single place.
|
||||
type CloseEnrollmentDeps struct {
|
||||
// Games mediates the authoritative game status transition.
|
||||
Games ports.GameStore
|
||||
|
||||
// Invites is scanned for created records that must transition to
|
||||
// expired alongside the close.
|
||||
Invites ports.InviteStore
|
||||
|
||||
// Intents publishes lobby.invite.expired notifications, one per
|
||||
// expired record. Failures are best-effort and do not roll back the
|
||||
// already-committed status transitions.
|
||||
Intents ports.IntentPublisher
|
||||
|
||||
// Logger receives best-effort warnings for invite-CAS conflicts and
|
||||
// notification-publish failures. CloseEnrollment falls back to
|
||||
// slog.Default when nil.
|
||||
Logger *slog.Logger
|
||||
|
||||
// Telemetry records `lobby.game.transitions` once per close and
|
||||
// `lobby.invite.outcomes` once per expired invite. Optional; nil
|
||||
// disables metric emission.
|
||||
Telemetry *telemetry.Runtime
|
||||
}
|
||||
|
||||
// CloseEnrollment performs the enrollment_open → ready_to_start transition
|
||||
// for gameID using trigger and at, then expires every still-created invite
|
||||
// attached to the game, publishing one lobby.invite.expired intent per
|
||||
// expired record. The post-transition game snapshot is returned.
|
||||
//
|
||||
// Failures of the game status transition (game.ErrConflict,
|
||||
// game.ErrInvalidTransition, store technical errors) are returned to the
|
||||
// caller so manualreadytostart can map them to HTTP responses and the
|
||||
// worker can log and continue. Failures of individual invite CAS updates
|
||||
// or intent publishes are logged and ignored — they are notification
|
||||
// degradations, not authoritative state changes.
|
||||
func CloseEnrollment(
|
||||
ctx context.Context,
|
||||
deps CloseEnrollmentDeps,
|
||||
gameID common.GameID,
|
||||
trigger game.Trigger,
|
||||
at time.Time,
|
||||
) (game.Game, error) {
|
||||
if ctx == nil {
|
||||
return game.Game{}, errors.New("close enrollment: nil context")
|
||||
}
|
||||
if deps.Games == nil {
|
||||
return game.Game{}, errors.New("close enrollment: nil game store")
|
||||
}
|
||||
if deps.Invites == nil {
|
||||
return game.Game{}, errors.New("close enrollment: nil invite store")
|
||||
}
|
||||
if deps.Intents == nil {
|
||||
return game.Game{}, errors.New("close enrollment: nil intent publisher")
|
||||
}
|
||||
if err := gameID.Validate(); err != nil {
|
||||
return game.Game{}, fmt.Errorf("close enrollment: %w", err)
|
||||
}
|
||||
if !trigger.IsKnown() {
|
||||
return game.Game{}, fmt.Errorf("close enrollment: trigger %q is unsupported", trigger)
|
||||
}
|
||||
if at.IsZero() {
|
||||
return game.Game{}, errors.New("close enrollment: at must not be zero")
|
||||
}
|
||||
|
||||
logger := deps.Logger
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
|
||||
if err := deps.Games.UpdateStatus(ctx, ports.UpdateStatusInput{
|
||||
GameID: gameID,
|
||||
ExpectedFrom: game.StatusEnrollmentOpen,
|
||||
To: game.StatusReadyToStart,
|
||||
Trigger: trigger,
|
||||
At: at,
|
||||
}); err != nil {
|
||||
return game.Game{}, fmt.Errorf("close enrollment: %w", err)
|
||||
}
|
||||
|
||||
deps.Telemetry.RecordGameTransition(ctx,
|
||||
string(game.StatusEnrollmentOpen),
|
||||
string(game.StatusReadyToStart),
|
||||
string(trigger),
|
||||
)
|
||||
|
||||
updated, err := deps.Games.Get(ctx, gameID)
|
||||
if err != nil {
|
||||
return game.Game{}, fmt.Errorf("close enrollment: %w", err)
|
||||
}
|
||||
|
||||
expireCreatedInvites(ctx, deps, logger, updated, at)
|
||||
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// expireCreatedInvites scans the invite store for the game and pushes
|
||||
// every still-created record to the expired terminal status, publishing a
|
||||
// per-invite lobby.invite.expired intent on success. All errors are
|
||||
// logged and swallowed: an expired-but-unnotified invite is acceptable
|
||||
// degradation while the authoritative game status is already advanced.
|
||||
func expireCreatedInvites(
|
||||
ctx context.Context,
|
||||
deps CloseEnrollmentDeps,
|
||||
logger *slog.Logger,
|
||||
gameRecord game.Game,
|
||||
at time.Time,
|
||||
) {
|
||||
invites, err := deps.Invites.GetByGame(ctx, gameRecord.GameID)
|
||||
if err != nil {
|
||||
logger.WarnContext(ctx, "list invites on enrollment close",
|
||||
"game_id", gameRecord.GameID.String(),
|
||||
"err", err.Error(),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
for _, record := range invites {
|
||||
if record.Status != invite.StatusCreated {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := deps.Invites.UpdateStatus(ctx, ports.UpdateInviteStatusInput{
|
||||
InviteID: record.InviteID,
|
||||
ExpectedFrom: invite.StatusCreated,
|
||||
To: invite.StatusExpired,
|
||||
At: at,
|
||||
}); err != nil {
|
||||
logger.WarnContext(ctx, "expire invite on enrollment close",
|
||||
"game_id", gameRecord.GameID.String(),
|
||||
"invite_id", record.InviteID.String(),
|
||||
"err", err.Error(),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
deps.Telemetry.RecordInviteOutcome(ctx, "expired")
|
||||
|
||||
intent, err := notificationintent.NewLobbyInviteExpiredIntent(
|
||||
notificationintent.Metadata{
|
||||
IdempotencyKey: "lobby.invite.expired:" + record.InviteID.String(),
|
||||
OccurredAt: at,
|
||||
},
|
||||
gameRecord.OwnerUserID,
|
||||
notificationintent.LobbyInviteExpiredPayload{
|
||||
GameID: gameRecord.GameID.String(),
|
||||
GameName: gameRecord.GameName,
|
||||
InviteeUserID: record.InviteeUserID,
|
||||
InviteeName: inviteeDisplayName(record),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
logger.ErrorContext(ctx, "build invite expired intent",
|
||||
"invite_id", record.InviteID.String(),
|
||||
"err", err.Error(),
|
||||
)
|
||||
continue
|
||||
}
|
||||
if _, publishErr := deps.Intents.Publish(ctx, intent); publishErr != nil {
|
||||
logger.WarnContext(ctx, "publish invite expired intent",
|
||||
"invite_id", record.InviteID.String(),
|
||||
"err", publishErr.Error(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// inviteeDisplayName returns the invitee display name carried by the
|
||||
// expired-invite notification. The invitee never redeemed (RaceName is
|
||||
// empty for created invites) so the user id stands in as the readable
|
||||
// label, matching the createinvite fallback for absent inviter
|
||||
// memberships.
|
||||
func inviteeDisplayName(record invite.Invite) string {
|
||||
if record.RaceName != "" {
|
||||
return record.RaceName
|
||||
}
|
||||
return record.InviteeUserID
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package shared_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/intentpubstub"
|
||||
"galaxy/lobby/internal/adapters/invitestub"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/domain/invite"
|
||||
"galaxy/lobby/internal/ports"
|
||||
"galaxy/lobby/internal/service/shared"
|
||||
"galaxy/notificationintent"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
closeOwnerUserID = "user-owner"
|
||||
closeGameID = common.GameID("game-private")
|
||||
closeGameName = "Friends Only"
|
||||
)
|
||||
|
||||
func silentLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }
|
||||
|
||||
type closeFixture struct {
|
||||
now time.Time
|
||||
games *gamestub.Store
|
||||
invites *invitestub.Store
|
||||
intents *intentpubstub.Publisher
|
||||
game game.Game
|
||||
}
|
||||
|
||||
func newCloseFixture(t *testing.T) *closeFixture {
|
||||
t.Helper()
|
||||
now := time.Date(2026, 4, 25, 10, 0, 0, 0, time.UTC)
|
||||
games := gamestub.NewStore()
|
||||
invites := invitestub.NewStore()
|
||||
intents := intentpubstub.NewPublisher()
|
||||
|
||||
gameRecord, err := game.New(game.NewGameInput{
|
||||
GameID: closeGameID,
|
||||
GameName: closeGameName,
|
||||
GameType: game.GameTypePrivate,
|
||||
OwnerUserID: closeOwnerUserID,
|
||||
MinPlayers: 2,
|
||||
MaxPlayers: 4,
|
||||
StartGapHours: 2,
|
||||
StartGapPlayers: 1,
|
||||
EnrollmentEndsAt: now.Add(24 * time.Hour),
|
||||
TurnSchedule: "0 */6 * * *",
|
||||
TargetEngineVersion: "1.0.0",
|
||||
Now: now,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
gameRecord.Status = game.StatusEnrollmentOpen
|
||||
require.NoError(t, games.Save(context.Background(), gameRecord))
|
||||
|
||||
return &closeFixture{
|
||||
now: now,
|
||||
games: games,
|
||||
invites: invites,
|
||||
intents: intents,
|
||||
game: gameRecord,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *closeFixture) addCreatedInvite(t *testing.T, inviteID common.InviteID, inviteeUserID string) invite.Invite {
|
||||
t.Helper()
|
||||
rec, err := invite.New(invite.NewInviteInput{
|
||||
InviteID: inviteID,
|
||||
GameID: f.game.GameID,
|
||||
InviterUserID: closeOwnerUserID,
|
||||
InviteeUserID: inviteeUserID,
|
||||
Now: f.now,
|
||||
ExpiresAt: f.game.EnrollmentEndsAt,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, f.invites.Save(context.Background(), rec))
|
||||
return rec
|
||||
}
|
||||
|
||||
func (f *closeFixture) deps() shared.CloseEnrollmentDeps {
|
||||
return shared.CloseEnrollmentDeps{
|
||||
Games: f.games,
|
||||
Invites: f.invites,
|
||||
Intents: f.intents,
|
||||
Logger: silentLogger(),
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloseEnrollmentTransitionsGameAndExpiresInvites(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newCloseFixture(t)
|
||||
f.addCreatedInvite(t, "invite-1", "user-a")
|
||||
f.addCreatedInvite(t, "invite-2", "user-b")
|
||||
|
||||
updated, err := shared.CloseEnrollment(
|
||||
context.Background(),
|
||||
f.deps(),
|
||||
f.game.GameID,
|
||||
game.TriggerManual,
|
||||
f.now.Add(time.Minute),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, game.StatusReadyToStart, updated.Status)
|
||||
|
||||
first, err := f.invites.Get(context.Background(), "invite-1")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, invite.StatusExpired, first.Status)
|
||||
|
||||
second, err := f.invites.Get(context.Background(), "invite-2")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, invite.StatusExpired, second.Status)
|
||||
|
||||
intents := f.intents.Published()
|
||||
require.Len(t, intents, 2)
|
||||
for _, intent := range intents {
|
||||
assert.Equal(t, notificationintent.NotificationTypeLobbyInviteExpired, intent.NotificationType)
|
||||
assert.Equal(t, []string{closeOwnerUserID}, intent.RecipientUserIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloseEnrollmentLeavesNonCreatedInvitesUntouched(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newCloseFixture(t)
|
||||
created := f.addCreatedInvite(t, "invite-1", "user-a")
|
||||
declined := f.addCreatedInvite(t, "invite-2", "user-b")
|
||||
|
||||
require.NoError(t, f.invites.UpdateStatus(context.Background(), ports.UpdateInviteStatusInput{
|
||||
InviteID: declined.InviteID,
|
||||
ExpectedFrom: invite.StatusCreated,
|
||||
To: invite.StatusDeclined,
|
||||
At: f.now,
|
||||
}))
|
||||
|
||||
_, err := shared.CloseEnrollment(
|
||||
context.Background(),
|
||||
f.deps(),
|
||||
f.game.GameID,
|
||||
game.TriggerManual,
|
||||
f.now.Add(time.Minute),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
createdAfter, err := f.invites.Get(context.Background(), created.InviteID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, invite.StatusExpired, createdAfter.Status)
|
||||
|
||||
declinedAfter, err := f.invites.Get(context.Background(), declined.InviteID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, invite.StatusDeclined, declinedAfter.Status)
|
||||
|
||||
intents := f.intents.Published()
|
||||
require.Len(t, intents, 1)
|
||||
}
|
||||
|
||||
func TestCloseEnrollmentSurfacesGameConflict(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newCloseFixture(t)
|
||||
f.addCreatedInvite(t, "invite-1", "user-a")
|
||||
|
||||
rec, err := f.games.Get(context.Background(), f.game.GameID)
|
||||
require.NoError(t, err)
|
||||
rec.Status = game.StatusDraft
|
||||
require.NoError(t, f.games.Save(context.Background(), rec))
|
||||
|
||||
_, err = shared.CloseEnrollment(
|
||||
context.Background(),
|
||||
f.deps(),
|
||||
f.game.GameID,
|
||||
game.TriggerManual,
|
||||
f.now.Add(time.Minute),
|
||||
)
|
||||
require.ErrorIs(t, err, game.ErrConflict)
|
||||
|
||||
stillCreated, err := f.invites.Get(context.Background(), "invite-1")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, invite.StatusCreated, stillCreated.Status)
|
||||
assert.Empty(t, f.intents.Published())
|
||||
}
|
||||
|
||||
func TestCloseEnrollmentSwallowsIntentPublishFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newCloseFixture(t)
|
||||
f.addCreatedInvite(t, "invite-1", "user-a")
|
||||
f.intents.SetError(errors.New("publisher offline"))
|
||||
|
||||
updated, err := shared.CloseEnrollment(
|
||||
context.Background(),
|
||||
f.deps(),
|
||||
f.game.GameID,
|
||||
game.TriggerManual,
|
||||
f.now.Add(time.Minute),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, game.StatusReadyToStart, updated.Status)
|
||||
|
||||
expired, err := f.invites.Get(context.Background(), "invite-1")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, invite.StatusExpired, expired.Status)
|
||||
}
|
||||
|
||||
func TestCloseEnrollmentIsIdempotentOnSecondCall(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newCloseFixture(t)
|
||||
f.addCreatedInvite(t, "invite-1", "user-a")
|
||||
|
||||
_, err := shared.CloseEnrollment(
|
||||
context.Background(),
|
||||
f.deps(),
|
||||
f.game.GameID,
|
||||
game.TriggerManual,
|
||||
f.now.Add(time.Minute),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, f.intents.Published(), 1)
|
||||
|
||||
_, err = shared.CloseEnrollment(
|
||||
context.Background(),
|
||||
f.deps(),
|
||||
f.game.GameID,
|
||||
game.TriggerManual,
|
||||
f.now.Add(2*time.Minute),
|
||||
)
|
||||
require.ErrorIs(t, err, game.ErrConflict)
|
||||
assert.Len(t, f.intents.Published(), 1)
|
||||
}
|
||||
|
||||
func TestCloseEnrollmentRejectsUnknownTrigger(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newCloseFixture(t)
|
||||
|
||||
_, err := shared.CloseEnrollment(
|
||||
context.Background(),
|
||||
f.deps(),
|
||||
f.game.GameID,
|
||||
game.Trigger("bogus"),
|
||||
f.now,
|
||||
)
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// DefaultPageSize is the default page_size returned when callers omit the
|
||||
// query parameter on list endpoints. It mirrors the OpenAPI
|
||||
// schema default declared in lobby/api/public-openapi.yaml.
|
||||
const DefaultPageSize = 50
|
||||
|
||||
// MaxPageSize bounds the page_size parameter on list endpoints.
|
||||
// It mirrors the OpenAPI schema maximum declared in
|
||||
// lobby/api/public-openapi.yaml.
|
||||
const MaxPageSize = 200
|
||||
|
||||
// Page describes a service-level pagination request derived from the
|
||||
// transport-layer page_size + page_token parameters. The service is
|
||||
// expected to assemble the full in-memory candidate slice, sort it
|
||||
// deterministically, and then call Window to compute the slice indices
|
||||
// to return alongside the optional continuation token.
|
||||
type Page struct {
|
||||
// Size stores the maximum number of items returned in one page.
|
||||
Size int
|
||||
|
||||
// Offset stores the zero-based position of the first item to return.
|
||||
Offset int
|
||||
}
|
||||
|
||||
// ParsePage decodes the raw transport values into a Page. Both arguments
|
||||
// are interpreted exactly as received from the request: the empty
|
||||
// rawSize string falls back to DefaultPageSize, and the empty rawToken
|
||||
// string represents an initial fetch starting at offset zero. Any
|
||||
// validation failure returns an error whose message starts with
|
||||
// "invalid " so the public-port writeErrorFromService helper translates
|
||||
// it into the OpenAPI-shaped invalid_request envelope.
|
||||
func ParsePage(rawSize, rawToken string) (Page, error) {
|
||||
page := Page{Size: DefaultPageSize}
|
||||
if rawSize != "" {
|
||||
n, err := strconv.Atoi(rawSize)
|
||||
if err != nil {
|
||||
return Page{}, fmt.Errorf("invalid page_size: %s is not an integer", rawSize)
|
||||
}
|
||||
if n < 1 || n > MaxPageSize {
|
||||
return Page{}, fmt.Errorf(
|
||||
"invalid page_size: %d must be between 1 and %d",
|
||||
n, MaxPageSize,
|
||||
)
|
||||
}
|
||||
page.Size = n
|
||||
}
|
||||
if rawToken != "" {
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(rawToken)
|
||||
if err != nil {
|
||||
return Page{}, fmt.Errorf("invalid page_token: not a base64url value")
|
||||
}
|
||||
n, err := strconv.Atoi(string(decoded))
|
||||
if err != nil {
|
||||
return Page{}, fmt.Errorf("invalid page_token: payload is not an integer")
|
||||
}
|
||||
if n < 0 {
|
||||
return Page{}, fmt.Errorf("invalid page_token: payload must not be negative")
|
||||
}
|
||||
page.Offset = n
|
||||
}
|
||||
return page, nil
|
||||
}
|
||||
|
||||
// EncodeToken returns the opaque continuation token that callers pass
|
||||
// back as page_token on the next request to fetch the next page. The
|
||||
// encoding is RFC 4648 §5 base64url without padding so the value is safe
|
||||
// to embed in URLs without further escaping.
|
||||
func EncodeToken(offset int) string {
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(strconv.Itoa(offset)))
|
||||
}
|
||||
|
||||
// Window computes the slice indices and next-page metadata for a
|
||||
// candidate slice of length n given page. start is clamped to [0, n]
|
||||
// and end to [start, min(n, start+page.Size)]; nextOffset is set to end
|
||||
// when more candidates remain after end, and zero otherwise. hasMore
|
||||
// reports whether the caller should emit a non-empty next_page_token.
|
||||
func Window(n int, page Page) (start, end, nextOffset int, hasMore bool) {
|
||||
if n < 0 {
|
||||
n = 0
|
||||
}
|
||||
size := page.Size
|
||||
if size <= 0 {
|
||||
size = DefaultPageSize
|
||||
}
|
||||
start = max(page.Offset, 0)
|
||||
start = min(start, n)
|
||||
end = min(start+size, n)
|
||||
if end < n {
|
||||
return start, end, end, true
|
||||
}
|
||||
return start, end, 0, false
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package shared_test
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"galaxy/lobby/internal/service/shared"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParsePageDefaults(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
page, err := shared.ParsePage("", "")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, shared.Page{Size: shared.DefaultPageSize, Offset: 0}, page)
|
||||
}
|
||||
|
||||
func TestParsePageHonoursSize(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
page, err := shared.ParsePage("75", "")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, shared.Page{Size: 75, Offset: 0}, page)
|
||||
}
|
||||
|
||||
func TestParsePageHonoursToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
token := shared.EncodeToken(150)
|
||||
page, err := shared.ParsePage("", token)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, shared.Page{Size: shared.DefaultPageSize, Offset: 150}, page)
|
||||
}
|
||||
|
||||
func TestParsePageRejectsBadSize(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []string{"abc", "0", "-3", strconv.Itoa(shared.MaxPageSize + 1)}
|
||||
for _, raw := range cases {
|
||||
t.Run(raw, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := shared.ParsePage(raw, "")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "invalid page_size")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePageRejectsBadToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
negative := base64.RawURLEncoding.EncodeToString([]byte("-1"))
|
||||
notInt := base64.RawURLEncoding.EncodeToString([]byte("abc"))
|
||||
|
||||
cases := []string{"!!!", negative, notInt}
|
||||
for _, raw := range cases {
|
||||
t.Run(raw, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := shared.ParsePage("", raw)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "invalid page_token")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeTokenRoundtrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
token := shared.EncodeToken(42)
|
||||
page, err := shared.ParsePage("", token)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 42, page.Offset)
|
||||
}
|
||||
|
||||
func TestWindowFirstPageWithMore(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
start, end, nextOffset, hasMore := shared.Window(120, shared.Page{Size: 50})
|
||||
require.Equal(t, 0, start)
|
||||
require.Equal(t, 50, end)
|
||||
require.Equal(t, 50, nextOffset)
|
||||
require.True(t, hasMore)
|
||||
}
|
||||
|
||||
func TestWindowMiddlePage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
start, end, nextOffset, hasMore := shared.Window(120, shared.Page{Size: 50, Offset: 50})
|
||||
require.Equal(t, 50, start)
|
||||
require.Equal(t, 100, end)
|
||||
require.Equal(t, 100, nextOffset)
|
||||
require.True(t, hasMore)
|
||||
}
|
||||
|
||||
func TestWindowLastPageExact(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
start, end, nextOffset, hasMore := shared.Window(100, shared.Page{Size: 50, Offset: 50})
|
||||
require.Equal(t, 50, start)
|
||||
require.Equal(t, 100, end)
|
||||
require.Equal(t, 0, nextOffset)
|
||||
require.False(t, hasMore)
|
||||
}
|
||||
|
||||
func TestWindowOffsetPastEnd(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
start, end, nextOffset, hasMore := shared.Window(10, shared.Page{Size: 50, Offset: 200})
|
||||
require.Equal(t, 10, start)
|
||||
require.Equal(t, 10, end)
|
||||
require.Equal(t, 0, nextOffset)
|
||||
require.False(t, hasMore)
|
||||
}
|
||||
|
||||
func TestWindowZeroSizeFallsBackToDefault(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
start, end, _, hasMore := shared.Window(120, shared.Page{Size: 0})
|
||||
require.Equal(t, 0, start)
|
||||
require.Equal(t, shared.DefaultPageSize, end)
|
||||
require.True(t, hasMore)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/membership"
|
||||
"galaxy/lobby/internal/ports"
|
||||
)
|
||||
|
||||
// CountActiveMemberships returns the number of memberships in
|
||||
// membership.StatusActive attached to gameID. It is used by the
|
||||
// application services (submitapplication, approveapplication) to
|
||||
// evaluate the max_players + start_gap_players roster cap and the
|
||||
// gap-window activation threshold without each service re-implementing
|
||||
// the same scan + filter.
|
||||
func CountActiveMemberships(
|
||||
ctx context.Context,
|
||||
store ports.MembershipStore,
|
||||
gameID common.GameID,
|
||||
) (int, error) {
|
||||
records, err := store.GetByGame(ctx, gameID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
count := 0
|
||||
for _, record := range records {
|
||||
if record.Status == membership.StatusActive {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// Package shared defines cross-service primitives used by Game Lobby
|
||||
// application services: the Actor identity carried into every service
|
||||
// call, the authorization sentinel errors translated to the
|
||||
// `forbidden` HTTP code at the transport boundary, and the helpers
|
||||
// that derive one from a transport layer.
|
||||
package shared
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ActorKind identifies the caller class of one Lobby service operation.
|
||||
type ActorKind string
|
||||
|
||||
const (
|
||||
// ActorKindAdmin reports that the caller is Admin Service routed through
|
||||
// the internal trusted HTTP port. Admin callers are pre-authorized by the
|
||||
// admin role check that Admin Service performs at the gateway boundary
|
||||
// before forwarding the request.
|
||||
ActorKindAdmin ActorKind = "admin"
|
||||
|
||||
// ActorKindUser reports that the caller is an authenticated platform user
|
||||
// routed through Edge Gateway. User callers are identified by the
|
||||
// `X-User-ID` header injected by Edge Gateway.
|
||||
ActorKindUser ActorKind = "user"
|
||||
)
|
||||
|
||||
// IsKnown reports whether kind belongs to the frozen actor-kind vocabulary.
|
||||
func (kind ActorKind) IsKnown() bool {
|
||||
switch kind {
|
||||
case ActorKindAdmin, ActorKindUser:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Actor identifies the caller of one Lobby service operation. User actors
|
||||
// carry a non-empty UserID; admin actors carry an empty UserID.
|
||||
type Actor struct {
|
||||
// Kind reports the caller class.
|
||||
Kind ActorKind
|
||||
|
||||
// UserID stores the platform user identifier for ActorKindUser callers.
|
||||
// It must be empty for ActorKindAdmin callers.
|
||||
UserID string
|
||||
}
|
||||
|
||||
// NewAdminActor returns one Actor that identifies the trusted admin caller.
|
||||
func NewAdminActor() Actor {
|
||||
return Actor{Kind: ActorKindAdmin}
|
||||
}
|
||||
|
||||
// NewUserActor returns one Actor that identifies the user caller with userID.
|
||||
func NewUserActor(userID string) Actor {
|
||||
return Actor{Kind: ActorKindUser, UserID: userID}
|
||||
}
|
||||
|
||||
// IsAdmin reports whether actor is the trusted admin caller.
|
||||
func (actor Actor) IsAdmin() bool {
|
||||
return actor.Kind == ActorKindAdmin
|
||||
}
|
||||
|
||||
// IsUser reports whether actor is an authenticated platform user.
|
||||
func (actor Actor) IsUser() bool {
|
||||
return actor.Kind == ActorKindUser
|
||||
}
|
||||
|
||||
// Validate reports whether actor carries a structurally valid identity.
|
||||
// Admin actors must not carry a user identifier; user actors must carry a
|
||||
// non-empty trimmed user identifier.
|
||||
func (actor Actor) Validate() error {
|
||||
if !actor.Kind.IsKnown() {
|
||||
return fmt.Errorf("actor kind %q is unsupported", actor.Kind)
|
||||
}
|
||||
switch actor.Kind {
|
||||
case ActorKindAdmin:
|
||||
if strings.TrimSpace(actor.UserID) != "" {
|
||||
return fmt.Errorf("admin actor must not carry a user id")
|
||||
}
|
||||
case ActorKindUser:
|
||||
if strings.TrimSpace(actor.UserID) == "" {
|
||||
return fmt.Errorf("user actor must carry a non-empty user id")
|
||||
}
|
||||
if strings.TrimSpace(actor.UserID) != actor.UserID {
|
||||
return fmt.Errorf("user actor id must not contain surrounding whitespace")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ErrForbidden reports that the caller is not authorized for the requested
|
||||
// operation on the requested resource. The transport layer translates it to
|
||||
// the HTTP `403 forbidden` error envelope.
|
||||
var ErrForbidden = errors.New("forbidden")
|
||||
|
||||
// ErrEligibilityDenied reports that the User Service eligibility snapshot
|
||||
// rejected the acting user for the requested operation. It covers both
|
||||
// "user not found" (Exists=false) and any sanction or marker that
|
||||
// collapses the relevant `can_*` flag to false. The transport layer
|
||||
// translates it to the HTTP `422 eligibility_denied` envelope.
|
||||
var ErrEligibilityDenied = errors.New("eligibility denied")
|
||||
|
||||
// ErrServiceUnavailable reports that an upstream synchronous dependency
|
||||
// (User Service, Game Master, etc.) is unreachable or violated its
|
||||
// contract. The transport layer translates it to the HTTP
|
||||
// `503 service_unavailable` envelope.
|
||||
var ErrServiceUnavailable = errors.New("service unavailable")
|
||||
|
||||
// ErrSubjectNotFound reports that the operation references a subject
|
||||
// (a user, a pending race-name registration, etc.) that is not present
|
||||
// in the relevant store and is not naturally surfaced through one of
|
||||
// the domain `ErrNotFound` sentinels. The transport layer translates
|
||||
// it to the HTTP `404 subject_not_found` envelope.
|
||||
var ErrSubjectNotFound = errors.New("subject not found")
|
||||
@@ -0,0 +1,85 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestActorKindIsKnown(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.True(t, ActorKindAdmin.IsKnown())
|
||||
require.True(t, ActorKindUser.IsKnown())
|
||||
require.False(t, ActorKind("").IsKnown())
|
||||
require.False(t, ActorKind("other").IsKnown())
|
||||
}
|
||||
|
||||
func TestNewActorConstructors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
admin := NewAdminActor()
|
||||
require.True(t, admin.IsAdmin())
|
||||
require.False(t, admin.IsUser())
|
||||
require.Equal(t, "", admin.UserID)
|
||||
|
||||
user := NewUserActor("user-123")
|
||||
require.True(t, user.IsUser())
|
||||
require.False(t, user.IsAdmin())
|
||||
require.Equal(t, "user-123", user.UserID)
|
||||
}
|
||||
|
||||
func TestActorValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
actor Actor
|
||||
wantErr string
|
||||
}{
|
||||
{name: "admin ok", actor: NewAdminActor()},
|
||||
{name: "user ok", actor: NewUserActor("user-42")},
|
||||
{name: "unknown kind", actor: Actor{Kind: "ghost"}, wantErr: "actor kind"},
|
||||
{name: "admin with user id", actor: Actor{Kind: ActorKindAdmin, UserID: "user-1"}, wantErr: "admin actor must not carry"},
|
||||
{name: "user without id", actor: Actor{Kind: ActorKindUser}, wantErr: "user actor must carry"},
|
||||
{name: "user id with whitespace", actor: Actor{Kind: ActorKindUser, UserID: " user-1 "}, wantErr: "whitespace"},
|
||||
{name: "user id empty trimmed", actor: Actor{Kind: ActorKindUser, UserID: " "}, wantErr: "user actor must carry"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := tt.actor.Validate()
|
||||
if tt.wantErr == "" {
|
||||
require.NoError(t, err)
|
||||
return
|
||||
}
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), tt.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrForbiddenIsStable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wrapped := errors.New("forbidden: actor kind is admin; private games require user caller")
|
||||
require.False(t, errors.Is(wrapped, ErrForbidden))
|
||||
|
||||
wrapped = errInvalid("forbidden: not owner")
|
||||
require.True(t, errors.Is(wrapped, ErrForbidden))
|
||||
}
|
||||
|
||||
func errInvalid(msg string) error {
|
||||
return wrappedError{msg: msg, base: ErrForbidden}
|
||||
}
|
||||
|
||||
type wrappedError struct {
|
||||
msg string
|
||||
base error
|
||||
}
|
||||
|
||||
func (err wrappedError) Error() string { return err.msg }
|
||||
func (err wrappedError) Unwrap() error { return err.base }
|
||||
Reference in New Issue
Block a user