194 lines
6.0 KiB
Go
194 lines
6.0 KiB
Go
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")
|
|
}
|