package playermappingstore_test import ( "context" "errors" "testing" "time" "galaxy/gamemaster/internal/adapters/postgres/internal/pgtest" "galaxy/gamemaster/internal/adapters/postgres/playermappingstore" "galaxy/gamemaster/internal/domain/playermapping" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestMain(m *testing.M) { pgtest.RunMain(m) } func newStore(t *testing.T) *playermappingstore.Store { t.Helper() pgtest.TruncateAll(t) store, err := playermappingstore.New(playermappingstore.Config{ DB: pgtest.Ensure(t).Pool(), OperationTimeout: pgtest.OperationTimeout, }) require.NoError(t, err) return store } func mapping(gameID, userID, raceName, uuid string, createdAt time.Time) playermapping.PlayerMapping { return playermapping.PlayerMapping{ GameID: gameID, UserID: userID, RaceName: raceName, EnginePlayerUUID: uuid, CreatedAt: createdAt, } } func TestNewRejectsInvalidConfig(t *testing.T) { _, err := playermappingstore.New(playermappingstore.Config{}) require.Error(t, err) store, err := playermappingstore.New(playermappingstore.Config{ DB: pgtest.Ensure(t).Pool(), OperationTimeout: 0, }) require.Error(t, err) require.Nil(t, store) } func TestBulkInsertHappy(t *testing.T) { ctx := context.Background() store := newStore(t) now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC) records := []playermapping.PlayerMapping{ mapping("game-001", "user-1", "Aelinari", "uuid-1", now), mapping("game-001", "user-2", "Drazi", "uuid-2", now), mapping("game-001", "user-3", "Voltori", "uuid-3", now), } require.NoError(t, store.BulkInsert(ctx, records)) for _, want := range records { got, err := store.Get(ctx, want.GameID, want.UserID) require.NoError(t, err) assert.Equal(t, want.RaceName, got.RaceName) assert.Equal(t, want.EnginePlayerUUID, got.EnginePlayerUUID) assert.True(t, got.CreatedAt.Equal(now)) assert.Equal(t, time.UTC, got.CreatedAt.Location()) } } func TestBulkInsertEmpty(t *testing.T) { ctx := context.Background() store := newStore(t) require.NoError(t, store.BulkInsert(ctx, nil)) require.NoError(t, store.BulkInsert(ctx, []playermapping.PlayerMapping{})) got, err := store.ListByGame(ctx, "game-001") require.NoError(t, err) assert.Empty(t, got) } func TestBulkInsertAtomicConflictRaceName(t *testing.T) { ctx := context.Background() store := newStore(t) now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC) // user-2 reuses Aelinari (already taken by user-1) inside the same // game — the unique (game_id, race_name) index must reject the // whole batch. records := []playermapping.PlayerMapping{ mapping("game-001", "user-1", "Aelinari", "uuid-1", now), mapping("game-001", "user-2", "Drazi", "uuid-2", now), mapping("game-001", "user-3", "Aelinari", "uuid-3", now), } err := store.BulkInsert(ctx, records) require.Error(t, err) require.True(t, errors.Is(err, playermapping.ErrConflict), "want ErrConflict, got %v", err) got, err := store.ListByGame(ctx, "game-001") require.NoError(t, err) assert.Empty(t, got, "atomic batch must roll back every row when any row fails") } func TestBulkInsertAtomicConflictUserID(t *testing.T) { ctx := context.Background() store := newStore(t) now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC) records := []playermapping.PlayerMapping{ mapping("game-001", "user-1", "Aelinari", "uuid-1", now), mapping("game-001", "user-1", "Drazi", "uuid-2", now), // user-1 twice } err := store.BulkInsert(ctx, records) require.Error(t, err) require.True(t, errors.Is(err, playermapping.ErrConflict)) } func TestBulkInsertConflictAcrossCalls(t *testing.T) { ctx := context.Background() store := newStore(t) now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC) require.NoError(t, store.BulkInsert(ctx, []playermapping.PlayerMapping{ mapping("game-001", "user-1", "Aelinari", "uuid-1", now), })) err := store.BulkInsert(ctx, []playermapping.PlayerMapping{ mapping("game-001", "user-1", "DifferentRace", "uuid-2", now), }) require.Error(t, err) require.True(t, errors.Is(err, playermapping.ErrConflict)) } func TestBulkInsertRejectsInvalid(t *testing.T) { ctx := context.Background() store := newStore(t) now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC) bad := []playermapping.PlayerMapping{ mapping("game-001", "user-1", "Aelinari", "uuid-1", now), {GameID: "game-001", UserID: "", RaceName: "Drazi", EnginePlayerUUID: "uuid-2", CreatedAt: now}, } err := store.BulkInsert(ctx, bad) require.Error(t, err) require.False(t, errors.Is(err, playermapping.ErrConflict)) got, err := store.ListByGame(ctx, "game-001") require.NoError(t, err) assert.Empty(t, got, "validation rejection must not insert any row") } func TestGetMissingReturnsNotFound(t *testing.T) { ctx := context.Background() store := newStore(t) _, err := store.Get(ctx, "game-001", "user-1") require.Error(t, err) require.True(t, errors.Is(err, playermapping.ErrNotFound)) } func TestGetByRace(t *testing.T) { ctx := context.Background() store := newStore(t) now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC) require.NoError(t, store.BulkInsert(ctx, []playermapping.PlayerMapping{ mapping("game-001", "user-1", "Aelinari", "uuid-1", now), mapping("game-001", "user-2", "Drazi", "uuid-2", now), })) got, err := store.GetByRace(ctx, "game-001", "Aelinari") require.NoError(t, err) assert.Equal(t, "user-1", got.UserID) _, err = store.GetByRace(ctx, "game-001", "Voltori") require.Error(t, err) require.True(t, errors.Is(err, playermapping.ErrNotFound)) } func TestListByGameSortedByUserID(t *testing.T) { ctx := context.Background() store := newStore(t) now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC) require.NoError(t, store.BulkInsert(ctx, []playermapping.PlayerMapping{ mapping("game-001", "user-c", "Aelinari", "uuid-1", now), mapping("game-001", "user-a", "Drazi", "uuid-2", now), mapping("game-001", "user-b", "Voltori", "uuid-3", now), // other game's mappings must not leak mapping("game-002", "user-z", "Outsider", "uuid-4", now), })) got, err := store.ListByGame(ctx, "game-001") require.NoError(t, err) require.Len(t, got, 3) assert.Equal(t, "user-a", got[0].UserID) assert.Equal(t, "user-b", got[1].UserID) assert.Equal(t, "user-c", got[2].UserID) } func TestListByGameUnknown(t *testing.T) { ctx := context.Background() store := newStore(t) got, err := store.ListByGame(ctx, "unknown-game") require.NoError(t, err) assert.Empty(t, got) } func TestDeleteByGameIdempotent(t *testing.T) { ctx := context.Background() store := newStore(t) now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC) require.NoError(t, store.BulkInsert(ctx, []playermapping.PlayerMapping{ mapping("game-001", "user-1", "Aelinari", "uuid-1", now), mapping("game-001", "user-2", "Drazi", "uuid-2", now), })) require.NoError(t, store.DeleteByGame(ctx, "game-001")) got, err := store.ListByGame(ctx, "game-001") require.NoError(t, err) assert.Empty(t, got) // Second call must be a no-op. require.NoError(t, store.DeleteByGame(ctx, "game-001")) } func TestGetRejectsEmptyArgs(t *testing.T) { ctx := context.Background() store := newStore(t) _, err := store.Get(ctx, "", "user-1") require.Error(t, err) _, err = store.Get(ctx, "game-001", "") require.Error(t, err) } func TestGetByRaceRejectsEmptyArgs(t *testing.T) { ctx := context.Background() store := newStore(t) _, err := store.GetByRace(ctx, "", "Aelinari") require.Error(t, err) _, err = store.GetByRace(ctx, "game-001", "") require.Error(t, err) } func TestListByGameRejectsEmpty(t *testing.T) { ctx := context.Background() store := newStore(t) _, err := store.ListByGame(ctx, "") require.Error(t, err) } func TestDeleteByGameRejectsEmpty(t *testing.T) { ctx := context.Background() store := newStore(t) err := store.DeleteByGame(ctx, "") require.Error(t, err) }