309 lines
10 KiB
Go
309 lines
10 KiB
Go
package capabilityevaluation_test
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"log/slog"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"galaxy/lobby/internal/adapters/evaluationguardstub"
|
|
"galaxy/lobby/internal/adapters/gamestub"
|
|
"galaxy/lobby/internal/adapters/gameturnstatsstub"
|
|
"galaxy/lobby/internal/adapters/membershipstub"
|
|
"galaxy/lobby/internal/adapters/racenamestub"
|
|
"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 *gamestub.Store
|
|
memberships *membershipstub.Store
|
|
stats *gameturnstatsstub.Store
|
|
directory *racenamestub.Directory
|
|
intents *spyIntents
|
|
guard *evaluationguardstub.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 := gamestub.NewStore()
|
|
memberships := membershipstub.NewStore()
|
|
stats := gameturnstatsstub.NewStore()
|
|
directory, err := racenamestub.NewDirectory(racenamestub.WithClock(fixedClock(now.Add(-time.Hour))))
|
|
require.NoError(t, err)
|
|
intents := &spyIntents{}
|
|
guard := evaluationguardstub.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)
|
|
}
|