325 lines
9.0 KiB
Go
325 lines
9.0 KiB
Go
package creategame_test
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"testing"
|
|
"time"
|
|
|
|
"galaxy/lobby/internal/adapters/gameinmem"
|
|
"galaxy/lobby/internal/adapters/idgen"
|
|
"galaxy/lobby/internal/domain/common"
|
|
"galaxy/lobby/internal/domain/game"
|
|
"galaxy/lobby/internal/ports"
|
|
"galaxy/lobby/internal/service/creategame"
|
|
"galaxy/lobby/internal/service/shared"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
type stubIDGenerator struct {
|
|
next common.GameID
|
|
err error
|
|
}
|
|
|
|
func (g *stubIDGenerator) NewGameID() (common.GameID, error) {
|
|
if g.err != nil {
|
|
return "", g.err
|
|
}
|
|
return g.next, nil
|
|
}
|
|
|
|
func (g *stubIDGenerator) NewApplicationID() (common.ApplicationID, error) {
|
|
return "application-stub", nil
|
|
}
|
|
|
|
func (g *stubIDGenerator) NewInviteID() (common.InviteID, error) {
|
|
return "invite-stub", nil
|
|
}
|
|
|
|
func (g *stubIDGenerator) NewMembershipID() (common.MembershipID, error) {
|
|
return "membership-stub", nil
|
|
}
|
|
|
|
func newFixedClock(at time.Time) func() time.Time {
|
|
return func() time.Time { return at }
|
|
}
|
|
|
|
func silentLogger() *slog.Logger {
|
|
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
}
|
|
|
|
func validPublicInput(now time.Time) creategame.Input {
|
|
return creategame.Input{
|
|
Actor: shared.NewAdminActor(),
|
|
GameName: "Spring Classic",
|
|
Description: "",
|
|
GameType: game.GameTypePublic,
|
|
MinPlayers: 2,
|
|
MaxPlayers: 4,
|
|
StartGapHours: 4,
|
|
StartGapPlayers: 1,
|
|
EnrollmentEndsAt: now.Add(24 * time.Hour),
|
|
TurnSchedule: "0 */6 * * *",
|
|
TargetEngineVersion: "1.0.0",
|
|
}
|
|
}
|
|
|
|
func validPrivateInput(now time.Time, userID string) creategame.Input {
|
|
return creategame.Input{
|
|
Actor: shared.NewUserActor(userID),
|
|
GameName: "Friends only",
|
|
GameType: game.GameTypePrivate,
|
|
MinPlayers: 2,
|
|
MaxPlayers: 4,
|
|
StartGapHours: 4,
|
|
StartGapPlayers: 1,
|
|
EnrollmentEndsAt: now.Add(12 * time.Hour),
|
|
TurnSchedule: "0 0 * * *",
|
|
TargetEngineVersion: "1.0.0",
|
|
}
|
|
}
|
|
|
|
func TestNewServiceRequiresStoreAndIDs(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := creategame.NewService(creategame.Dependencies{})
|
|
require.Error(t, err)
|
|
|
|
_, err = creategame.NewService(creategame.Dependencies{Games: gameinmem.NewStore()})
|
|
require.Error(t, err)
|
|
|
|
_, err = creategame.NewService(creategame.Dependencies{
|
|
Games: gameinmem.NewStore(),
|
|
IDs: &stubIDGenerator{next: "game-ok"},
|
|
})
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
func TestHandleAdminCreatesPublicGame(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
|
store := gameinmem.NewStore()
|
|
service, err := creategame.NewService(creategame.Dependencies{
|
|
Games: store,
|
|
IDs: &stubIDGenerator{next: "game-alpha"},
|
|
Clock: newFixedClock(now),
|
|
Logger: silentLogger(),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
record, err := service.Handle(context.Background(), validPublicInput(now))
|
|
require.NoError(t, err)
|
|
require.Equal(t, common.GameID("game-alpha"), record.GameID)
|
|
require.Equal(t, game.GameTypePublic, record.GameType)
|
|
require.Equal(t, game.StatusDraft, record.Status)
|
|
require.Equal(t, "", record.OwnerUserID)
|
|
require.Equal(t, now.UTC(), record.CreatedAt)
|
|
require.Equal(t, now.UTC(), record.UpdatedAt)
|
|
|
|
stored, err := store.Get(context.Background(), record.GameID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, record, stored)
|
|
}
|
|
|
|
func TestHandleUserCreatesPrivateGame(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
now := time.Date(2026, 4, 24, 11, 0, 0, 0, time.UTC)
|
|
store := gameinmem.NewStore()
|
|
service, err := creategame.NewService(creategame.Dependencies{
|
|
Games: store,
|
|
IDs: &stubIDGenerator{next: "game-beta"},
|
|
Clock: newFixedClock(now),
|
|
Logger: silentLogger(),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
record, err := service.Handle(context.Background(), validPrivateInput(now, "user-42"))
|
|
require.NoError(t, err)
|
|
require.Equal(t, common.GameID("game-beta"), record.GameID)
|
|
require.Equal(t, game.GameTypePrivate, record.GameType)
|
|
require.Equal(t, "user-42", record.OwnerUserID)
|
|
}
|
|
|
|
func TestHandleAdminForbiddenForPrivateGame(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
|
service, err := creategame.NewService(creategame.Dependencies{
|
|
Games: gameinmem.NewStore(),
|
|
IDs: &stubIDGenerator{next: "game-x"},
|
|
Clock: newFixedClock(now),
|
|
Logger: silentLogger(),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
input := validPrivateInput(now, "user-1")
|
|
input.Actor = shared.NewAdminActor()
|
|
|
|
_, err = service.Handle(context.Background(), input)
|
|
require.ErrorIs(t, err, shared.ErrForbidden)
|
|
}
|
|
|
|
func TestHandleUserForbiddenForPublicGame(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
|
service, err := creategame.NewService(creategame.Dependencies{
|
|
Games: gameinmem.NewStore(),
|
|
IDs: &stubIDGenerator{next: "game-x"},
|
|
Clock: newFixedClock(now),
|
|
Logger: silentLogger(),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
input := validPublicInput(now)
|
|
input.Actor = shared.NewUserActor("user-1")
|
|
|
|
_, err = service.Handle(context.Background(), input)
|
|
require.ErrorIs(t, err, shared.ErrForbidden)
|
|
}
|
|
|
|
func TestHandleInvalidActorReturnsError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
|
service, err := creategame.NewService(creategame.Dependencies{
|
|
Games: gameinmem.NewStore(),
|
|
IDs: &stubIDGenerator{next: "game-x"},
|
|
Clock: newFixedClock(now),
|
|
Logger: silentLogger(),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
input := validPublicInput(now)
|
|
input.Actor = shared.Actor{Kind: shared.ActorKindUser} // missing user id
|
|
|
|
_, err = service.Handle(context.Background(), input)
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "actor")
|
|
}
|
|
|
|
func TestHandleDomainValidationFailurePropagates(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
|
service, err := creategame.NewService(creategame.Dependencies{
|
|
Games: gameinmem.NewStore(),
|
|
IDs: &stubIDGenerator{next: "game-bad-cron"},
|
|
Clock: newFixedClock(now),
|
|
Logger: silentLogger(),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
input := validPublicInput(now)
|
|
input.TurnSchedule = "not a cron"
|
|
|
|
_, err = service.Handle(context.Background(), input)
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "turn schedule")
|
|
}
|
|
|
|
func TestHandleEnrollmentDeadlineInPastFails(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
|
service, err := creategame.NewService(creategame.Dependencies{
|
|
Games: gameinmem.NewStore(),
|
|
IDs: &stubIDGenerator{next: "game-past"},
|
|
Clock: newFixedClock(now),
|
|
Logger: silentLogger(),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
input := validPublicInput(now)
|
|
input.EnrollmentEndsAt = now.Add(-time.Hour)
|
|
|
|
_, err = service.Handle(context.Background(), input)
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "enrollment ends at")
|
|
}
|
|
|
|
func TestHandleIDGeneratorErrorPropagates(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
|
boom := errors.New("entropy exhausted")
|
|
service, err := creategame.NewService(creategame.Dependencies{
|
|
Games: gameinmem.NewStore(),
|
|
IDs: &stubIDGenerator{err: boom},
|
|
Clock: newFixedClock(now),
|
|
Logger: silentLogger(),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
_, err = service.Handle(context.Background(), validPublicInput(now))
|
|
require.ErrorIs(t, err, boom)
|
|
}
|
|
|
|
func TestHandleStoreErrorPropagates(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
|
boom := errors.New("redis down")
|
|
service, err := creategame.NewService(creategame.Dependencies{
|
|
Games: failingStore{err: boom},
|
|
IDs: &stubIDGenerator{next: "game-fail"},
|
|
Clock: newFixedClock(now),
|
|
Logger: silentLogger(),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
_, err = service.Handle(context.Background(), validPublicInput(now))
|
|
require.ErrorIs(t, err, boom)
|
|
}
|
|
|
|
// failingStore is a ports.GameStore whose mutating methods always fail.
|
|
type failingStore struct {
|
|
err error
|
|
}
|
|
|
|
func (s failingStore) Save(context.Context, game.Game) error { return s.err }
|
|
func (s failingStore) Get(context.Context, common.GameID) (game.Game, error) {
|
|
return game.Game{}, s.err
|
|
}
|
|
func (s failingStore) GetByStatus(context.Context, game.Status) ([]game.Game, error) {
|
|
return nil, s.err
|
|
}
|
|
func (s failingStore) GetByOwner(context.Context, string) ([]game.Game, error) {
|
|
return nil, s.err
|
|
}
|
|
func (s failingStore) UpdateStatus(context.Context, ports.UpdateStatusInput) error {
|
|
return s.err
|
|
}
|
|
func (s failingStore) UpdateRuntimeSnapshot(context.Context, ports.UpdateRuntimeSnapshotInput) error {
|
|
return s.err
|
|
}
|
|
func (s failingStore) UpdateRuntimeBinding(context.Context, ports.UpdateRuntimeBindingInput) error {
|
|
return s.err
|
|
}
|
|
func (s failingStore) CountByStatus(context.Context) (map[game.Status]int, error) {
|
|
return nil, s.err
|
|
}
|
|
|
|
func TestHandleUsesRealIDGeneratorShape(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
now := time.Date(2026, 4, 24, 12, 0, 0, 0, time.UTC)
|
|
store := gameinmem.NewStore()
|
|
service, err := creategame.NewService(creategame.Dependencies{
|
|
Games: store,
|
|
IDs: idgen.NewGenerator(),
|
|
Clock: newFixedClock(now),
|
|
Logger: silentLogger(),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
record, err := service.Handle(context.Background(), validPublicInput(now))
|
|
require.NoError(t, err)
|
|
require.NoError(t, record.GameID.Validate())
|
|
}
|