364 lines
11 KiB
Go
364 lines
11 KiB
Go
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
|
|
}
|