501 lines
16 KiB
Go
501 lines
16 KiB
Go
package userlifecycle_test
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"galaxy/lobby/internal/adapters/applicationinmem"
|
|
"galaxy/lobby/internal/adapters/gameinmem"
|
|
"galaxy/lobby/internal/adapters/inviteinmem"
|
|
"galaxy/lobby/internal/adapters/membershipinmem"
|
|
"galaxy/lobby/internal/adapters/mocks"
|
|
"galaxy/lobby/internal/adapters/racenameinmem"
|
|
"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"
|
|
"go.uber.org/mock/gomock"
|
|
)
|
|
|
|
type intentRec struct {
|
|
mu sync.Mutex
|
|
published []notificationintent.Intent
|
|
}
|
|
|
|
func (r *intentRec) record(_ context.Context, intent notificationintent.Intent) (string, error) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
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...)
|
|
}
|
|
|
|
type runtimeRec struct {
|
|
mu sync.Mutex
|
|
stopIDs []string
|
|
stopReas []ports.StopReason
|
|
stopErr error
|
|
}
|
|
|
|
func (r *runtimeRec) recordStart(_ context.Context, _, _ string) error { return nil }
|
|
|
|
func (r *runtimeRec) recordStop(_ context.Context, gameID string, reason ports.StopReason) error {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if r.stopErr != nil {
|
|
return r.stopErr
|
|
}
|
|
r.stopIDs = append(r.stopIDs, gameID)
|
|
r.stopReas = append(r.stopReas, reason)
|
|
return nil
|
|
}
|
|
|
|
func (r *runtimeRec) stopJobs() []string {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return append([]string(nil), r.stopIDs...)
|
|
}
|
|
|
|
func (r *runtimeRec) stopReasons() []ports.StopReason {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return append([]ports.StopReason(nil), r.stopReas...)
|
|
}
|
|
|
|
func (r *runtimeRec) setStopErr(err error) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.stopErr = 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 newRuntimeMock(t *testing.T, rec *runtimeRec) *mocks.MockRuntimeManager {
|
|
t.Helper()
|
|
m := mocks.NewMockRuntimeManager(gomock.NewController(t))
|
|
m.EXPECT().PublishStartJob(gomock.Any(), gomock.Any(), gomock.Any()).
|
|
DoAndReturn(rec.recordStart).AnyTimes()
|
|
m.EXPECT().PublishStopJob(gomock.Any(), gomock.Any(), gomock.Any()).
|
|
DoAndReturn(rec.recordStop).AnyTimes()
|
|
return m
|
|
}
|
|
|
|
func silentLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }
|
|
|
|
type fixture struct {
|
|
directory *racenameinmem.Directory
|
|
memberships *membershipinmem.Store
|
|
applications *applicationinmem.Store
|
|
invites *inviteinmem.Store
|
|
games *gameinmem.Store
|
|
runtimeRec *runtimeRec
|
|
runtimeManager *mocks.MockRuntimeManager
|
|
intentRec *intentRec
|
|
intents *mocks.MockIntentPublisher
|
|
worker *userlifecycle.Worker
|
|
now time.Time
|
|
}
|
|
|
|
func newFixture(t *testing.T) *fixture {
|
|
t.Helper()
|
|
|
|
directory, err := racenameinmem.NewDirectory()
|
|
require.NoError(t, err)
|
|
|
|
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
|
rtRec := &runtimeRec{}
|
|
intRec := &intentRec{}
|
|
f := &fixture{
|
|
directory: directory,
|
|
memberships: membershipinmem.NewStore(),
|
|
applications: applicationinmem.NewStore(),
|
|
invites: inviteinmem.NewStore(),
|
|
games: gameinmem.NewStore(),
|
|
runtimeRec: rtRec,
|
|
runtimeManager: newRuntimeMock(t, rtRec),
|
|
intentRec: intRec,
|
|
intents: newIntentMock(t, intRec),
|
|
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.runtimeRec.stopJobs()
|
|
require.Len(t, stopJobs, 1)
|
|
assert.Equal(t, ownedRunning.GameID.String(), stopJobs[0])
|
|
stopReasons := f.runtimeRec.stopReasons()
|
|
require.Len(t, stopReasons, 1)
|
|
assert.Equal(t, ports.StopReasonCancelled, stopReasons[0],
|
|
"user-lifecycle cascade must classify the stop job as cancelled")
|
|
|
|
// Notification published only for the third-party private game owner.
|
|
intents := f.intentRec.snapshot()
|
|
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.intentRec.snapshot()
|
|
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.intentRec.snapshot())
|
|
}
|
|
|
|
func TestHandlePropagatesStopJobError(t *testing.T) {
|
|
t.Parallel()
|
|
f := newFixture(t)
|
|
f.seedGame(t, "game-owned-3", game.GameTypePrivate, "user-victim", game.StatusRunning)
|
|
|
|
f.runtimeRec.setStopErr(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 membershipinmem.Store with a one-shot
|
|
// UpdateStatus failure injection used by the retry-after-error test.
|
|
type flakyMembershipStore struct {
|
|
*membershipinmem.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)
|
|
}
|