795 lines
26 KiB
Go
795 lines
26 KiB
Go
package storage
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"chess/internal/server/storage/pgtest"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
)
|
|
|
|
func TestInitDBIsIdempotentAndRejectsNewerSchema(t *testing.T) {
|
|
dsn := pgtest.DSN(t)
|
|
store := openStore(t, dsn)
|
|
if err := store.InitDB(); err != nil {
|
|
t.Fatalf("second InitDB: %v", err)
|
|
}
|
|
if version, err := store.SchemaVersion(); err != nil || version != schemaVersion {
|
|
t.Fatalf("schema version = %d, %v; want %d", version, err, schemaVersion)
|
|
}
|
|
|
|
if _, err := store.db.Exec(`UPDATE schema_version SET version = $1`, schemaVersion+1); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := store.InitDB(); err == nil || !strings.Contains(err.Error(), "newer than supported") {
|
|
t.Fatalf("InitDB error = %v, want newer-version rejection", err)
|
|
}
|
|
if version, _ := store.SchemaVersion(); version != schemaVersion+1 {
|
|
t.Fatalf("newer schema version was overwritten: %d", version)
|
|
}
|
|
}
|
|
|
|
func TestInitDBIssuesNoDDLWhenCurrent(t *testing.T) {
|
|
dsn := pgtest.DSN(t)
|
|
openStore(t, dsn) // migrates
|
|
|
|
// A read-only session rejects any DDL or write, standing in for a runtime
|
|
// role that holds only DML privileges on an owner-migrated schema.
|
|
readOnly, err := NewStore(pgtest.WithParam(dsn, "default_transaction_read_only", "on"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = readOnly.Close() })
|
|
if err := readOnly.InitDB(); err != nil {
|
|
t.Fatalf("InitDB on a current schema must not write: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDropSchemaRemovesOwnedTables(t *testing.T) {
|
|
store := openStore(t, pgtest.DSN(t))
|
|
if err := store.DropSchema(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if version, err := store.SchemaVersion(); err != nil || version != 0 {
|
|
t.Fatalf("schema version after drop = %d, %v; want 0", version, err)
|
|
}
|
|
if err := store.InitDB(); err != nil {
|
|
t.Fatalf("recreate after drop: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSchemaConstraintsRejectInconsistentRows(t *testing.T) {
|
|
store := openStore(t, pgtest.DSN(t))
|
|
gameID := uuid.NewString()
|
|
for name, query := range map[string]string{
|
|
"result without end time": `INSERT INTO games (game_id, initial_fen, white_player_id, white_type,
|
|
black_player_id, black_type, result) VALUES ($1, 'fen', $1, 1, $1, 1, 'draw')`,
|
|
"end time without result": `INSERT INTO games (game_id, initial_fen, white_player_id, white_type,
|
|
black_player_id, black_type, end_time_utc) VALUES ($1, 'fen', $1, 1, $1, 1, now())`,
|
|
"invalid player type": `INSERT INTO games (game_id, initial_fen, white_player_id, white_type,
|
|
black_player_id, black_type) VALUES ($1, 'fen', $1, 3, $1, 1)`,
|
|
"uppercase username": `INSERT INTO users (user_id, username, password_hash)
|
|
VALUES ($1, 'Alice', 'hash')`,
|
|
"empty player name": `INSERT INTO games (game_id, initial_fen, white_player_id, white_type,
|
|
black_player_id, black_type, white_name) VALUES ($1, 'fen', $1, 1, $1, 1, '')`,
|
|
} {
|
|
if _, err := store.db.Exec(query, gameID); err == nil {
|
|
t.Errorf("%s: insert succeeded", name)
|
|
}
|
|
}
|
|
if _, err := store.db.Exec(`INSERT INTO games (game_id, initial_fen, white_player_id, white_type,
|
|
black_player_id, black_type) VALUES ($1, 'fen', $1, 1, $1, 1)`, gameID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := store.db.Exec(`INSERT INTO moves (game_id, move_number, move_uci, fen_after_move,
|
|
player_color) VALUES ($1, 1, 'e2e4; DROP', 'fen', 'w')`, gameID); err == nil {
|
|
t.Error("malformed UCI move accepted")
|
|
}
|
|
}
|
|
|
|
func TestReplayPersistenceIsAtomicAndReadAfterWriteConsistent(t *testing.T) {
|
|
store := openStore(t, pgtest.DSN(t))
|
|
gameID, anonymous, black, user := uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString()
|
|
started := time.Date(2026, 9, 7, 1, 2, 3, 0, time.UTC)
|
|
ended := started.Add(5 * time.Minute)
|
|
|
|
if err := store.RecordNewGame(GameRecord{
|
|
GameID: gameID, InitialFEN: "initial",
|
|
WhitePlayerID: anonymous, WhiteType: 1,
|
|
BlackPlayerID: black, BlackType: 1,
|
|
StartTimeUTC: started,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := store.RecordMove(MovePersistence{
|
|
Move: MoveRecord{
|
|
GameID: gameID, MoveNumber: 1, MoveUCI: "e2e4",
|
|
FENAfterMove: "after-e2e4", PlayerColor: "w", MoveTimeUTC: ended,
|
|
},
|
|
ClaimColor: "w", ClaimedBy: user,
|
|
Result: "white_wins", EndTimeUTC: &ended,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// GetGameHistory must observe both queued writes without sleeps or polling.
|
|
record, moves, err := store.GetGameHistory(gameID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if record.WhiteClaimedBy != user || record.Result != "white_wins" ||
|
|
record.EndTimeUTC == nil || !record.EndTimeUTC.Equal(ended) || !record.StartTimeUTC.Equal(started) {
|
|
t.Fatalf("durable game mutation incomplete: %+v", record)
|
|
}
|
|
if record.StartTimeUTC.Location() != time.UTC {
|
|
t.Fatalf("start time location = %v, want UTC", record.StartTimeUTC.Location())
|
|
}
|
|
if len(moves) != 1 || moves[0].MoveNumber != 1 || moves[0].FENAfterMove != "after-e2e4" {
|
|
t.Fatalf("moves = %+v", moves)
|
|
}
|
|
|
|
owned, err := store.QueryGamesForUser(user, 10, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(owned) != 1 || owned[0].MoveCount != 1 || owned[0].FinalFEN != "after-e2e4" {
|
|
t.Fatalf("claimed game lookup = %+v", owned)
|
|
}
|
|
|
|
if err := store.RewindGame(gameID, 0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
record, moves, err = store.GetGameHistory(gameID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if record.Result != "" || record.EndTimeUTC != nil || len(moves) != 0 {
|
|
t.Fatalf("rewind left stale replay data: game=%+v moves=%+v", record, moves)
|
|
}
|
|
owned, err = store.QueryGamesForUser(user, 10, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(owned) != 1 || owned[0].MoveCount != 0 || owned[0].FinalFEN != "initial" {
|
|
t.Fatalf("rewound summary = %+v", owned)
|
|
}
|
|
|
|
if _, _, err := store.GetGameHistory("not-a-uuid"); !IsGameNotFound(err) {
|
|
t.Fatalf("malformed ID error = %v, want not found", err)
|
|
}
|
|
if _, _, err := store.GetGameHistory(uuid.NewString()); !IsGameNotFound(err) {
|
|
t.Fatalf("unknown ID error = %v, want not found", err)
|
|
}
|
|
}
|
|
|
|
func TestUserGamesArePagedNewestFirstWithoutDuplicates(t *testing.T) {
|
|
store := openStore(t, pgtest.DSN(t))
|
|
user, other := uuid.NewString(), uuid.NewString()
|
|
base := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)
|
|
var want []string
|
|
for i := range 5 {
|
|
record := GameRecord{
|
|
GameID: uuid.NewString(), InitialFEN: "initial",
|
|
WhitePlayerID: uuid.NewString(), WhiteType: 1,
|
|
BlackPlayerID: uuid.NewString(), BlackType: 1,
|
|
StartTimeUTC: base.Add(time.Duration(i) * time.Minute),
|
|
}
|
|
switch i {
|
|
case 0:
|
|
record.WhiteClaimedBy = user
|
|
case 1:
|
|
record.BlackClaimedBy = user
|
|
case 2:
|
|
record.WhiteClaimedBy, record.BlackClaimedBy = user, user // self-play
|
|
case 3:
|
|
record.WhiteClaimedBy = other
|
|
case 4:
|
|
record.BlackClaimedBy, record.WhiteClaimedBy = user, other
|
|
}
|
|
if err := store.RecordNewGame(record); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if i != 3 {
|
|
want = append([]string{record.GameID}, want...)
|
|
}
|
|
}
|
|
|
|
var got []string
|
|
for offset := 0; ; offset += 2 {
|
|
page, err := store.QueryGamesForUser(user, 2, offset)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, game := range page {
|
|
got = append(got, game.GameID)
|
|
}
|
|
if len(page) < 2 {
|
|
break
|
|
}
|
|
}
|
|
if fmt.Sprint(got) != fmt.Sprint(want) {
|
|
t.Fatalf("user games = %v, want %v", got, want)
|
|
}
|
|
if games, err := store.QueryGamesForUser("not-a-uuid", 10, 0); err != nil || len(games) != 0 {
|
|
t.Fatalf("malformed user ID = %v, %v; want empty", games, err)
|
|
}
|
|
}
|
|
|
|
func TestQueryPlansUsePurposeBuiltIndexes(t *testing.T) {
|
|
store := openStore(t, pgtest.DSN(t))
|
|
userID := uuid.NewString()
|
|
|
|
tests := []struct {
|
|
name string
|
|
query string
|
|
args []any
|
|
want []string
|
|
}{
|
|
{
|
|
name: "user games use both claim indexes",
|
|
query: `SELECT game_id FROM games g
|
|
WHERE g.white_claimed_by = $1 OR g.black_claimed_by = $1
|
|
ORDER BY g.start_time_utc DESC, g.game_id DESC LIMIT 50`,
|
|
args: []any{userID},
|
|
want: []string{"games_white_claimed_idx", "games_black_claimed_idx"},
|
|
},
|
|
{
|
|
name: "last move probe uses the moves primary key",
|
|
query: `SELECT move_number FROM moves WHERE game_id = $1
|
|
ORDER BY move_number DESC LIMIT 1`,
|
|
args: []any{userID},
|
|
want: []string{"moves_pkey"},
|
|
},
|
|
{
|
|
name: "anonymous purge candidates",
|
|
query: `SELECT game_id FROM games g
|
|
WHERE g.white_claimed_by IS NULL AND g.black_claimed_by IS NULL
|
|
AND g.start_time_utc < $1`,
|
|
args: []any{time.Now()},
|
|
want: []string{"games_anonymous_start_idx"},
|
|
},
|
|
{
|
|
name: "session expiry cleanup",
|
|
query: `SELECT session_id FROM sessions WHERE expires_at < $1`,
|
|
args: []any{time.Now()},
|
|
want: []string{"sessions_expires_at_idx"},
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
plan := explain(t, store.db, test.query, test.args...)
|
|
for _, index := range test.want {
|
|
if !strings.Contains(plan, index) {
|
|
t.Errorf("query plan does not use %s:\n%s", index, plan)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAcceptedWriteCommitsAfterQueueAdmissionFailureMarksHealthDegraded(t *testing.T) {
|
|
store := openStore(t, pgtest.DSN(t))
|
|
store.healthStatus.Store(false) // Queue saturation rejects new work but accepted work must drain.
|
|
userID := uuid.NewString()
|
|
done := make(chan error, 1)
|
|
store.handleWrite(writeRequest{
|
|
operation: "accepted_before_saturation",
|
|
run: func(ctx context.Context, tx *sql.Tx) error {
|
|
_, err := tx.ExecContext(ctx, `INSERT INTO users
|
|
(user_id, username, password_hash, created_at)
|
|
VALUES ($1, 'alice', 'hash', now())`, userID)
|
|
return err
|
|
},
|
|
barrier: done,
|
|
})
|
|
if err := <-done; err != nil {
|
|
t.Fatalf("accepted write was discarded: %v", err)
|
|
}
|
|
if _, err := store.GetUserByID(userID); err != nil {
|
|
t.Fatalf("accepted write was not committed: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestWritesAfterTransactionFailureAreSkippedExplicitly(t *testing.T) {
|
|
store := openStore(t, pgtest.DSN(t))
|
|
failed := make(chan error, 1)
|
|
store.handleWrite(writeRequest{
|
|
operation: "forced_failure",
|
|
run: func(context.Context, *sql.Tx) error { return errors.New("forced failure") },
|
|
barrier: failed,
|
|
})
|
|
if err := <-failed; err == nil {
|
|
t.Fatal("forced transaction failure was not reported")
|
|
}
|
|
if store.IsHealthy() {
|
|
t.Fatal("permanent failure did not degrade storage")
|
|
}
|
|
|
|
ran := false
|
|
skipped := make(chan error, 1)
|
|
store.handleWrite(writeRequest{
|
|
operation: "after_failure",
|
|
run: func(context.Context, *sql.Tx) error {
|
|
ran = true
|
|
return nil
|
|
},
|
|
barrier: skipped,
|
|
})
|
|
if err := <-skipped; !errors.Is(err, ErrStorageDegraded) {
|
|
t.Fatalf("skipped write error = %v, want ErrStorageDegraded", err)
|
|
}
|
|
if ran {
|
|
t.Fatal("write ran after an earlier transaction broke ordering")
|
|
}
|
|
}
|
|
|
|
func TestWriteSurvivesTerminatedConnection(t *testing.T) {
|
|
dsn := pgtest.DSN(t)
|
|
store := openStore(t, dsn)
|
|
store.db.SetMaxOpenConns(1)
|
|
store.db.SetMaxIdleConns(1)
|
|
|
|
var pid int
|
|
if err := store.db.QueryRow(`SELECT pg_backend_pid()`).Scan(&pid); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
admin, err := sql.Open("pgx", dsn)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer admin.Close()
|
|
var terminated bool
|
|
if err := admin.QueryRow(`SELECT pg_terminate_backend($1)`, pid).Scan(&terminated); err != nil || !terminated {
|
|
t.Skipf("cannot terminate own backend (terminated=%v): %v", terminated, err)
|
|
}
|
|
|
|
// The pooled connection is now dead. The ordered writer must reconnect and
|
|
// commit instead of degrading storage.
|
|
gameID := uuid.NewString()
|
|
if err := store.RecordNewGame(GameRecord{
|
|
GameID: gameID, InitialFEN: "initial",
|
|
WhitePlayerID: uuid.NewString(), WhiteType: 1,
|
|
BlackPlayerID: uuid.NewString(), BlackType: 1,
|
|
StartTimeUTC: time.Now().UTC(),
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, _, err := store.GetGameHistory(gameID); err != nil {
|
|
t.Fatalf("write after connection loss: %v", err)
|
|
}
|
|
if !store.IsHealthy() {
|
|
t.Fatal("transient connection loss degraded storage")
|
|
}
|
|
}
|
|
|
|
func TestTransientErrorClassification(t *testing.T) {
|
|
for _, test := range []struct {
|
|
err error
|
|
want bool
|
|
}{
|
|
{&pgconn.PgError{Code: "57P01"}, true},
|
|
{&pgconn.PgError{Code: "08006"}, true},
|
|
{&pgconn.PgError{Code: "40001"}, true},
|
|
{fmt.Errorf("wrapped: %w", io.ErrUnexpectedEOF), true},
|
|
{&pgconn.PgError{Code: "23505"}, false},
|
|
{&pgconn.PgError{Code: "42P01"}, false},
|
|
{context.DeadlineExceeded, false},
|
|
{errors.New("forced"), false},
|
|
} {
|
|
if got := isTransient(test.err); got != test.want {
|
|
t.Errorf("isTransient(%v) = %v, want %v", test.err, got, test.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestForeignKeyCascadeAppliesToSessions(t *testing.T) {
|
|
store := openStore(t, pgtest.DSN(t))
|
|
now := time.Now().UTC()
|
|
userID, sessionID := uuid.NewString(), uuid.NewString()
|
|
if err := store.CreateUser(UserRecord{
|
|
UserID: userID, Username: "user1", PasswordHash: "hash", CreatedAt: now,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := store.CreateSession(SessionRecord{
|
|
SessionID: sessionID, UserID: userID, CreatedAt: now, ExpiresAt: now.Add(time.Hour),
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
user, err := store.GetUserByID(userID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if user.LastLoginAt == nil || !user.LastLoginAt.Equal(now.Truncate(time.Microsecond)) {
|
|
t.Fatalf("session creation did not record login time: %v", user.LastLoginAt)
|
|
}
|
|
if err := store.DeleteUser(userID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := store.GetSession(sessionID); !errors.Is(err, sql.ErrNoRows) {
|
|
t.Fatalf("session survived user cascade: %v", err)
|
|
}
|
|
if err := store.DeleteUser(userID); !errors.Is(err, sql.ErrNoRows) {
|
|
t.Fatalf("deleting a missing user = %v, want sql.ErrNoRows", err)
|
|
}
|
|
}
|
|
|
|
func TestLimitedUserCreationIsAtomicWithInitialSession(t *testing.T) {
|
|
store := openStore(t, pgtest.DSN(t))
|
|
now := time.Now().UTC()
|
|
limits := UserLimits{MaxUsers: 2}
|
|
|
|
first := UserRecord{
|
|
UserID: uuid.NewString(), Username: "alice", Email: "Alice@Example.com",
|
|
PasswordHash: "hash", CreatedAt: now,
|
|
}
|
|
firstSession := SessionRecord{
|
|
SessionID: uuid.NewString(), UserID: first.UserID,
|
|
CreatedAt: now, ExpiresAt: now.Add(time.Hour),
|
|
}
|
|
if err := store.CreateUserWithinLimits(first, &firstSession, limits); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := store.GetSession(firstSession.SessionID); err != nil {
|
|
t.Fatalf("initial session not committed with user: %v", err)
|
|
}
|
|
|
|
// A session failure rolls back the account.
|
|
conflicting := UserRecord{UserID: uuid.NewString(), Username: "carol", PasswordHash: "hash", CreatedAt: now}
|
|
conflictingSession := SessionRecord{
|
|
SessionID: firstSession.SessionID, UserID: conflicting.UserID,
|
|
CreatedAt: now, ExpiresAt: now.Add(time.Hour),
|
|
}
|
|
if err := store.CreateUserWithinLimits(conflicting, &conflictingSession, limits); err == nil {
|
|
t.Fatal("expected duplicate session failure")
|
|
}
|
|
if _, err := store.GetUserByID(conflicting.UserID); !errors.Is(err, sql.ErrNoRows) {
|
|
t.Fatalf("session failure did not roll back user: %v", err)
|
|
}
|
|
|
|
second := UserRecord{UserID: uuid.NewString(), Username: "bob", PasswordHash: "hash", CreatedAt: now}
|
|
if err := store.CreateUserWithinLimits(second, nil, limits); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// At capacity, a duplicate is still reported as a duplicate, a new name
|
|
// is refused, and nobody is removed to make room.
|
|
for _, duplicate := range []UserRecord{
|
|
{Username: "ALICE", Email: "other@example.com"},
|
|
{Username: "dave", Email: "alice@EXAMPLE.com"},
|
|
} {
|
|
duplicate.UserID, duplicate.PasswordHash, duplicate.CreatedAt = uuid.NewString(), "hash", now
|
|
if err := store.CreateUserWithinLimits(duplicate, nil, limits); !errors.Is(err, ErrUserAlreadyExists) {
|
|
t.Fatalf("duplicate %+v error = %v, want ErrUserAlreadyExists", duplicate, err)
|
|
}
|
|
}
|
|
third := UserRecord{UserID: uuid.NewString(), Username: "erin", PasswordHash: "hash", CreatedAt: now}
|
|
if err := store.CreateUserWithinLimits(third, nil, limits); !errors.Is(err, ErrUserCapacity) {
|
|
t.Fatalf("registration at capacity = %v, want ErrUserCapacity", err)
|
|
}
|
|
for _, existing := range []string{first.UserID, second.UserID} {
|
|
if _, err := store.GetUserByID(existing); err != nil {
|
|
t.Fatalf("existing user %s removed at capacity: %v", existing, err)
|
|
}
|
|
}
|
|
|
|
// Administrative creation and a zero limit are not capped.
|
|
if err := store.CreateUser(third); err != nil {
|
|
t.Fatalf("CLI creation beyond the public limit: %v", err)
|
|
}
|
|
unlimited := UserRecord{UserID: uuid.NewString(), Username: "frank", PasswordHash: "hash", CreatedAt: now}
|
|
if err := store.CreateUserWithinLimits(unlimited, nil, UserLimits{}); err != nil {
|
|
t.Fatalf("zero limit: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestConcurrentRegistrationsRespectCapacity(t *testing.T) {
|
|
store := openStore(t, pgtest.DSN(t))
|
|
limits := UserLimits{MaxUsers: 3}
|
|
var wg sync.WaitGroup
|
|
errs := make(chan error, 12)
|
|
for i := range 12 {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
errs <- store.CreateUserWithinLimits(UserRecord{
|
|
UserID: uuid.NewString(), Username: fmt.Sprintf("user%d", i),
|
|
PasswordHash: "hash", CreatedAt: time.Now().UTC(),
|
|
}, nil, limits)
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
close(errs)
|
|
created := 0
|
|
for err := range errs {
|
|
switch {
|
|
case err == nil:
|
|
created++
|
|
case !errors.Is(err, ErrUserCapacity):
|
|
t.Errorf("registration failed: %v", err)
|
|
}
|
|
}
|
|
users, err := store.GetAllUsers()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if created != limits.MaxUsers || len(users) != limits.MaxUsers {
|
|
t.Fatalf("created %d, stored %d; want %d", created, len(users), limits.MaxUsers)
|
|
}
|
|
}
|
|
|
|
func TestUserLookupsAndUpdates(t *testing.T) {
|
|
store := openStore(t, pgtest.DSN(t))
|
|
now := time.Now().UTC()
|
|
alice := UserRecord{
|
|
UserID: uuid.NewString(), Username: "Alice", Email: "Alice@Example.com",
|
|
PasswordHash: "hash", CreatedAt: now,
|
|
}
|
|
bob := UserRecord{UserID: uuid.NewString(), Username: "bob", PasswordHash: "hash", CreatedAt: now}
|
|
for _, user := range []UserRecord{alice, bob} {
|
|
if err := store.CreateUser(user); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
byName, err := store.GetUserByUsername("ALICE")
|
|
if err != nil || byName.UserID != alice.UserID || byName.Username != "alice" || byName.Email != "alice@example.com" {
|
|
t.Fatalf("username lookup = %+v, %v", byName, err)
|
|
}
|
|
if byEmail, err := store.GetUserByEmail("ALICE@example.COM"); err != nil || byEmail.UserID != alice.UserID {
|
|
t.Fatalf("email lookup = %+v, %v", byEmail, err)
|
|
}
|
|
if byID, err := store.GetUserByID(bob.UserID); err != nil || byID.Email != "" {
|
|
t.Fatalf("user without email = %+v, %v", byID, err)
|
|
}
|
|
if _, err := store.GetUserByEmail(""); !errors.Is(err, sql.ErrNoRows) {
|
|
t.Fatalf("empty email lookup = %v, want sql.ErrNoRows", err)
|
|
}
|
|
if _, err := store.GetUserByID("not-a-uuid"); !errors.Is(err, sql.ErrNoRows) {
|
|
t.Fatalf("malformed ID lookup = %v, want sql.ErrNoRows", err)
|
|
}
|
|
|
|
if err := store.UpdateUserUsername(bob.UserID, "ALICE"); !errors.Is(err, ErrUserAlreadyExists) {
|
|
t.Fatalf("conflicting rename = %v, want ErrUserAlreadyExists", err)
|
|
}
|
|
if err := store.UpdateUserEmail(bob.UserID, "alice@example.com"); !errors.Is(err, ErrUserAlreadyExists) {
|
|
t.Fatalf("conflicting email = %v, want ErrUserAlreadyExists", err)
|
|
}
|
|
if err := store.UpdateUserEmail(alice.UserID, ""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := store.UpdateUserEmail(bob.UserID, "alice@example.com"); err != nil {
|
|
t.Fatalf("released email not reusable: %v", err)
|
|
}
|
|
if err := store.UpdateUserPassword(uuid.NewString(), "hash"); !errors.Is(err, sql.ErrNoRows) {
|
|
t.Fatalf("update of missing user = %v, want sql.ErrNoRows", err)
|
|
}
|
|
}
|
|
|
|
func TestExpiredSessionsAreDeleted(t *testing.T) {
|
|
store := openStore(t, pgtest.DSN(t))
|
|
now := time.Now().UTC()
|
|
userID := uuid.NewString()
|
|
if err := store.CreateUser(UserRecord{
|
|
UserID: userID, Username: "user", PasswordHash: "hash", CreatedAt: now,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
oldSession := uuid.NewString()
|
|
if err := store.CreateSession(SessionRecord{
|
|
SessionID: oldSession, UserID: userID,
|
|
CreatedAt: now.Add(-2 * time.Hour), ExpiresAt: now.Add(-time.Hour),
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if valid, err := store.IsSessionValidForUser(oldSession, userID); err != nil || valid {
|
|
t.Fatalf("expired session valid = %v, %v", valid, err)
|
|
}
|
|
if deleted, err := store.DeleteExpiredSessions(); err != nil || deleted != 1 {
|
|
t.Fatalf("expired sessions deleted = %d, %v; want 1", deleted, err)
|
|
}
|
|
if _, err := store.GetUserByID(userID); err != nil {
|
|
t.Fatalf("session cleanup removed the account: %v", err)
|
|
}
|
|
if valid, err := store.IsSessionValidForUser("not-a-uuid", userID); err != nil || valid {
|
|
t.Fatalf("malformed session valid = %v, %v", valid, err)
|
|
}
|
|
}
|
|
|
|
func TestAnonymousGamesArePurgedAfterInactivity(t *testing.T) {
|
|
store := openStore(t, pgtest.DSN(t))
|
|
now := time.Now().UTC()
|
|
cutoff := now.Add(-24 * time.Hour)
|
|
old := now.Add(-48 * time.Hour)
|
|
userID := uuid.NewString()
|
|
|
|
newGame := func(started time.Time, claimedBy string, result string, ended *time.Time) string {
|
|
t.Helper()
|
|
record := GameRecord{
|
|
GameID: uuid.NewString(), InitialFEN: "initial",
|
|
WhitePlayerID: uuid.NewString(), WhiteType: 1, WhiteClaimedBy: claimedBy,
|
|
BlackPlayerID: uuid.NewString(), BlackType: 2, BlackLevel: 5, BlackSearchTime: 100,
|
|
StartTimeUTC: started, Result: result, EndTimeUTC: ended,
|
|
}
|
|
if err := store.RecordNewGame(record); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return record.GameID
|
|
}
|
|
move := func(gameID string, number int, at time.Time) {
|
|
t.Helper()
|
|
color := "w"
|
|
if number%2 == 0 {
|
|
color = "b"
|
|
}
|
|
if err := store.RecordMove(MovePersistence{Move: MoveRecord{
|
|
GameID: gameID, MoveNumber: number, MoveUCI: "e2e4",
|
|
FENAfterMove: "after", PlayerColor: color, MoveTimeUTC: at,
|
|
}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
idle := newGame(old, "", "", nil)
|
|
move(idle, 1, old.Add(time.Minute))
|
|
recentMove := newGame(old, "", "", nil)
|
|
move(recentMove, 1, now.Add(-time.Hour))
|
|
recentEnd := newGame(old, "", "stalemate", timePointer(now.Add(-time.Hour)))
|
|
recentStart := newGame(now.Add(-time.Hour), "", "", nil)
|
|
live := newGame(old, "", "", nil)
|
|
claimed := newGame(old, userID, "", nil)
|
|
idleFinished := newGame(old, "", "white_wins", timePointer(old.Add(time.Hour)))
|
|
|
|
if err := store.DeleteAnonymousGames(cutoff, []string{live}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for gameID, wantKept := range map[string]bool{
|
|
idle: false, idleFinished: false,
|
|
recentMove: true, recentEnd: true, recentStart: true, live: true, claimed: true,
|
|
} {
|
|
_, moves, err := store.GetGameHistory(gameID)
|
|
switch {
|
|
case wantKept && err != nil:
|
|
t.Errorf("game %s deleted: %v", gameID, err)
|
|
case !wantKept && !IsGameNotFound(err):
|
|
t.Errorf("game %s kept (err=%v, moves=%d)", gameID, err, len(moves))
|
|
}
|
|
}
|
|
var orphanMoves int
|
|
if err := store.db.QueryRow(`SELECT count(*) FROM moves WHERE game_id = $1`, idle).Scan(&orphanMoves); err != nil || orphanMoves != 0 {
|
|
t.Fatalf("moves of deleted game = %d, %v", orphanMoves, err)
|
|
}
|
|
if err := store.DeleteAnonymousGames(cutoff, nil); err != nil {
|
|
t.Fatalf("nil live set: %v", err)
|
|
}
|
|
if _, _, err := store.GetGameHistory(live); !IsGameNotFound(err) {
|
|
t.Fatalf("idle game kept after leaving memory: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestClaimsSnapshotPlayerNames(t *testing.T) {
|
|
store := openStore(t, pgtest.DSN(t))
|
|
now := time.Now().UTC()
|
|
alice, bob := uuid.NewString(), uuid.NewString()
|
|
for id, name := range map[string]string{alice: "alice", bob: "bob"} {
|
|
if err := store.CreateUser(UserRecord{UserID: id, Username: name, PasswordHash: "hash", CreatedAt: now}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
gameID := uuid.NewString()
|
|
if err := store.RecordNewGame(GameRecord{
|
|
GameID: gameID, InitialFEN: "initial",
|
|
WhitePlayerID: alice, WhiteType: 1, WhiteClaimedBy: alice,
|
|
BlackPlayerID: uuid.NewString(), BlackType: 1,
|
|
StartTimeUTC: now,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for number, claim := range map[int]string{1: "", 2: bob} {
|
|
color := map[int]string{1: "w", 2: "b"}[number]
|
|
persistence := MovePersistence{Move: MoveRecord{
|
|
GameID: gameID, MoveNumber: number, MoveUCI: "e2e4",
|
|
FENAfterMove: "after", PlayerColor: color, MoveTimeUTC: now,
|
|
}}
|
|
if claim != "" {
|
|
persistence.ClaimColor, persistence.ClaimedBy = color, claim
|
|
}
|
|
if err := store.RecordMove(persistence); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
record, _, err := store.GetGameHistory(gameID)
|
|
if err != nil || record.WhiteName != "alice" || record.BlackName != "bob" {
|
|
t.Fatalf("names = %q/%q, %v", record.WhiteName, record.BlackName, err)
|
|
}
|
|
|
|
// The snapshot survives a rename and account deletion.
|
|
if err := store.UpdateUserUsername(bob, "robert"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := store.DeleteUser(alice); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
record, _, err = store.GetGameHistory(gameID)
|
|
if err != nil || record.WhiteName != "alice" || record.BlackName != "bob" || record.WhiteClaimedBy != alice {
|
|
t.Fatalf("names after rename/delete = %q/%q claim=%q, %v",
|
|
record.WhiteName, record.BlackName, record.WhiteClaimedBy, err)
|
|
}
|
|
|
|
// A claimant without an account (removed before the write) has no name.
|
|
orphan := uuid.NewString()
|
|
if err := store.RecordNewGame(GameRecord{
|
|
GameID: orphan, InitialFEN: "initial",
|
|
WhitePlayerID: uuid.NewString(), WhiteType: 1, WhiteClaimedBy: uuid.NewString(),
|
|
BlackPlayerID: uuid.NewString(), BlackType: 2,
|
|
StartTimeUTC: now,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if record, _, err := store.GetGameHistory(orphan); err != nil || record.WhiteName != "" {
|
|
t.Fatalf("orphan claim name = %q, %v", record.WhiteName, err)
|
|
}
|
|
}
|
|
|
|
func timePointer(value time.Time) *time.Time {
|
|
return &value
|
|
}
|
|
|
|
func openStore(t *testing.T, dsn string) *Store {
|
|
t.Helper()
|
|
store, err := NewStore(dsn)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = store.Close() })
|
|
if err := store.InitDB(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return store
|
|
}
|
|
|
|
// explain returns the plan with sequential scans disabled, so the assertion is
|
|
// about index usability rather than the planner's choice for tiny tables.
|
|
func explain(t *testing.T, db *sql.DB, query string, args ...any) string {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
conn, err := db.Conn(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer conn.Close()
|
|
if _, err := conn.ExecContext(ctx, "SET enable_seqscan = off"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer conn.ExecContext(ctx, "RESET enable_seqscan")
|
|
|
|
rows, err := conn.QueryContext(ctx, "EXPLAIN "+query, args...)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer rows.Close()
|
|
var lines []string
|
|
for rows.Next() {
|
|
var line string
|
|
if err := rows.Scan(&line); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
lines = append(lines, line)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|