package racenamedir_test import ( "context" "database/sql" "strconv" "testing" "time" "galaxy/lobby/internal/adapters/postgres/internal/pgtest" "galaxy/lobby/internal/adapters/postgres/racenamedir" "galaxy/lobby/internal/domain/racename" "galaxy/lobby/internal/ports" "galaxy/lobby/internal/ports/racenamedirtest" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // TestMain wires the per-package PostgreSQL container shared by every // store test in this module. func TestMain(m *testing.M) { pgtest.RunMain(m) } // newDirectory builds one Race Name Directory adapter against a freshly // truncated lobby schema. now selects between the deterministic clock the // shared suite supplies and the default time.Now. func newDirectory(t *testing.T, now func() time.Time) *racenamedir.Directory { t.Helper() pgtest.TruncateAll(t) policy, err := racename.NewPolicy() require.NoError(t, err) cfg := racenamedir.Config{ DB: pgtest.Ensure(t).Pool(), OperationTimeout: pgtest.OperationTimeout, Policy: policy, } if now != nil { cfg.Clock = now } directory, err := racenamedir.New(cfg) require.NoError(t, err) return directory } // TestRaceNameDirectoryContract runs the shared behavioural suite that // every ports.RaceNameDirectory implementation must pass. func TestRaceNameDirectoryContract(t *testing.T) { racenamedirtest.Run(t, func(now func() time.Time) ports.RaceNameDirectory { return newDirectory(t, now) }) } func TestNewRejectsNilDB(t *testing.T) { policy, err := racename.NewPolicy() require.NoError(t, err) _, err = racenamedir.New(racenamedir.Config{ OperationTimeout: pgtest.OperationTimeout, Policy: policy, }) require.Error(t, err) } func TestNewRejectsNilPolicy(t *testing.T) { _, err := racenamedir.New(racenamedir.Config{ DB: pgtest.Ensure(t).Pool(), OperationTimeout: pgtest.OperationTimeout, }) require.Error(t, err) } func TestNewRejectsNonPositiveTimeout(t *testing.T) { policy, err := racename.NewPolicy() require.NoError(t, err) _, err = racenamedir.New(racenamedir.Config{ DB: pgtest.Ensure(t).Pool(), Policy: policy, }) require.Error(t, err) } // TestRegisteredRowShape validates the on-disk shape of a registered // binding so future schema migrations have an explicit anchor. func TestRegisteredRowShape(t *testing.T) { now := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC) directory := newDirectory(t, func() time.Time { return now }) ctx := context.Background() const ( gameID = "game-shape-1" userID = "user-shape-1" raceName = "PilotNova" ) require.NoError(t, directory.Reserve(ctx, gameID, userID, raceName)) require.NoError(t, directory.MarkPendingRegistration(ctx, gameID, userID, raceName, now.Add(time.Hour))) require.NoError(t, directory.Register(ctx, gameID, userID, raceName)) pool := pgtest.Ensure(t).Pool() canonical, err := directory.Canonicalize(raceName) require.NoError(t, err) row := pool.QueryRowContext(ctx, ` SELECT canonical_key, game_id, holder_user_id, race_name, binding_kind, source_game_id, reserved_at_ms, eligible_until_ms, registered_at_ms FROM race_names WHERE canonical_key = $1 `, canonical) var ( canonicalKey string storedGameID string holderUserID string raceNameCol string bindingKind string sourceGameID string reservedAtMs int64 eligibleAtMs sql.NullInt64 registeredAtMs sql.NullInt64 ) require.NoError(t, row.Scan( &canonicalKey, &storedGameID, &holderUserID, &raceNameCol, &bindingKind, &sourceGameID, &reservedAtMs, &eligibleAtMs, ®isteredAtMs, )) assert.Equal(t, canonical, canonicalKey) assert.Equal(t, "", storedGameID, "registered rows store game_id = ''") assert.Equal(t, userID, holderUserID) assert.Equal(t, raceName, raceNameCol) assert.Equal(t, ports.KindRegistered, bindingKind) assert.Equal(t, gameID, sourceGameID) assert.True(t, registeredAtMs.Valid) assert.Equal(t, now.UTC().UnixMilli(), registeredAtMs.Int64) assert.False(t, eligibleAtMs.Valid, "registered rows null out eligible_until_ms") assert.Equal(t, now.UTC().UnixMilli(), reservedAtMs, "reserved_at_ms is preserved across promote+register") } // TestRegisteredPartialUniqueIndex confirms that a second user cannot // register the same canonical key, even when they own a separate // reservation row at a different (canonical_key, game_id) PK. func TestRegisteredPartialUniqueIndex(t *testing.T) { now := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC) directory := newDirectory(t, func() time.Time { return now }) ctx := context.Background() const ( raceName = "PilotNova" gameA = "game-unique-a" userA = "user-unique-a" userB = "user-unique-b" ) require.NoError(t, directory.Reserve(ctx, gameA, userA, raceName)) require.NoError(t, directory.MarkPendingRegistration(ctx, gameA, userA, raceName, now.Add(time.Hour))) require.NoError(t, directory.Register(ctx, gameA, userA, raceName)) err := directory.Reserve(ctx, gameA, userB, raceName) require.ErrorIs(t, err, ports.ErrNameTaken) } // TestExpirePendingRegistrationsBatched seeds two pending entries with // distinct canonical keys and asserts both are released by a single pass // even when the worker iterates via separate advisory locks. func TestExpirePendingRegistrationsBatched(t *testing.T) { now := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC) directory := newDirectory(t, func() time.Time { return now }) ctx := context.Background() for index := range 3 { gameID := "game-batch-" + strconv.Itoa(index) userID := "user-batch-" + strconv.Itoa(index) raceName := "PilotBatch" + strconv.Itoa(index) require.NoError(t, directory.Reserve(ctx, gameID, userID, raceName)) require.NoError(t, directory.MarkPendingRegistration(ctx, gameID, userID, raceName, now.Add(time.Hour))) } expired, err := directory.ExpirePendingRegistrations(ctx, now.Add(2*time.Hour)) require.NoError(t, err) require.Len(t, expired, 3) expired, err = directory.ExpirePendingRegistrations(ctx, now.Add(2*time.Hour)) require.NoError(t, err) assert.Empty(t, expired, "second pass releases nothing") }