package redisstate_test import ( "context" "errors" "sort" "strings" "testing" "time" "galaxy/lobby/internal/adapters/redisstate" "galaxy/lobby/internal/domain/common" "galaxy/lobby/internal/domain/membership" "galaxy/lobby/internal/ports" "github.com/alicebob/miniredis/v2" "github.com/redis/go-redis/v9" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func newMembershipTestStore(t *testing.T) (*redisstate.MembershipStore, *miniredis.Miniredis, *redis.Client) { t.Helper() server := miniredis.RunT(t) client := redis.NewClient(&redis.Options{Addr: server.Addr()}) t.Cleanup(func() { _ = client.Close() }) store, err := redisstate.NewMembershipStore(client) require.NoError(t, err) return store, server, client } func fixtureMembership(t *testing.T, id common.MembershipID, userID, raceName string, gameID common.GameID) membership.Membership { t.Helper() now := time.Date(2026, 4, 23, 12, 0, 0, 0, time.UTC) record, err := membership.New(membership.NewMembershipInput{ MembershipID: id, GameID: gameID, UserID: userID, RaceName: raceName, CanonicalKey: strings.ToLower(strings.ReplaceAll(raceName, " ", "")), Now: now, }) require.NoError(t, err) return record } func TestNewMembershipStoreRejectsNilClient(t *testing.T) { _, err := redisstate.NewMembershipStore(nil) require.Error(t, err) } func TestMembershipStoreSaveAndGet(t *testing.T) { ctx := context.Background() store, _, client := newMembershipTestStore(t) record := fixtureMembership(t, "membership-a", "user-1", "Solar Pilot", "game-1") require.NoError(t, store.Save(ctx, record)) got, err := store.Get(ctx, record.MembershipID) require.NoError(t, err) assert.Equal(t, record.MembershipID, got.MembershipID) assert.Equal(t, "Solar Pilot", got.RaceName) assert.Equal(t, membership.StatusActive, got.Status) assert.Nil(t, got.RemovedAt) byGame, err := client.SMembers(ctx, "lobby:game_memberships:"+base64URL(record.GameID.String())).Result() require.NoError(t, err) assert.ElementsMatch(t, []string{record.MembershipID.String()}, byGame) byUser, err := client.SMembers(ctx, "lobby:user_memberships:"+base64URL(record.UserID)).Result() require.NoError(t, err) assert.ElementsMatch(t, []string{record.MembershipID.String()}, byUser) } func TestMembershipStoreGetReturnsNotFound(t *testing.T) { ctx := context.Background() store, _, _ := newMembershipTestStore(t) _, err := store.Get(ctx, common.MembershipID("membership-missing")) require.ErrorIs(t, err, membership.ErrNotFound) } func TestMembershipStoreSaveRejectsNonActive(t *testing.T) { ctx := context.Background() store, _, _ := newMembershipTestStore(t) record := fixtureMembership(t, "membership-a", "user-1", "Solar Pilot", "game-1") record.Status = membership.StatusRemoved removedAt := record.JoinedAt.Add(time.Hour) record.RemovedAt = &removedAt err := store.Save(ctx, record) require.Error(t, err) assert.False(t, errors.Is(err, membership.ErrConflict)) } func TestMembershipStoreSaveRejectsDuplicate(t *testing.T) { ctx := context.Background() store, _, _ := newMembershipTestStore(t) record := fixtureMembership(t, "membership-a", "user-1", "Solar Pilot", "game-1") require.NoError(t, store.Save(ctx, record)) err := store.Save(ctx, record) require.Error(t, err) assert.True(t, errors.Is(err, membership.ErrConflict)) } func TestMembershipStoreUpdateStatusSetsRemovedAt(t *testing.T) { cases := []struct { name string target membership.Status }{ {"removed", membership.StatusRemoved}, {"blocked", membership.StatusBlocked}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { ctx := context.Background() store, _, _ := newMembershipTestStore(t) record := fixtureMembership(t, common.MembershipID("membership-"+tc.name), "user-1", "Solar Pilot", "game-1") require.NoError(t, store.Save(ctx, record)) at := record.JoinedAt.Add(2 * time.Hour) require.NoError(t, store.UpdateStatus(ctx, ports.UpdateMembershipStatusInput{ MembershipID: record.MembershipID, ExpectedFrom: membership.StatusActive, To: tc.target, At: at, })) got, err := store.Get(ctx, record.MembershipID) require.NoError(t, err) assert.Equal(t, tc.target, got.Status) require.NotNil(t, got.RemovedAt) assert.True(t, got.RemovedAt.Equal(at.UTC())) }) } } func TestMembershipStoreUpdateStatusRejectsInvalidTransitionWithoutMutation(t *testing.T) { ctx := context.Background() store, _, _ := newMembershipTestStore(t) record := fixtureMembership(t, "membership-a", "user-1", "Solar Pilot", "game-1") require.NoError(t, store.Save(ctx, record)) err := store.UpdateStatus(ctx, ports.UpdateMembershipStatusInput{ MembershipID: record.MembershipID, ExpectedFrom: membership.StatusRemoved, To: membership.StatusBlocked, At: record.JoinedAt.Add(time.Minute), }) require.Error(t, err) assert.True(t, errors.Is(err, membership.ErrInvalidTransition)) got, err := store.Get(ctx, record.MembershipID) require.NoError(t, err) assert.Equal(t, membership.StatusActive, got.Status) assert.Nil(t, got.RemovedAt) } func TestMembershipStoreUpdateStatusReturnsConflictWhenStatusDiverges(t *testing.T) { ctx := context.Background() store, _, _ := newMembershipTestStore(t) record := fixtureMembership(t, "membership-a", "user-1", "Solar Pilot", "game-1") require.NoError(t, store.Save(ctx, record)) require.NoError(t, store.UpdateStatus(ctx, ports.UpdateMembershipStatusInput{ MembershipID: record.MembershipID, ExpectedFrom: membership.StatusActive, To: membership.StatusBlocked, At: record.JoinedAt.Add(time.Minute), })) err := store.UpdateStatus(ctx, ports.UpdateMembershipStatusInput{ MembershipID: record.MembershipID, ExpectedFrom: membership.StatusActive, To: membership.StatusRemoved, At: record.JoinedAt.Add(2 * time.Minute), }) require.Error(t, err) assert.True(t, errors.Is(err, membership.ErrConflict)) } func TestMembershipStoreUpdateStatusReturnsNotFoundForMissingRecord(t *testing.T) { ctx := context.Background() store, _, _ := newMembershipTestStore(t) err := store.UpdateStatus(ctx, ports.UpdateMembershipStatusInput{ MembershipID: common.MembershipID("membership-missing"), ExpectedFrom: membership.StatusActive, To: membership.StatusRemoved, At: time.Now().UTC(), }) require.ErrorIs(t, err, membership.ErrNotFound) } func TestMembershipStoreGetByGameAndByUser(t *testing.T) { ctx := context.Background() store, _, _ := newMembershipTestStore(t) m1 := fixtureMembership(t, "membership-a1", "user-1", "Racer A", "game-1") m2 := fixtureMembership(t, "membership-a2", "user-2", "Racer B", "game-1") m3 := fixtureMembership(t, "membership-a3", "user-1", "Racer C", "game-2") for _, record := range []membership.Membership{m1, m2, m3} { require.NoError(t, store.Save(ctx, record)) } byGame1, err := store.GetByGame(ctx, "game-1") require.NoError(t, err) require.Len(t, byGame1, 2) byUser1, err := store.GetByUser(ctx, "user-1") require.NoError(t, err) require.Len(t, byUser1, 2) ids := collectMembershipIDs(byUser1) sort.Strings(ids) assert.Equal(t, []string{"membership-a1", "membership-a3"}, ids) byUserMissing, err := store.GetByUser(ctx, "user-missing") require.NoError(t, err) assert.Empty(t, byUserMissing) } func TestMembershipStoreGetByUserDropsStaleIndexEntries(t *testing.T) { ctx := context.Background() store, server, _ := newMembershipTestStore(t) record := fixtureMembership(t, "membership-a", "user-1", "Solar Pilot", "game-1") require.NoError(t, store.Save(ctx, record)) server.Del("lobby:memberships:" + base64URL(record.MembershipID.String())) records, err := store.GetByUser(ctx, record.UserID) require.NoError(t, err) assert.Empty(t, records) } func TestMembershipStoreDeleteRemovesPrimaryAndIndexes(t *testing.T) { ctx := context.Background() store, _, client := newMembershipTestStore(t) record := fixtureMembership(t, "membership-a", "user-1", "Solar Pilot", "game-1") require.NoError(t, store.Save(ctx, record)) require.NoError(t, store.Delete(ctx, record.MembershipID)) _, err := store.Get(ctx, record.MembershipID) require.ErrorIs(t, err, membership.ErrNotFound) byGame, err := client.SMembers(ctx, "lobby:game_memberships:"+base64URL(record.GameID.String())).Result() require.NoError(t, err) assert.Empty(t, byGame) byUser, err := client.SMembers(ctx, "lobby:user_memberships:"+base64URL(record.UserID)).Result() require.NoError(t, err) assert.Empty(t, byUser) } func TestMembershipStoreDeleteReturnsNotFoundForMissingRecord(t *testing.T) { ctx := context.Background() store, _, _ := newMembershipTestStore(t) err := store.Delete(ctx, common.MembershipID("membership-missing")) require.ErrorIs(t, err, membership.ErrNotFound) } func TestMembershipStoreDeleteIsIdempotentAfterFirstSuccess(t *testing.T) { ctx := context.Background() store, _, _ := newMembershipTestStore(t) record := fixtureMembership(t, "membership-a", "user-1", "Solar Pilot", "game-1") require.NoError(t, store.Save(ctx, record)) require.NoError(t, store.Delete(ctx, record.MembershipID)) err := store.Delete(ctx, record.MembershipID) require.ErrorIs(t, err, membership.ErrNotFound) } func collectMembershipIDs(records []membership.Membership) []string { ids := make([]string, len(records)) for index, record := range records { ids[index] = record.MembershipID.String() } return ids }