361 lines
12 KiB
Go
361 lines
12 KiB
Go
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
|
|
}
|