feat: game lobby service

This commit is contained in:
Ilia Denisov
2026-04-25 23:20:55 +02:00
committed by GitHub
parent 32dc29359a
commit 48b0056b49
336 changed files with 57074 additions and 1418 deletions
@@ -0,0 +1,360 @@
package redisstate_test
import (
"context"
"errors"
"sort"
"sync"
"sync/atomic"
"testing"
"time"
"galaxy/lobby/internal/adapters/redisstate"
"galaxy/lobby/internal/domain/application"
"galaxy/lobby/internal/domain/common"
"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 newApplicationTestStore(t *testing.T) (*redisstate.ApplicationStore, *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.NewApplicationStore(client)
require.NoError(t, err)
return store, server, client
}
func fixtureApplication(t *testing.T, id common.ApplicationID, userID string, gameID common.GameID) application.Application {
t.Helper()
now := time.Date(2026, 4, 23, 12, 0, 0, 0, time.UTC)
record, err := application.New(application.NewApplicationInput{
ApplicationID: id,
GameID: gameID,
ApplicantUserID: userID,
RaceName: "Spring Racer",
Now: now,
})
require.NoError(t, err)
return record
}
func TestNewApplicationStoreRejectsNilClient(t *testing.T) {
_, err := redisstate.NewApplicationStore(nil)
require.Error(t, err)
}
func TestApplicationStoreSaveAndGet(t *testing.T) {
ctx := context.Background()
store, _, client := newApplicationTestStore(t)
record := fixtureApplication(t, "application-a", "user-1", "game-1")
require.NoError(t, store.Save(ctx, record))
got, err := store.Get(ctx, record.ApplicationID)
require.NoError(t, err)
assert.Equal(t, record.ApplicationID, got.ApplicationID)
assert.Equal(t, record.GameID, got.GameID)
assert.Equal(t, record.ApplicantUserID, got.ApplicantUserID)
assert.Equal(t, record.RaceName, got.RaceName)
assert.Equal(t, application.StatusSubmitted, got.Status)
assert.Nil(t, got.DecidedAt)
byGame, err := client.SMembers(ctx, "lobby:game_applications:"+base64URL(record.GameID.String())).Result()
require.NoError(t, err)
assert.ElementsMatch(t, []string{record.ApplicationID.String()}, byGame)
byUser, err := client.SMembers(ctx, "lobby:user_applications:"+base64URL(record.ApplicantUserID)).Result()
require.NoError(t, err)
assert.ElementsMatch(t, []string{record.ApplicationID.String()}, byUser)
active, err := client.Get(ctx,
"lobby:user_game_application:"+base64URL(record.ApplicantUserID)+":"+base64URL(record.GameID.String()),
).Result()
require.NoError(t, err)
assert.Equal(t, record.ApplicationID.String(), active)
}
func TestApplicationStoreGetReturnsNotFound(t *testing.T) {
ctx := context.Background()
store, _, _ := newApplicationTestStore(t)
_, err := store.Get(ctx, common.ApplicationID("application-missing"))
require.ErrorIs(t, err, application.ErrNotFound)
}
func TestApplicationStoreSaveRejectsNonSubmitted(t *testing.T) {
ctx := context.Background()
store, _, _ := newApplicationTestStore(t)
record := fixtureApplication(t, "application-a", "user-1", "game-1")
record.Status = application.StatusApproved
decidedAt := record.CreatedAt.Add(time.Minute)
record.DecidedAt = &decidedAt
err := store.Save(ctx, record)
require.Error(t, err)
assert.False(t, errors.Is(err, application.ErrConflict))
}
func TestApplicationStoreSaveRejectsSecondActiveForSameUserGame(t *testing.T) {
ctx := context.Background()
store, _, _ := newApplicationTestStore(t)
first := fixtureApplication(t, "application-a", "user-1", "game-1")
require.NoError(t, store.Save(ctx, first))
second := fixtureApplication(t, "application-b", "user-1", "game-1")
err := store.Save(ctx, second)
require.Error(t, err)
assert.True(t, errors.Is(err, application.ErrConflict))
_, err = store.Get(ctx, second.ApplicationID)
require.ErrorIs(t, err, application.ErrNotFound)
}
func TestApplicationStoreSaveRejectsDuplicateApplicationID(t *testing.T) {
ctx := context.Background()
store, _, _ := newApplicationTestStore(t)
first := fixtureApplication(t, "application-a", "user-1", "game-1")
require.NoError(t, store.Save(ctx, first))
err := store.Save(ctx, first)
require.Error(t, err)
assert.True(t, errors.Is(err, application.ErrConflict))
}
func TestApplicationStoreSaveAllowsSameUserDifferentGame(t *testing.T) {
ctx := context.Background()
store, _, _ := newApplicationTestStore(t)
first := fixtureApplication(t, "application-a", "user-1", "game-1")
second := fixtureApplication(t, "application-b", "user-1", "game-2")
require.NoError(t, store.Save(ctx, first))
require.NoError(t, store.Save(ctx, second))
byUser, err := store.GetByUser(ctx, "user-1")
require.NoError(t, err)
require.Len(t, byUser, 2)
}
func TestApplicationStoreUpdateStatusApproveKeepsActiveKey(t *testing.T) {
ctx := context.Background()
store, _, client := newApplicationTestStore(t)
record := fixtureApplication(t, "application-a", "user-1", "game-1")
require.NoError(t, store.Save(ctx, record))
at := record.CreatedAt.Add(time.Hour)
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateApplicationStatusInput{
ApplicationID: record.ApplicationID,
ExpectedFrom: application.StatusSubmitted,
To: application.StatusApproved,
At: at,
}))
got, err := store.Get(ctx, record.ApplicationID)
require.NoError(t, err)
assert.Equal(t, application.StatusApproved, got.Status)
require.NotNil(t, got.DecidedAt)
assert.True(t, got.DecidedAt.Equal(at.UTC()))
activeKey := "lobby:user_game_application:" + base64URL(record.ApplicantUserID) + ":" + base64URL(record.GameID.String())
stored, err := client.Get(ctx, activeKey).Result()
require.NoError(t, err)
assert.Equal(t, record.ApplicationID.String(), stored)
}
func TestApplicationStoreUpdateStatusRejectClearsActiveKey(t *testing.T) {
ctx := context.Background()
store, _, client := newApplicationTestStore(t)
record := fixtureApplication(t, "application-a", "user-1", "game-1")
require.NoError(t, store.Save(ctx, record))
at := record.CreatedAt.Add(time.Hour)
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateApplicationStatusInput{
ApplicationID: record.ApplicationID,
ExpectedFrom: application.StatusSubmitted,
To: application.StatusRejected,
At: at,
}))
got, err := store.Get(ctx, record.ApplicationID)
require.NoError(t, err)
assert.Equal(t, application.StatusRejected, got.Status)
require.NotNil(t, got.DecidedAt)
activeKey := "lobby:user_game_application:" + base64URL(record.ApplicantUserID) + ":" + base64URL(record.GameID.String())
_, err = client.Get(ctx, activeKey).Result()
require.ErrorIs(t, err, redis.Nil)
// After rejection, the same user may re-apply to the same game.
reapplied := fixtureApplication(t, "application-b", "user-1", "game-1")
require.NoError(t, store.Save(ctx, reapplied))
}
func TestApplicationStoreUpdateStatusRejectsInvalidTransitionWithoutMutation(t *testing.T) {
ctx := context.Background()
store, _, _ := newApplicationTestStore(t)
record := fixtureApplication(t, "application-a", "user-1", "game-1")
require.NoError(t, store.Save(ctx, record))
err := store.UpdateStatus(ctx, ports.UpdateApplicationStatusInput{
ApplicationID: record.ApplicationID,
ExpectedFrom: application.StatusApproved,
To: application.StatusSubmitted,
At: record.CreatedAt.Add(time.Minute),
})
require.Error(t, err)
assert.True(t, errors.Is(err, application.ErrInvalidTransition))
got, err := store.Get(ctx, record.ApplicationID)
require.NoError(t, err)
assert.Equal(t, application.StatusSubmitted, got.Status)
assert.Nil(t, got.DecidedAt)
}
func TestApplicationStoreUpdateStatusReturnsConflictOnExpectedFromMismatch(t *testing.T) {
ctx := context.Background()
store, _, _ := newApplicationTestStore(t)
record := fixtureApplication(t, "application-a", "user-1", "game-1")
require.NoError(t, store.Save(ctx, record))
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateApplicationStatusInput{
ApplicationID: record.ApplicationID,
ExpectedFrom: application.StatusSubmitted,
To: application.StatusApproved,
At: record.CreatedAt.Add(time.Minute),
}))
err := store.UpdateStatus(ctx, ports.UpdateApplicationStatusInput{
ApplicationID: record.ApplicationID,
ExpectedFrom: application.StatusSubmitted,
To: application.StatusRejected,
At: record.CreatedAt.Add(2 * time.Minute),
})
require.Error(t, err)
assert.True(t, errors.Is(err, application.ErrConflict))
}
func TestApplicationStoreUpdateStatusReturnsNotFoundForMissingRecord(t *testing.T) {
ctx := context.Background()
store, _, _ := newApplicationTestStore(t)
err := store.UpdateStatus(ctx, ports.UpdateApplicationStatusInput{
ApplicationID: common.ApplicationID("application-missing"),
ExpectedFrom: application.StatusSubmitted,
To: application.StatusApproved,
At: time.Now().UTC(),
})
require.ErrorIs(t, err, application.ErrNotFound)
}
func TestApplicationStoreGetByGameAndByUser(t *testing.T) {
ctx := context.Background()
store, _, _ := newApplicationTestStore(t)
a1 := fixtureApplication(t, "application-a1", "user-1", "game-1")
a2 := fixtureApplication(t, "application-a2", "user-2", "game-1")
a3 := fixtureApplication(t, "application-a3", "user-1", "game-2")
for _, record := range []application.Application{a1, a2, a3} {
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 := collectApplicationIDs(byUser1)
sort.Strings(ids)
assert.Equal(t, []string{"application-a1", "application-a3"}, ids)
byUser3, err := store.GetByUser(ctx, "user-missing")
require.NoError(t, err)
assert.Empty(t, byUser3)
}
func TestApplicationStoreGetByGameDropsStaleIndexEntries(t *testing.T) {
ctx := context.Background()
store, server, _ := newApplicationTestStore(t)
record := fixtureApplication(t, "application-a", "user-1", "game-1")
require.NoError(t, store.Save(ctx, record))
server.Del("lobby:applications:" + base64URL(record.ApplicationID.String()))
records, err := store.GetByGame(ctx, record.GameID)
require.NoError(t, err)
assert.Empty(t, records)
}
func TestApplicationStoreConcurrentSaveHasExactlyOneWinner(t *testing.T) {
ctx := context.Background()
_, _, client := newApplicationTestStore(t)
storeA, err := redisstate.NewApplicationStore(client)
require.NoError(t, err)
storeB, err := redisstate.NewApplicationStore(client)
require.NoError(t, err)
recordA := fixtureApplication(t, "application-a", "user-1", "game-1")
recordB := fixtureApplication(t, "application-b", "user-1", "game-1")
var (
wg sync.WaitGroup
successes atomic.Int32
conflicts atomic.Int32
others atomic.Int32
)
apply := func(target *redisstate.ApplicationStore, record application.Application) {
defer wg.Done()
err := target.Save(ctx, record)
switch {
case err == nil:
successes.Add(1)
case errors.Is(err, application.ErrConflict):
conflicts.Add(1)
default:
others.Add(1)
}
}
wg.Add(2)
go apply(storeA, recordA)
go apply(storeB, recordB)
wg.Wait()
assert.Equal(t, int32(0), others.Load(), "unexpected non-conflict error")
assert.Equal(t, int32(1), successes.Load(), "expected exactly one success")
assert.Equal(t, int32(1), conflicts.Load(), "expected exactly one conflict")
}
func collectApplicationIDs(records []application.Application) []string {
ids := make([]string, len(records))
for index, record := range records {
ids[index] = record.ApplicationID.String()
}
return ids
}