Files
galaxy-game/lobby/internal/worker/userlifecycle/worker_test.go
T
2026-04-25 23:20:55 +02:00

417 lines
13 KiB
Go

package userlifecycle_test
import (
"context"
"errors"
"io"
"log/slog"
"strings"
"testing"
"time"
"galaxy/lobby/internal/adapters/applicationstub"
"galaxy/lobby/internal/adapters/gamestub"
"galaxy/lobby/internal/adapters/intentpubstub"
"galaxy/lobby/internal/adapters/invitestub"
"galaxy/lobby/internal/adapters/membershipstub"
"galaxy/lobby/internal/adapters/racenamestub"
"galaxy/lobby/internal/adapters/runtimemanagerstub"
"galaxy/lobby/internal/domain/application"
"galaxy/lobby/internal/domain/common"
"galaxy/lobby/internal/domain/game"
"galaxy/lobby/internal/domain/invite"
"galaxy/lobby/internal/domain/membership"
"galaxy/lobby/internal/ports"
"galaxy/lobby/internal/worker/userlifecycle"
"galaxy/notificationintent"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func silentLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }
type fixture struct {
directory *racenamestub.Directory
memberships *membershipstub.Store
applications *applicationstub.Store
invites *invitestub.Store
games *gamestub.Store
runtimeManager *runtimemanagerstub.Publisher
intents *intentpubstub.Publisher
worker *userlifecycle.Worker
now time.Time
}
func newFixture(t *testing.T) *fixture {
t.Helper()
directory, err := racenamestub.NewDirectory()
require.NoError(t, err)
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
f := &fixture{
directory: directory,
memberships: membershipstub.NewStore(),
applications: applicationstub.NewStore(),
invites: invitestub.NewStore(),
games: gamestub.NewStore(),
runtimeManager: runtimemanagerstub.NewPublisher(),
intents: intentpubstub.NewPublisher(),
now: now,
}
worker, err := userlifecycle.NewWorker(userlifecycle.Dependencies{
Directory: directory,
Memberships: f.memberships,
Applications: f.applications,
Invites: f.invites,
Games: f.games,
RuntimeManager: f.runtimeManager,
Intents: f.intents,
Clock: func() time.Time { return now },
Logger: silentLogger(),
})
require.NoError(t, err)
f.worker = worker
return f
}
func (f *fixture) seedGame(
t *testing.T,
id common.GameID,
gameType game.GameType,
ownerUserID string,
status game.Status,
) game.Game {
t.Helper()
createdAt := f.now.Add(-2 * time.Hour)
record, err := game.New(game.NewGameInput{
GameID: id,
GameName: "cascade " + id.String(),
GameType: gameType,
OwnerUserID: ownerUserID,
MinPlayers: 2,
MaxPlayers: 4,
StartGapHours: 1,
StartGapPlayers: 1,
EnrollmentEndsAt: createdAt.Add(24 * time.Hour),
TurnSchedule: "0 18 * * *",
TargetEngineVersion: "v1.0.0",
Now: createdAt,
})
require.NoError(t, err)
if status != game.StatusDraft {
record.Status = status
record.UpdatedAt = f.now
switch status {
case game.StatusRunning, game.StatusPaused:
startedAt := f.now.Add(-time.Hour)
record.StartedAt = &startedAt
}
}
require.NoError(t, f.games.Save(context.Background(), record))
return record
}
func (f *fixture) seedMembership(
t *testing.T,
gameID common.GameID,
id common.MembershipID,
userID, raceName string,
) membership.Membership {
t.Helper()
record, err := membership.New(membership.NewMembershipInput{
MembershipID: id,
GameID: gameID,
UserID: userID,
RaceName: raceName,
CanonicalKey: strings.ToLower(strings.ReplaceAll(raceName, " ", "")),
Now: f.now,
})
require.NoError(t, err)
require.NoError(t, f.memberships.Save(context.Background(), record))
require.NoError(t, f.directory.Reserve(context.Background(), gameID.String(), userID, raceName))
return record
}
func (f *fixture) seedApplication(
t *testing.T,
gameID common.GameID,
id common.ApplicationID,
userID, raceName string,
) application.Application {
t.Helper()
record, err := application.New(application.NewApplicationInput{
ApplicationID: id,
GameID: gameID,
ApplicantUserID: userID,
RaceName: raceName,
Now: f.now,
})
require.NoError(t, err)
require.NoError(t, f.applications.Save(context.Background(), record))
return record
}
func (f *fixture) seedInvite(
t *testing.T,
gameID common.GameID,
id common.InviteID,
inviterUserID, inviteeUserID string,
) invite.Invite {
t.Helper()
record, err := invite.New(invite.NewInviteInput{
InviteID: id,
GameID: gameID,
InviterUserID: inviterUserID,
InviteeUserID: inviteeUserID,
Now: f.now,
ExpiresAt: f.now.Add(48 * time.Hour),
})
require.NoError(t, err)
require.NoError(t, f.invites.Save(context.Background(), record))
return record
}
func (f *fixture) reserveRegistered(t *testing.T, gameID, userID, raceName string, registered bool) {
t.Helper()
require.NoError(t, f.directory.Reserve(context.Background(), gameID, userID, raceName))
if registered {
require.NoError(t, f.directory.MarkPendingRegistration(
context.Background(), gameID, userID, raceName, f.now.Add(30*24*time.Hour)))
require.NoError(t, f.directory.Register(context.Background(), gameID, userID, raceName))
}
}
func TestNewWorkerRejectsMissingDeps(t *testing.T) {
t.Parallel()
_, err := userlifecycle.NewWorker(userlifecycle.Dependencies{})
require.Error(t, err)
}
func TestHandleFullCascadePermanentBlock(t *testing.T) {
t.Parallel()
f := newFixture(t)
// Owned private game in running status (must publish stop job).
ownedRunning := f.seedGame(t, "game-owned-1", game.GameTypePrivate, "user-victim", game.StatusRunning)
// Owned private game in enrollment_open (no stop job needed).
ownedDraft := f.seedGame(t, "game-owned-2", game.GameTypePrivate, "user-victim", game.StatusEnrollmentOpen)
// Third party private game where the victim has an active membership.
thirdPartyGame := f.seedGame(t, "game-third-1", game.GameTypePrivate, "owner-other", game.StatusEnrollmentOpen)
member := f.seedMembership(t, thirdPartyGame.GameID, "membership-1", "user-victim", "PrismHawk")
// Public game where the victim has an active membership.
publicGame := f.seedGame(t, "game-pub-1", game.GameTypePublic, "", game.StatusRunning)
publicMember := f.seedMembership(t, publicGame.GameID, "membership-2", "user-victim", "Nebula")
// Pending application by the victim.
app := f.seedApplication(t, "game-pub-1", "application-1", "user-victim", "Nebula")
// Pending invite addressed to the victim.
inv1 := f.seedInvite(t, "game-third-1", "invite-1", "owner-other", "user-victim")
// Pending invite where the victim is the inviter.
inv2 := f.seedInvite(t, "game-owned-2", "invite-2", "user-victim", "guest-1")
// Race name registered by the victim (RND should release it).
f.reserveRegistered(t, "game-third-1", "user-victim", "PrismHawk", true)
require.NoError(t, f.worker.Handle(context.Background(), ports.UserLifecycleEvent{
EntryID: "1700000000000-0",
EventType: ports.UserLifecycleEventTypePermanentBlocked,
UserID: "user-victim",
OccurredAt: f.now,
Source: "admin_internal_api",
ActorType: "admin_user",
ActorID: "admin-1",
ReasonCode: "policy_violation",
}))
// RND is fully cleared for the user.
registered, err := f.directory.ListRegistered(context.Background(), "user-victim")
require.NoError(t, err)
assert.Empty(t, registered)
pending, err := f.directory.ListPendingRegistrations(context.Background(), "user-victim")
require.NoError(t, err)
assert.Empty(t, pending)
reservations, err := f.directory.ListReservations(context.Background(), "user-victim")
require.NoError(t, err)
assert.Empty(t, reservations)
// Both memberships are blocked.
got, err := f.memberships.Get(context.Background(), member.MembershipID)
require.NoError(t, err)
assert.Equal(t, membership.StatusBlocked, got.Status)
gotPub, err := f.memberships.Get(context.Background(), publicMember.MembershipID)
require.NoError(t, err)
assert.Equal(t, membership.StatusBlocked, gotPub.Status)
// Application rejected.
gotApp, err := f.applications.Get(context.Background(), app.ApplicationID)
require.NoError(t, err)
assert.Equal(t, application.StatusRejected, gotApp.Status)
// Both invites revoked.
gotInv1, err := f.invites.Get(context.Background(), inv1.InviteID)
require.NoError(t, err)
assert.Equal(t, invite.StatusRevoked, gotInv1.Status)
gotInv2, err := f.invites.Get(context.Background(), inv2.InviteID)
require.NoError(t, err)
assert.Equal(t, invite.StatusRevoked, gotInv2.Status)
// Owned games cancelled, stop job published only for in-flight ones.
gotOwned1, err := f.games.Get(context.Background(), ownedRunning.GameID)
require.NoError(t, err)
assert.Equal(t, game.StatusCancelled, gotOwned1.Status)
gotOwned2, err := f.games.Get(context.Background(), ownedDraft.GameID)
require.NoError(t, err)
assert.Equal(t, game.StatusCancelled, gotOwned2.Status)
stopJobs := f.runtimeManager.StopJobs()
require.Len(t, stopJobs, 1)
assert.Equal(t, ownedRunning.GameID.String(), stopJobs[0])
// Notification published only for the third-party private game owner.
intents := f.intents.Published()
require.Len(t, intents, 1)
assert.Equal(t, notificationintent.NotificationTypeLobbyMembershipBlocked, intents[0].NotificationType)
assert.Equal(t, []string{"owner-other"}, intents[0].RecipientUserIDs)
assert.Contains(t, intents[0].PayloadJSON, `"reason":"permanent_blocked"`)
assert.Contains(t, intents[0].PayloadJSON, `"membership_user_id":"user-victim"`)
}
func TestHandleIsIdempotentOnReplay(t *testing.T) {
t.Parallel()
f := newFixture(t)
thirdParty := f.seedGame(t, "game-third-2", game.GameTypePrivate, "owner-other", game.StatusEnrollmentOpen)
f.seedMembership(t, thirdParty.GameID, "membership-3", "user-victim", "PrismHawk")
event := ports.UserLifecycleEvent{
EntryID: "1700000000000-0",
EventType: ports.UserLifecycleEventTypeDeleted,
UserID: "user-victim",
OccurredAt: f.now,
Source: "admin_internal_api",
ActorType: "system",
ReasonCode: "user_request",
}
require.NoError(t, f.worker.Handle(context.Background(), event))
require.NoError(t, f.worker.Handle(context.Background(), event))
intents := f.intents.Published()
require.Len(t, intents, 1, "second pass must not double-publish")
assert.Contains(t, intents[0].PayloadJSON, `"reason":"deleted"`)
}
func TestHandleRetryAfterMembershipBackendError(t *testing.T) {
t.Parallel()
f := newFixture(t)
thirdParty := f.seedGame(t, "game-third-3", game.GameTypePrivate, "owner-other", game.StatusEnrollmentOpen)
member := f.seedMembership(t, thirdParty.GameID, "membership-4", "user-victim", "Stardust")
failingMemberships := &flakyMembershipStore{
Store: f.memberships,
failOnce: true,
failError: errors.New("redis flake"),
}
worker, err := userlifecycle.NewWorker(userlifecycle.Dependencies{
Directory: f.directory,
Memberships: failingMemberships,
Applications: f.applications,
Invites: f.invites,
Games: f.games,
RuntimeManager: f.runtimeManager,
Intents: f.intents,
Clock: func() time.Time { return f.now },
Logger: silentLogger(),
})
require.NoError(t, err)
event := ports.UserLifecycleEvent{
EntryID: "1700000000000-0",
EventType: ports.UserLifecycleEventTypePermanentBlocked,
UserID: "user-victim",
OccurredAt: f.now,
Source: "admin_internal_api",
ActorType: "admin_user",
ReasonCode: "abuse",
}
err = worker.Handle(context.Background(), event)
require.Error(t, err)
// The failing call already consumed its single failure budget.
require.NoError(t, worker.Handle(context.Background(), event))
// Confirm membership is now blocked.
got, err := f.memberships.Get(context.Background(), member.MembershipID)
require.NoError(t, err)
assert.Equal(t, membership.StatusBlocked, got.Status)
}
func TestHandleUnknownEventTypeIsNoop(t *testing.T) {
t.Parallel()
f := newFixture(t)
thirdParty := f.seedGame(t, "game-third-4", game.GameTypePrivate, "owner-other", game.StatusEnrollmentOpen)
member := f.seedMembership(t, thirdParty.GameID, "membership-5", "user-victim", "Comet")
require.NoError(t, f.worker.Handle(context.Background(), ports.UserLifecycleEvent{
EntryID: "1700000000000-0",
EventType: ports.UserLifecycleEventType("user.lifecycle.unknown"),
UserID: "user-victim",
OccurredAt: f.now,
}))
got, err := f.memberships.Get(context.Background(), member.MembershipID)
require.NoError(t, err)
assert.Equal(t, membership.StatusActive, got.Status)
assert.Empty(t, f.intents.Published())
}
func TestHandlePropagatesStopJobError(t *testing.T) {
t.Parallel()
f := newFixture(t)
f.seedGame(t, "game-owned-3", game.GameTypePrivate, "user-victim", game.StatusRunning)
f.runtimeManager.SetStopError(errors.New("runtime down"))
err := f.worker.Handle(context.Background(), ports.UserLifecycleEvent{
EntryID: "1700000000000-0",
EventType: ports.UserLifecycleEventTypePermanentBlocked,
UserID: "user-victim",
OccurredAt: f.now,
ActorType: "admin_user",
ReasonCode: "abuse",
})
require.Error(t, err)
}
// flakyMembershipStore wraps membershipstub.Store with a one-shot
// UpdateStatus failure injection used by the retry-after-error test.
type flakyMembershipStore struct {
*membershipstub.Store
failOnce bool
failError error
}
func (f *flakyMembershipStore) UpdateStatus(ctx context.Context, input ports.UpdateMembershipStatusInput) error {
if f.failOnce {
f.failOnce = false
return f.failError
}
return f.Store.UpdateStatus(ctx, input)
}