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,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)
}
+99
View File
@@ -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
}
+125
View File
@@ -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)
}
+33
View File
@@ -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
}
+118
View File
@@ -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 }