The crash cooldown system was already implemented across engine, worker, and matchmaker. This adds comprehensive integration tests that verify: - Single crash does not trigger cooldown - Two crashes do not trigger cooldown - Three consecutive crashes trigger 30-min cooldown - Successful match resets strike counter - Interleaved crash/success resets counter correctly - Cooldown extends on repeated crashes while on cooldown - Matchmaker eligibility query excludes bots on active cooldown - Matchmaker eligibility query includes bots with expired cooldown - Full end-to-end flow: 3 crashes → excluded → cooldown expires → re-pair Tests use ACB_TEST_DATABASE_URL env var for PostgreSQL integration tests and skip gracefully when not configured. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
344 lines
9.7 KiB
Go
344 lines
9.7 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
_ "github.com/lib/pq"
|
|
)
|
|
|
|
// openTestDBMatchmaker opens a test database for matchmaker tests.
|
|
func openTestDBMatchmaker(t *testing.T) *sql.DB {
|
|
t.Helper()
|
|
dsn := os.Getenv("ACB_TEST_DATABASE_URL")
|
|
if dsn == "" {
|
|
t.Skip("ACB_TEST_DATABASE_URL not set, skipping integration test")
|
|
}
|
|
db, err := sql.Open("postgres", dsn)
|
|
if err != nil {
|
|
t.Fatalf("open test db: %v", err)
|
|
}
|
|
db.SetMaxOpenConns(2)
|
|
return db
|
|
}
|
|
|
|
// setupMatchmakerTestSchema creates the tables needed for matchmaker tests.
|
|
func setupMatchmakerTestSchema(t *testing.T, db *sql.DB) {
|
|
t.Helper()
|
|
_, err := db.Exec(`
|
|
CREATE TABLE IF NOT EXISTS bots (
|
|
bot_id VARCHAR(16) PRIMARY KEY,
|
|
name VARCHAR(32) UNIQUE NOT NULL,
|
|
owner VARCHAR(128) NOT NULL DEFAULT 'test',
|
|
endpoint_url TEXT NOT NULL DEFAULT 'http://localhost:8080',
|
|
shared_secret TEXT NOT NULL DEFAULT 'secret',
|
|
status VARCHAR(16) NOT NULL DEFAULT 'active',
|
|
rating_mu DOUBLE PRECISION NOT NULL DEFAULT 1500.0,
|
|
rating_phi DOUBLE PRECISION NOT NULL DEFAULT 350.0,
|
|
rating_sigma DOUBLE PRECISION NOT NULL DEFAULT 0.06,
|
|
evolved BOOLEAN NOT NULL DEFAULT FALSE,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
crash_strikes INTEGER NOT NULL DEFAULT 0,
|
|
cooldown_until TIMESTAMPTZ
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS maps (
|
|
map_id VARCHAR(32) PRIMARY KEY,
|
|
grid_width INTEGER NOT NULL DEFAULT 60,
|
|
grid_height INTEGER NOT NULL DEFAULT 60,
|
|
map_json JSONB NOT NULL DEFAULT '{}'
|
|
);
|
|
`)
|
|
if err != nil {
|
|
t.Fatalf("create tables: %v", err)
|
|
}
|
|
// Seed a map for match creation
|
|
_, _ = db.Exec(`INSERT INTO maps (map_id) VALUES ('map_test') ON CONFLICT DO NOTHING`)
|
|
}
|
|
|
|
// insertMMTestBot inserts a bot with specified status and cooldown state.
|
|
func insertMMTestBot(t *testing.T, db *sql.DB, botID string, status string, strikes int, cooldownUntil *time.Time) {
|
|
t.Helper()
|
|
var cooldownVal interface{}
|
|
if cooldownUntil != nil {
|
|
cooldownVal = *cooldownUntil
|
|
}
|
|
_, err := db.Exec(`
|
|
INSERT INTO bots (bot_id, name, status, crash_strikes, cooldown_until)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
ON CONFLICT (bot_id) DO UPDATE SET
|
|
status = $3, crash_strikes = $4, cooldown_until = $5
|
|
`, botID, botID, status, strikes, cooldownVal)
|
|
if err != nil {
|
|
t.Fatalf("insert bot %s: %v", botID, err)
|
|
}
|
|
}
|
|
|
|
// TestMatchmakerQuery_ExcludesCooldown tests that the matchmaker eligibility query
|
|
// excludes bots whose cooldown_until is in the future.
|
|
func TestMatchmakerQuery_ExcludesCooldown(t *testing.T) {
|
|
db := openTestDBMatchmaker(t)
|
|
defer db.Close()
|
|
setupMatchmakerTestSchema(t, db)
|
|
|
|
ctx := context.Background()
|
|
|
|
// Insert 3 bots: one active, one active but on cooldown, one inactive
|
|
activeBot := fmt.Sprintf("b_%s_active", t.Name())
|
|
cooldownBot := fmt.Sprintf("b_%s_cool", t.Name())
|
|
inactiveBot := fmt.Sprintf("b_%s_inact", t.Name())
|
|
|
|
future := time.Now().Add(30 * time.Minute) // cooldown in the future
|
|
|
|
insertMMTestBot(t, db, activeBot, "active", 0, nil)
|
|
insertMMTestBot(t, db, cooldownBot, "active", 3, &future)
|
|
insertMMTestBot(t, db, inactiveBot, "inactive", 0, nil)
|
|
|
|
// This is the same query used in tickMatchmaker
|
|
rows, err := db.QueryContext(ctx, `
|
|
SELECT bot_id FROM bots WHERE status = 'active'
|
|
AND (cooldown_until IS NULL OR cooldown_until < NOW())
|
|
ORDER BY rating_mu DESC
|
|
`)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var eligible []string
|
|
for rows.Next() {
|
|
var id string
|
|
if err := rows.Scan(&id); err != nil {
|
|
rows.Close()
|
|
t.Fatal(err)
|
|
}
|
|
eligible = append(eligible, id)
|
|
}
|
|
rows.Close()
|
|
|
|
// Only activeBot should be eligible
|
|
for _, id := range eligible {
|
|
if id == cooldownBot {
|
|
t.Error("bot on cooldown should NOT be eligible for pairing")
|
|
}
|
|
if id == inactiveBot {
|
|
t.Error("inactive bot should NOT be eligible for pairing")
|
|
}
|
|
}
|
|
|
|
found := false
|
|
for _, id := range eligible {
|
|
if id == activeBot {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("active bot without cooldown should be eligible for pairing")
|
|
}
|
|
}
|
|
|
|
// TestMatchmakerQuery_CooldownExpired tests that a bot whose cooldown has expired
|
|
// is eligible for pairing again.
|
|
func TestMatchmakerQuery_CooldownExpired(t *testing.T) {
|
|
db := openTestDBMatchmaker(t)
|
|
defer db.Close()
|
|
setupMatchmakerTestSchema(t, db)
|
|
|
|
ctx := context.Background()
|
|
|
|
botID := fmt.Sprintf("b_%s", t.Name())
|
|
past := time.Now().Add(-1 * time.Second) // cooldown expired
|
|
|
|
insertMMTestBot(t, db, botID, "active", 3, &past)
|
|
|
|
rows, err := db.QueryContext(ctx, `
|
|
SELECT bot_id FROM bots WHERE status = 'active'
|
|
AND (cooldown_until IS NULL OR cooldown_until < NOW())
|
|
`)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var eligible []string
|
|
for rows.Next() {
|
|
var id string
|
|
if err := rows.Scan(&id); err != nil {
|
|
rows.Close()
|
|
t.Fatal(err)
|
|
}
|
|
eligible = append(eligible, id)
|
|
}
|
|
rows.Close()
|
|
|
|
if len(eligible) != 1 || eligible[0] != botID {
|
|
t.Errorf("bot with expired cooldown should be eligible, got: %v", eligible)
|
|
}
|
|
}
|
|
|
|
// TestMatchmakerQuery_NoCooldown tests that a bot with NULL cooldown_until is eligible.
|
|
func TestMatchmakerQuery_NoCooldown(t *testing.T) {
|
|
db := openTestDBMatchmaker(t)
|
|
defer db.Close()
|
|
setupMatchmakerTestSchema(t, db)
|
|
|
|
ctx := context.Background()
|
|
|
|
botID := fmt.Sprintf("b_%s", t.Name())
|
|
insertMMTestBot(t, db, botID, "active", 1, nil) // 1 strike, no cooldown
|
|
|
|
rows, err := db.QueryContext(ctx, `
|
|
SELECT bot_id FROM bots WHERE status = 'active'
|
|
AND (cooldown_until IS NULL OR cooldown_until < NOW())
|
|
`)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var eligible []string
|
|
for rows.Next() {
|
|
var id string
|
|
if err := rows.Scan(&id); err != nil {
|
|
rows.Close()
|
|
t.Fatal(err)
|
|
}
|
|
eligible = append(eligible, id)
|
|
}
|
|
rows.Close()
|
|
|
|
if len(eligible) != 1 || eligible[0] != botID {
|
|
t.Errorf("bot with no cooldown should be eligible, got: %v", eligible)
|
|
}
|
|
}
|
|
|
|
// TestMatchmakerQuery_SeriesEligibility tests the series scheduling cooldown filter.
|
|
func TestMatchmakerQuery_SeriesEligibility(t *testing.T) {
|
|
db := openTestDBMatchmaker(t)
|
|
defer db.Close()
|
|
setupMatchmakerTestSchema(t, db)
|
|
|
|
ctx := context.Background()
|
|
|
|
cooldownBot := fmt.Sprintf("b_%s_cool", t.Name())
|
|
okBot := fmt.Sprintf("b_%s_ok", t.Name())
|
|
|
|
future := time.Now().Add(30 * time.Minute)
|
|
insertMMTestBot(t, db, cooldownBot, "active", 3, &future)
|
|
insertMMTestBot(t, db, okBot, "active", 0, nil)
|
|
|
|
// This is the same query used in scheduleNextSeriesGames
|
|
var eligible bool
|
|
err := db.QueryRowContext(ctx, `
|
|
SELECT EXISTS(SELECT 1 FROM bots WHERE bot_id = $1 AND status = 'active'
|
|
AND (cooldown_until IS NULL OR cooldown_until < NOW()))
|
|
`, cooldownBot).Scan(&eligible)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if eligible {
|
|
t.Error("bot on cooldown should NOT be eligible for series games")
|
|
}
|
|
|
|
err = db.QueryRowContext(ctx, `
|
|
SELECT EXISTS(SELECT 1 FROM bots WHERE bot_id = $1 AND status = 'active'
|
|
AND (cooldown_until IS NULL OR cooldown_until < NOW()))
|
|
`, okBot).Scan(&eligible)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !eligible {
|
|
t.Error("active bot without cooldown should be eligible for series games")
|
|
}
|
|
}
|
|
|
|
// TestCrashCooldownIntegration tests the full flow: crash 3 matches → cooldown → exclude from pairing → cooldown expires → re-pair.
|
|
// This uses direct SQL to simulate what updateCrashStrikes does in the worker, verifying the matchmaker
|
|
// query responds correctly to each state transition.
|
|
func TestCrashCooldownIntegration(t *testing.T) {
|
|
db := openTestDBMatchmaker(t)
|
|
defer db.Close()
|
|
setupMatchmakerTestSchema(t, db)
|
|
|
|
ctx := context.Background()
|
|
botID := fmt.Sprintf("b_%s", t.Name())
|
|
insertMMTestBot(t, db, botID, "active", 0, nil)
|
|
|
|
// Simulate 3 consecutive crashes using the same SQL logic as updateCrashStrikes
|
|
maxStrikes := 3
|
|
cooldownDur := 30 * time.Minute
|
|
for i := range 3 {
|
|
_, err := db.ExecContext(ctx, `
|
|
UPDATE bots
|
|
SET crash_strikes = crash_strikes + 1,
|
|
cooldown_until = CASE
|
|
WHEN crash_strikes + 1 >= $1 THEN NOW() + $2
|
|
ELSE cooldown_until
|
|
END
|
|
WHERE bot_id = $3
|
|
`, maxStrikes, cooldownDur, botID)
|
|
if err != nil {
|
|
t.Fatalf("crash %d: %v", i+1, err)
|
|
}
|
|
}
|
|
|
|
// Verify bot is on cooldown
|
|
var strikes int
|
|
var cooldown *time.Time
|
|
err := db.QueryRowContext(ctx, `SELECT crash_strikes, cooldown_until FROM bots WHERE bot_id = $1`, botID).Scan(&strikes, &cooldown)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strikes != 3 {
|
|
t.Fatalf("strikes = %d, want 3", strikes)
|
|
}
|
|
if cooldown == nil {
|
|
t.Fatal("cooldown should be set after 3 crashes")
|
|
}
|
|
|
|
// Verify bot is excluded from matchmaker eligibility
|
|
var eligible bool
|
|
err = db.QueryRowContext(ctx, `
|
|
SELECT EXISTS(SELECT 1 FROM bots WHERE bot_id = $1 AND status = 'active'
|
|
AND (cooldown_until IS NULL OR cooldown_until < NOW()))
|
|
`, botID).Scan(&eligible)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if eligible {
|
|
t.Error("bot should NOT be eligible for pairing while on cooldown")
|
|
}
|
|
|
|
// Simulate cooldown expiry by setting cooldown_until to the past
|
|
_, err = db.ExecContext(ctx, `UPDATE bots SET cooldown_until = NOW() - INTERVAL '1 second' WHERE bot_id = $1`, botID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Verify bot is eligible again after cooldown expires
|
|
err = db.QueryRowContext(ctx, `
|
|
SELECT EXISTS(SELECT 1 FROM bots WHERE bot_id = $1 AND status = 'active'
|
|
AND (cooldown_until IS NULL OR cooldown_until < NOW()))
|
|
`, botID).Scan(&eligible)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !eligible {
|
|
t.Error("bot should be eligible for pairing after cooldown expires")
|
|
}
|
|
|
|
// Simulate a successful match — strikes should reset
|
|
_, err = db.ExecContext(ctx, `UPDATE bots SET crash_strikes = 0 WHERE bot_id = $1`, botID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
err = db.QueryRowContext(ctx, `SELECT crash_strikes FROM bots WHERE bot_id = $1`, botID).Scan(&strikes)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strikes != 0 {
|
|
t.Errorf("strikes after successful match = %d, want 0", strikes)
|
|
}
|
|
}
|