feat: game lobby service
This commit is contained in:
@@ -0,0 +1,363 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user