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