feat: runtime manager

This commit is contained in:
Ilia Denisov
2026-04-28 20:39:18 +02:00
committed by GitHub
parent e0a99b346b
commit a7cee15115
289 changed files with 45660 additions and 2207 deletions
@@ -5,16 +5,16 @@ import (
"errors"
"io"
"log/slog"
"sync"
"testing"
"time"
"galaxy/lobby/internal/adapters/gamestub"
"galaxy/lobby/internal/adapters/gapactivationstub"
"galaxy/lobby/internal/adapters/intentpubstub"
"galaxy/lobby/internal/adapters/invitestub"
"galaxy/lobby/internal/adapters/membershipstub"
"galaxy/lobby/internal/adapters/racenamestub"
"galaxy/lobby/internal/adapters/userservicestub"
"galaxy/lobby/internal/adapters/gameinmem"
"galaxy/lobby/internal/adapters/gapactivationinmem"
"galaxy/lobby/internal/adapters/inviteinmem"
"galaxy/lobby/internal/adapters/membershipinmem"
"galaxy/lobby/internal/adapters/mocks"
"galaxy/lobby/internal/adapters/racenameinmem"
"galaxy/lobby/internal/domain/common"
"galaxy/lobby/internal/domain/game"
"galaxy/lobby/internal/domain/invite"
@@ -26,8 +26,87 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
)
type intentRec struct {
mu sync.Mutex
published []notificationintent.Intent
err error
}
func (r *intentRec) record(_ context.Context, intent notificationintent.Intent) (string, error) {
r.mu.Lock()
defer r.mu.Unlock()
if r.err != nil {
return "", r.err
}
r.published = append(r.published, intent)
return "1", nil
}
func (r *intentRec) snapshot() []notificationintent.Intent {
r.mu.Lock()
defer r.mu.Unlock()
return append([]notificationintent.Intent(nil), r.published...)
}
func (r *intentRec) setErr(err error) {
r.mu.Lock()
defer r.mu.Unlock()
r.err = err
}
type userRec struct {
mu sync.Mutex
elig map[string]ports.Eligibility
failures map[string]error
}
func (r *userRec) record(_ context.Context, userID string) (ports.Eligibility, error) {
r.mu.Lock()
defer r.mu.Unlock()
if err, ok := r.failures[userID]; ok {
return ports.Eligibility{}, err
}
if e, ok := r.elig[userID]; ok {
return e, nil
}
return ports.Eligibility{Exists: false}, nil
}
func (r *userRec) setEligibility(userID string, e ports.Eligibility) {
r.mu.Lock()
defer r.mu.Unlock()
if r.elig == nil {
r.elig = make(map[string]ports.Eligibility)
}
r.elig[userID] = e
}
func (r *userRec) setFailure(userID string, err error) {
r.mu.Lock()
defer r.mu.Unlock()
if r.failures == nil {
r.failures = make(map[string]error)
}
r.failures[userID] = err
}
func newIntentMock(t *testing.T, rec *intentRec) *mocks.MockIntentPublisher {
t.Helper()
m := mocks.NewMockIntentPublisher(gomock.NewController(t))
m.EXPECT().Publish(gomock.Any(), gomock.Any()).DoAndReturn(rec.record).AnyTimes()
return m
}
func newUserMock(t *testing.T, rec *userRec) *mocks.MockUserService {
t.Helper()
m := mocks.NewMockUserService(gomock.NewController(t))
m.EXPECT().GetEligibility(gomock.Any(), gomock.Any()).DoAndReturn(rec.record).AnyTimes()
return m
}
const (
ownerUserID = "user-owner"
inviteeUserID = "user-invitee"
@@ -49,13 +128,15 @@ func (f fixedIDs) NewMembershipID() (common.MembershipID, error) { return f.me
type fixture struct {
now time.Time
games *gamestub.Store
invites *invitestub.Store
memberships *membershipstub.Store
directory *racenamestub.Directory
users *userservicestub.Service
gapStore *gapactivationstub.Store
intents *intentpubstub.Publisher
games *gameinmem.Store
invites *inviteinmem.Store
memberships *membershipinmem.Store
directory *racenameinmem.Directory
users *userRec
usersMock *mocks.MockUserService
gapStore *gapactivationinmem.Store
intents *intentRec
intentsMock *mocks.MockIntentPublisher
ids fixedIDs
game game.Game
}
@@ -63,11 +144,11 @@ type fixture struct {
func newFixture(t *testing.T, maxPlayers, gapPlayers int) *fixture {
t.Helper()
now := time.Date(2026, 4, 25, 10, 0, 0, 0, time.UTC)
dir, err := racenamestub.NewDirectory(racenamestub.WithClock(fixedClock(now)))
dir, err := racenameinmem.NewDirectory(racenameinmem.WithClock(fixedClock(now)))
require.NoError(t, err)
games := gamestub.NewStore()
invites := invitestub.NewStore()
memberships := membershipstub.NewStore()
games := gameinmem.NewStore()
invites := inviteinmem.NewStore()
memberships := membershipinmem.NewStore()
gameRecord, err := game.New(game.NewGameInput{
GameID: "game-private",
@@ -87,7 +168,7 @@ func newFixture(t *testing.T, maxPlayers, gapPlayers int) *fixture {
gameRecord.Status = game.StatusEnrollmentOpen
require.NoError(t, games.Save(context.Background(), gameRecord))
users := userservicestub.NewService()
users := &userRec{}
activeEligibility := ports.Eligibility{
Exists: true,
CanLogin: true,
@@ -96,9 +177,10 @@ func newFixture(t *testing.T, maxPlayers, gapPlayers int) *fixture {
CanJoinGame: true,
CanUpdateProfile: true,
}
users.SetEligibility(ownerUserID, activeEligibility)
users.SetEligibility(inviteeUserID, activeEligibility)
users.setEligibility(ownerUserID, activeEligibility)
users.setEligibility(inviteeUserID, activeEligibility)
intents := &intentRec{}
return &fixture{
now: now,
games: games,
@@ -106,8 +188,10 @@ func newFixture(t *testing.T, maxPlayers, gapPlayers int) *fixture {
memberships: memberships,
directory: dir,
users: users,
gapStore: gapactivationstub.NewStore(),
intents: intentpubstub.NewPublisher(),
usersMock: newUserMock(t, users),
gapStore: gapactivationinmem.NewStore(),
intents: intents,
intentsMock: newIntentMock(t, intents),
ids: fixedIDs{membershipID: "membership-fixed"},
game: gameRecord,
}
@@ -120,9 +204,9 @@ func newService(t *testing.T, f *fixture) *redeeminvite.Service {
Invites: f.invites,
Memberships: f.memberships,
Directory: f.directory,
Users: f.users,
Users: f.usersMock,
GapStore: f.gapStore,
Intents: f.intents,
Intents: f.intentsMock,
IDs: f.ids,
Clock: fixedClock(f.now),
Logger: silentLogger(),
@@ -181,7 +265,7 @@ func TestRedeemHappyPath(t *testing.T) {
assert.True(t, avail.Taken)
assert.Equal(t, inviteeUserID, avail.HolderUserID)
intents := f.intents.Published()
intents := f.intents.snapshot()
require.Len(t, intents, 1)
assert.Equal(t, notificationintent.NotificationTypeLobbyInviteRedeemed, intents[0].NotificationType)
assert.Equal(t, []string{ownerUserID}, intents[0].RecipientUserIDs)
@@ -194,7 +278,7 @@ func TestRedeemRejectsInviterPermanentBlock(t *testing.T) {
t.Parallel()
f := newFixture(t, 4, 1)
inv := seedCreatedInvite(t, f, "invite-1", inviteeUserID)
f.users.SetEligibility(ownerUserID, ports.Eligibility{
f.users.setEligibility(ownerUserID, ports.Eligibility{
Exists: true,
PermanentBlocked: true,
})
@@ -212,7 +296,7 @@ func TestRedeemRejectsInviteePermanentBlock(t *testing.T) {
t.Parallel()
f := newFixture(t, 4, 1)
inv := seedCreatedInvite(t, f, "invite-1", inviteeUserID)
f.users.SetEligibility(inviteeUserID, ports.Eligibility{
f.users.setEligibility(inviteeUserID, ports.Eligibility{
Exists: true,
PermanentBlocked: true,
})
@@ -226,7 +310,7 @@ func TestRedeemRejectsDeletedInviter(t *testing.T) {
t.Parallel()
f := newFixture(t, 4, 1)
inv := seedCreatedInvite(t, f, "invite-1", inviteeUserID)
f.users.SetEligibility(ownerUserID, ports.Eligibility{Exists: false})
f.users.setEligibility(ownerUserID, ports.Eligibility{Exists: false})
svc := newService(t, f)
_, err := svc.Handle(context.Background(), defaultInput(f, inv))
@@ -237,7 +321,7 @@ func TestRedeemSurfacesUserServiceTransportFailure(t *testing.T) {
t.Parallel()
f := newFixture(t, 4, 1)
inv := seedCreatedInvite(t, f, "invite-1", inviteeUserID)
f.users.SetFailure(ownerUserID, ports.ErrUserServiceUnavailable)
f.users.setFailure(ownerUserID, ports.ErrUserServiceUnavailable)
svc := newService(t, f)
_, err := svc.Handle(context.Background(), defaultInput(f, inv))
@@ -410,10 +494,10 @@ func TestRedeemInvalidRaceName(t *testing.T) {
require.ErrorIs(t, err, ports.ErrInvalidName)
}
// redeemCASStub wraps invitestub.Store but injects ErrConflict on the next
// redeemCASStub wraps inviteinmem.Store but injects ErrConflict on the next
// UpdateStatus call so we can observe the rollback path.
type redeemCASStub struct {
*invitestub.Store
*inviteinmem.Store
failNext bool
}
@@ -436,9 +520,9 @@ func TestRedeemCASConflictReleasesReservation(t *testing.T) {
Invites: cas,
Memberships: f.memberships,
Directory: f.directory,
Users: f.users,
Users: f.usersMock,
GapStore: f.gapStore,
Intents: f.intents,
Intents: f.intentsMock,
IDs: f.ids,
Clock: fixedClock(f.now),
Logger: silentLogger(),
@@ -458,7 +542,7 @@ func TestRedeemPublishFailureDoesNotRollback(t *testing.T) {
t.Parallel()
f := newFixture(t, 4, 1)
inv := seedCreatedInvite(t, f, "invite-1", inviteeUserID)
f.intents.SetError(errors.New("publish failed"))
f.intents.setErr(errors.New("publish failed"))
svc := newService(t, f)
got, err := svc.Handle(context.Background(), defaultInput(f, inv))