Files
galaxy-game/lobby/internal/service/capabilityevaluation/service_test.go
T
2026-04-28 20:39:18 +02:00

309 lines
10 KiB
Go

package capabilityevaluation_test
import (
"context"
"io"
"log/slog"
"sync"
"testing"
"time"
"galaxy/lobby/internal/adapters/evaluationguardinmem"
"galaxy/lobby/internal/adapters/gameinmem"
"galaxy/lobby/internal/adapters/gameturnstatsinmem"
"galaxy/lobby/internal/adapters/membershipinmem"
"galaxy/lobby/internal/adapters/racenameinmem"
"galaxy/lobby/internal/domain/common"
"galaxy/lobby/internal/domain/game"
"galaxy/lobby/internal/domain/membership"
"galaxy/lobby/internal/ports"
"galaxy/lobby/internal/service/capabilityevaluation"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
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 spyIntents struct {
mu sync.Mutex
pending []capabilityevaluation.EligibleEvent
denied []capabilityevaluation.DeniedEvent
}
func (spy *spyIntents) PublishEligible(_ context.Context, ev capabilityevaluation.EligibleEvent) error {
spy.mu.Lock()
defer spy.mu.Unlock()
spy.pending = append(spy.pending, ev)
return nil
}
func (spy *spyIntents) PublishDenied(_ context.Context, ev capabilityevaluation.DeniedEvent) error {
spy.mu.Lock()
defer spy.mu.Unlock()
spy.denied = append(spy.denied, ev)
return nil
}
type fixture struct {
finishedAt time.Time
gameID common.GameID
gameName string
games *gameinmem.Store
memberships *membershipinmem.Store
stats *gameturnstatsinmem.Store
directory *racenameinmem.Directory
intents *spyIntents
guard *evaluationguardinmem.Store
service *capabilityevaluation.Service
}
func newFixture(t *testing.T) *fixture {
t.Helper()
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
finishedAt := now
games := gameinmem.NewStore()
memberships := membershipinmem.NewStore()
stats := gameturnstatsinmem.NewStore()
directory, err := racenameinmem.NewDirectory(racenameinmem.WithClock(fixedClock(now.Add(-time.Hour))))
require.NoError(t, err)
intents := &spyIntents{}
guard := evaluationguardinmem.NewStore()
gameID := common.GameID("game-finished")
gameName := "Final Showdown"
gameRecord, err := game.New(game.NewGameInput{
GameID: gameID,
GameName: gameName,
GameType: game.GameTypePublic,
MinPlayers: 2,
MaxPlayers: 4,
StartGapHours: 2,
StartGapPlayers: 1,
EnrollmentEndsAt: now.Add(-12 * time.Hour),
TurnSchedule: "0 */6 * * *",
TargetEngineVersion: "1.0.0",
Now: now.Add(-24 * time.Hour),
})
require.NoError(t, err)
gameRecord.Status = game.StatusFinished
startedAt := now.Add(-12 * time.Hour)
gameRecord.StartedAt = &startedAt
gameRecord.FinishedAt = &finishedAt
require.NoError(t, games.Save(context.Background(), gameRecord))
service, err := capabilityevaluation.NewService(capabilityevaluation.Dependencies{
Games: games,
Memberships: memberships,
Stats: stats,
Directory: directory,
Intents: intents,
Guard: guard,
Clock: fixedClock(now),
Logger: silentLogger(),
})
require.NoError(t, err)
return &fixture{
finishedAt: finishedAt,
gameID: gameID,
gameName: gameName,
games: games,
memberships: memberships,
stats: stats,
directory: directory,
intents: intents,
guard: guard,
service: service,
}
}
func (f *fixture) addMember(t *testing.T, userID, raceName string, status membership.Status) {
t.Helper()
member, err := membership.New(membership.NewMembershipInput{
MembershipID: common.MembershipID("membership-" + userID),
GameID: f.gameID,
UserID: userID,
RaceName: raceName,
CanonicalKey: raceName,
Now: f.finishedAt.Add(-3 * time.Hour),
})
require.NoError(t, err)
require.NoError(t, f.memberships.Save(context.Background(), member))
if status != membership.StatusActive {
require.NoError(t, f.memberships.UpdateStatus(context.Background(), ports.UpdateMembershipStatusInput{
MembershipID: member.MembershipID,
ExpectedFrom: membership.StatusActive,
To: status,
At: f.finishedAt.Add(-time.Hour),
}))
}
}
func (f *fixture) reserveRaceName(t *testing.T, userID, raceName string) {
t.Helper()
require.NoError(t, f.directory.Reserve(context.Background(), f.gameID.String(), userID, raceName))
}
func (f *fixture) seedStats(t *testing.T, userID string, initialPlanets, initialPopulation, maxPlanets, maxPopulation int64) {
t.Helper()
require.NoError(t, f.stats.SaveInitial(context.Background(), f.gameID, []ports.PlayerInitialStats{
{UserID: userID, Planets: initialPlanets, Population: initialPopulation, ShipsBuilt: 0},
}))
require.NoError(t, f.stats.UpdateMax(context.Background(), f.gameID, []ports.PlayerObservedStats{
{UserID: userID, Planets: maxPlanets, Population: maxPopulation, ShipsBuilt: 0},
}))
}
func TestEvaluateRequiresFinishedStatus(t *testing.T) {
f := newFixture(t)
record, err := f.games.Get(context.Background(), f.gameID)
require.NoError(t, err)
record.Status = game.StatusRunning
record.FinishedAt = nil
require.NoError(t, f.games.Save(context.Background(), record))
err = f.service.Evaluate(context.Background(), f.gameID, f.finishedAt)
require.Error(t, err)
}
func TestEvaluateCapableMemberMarksPending(t *testing.T) {
f := newFixture(t)
f.reserveRaceName(t, "user-capable", "Stellaris")
f.addMember(t, "user-capable", "Stellaris", membership.StatusActive)
f.seedStats(t, "user-capable", 3, 100, 5, 200)
require.NoError(t, f.service.Evaluate(context.Background(), f.gameID, f.finishedAt))
pending, err := f.directory.ListPendingRegistrations(context.Background(), "user-capable")
require.NoError(t, err)
require.Len(t, pending, 1)
assert.Equal(t, "Stellaris", pending[0].RaceName)
expectedEligible := f.finishedAt.Add(capabilityevaluation.PendingRegistrationWindow).UnixMilli()
assert.Equal(t, expectedEligible, pending[0].EligibleUntilMs)
require.Len(t, f.intents.pending, 1)
assert.Equal(t, "user-capable", f.intents.pending[0].UserID)
assert.Equal(t, f.gameName, f.intents.pending[0].GameName)
assert.Empty(t, f.intents.denied)
}
func TestEvaluateIncapableMemberReleasesReservation(t *testing.T) {
f := newFixture(t)
f.reserveRaceName(t, "user-incap", "Loseman")
f.addMember(t, "user-incap", "Loseman", membership.StatusActive)
f.seedStats(t, "user-incap", 3, 100, 3, 100)
require.NoError(t, f.service.Evaluate(context.Background(), f.gameID, f.finishedAt))
reservations, err := f.directory.ListReservations(context.Background(), "user-incap")
require.NoError(t, err)
assert.Empty(t, reservations)
pending, err := f.directory.ListPendingRegistrations(context.Background(), "user-incap")
require.NoError(t, err)
assert.Empty(t, pending)
require.Len(t, f.intents.denied, 1)
assert.Equal(t, capabilityevaluation.ReasonCapabilityNotMet, f.intents.denied[0].Reason)
}
func TestEvaluateMissingStatsReleasesAndReportsReason(t *testing.T) {
f := newFixture(t)
f.reserveRaceName(t, "user-no-stats", "Phantom")
f.addMember(t, "user-no-stats", "Phantom", membership.StatusActive)
require.NoError(t, f.service.Evaluate(context.Background(), f.gameID, f.finishedAt))
require.Len(t, f.intents.denied, 1)
assert.Equal(t, capabilityevaluation.ReasonMissingStats, f.intents.denied[0].Reason)
reservations, err := f.directory.ListReservations(context.Background(), "user-no-stats")
require.NoError(t, err)
assert.Empty(t, reservations)
}
func TestEvaluateMixedRoster(t *testing.T) {
f := newFixture(t)
f.reserveRaceName(t, "user-A", "Alpha")
f.addMember(t, "user-A", "Alpha", membership.StatusActive)
f.seedStats(t, "user-A", 1, 10, 9, 100)
f.reserveRaceName(t, "user-B", "Beta")
f.addMember(t, "user-B", "Beta", membership.StatusActive)
f.seedStats(t, "user-B", 4, 50, 4, 50)
f.reserveRaceName(t, "user-C", "Gamma")
f.addMember(t, "user-C", "Gamma", membership.StatusRemoved)
f.reserveRaceName(t, "user-D", "Delta")
f.addMember(t, "user-D", "Delta", membership.StatusBlocked)
require.NoError(t, f.service.Evaluate(context.Background(), f.gameID, f.finishedAt))
pending, err := f.directory.ListPendingRegistrations(context.Background(), "user-A")
require.NoError(t, err)
assert.Len(t, pending, 1)
for _, userID := range []string{"user-B", "user-C", "user-D"} {
reservations, err := f.directory.ListReservations(context.Background(), userID)
require.NoError(t, err)
assert.Empty(t, reservations, "user %s reservation not released", userID)
}
require.Len(t, f.intents.pending, 1)
require.Len(t, f.intents.denied, 1, "removed/blocked must not produce denied intents")
assert.Equal(t, "user-A", f.intents.pending[0].UserID)
assert.Equal(t, "user-B", f.intents.denied[0].UserID)
}
func TestEvaluateReplayDoesNotDoubleEmit(t *testing.T) {
f := newFixture(t)
f.reserveRaceName(t, "user-A", "Alpha")
f.addMember(t, "user-A", "Alpha", membership.StatusActive)
f.seedStats(t, "user-A", 1, 10, 9, 100)
require.NoError(t, f.service.Evaluate(context.Background(), f.gameID, f.finishedAt))
require.NoError(t, f.service.Evaluate(context.Background(), f.gameID, f.finishedAt))
pending, err := f.directory.ListPendingRegistrations(context.Background(), "user-A")
require.NoError(t, err)
assert.Len(t, pending, 1)
assert.Len(t, f.intents.pending, 1)
assert.Empty(t, f.intents.denied)
}
func TestEvaluateDeletesStatsAggregate(t *testing.T) {
f := newFixture(t)
f.reserveRaceName(t, "user-A", "Alpha")
f.addMember(t, "user-A", "Alpha", membership.StatusActive)
f.seedStats(t, "user-A", 1, 10, 2, 11)
require.NoError(t, f.service.Evaluate(context.Background(), f.gameID, f.finishedAt))
aggregate, err := f.stats.Load(context.Background(), f.gameID)
require.NoError(t, err)
assert.Empty(t, aggregate.Players)
}
func TestEvaluateRequiresFinishedAt(t *testing.T) {
f := newFixture(t)
err := f.service.Evaluate(context.Background(), f.gameID, time.Time{})
require.Error(t, err)
}
func TestEvaluateRejectsInvalidGameID(t *testing.T) {
f := newFixture(t)
err := f.service.Evaluate(context.Background(), common.GameID(""), f.finishedAt)
require.Error(t, err)
}