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 }