feat: runtime manager
This commit is contained in:
@@ -6,12 +6,12 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/intentpubstub"
|
||||
"galaxy/lobby/internal/adapters/racenamestub"
|
||||
"galaxy/lobby/internal/adapters/userservicestub"
|
||||
"galaxy/lobby/internal/adapters/mocks"
|
||||
"galaxy/lobby/internal/adapters/racenameinmem"
|
||||
"galaxy/lobby/internal/ports"
|
||||
"galaxy/lobby/internal/service/registerracename"
|
||||
"galaxy/lobby/internal/service/shared"
|
||||
@@ -19,28 +19,113 @@ 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
|
||||
}
|
||||
|
||||
func silentLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }
|
||||
|
||||
func fixedClock(at time.Time) func() time.Time { return func() time.Time { return at } }
|
||||
|
||||
type fixture struct {
|
||||
now time.Time
|
||||
directory *racenamestub.Directory
|
||||
users *userservicestub.Service
|
||||
intents *intentpubstub.Publisher
|
||||
directory *racenameinmem.Directory
|
||||
users *userRec
|
||||
usersMock *mocks.MockUserService
|
||||
intents *intentRec
|
||||
pubMock *mocks.MockIntentPublisher
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T, now time.Time) *fixture {
|
||||
t.Helper()
|
||||
directory, err := racenamestub.NewDirectory(racenamestub.WithClock(fixedClock(now)))
|
||||
directory, err := racenameinmem.NewDirectory(racenameinmem.WithClock(fixedClock(now)))
|
||||
require.NoError(t, err)
|
||||
users := &userRec{}
|
||||
intents := &intentRec{}
|
||||
return &fixture{
|
||||
now: now,
|
||||
directory: directory,
|
||||
users: userservicestub.NewService(),
|
||||
intents: intentpubstub.NewPublisher(),
|
||||
users: users,
|
||||
usersMock: newUserMock(t, users),
|
||||
intents: intents,
|
||||
pubMock: newIntentMock(t, intents),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,8 +133,8 @@ func (f *fixture) newService(t *testing.T) *registerracename.Service {
|
||||
t.Helper()
|
||||
svc, err := registerracename.NewService(registerracename.Dependencies{
|
||||
Directory: f.directory,
|
||||
Users: f.users,
|
||||
Intents: f.intents,
|
||||
Users: f.usersMock,
|
||||
Intents: f.pubMock,
|
||||
Clock: fixedClock(f.now),
|
||||
Logger: silentLogger(),
|
||||
})
|
||||
@@ -102,7 +187,7 @@ func TestRegisterRaceNameHappyPath(t *testing.T) {
|
||||
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
f := newFixture(t, now)
|
||||
f.users.SetEligibility("user-1", defaultEligibility(2))
|
||||
f.users.setEligibility("user-1", defaultEligibility(2))
|
||||
f.seedPending(t, "game-1", "user-1", "Stellaris", now.Add(7*24*time.Hour))
|
||||
|
||||
svc := f.newService(t)
|
||||
@@ -128,7 +213,7 @@ func TestRegisterRaceNameHappyPath(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, pending)
|
||||
|
||||
intents := f.intents.Published()
|
||||
intents := f.intents.snapshot()
|
||||
require.Len(t, intents, 1)
|
||||
intent := intents[0]
|
||||
assert.Equal(t, notificationintent.NotificationTypeLobbyRaceNameRegistered, intent.NotificationType)
|
||||
@@ -144,7 +229,7 @@ func TestRegisterRaceNameIdempotentRetry(t *testing.T) {
|
||||
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
f := newFixture(t, now)
|
||||
f.users.SetEligibility("user-1", defaultEligibility(1))
|
||||
f.users.setEligibility("user-1", defaultEligibility(1))
|
||||
f.seedPending(t, "game-1", "user-1", "Stellaris", now.Add(7*24*time.Hour))
|
||||
|
||||
svc := f.newService(t)
|
||||
@@ -167,7 +252,7 @@ func TestRegisterRaceNameIdempotentRetry(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, registered, 1, "registration must remain idempotent")
|
||||
|
||||
intents := f.intents.Published()
|
||||
intents := f.intents.snapshot()
|
||||
require.Len(t, intents, 2, "idempotent retry republishes the intent")
|
||||
for _, intent := range intents {
|
||||
assert.Equal(t, "lobby.race_name.registered:game-1:user-1", intent.IdempotencyKey)
|
||||
@@ -257,7 +342,7 @@ func TestRegisterRaceNameRejectsPermanentBlock(t *testing.T) {
|
||||
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
f := newFixture(t, now)
|
||||
f.users.SetEligibility("user-1", ports.Eligibility{
|
||||
f.users.setEligibility("user-1", ports.Eligibility{
|
||||
Exists: true,
|
||||
PermanentBlocked: true,
|
||||
MaxRegisteredRaceNames: 2,
|
||||
@@ -278,7 +363,7 @@ func TestRegisterRaceNamePendingMissing(t *testing.T) {
|
||||
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
f := newFixture(t, now)
|
||||
f.users.SetEligibility("user-1", defaultEligibility(2))
|
||||
f.users.setEligibility("user-1", defaultEligibility(2))
|
||||
|
||||
svc := f.newService(t)
|
||||
_, err := svc.Handle(context.Background(), registerracename.Input{
|
||||
@@ -294,7 +379,7 @@ func TestRegisterRaceNamePendingForOtherUserSurfacesAsMissing(t *testing.T) {
|
||||
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
f := newFixture(t, now)
|
||||
f.users.SetEligibility("user-1", defaultEligibility(2))
|
||||
f.users.setEligibility("user-1", defaultEligibility(2))
|
||||
// Pending exists for a different user; the actor has none.
|
||||
f.seedPending(t, "game-1", "user-other", "Stellaris", now.Add(24*time.Hour))
|
||||
|
||||
@@ -316,7 +401,7 @@ func TestRegisterRaceNamePendingExpired(t *testing.T) {
|
||||
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
f := newFixture(t, now)
|
||||
f.users.SetEligibility("user-1", defaultEligibility(2))
|
||||
f.users.setEligibility("user-1", defaultEligibility(2))
|
||||
// Pending elig until is in the past relative to now.
|
||||
f.seedPending(t, "game-1", "user-1", "Stellaris", now.Add(-time.Minute))
|
||||
|
||||
@@ -335,7 +420,7 @@ func TestRegisterRaceNameQuotaExceeded(t *testing.T) {
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
f := newFixture(t, now)
|
||||
// Free-tier quota = 1; user already has one registered name.
|
||||
f.users.SetEligibility("user-1", defaultEligibility(1))
|
||||
f.users.setEligibility("user-1", defaultEligibility(1))
|
||||
f.seedRegistered(t, "game-existing", "user-1", "OldName")
|
||||
f.seedPending(t, "game-new", "user-1", "Stellaris", now.Add(24*time.Hour))
|
||||
|
||||
@@ -354,7 +439,7 @@ func TestRegisterRaceNameUnlimitedQuotaAllowsManyRegistrations(t *testing.T) {
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
f := newFixture(t, now)
|
||||
// MaxRegisteredRaceNames=0 marker → unlimited.
|
||||
f.users.SetEligibility("user-1", defaultEligibility(0))
|
||||
f.users.setEligibility("user-1", defaultEligibility(0))
|
||||
f.seedRegistered(t, "game-a", "user-1", "First")
|
||||
f.seedRegistered(t, "game-b", "user-1", "Second")
|
||||
f.seedPending(t, "game-c", "user-1", "Third", now.Add(24*time.Hour))
|
||||
@@ -373,7 +458,7 @@ func TestRegisterRaceNameUserServiceUnavailable(t *testing.T) {
|
||||
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
f := newFixture(t, now)
|
||||
f.users.SetFailure("user-1", ports.ErrUserServiceUnavailable)
|
||||
f.users.setFailure("user-1", ports.ErrUserServiceUnavailable)
|
||||
f.seedPending(t, "game-1", "user-1", "Stellaris", now.Add(24*time.Hour))
|
||||
|
||||
svc := f.newService(t)
|
||||
@@ -390,9 +475,9 @@ func TestRegisterRaceNameCommitsEvenIfPublishFails(t *testing.T) {
|
||||
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
f := newFixture(t, now)
|
||||
f.users.SetEligibility("user-1", defaultEligibility(2))
|
||||
f.users.setEligibility("user-1", defaultEligibility(2))
|
||||
f.seedPending(t, "game-1", "user-1", "Stellaris", now.Add(7*24*time.Hour))
|
||||
f.intents.SetError(errors.New("notification stream unavailable"))
|
||||
f.intents.setErr(errors.New("notification stream unavailable"))
|
||||
|
||||
svc := f.newService(t)
|
||||
out, err := svc.Handle(context.Background(), registerracename.Input{
|
||||
|
||||
Reference in New Issue
Block a user