package redisstate_test import ( "context" "errors" "sort" "testing" "time" "galaxy/lobby/internal/adapters/redisstate" "galaxy/lobby/internal/domain/common" "galaxy/lobby/internal/domain/invite" "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 newInviteTestStore(t *testing.T) (*redisstate.InviteStore, *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.NewInviteStore(client) require.NoError(t, err) return store, server, client } func fixtureInvite(t *testing.T, id common.InviteID, inviter, invitee string, gameID common.GameID) invite.Invite { t.Helper() now := time.Date(2026, 4, 23, 12, 0, 0, 0, time.UTC) record, err := invite.New(invite.NewInviteInput{ InviteID: id, GameID: gameID, InviterUserID: inviter, InviteeUserID: invitee, Now: now, ExpiresAt: now.Add(7 * 24 * time.Hour), }) require.NoError(t, err) return record } func TestNewInviteStoreRejectsNilClient(t *testing.T) { _, err := redisstate.NewInviteStore(nil) require.Error(t, err) } func TestInviteStoreSaveAndGet(t *testing.T) { ctx := context.Background() store, _, client := newInviteTestStore(t) record := fixtureInvite(t, "invite-a", "user-owner", "user-guest", "game-1") require.NoError(t, store.Save(ctx, record)) got, err := store.Get(ctx, record.InviteID) require.NoError(t, err) assert.Equal(t, record.InviteID, got.InviteID) assert.Equal(t, record.InviteeUserID, got.InviteeUserID) assert.Equal(t, invite.StatusCreated, got.Status) assert.Equal(t, "", got.RaceName) assert.Nil(t, got.DecidedAt) assert.True(t, got.ExpiresAt.Equal(record.ExpiresAt)) byGame, err := client.SMembers(ctx, "lobby:game_invites:"+base64URL(record.GameID.String())).Result() require.NoError(t, err) assert.ElementsMatch(t, []string{record.InviteID.String()}, byGame) byUser, err := client.SMembers(ctx, "lobby:user_invites:"+base64URL(record.InviteeUserID)).Result() require.NoError(t, err) assert.ElementsMatch(t, []string{record.InviteID.String()}, byUser) } func TestInviteStoreGetReturnsNotFound(t *testing.T) { ctx := context.Background() store, _, _ := newInviteTestStore(t) _, err := store.Get(ctx, common.InviteID("invite-missing")) require.ErrorIs(t, err, invite.ErrNotFound) } func TestInviteStoreSaveRejectsDuplicate(t *testing.T) { ctx := context.Background() store, _, _ := newInviteTestStore(t) record := fixtureInvite(t, "invite-a", "user-owner", "user-guest", "game-1") require.NoError(t, store.Save(ctx, record)) err := store.Save(ctx, record) require.Error(t, err) assert.True(t, errors.Is(err, invite.ErrConflict)) } func TestInviteStoreSaveRejectsNonCreated(t *testing.T) { ctx := context.Background() store, _, _ := newInviteTestStore(t) record := fixtureInvite(t, "invite-a", "user-owner", "user-guest", "game-1") record.Status = invite.StatusRevoked decidedAt := record.CreatedAt.Add(time.Minute) record.DecidedAt = &decidedAt err := store.Save(ctx, record) require.Error(t, err) assert.False(t, errors.Is(err, invite.ErrConflict)) } func TestInviteStoreUpdateStatusRedeemSetsRaceName(t *testing.T) { ctx := context.Background() store, _, _ := newInviteTestStore(t) record := fixtureInvite(t, "invite-a", "user-owner", "user-guest", "game-1") require.NoError(t, store.Save(ctx, record)) at := record.CreatedAt.Add(time.Hour) require.NoError(t, store.UpdateStatus(ctx, ports.UpdateInviteStatusInput{ InviteID: record.InviteID, ExpectedFrom: invite.StatusCreated, To: invite.StatusRedeemed, At: at, RaceName: "Lunar Raider", })) got, err := store.Get(ctx, record.InviteID) require.NoError(t, err) assert.Equal(t, invite.StatusRedeemed, got.Status) assert.Equal(t, "Lunar Raider", got.RaceName) require.NotNil(t, got.DecidedAt) assert.True(t, got.DecidedAt.Equal(at.UTC())) } func TestInviteStoreUpdateStatusTerminalTransitions(t *testing.T) { cases := []struct { name string target invite.Status }{ {"declined", invite.StatusDeclined}, {"revoked", invite.StatusRevoked}, {"expired", invite.StatusExpired}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { ctx := context.Background() store, _, _ := newInviteTestStore(t) record := fixtureInvite(t, common.InviteID("invite-"+tc.name), "user-owner", "user-guest", "game-1") require.NoError(t, store.Save(ctx, record)) at := record.CreatedAt.Add(30 * time.Minute) require.NoError(t, store.UpdateStatus(ctx, ports.UpdateInviteStatusInput{ InviteID: record.InviteID, ExpectedFrom: invite.StatusCreated, To: tc.target, At: at, })) got, err := store.Get(ctx, record.InviteID) require.NoError(t, err) assert.Equal(t, tc.target, got.Status) assert.Equal(t, "", got.RaceName) require.NotNil(t, got.DecidedAt) assert.True(t, got.DecidedAt.Equal(at.UTC())) }) } } func TestInviteStoreUpdateStatusRejectsRedeemWithoutRaceName(t *testing.T) { ctx := context.Background() store, _, _ := newInviteTestStore(t) record := fixtureInvite(t, "invite-a", "user-owner", "user-guest", "game-1") require.NoError(t, store.Save(ctx, record)) err := store.UpdateStatus(ctx, ports.UpdateInviteStatusInput{ InviteID: record.InviteID, ExpectedFrom: invite.StatusCreated, To: invite.StatusRedeemed, At: record.CreatedAt.Add(time.Minute), }) require.Error(t, err) assert.False(t, errors.Is(err, invite.ErrInvalidTransition)) } func TestInviteStoreUpdateStatusRejectsRaceNameOnNonRedeem(t *testing.T) { ctx := context.Background() store, _, _ := newInviteTestStore(t) record := fixtureInvite(t, "invite-a", "user-owner", "user-guest", "game-1") require.NoError(t, store.Save(ctx, record)) err := store.UpdateStatus(ctx, ports.UpdateInviteStatusInput{ InviteID: record.InviteID, ExpectedFrom: invite.StatusCreated, To: invite.StatusDeclined, At: record.CreatedAt.Add(time.Minute), RaceName: "Nope", }) require.Error(t, err) assert.False(t, errors.Is(err, invite.ErrInvalidTransition)) } func TestInviteStoreUpdateStatusRejectsInvalidTransitionWithoutMutation(t *testing.T) { ctx := context.Background() store, _, _ := newInviteTestStore(t) record := fixtureInvite(t, "invite-a", "user-owner", "user-guest", "game-1") require.NoError(t, store.Save(ctx, record)) err := store.UpdateStatus(ctx, ports.UpdateInviteStatusInput{ InviteID: record.InviteID, ExpectedFrom: invite.StatusRedeemed, To: invite.StatusExpired, At: record.CreatedAt.Add(time.Minute), }) require.Error(t, err) assert.True(t, errors.Is(err, invite.ErrInvalidTransition)) } func TestInviteStoreUpdateStatusReturnsConflictOnExpectedFromMismatch(t *testing.T) { ctx := context.Background() store, _, _ := newInviteTestStore(t) record := fixtureInvite(t, "invite-a", "user-owner", "user-guest", "game-1") require.NoError(t, store.Save(ctx, record)) require.NoError(t, store.UpdateStatus(ctx, ports.UpdateInviteStatusInput{ InviteID: record.InviteID, ExpectedFrom: invite.StatusCreated, To: invite.StatusRevoked, At: record.CreatedAt.Add(time.Minute), })) err := store.UpdateStatus(ctx, ports.UpdateInviteStatusInput{ InviteID: record.InviteID, ExpectedFrom: invite.StatusCreated, To: invite.StatusDeclined, At: record.CreatedAt.Add(2 * time.Minute), }) require.Error(t, err) assert.True(t, errors.Is(err, invite.ErrConflict)) } func TestInviteStoreUpdateStatusReturnsNotFoundForMissingRecord(t *testing.T) { ctx := context.Background() store, _, _ := newInviteTestStore(t) err := store.UpdateStatus(ctx, ports.UpdateInviteStatusInput{ InviteID: common.InviteID("invite-missing"), ExpectedFrom: invite.StatusCreated, To: invite.StatusDeclined, At: time.Now().UTC(), }) require.ErrorIs(t, err, invite.ErrNotFound) } func TestInviteStoreGetByGameAndByUser(t *testing.T) { ctx := context.Background() store, _, _ := newInviteTestStore(t) i1 := fixtureInvite(t, "invite-a1", "user-owner", "user-1", "game-1") i2 := fixtureInvite(t, "invite-a2", "user-owner", "user-2", "game-1") i3 := fixtureInvite(t, "invite-a3", "user-owner", "user-1", "game-2") for _, record := range []invite.Invite{i1, i2, i3} { 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 := collectInviteIDs(byUser1) sort.Strings(ids) assert.Equal(t, []string{"invite-a1", "invite-a3"}, ids) byGameMissing, err := store.GetByGame(ctx, "game-missing") require.NoError(t, err) assert.Empty(t, byGameMissing) } func TestInviteStoreGetByInviter(t *testing.T) { ctx := context.Background() store, _, _ := newInviteTestStore(t) i1 := fixtureInvite(t, "invite-i1", "user-owner-a", "user-guest-1", "game-1") i2 := fixtureInvite(t, "invite-i2", "user-owner-a", "user-guest-2", "game-2") i3 := fixtureInvite(t, "invite-i3", "user-owner-b", "user-guest-1", "game-3") for _, record := range []invite.Invite{i1, i2, i3} { require.NoError(t, store.Save(ctx, record)) } byInviterA, err := store.GetByInviter(ctx, "user-owner-a") require.NoError(t, err) require.Len(t, byInviterA, 2) idsA := collectInviteIDs(byInviterA) sort.Strings(idsA) assert.Equal(t, []string{"invite-i1", "invite-i2"}, idsA) byInviterB, err := store.GetByInviter(ctx, "user-owner-b") require.NoError(t, err) require.Len(t, byInviterB, 1) assert.Equal(t, "invite-i3", byInviterB[0].InviteID.String()) byInviterMissing, err := store.GetByInviter(ctx, "user-owner-none") require.NoError(t, err) assert.Empty(t, byInviterMissing) } func TestInviteStoreGetByInviterRetainsAfterStatusChange(t *testing.T) { ctx := context.Background() store, _, _ := newInviteTestStore(t) record := fixtureInvite(t, "invite-i", "user-owner-a", "user-guest", "game-1") require.NoError(t, store.Save(ctx, record)) require.NoError(t, store.UpdateStatus(ctx, ports.UpdateInviteStatusInput{ InviteID: record.InviteID, ExpectedFrom: invite.StatusCreated, To: invite.StatusRevoked, At: record.CreatedAt.Add(time.Minute), })) matches, err := store.GetByInviter(ctx, "user-owner-a") require.NoError(t, err) require.Len(t, matches, 1) assert.Equal(t, invite.StatusRevoked, matches[0].Status) } func TestInviteStoreGetByGameDropsStaleIndexEntries(t *testing.T) { ctx := context.Background() store, server, _ := newInviteTestStore(t) record := fixtureInvite(t, "invite-a", "user-owner", "user-guest", "game-1") require.NoError(t, store.Save(ctx, record)) server.Del("lobby:invites:" + base64URL(record.InviteID.String())) records, err := store.GetByGame(ctx, record.GameID) require.NoError(t, err) assert.Empty(t, records) } func collectInviteIDs(records []invite.Invite) []string { ids := make([]string, len(records)) for index, record := range records { ids[index] = record.InviteID.String() } return ids }