feat: use postgres
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,193 @@
|
||||
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")
|
||||
}
|
||||
Reference in New Issue
Block a user