test: integration tests for multi-match crash cooldown (3 strikes / 30 min) per §4.5 + §6.1
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>
This commit is contained in:
parent
d43cf83471
commit
fb707b8461
2 changed files with 723 additions and 0 deletions
344
cmd/acb-matchmaker/cooldown_test.go
Normal file
344
cmd/acb-matchmaker/cooldown_test.go
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
379
cmd/acb-worker/crash_test.go
Normal file
379
cmd/acb-worker/crash_test.go
Normal file
|
|
@ -0,0 +1,379 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
// openTestDB opens a connection to the test database if ACB_TEST_DATABASE_URL is set.
|
||||
// Returns nil if no test database is configured.
|
||||
func openTestDB(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
|
||||
}
|
||||
|
||||
// setupTestSchema creates the bots table used by crash strike tests.
|
||||
func setupTestSchema(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
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("create table: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// insertTestBot inserts a bot with the given ID and crash_strikes/cooldown state.
|
||||
func insertTestBot(t *testing.T, db *sql.DB, botID 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, crash_strikes, cooldown_until)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (bot_id) DO UPDATE SET
|
||||
crash_strikes = $3, cooldown_until = $4
|
||||
`, botID, botID, strikes, cooldownVal)
|
||||
if err != nil {
|
||||
t.Fatalf("insert bot %s: %v", botID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// getBotStrikes reads crash_strikes and cooldown_until for a bot.
|
||||
func getBotStrikes(t *testing.T, db *sql.DB, botID string) (int, *time.Time) {
|
||||
t.Helper()
|
||||
var strikes int
|
||||
var cooldown *time.Time
|
||||
err := db.QueryRow(`SELECT crash_strikes, cooldown_until FROM bots WHERE bot_id = $1`, botID).Scan(&strikes, &cooldown)
|
||||
if err != nil {
|
||||
t.Fatalf("get bot %s: %v", botID, err)
|
||||
}
|
||||
return strikes, cooldown
|
||||
}
|
||||
|
||||
// TestCrashStrikesConstants verifies the constants match the spec (§4.5, §6.1).
|
||||
func TestCrashStrikesConstants(t *testing.T) {
|
||||
if MaxCrashStrikes != 3 {
|
||||
t.Errorf("MaxCrashStrikes = %d, want 3", MaxCrashStrikes)
|
||||
}
|
||||
if CrashCooldownDuration != 30*time.Minute {
|
||||
t.Errorf("CrashCooldownDuration = %v, want 30m", CrashCooldownDuration)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrashStrikes_SingleCrashNoCooldown tests that a single crash does NOT trigger cooldown.
|
||||
func TestCrashStrikes_SingleCrashNoCooldown(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
defer db.Close()
|
||||
setupTestSchema(t, db)
|
||||
|
||||
botID := fmt.Sprintf("b_%s", t.Name())
|
||||
insertTestBot(t, db, botID, 0, nil)
|
||||
|
||||
ctx := context.Background()
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = updateCrashStrikes(ctx, tx, map[string]bool{botID: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
strikes, cooldown := getBotStrikes(t, db, botID)
|
||||
if strikes != 1 {
|
||||
t.Errorf("strikes after 1 crash = %d, want 1", strikes)
|
||||
}
|
||||
if cooldown != nil {
|
||||
t.Errorf("cooldown after 1 crash = %v, want nil", cooldown)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrashStrikes_TwoCrashesNoCooldown tests that 2 crashes do NOT trigger cooldown.
|
||||
func TestCrashStrikes_TwoCrashesNoCooldown(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
defer db.Close()
|
||||
setupTestSchema(t, db)
|
||||
|
||||
botID := fmt.Sprintf("b_%s", t.Name())
|
||||
insertTestBot(t, db, botID, 0, nil)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// First crash
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = updateCrashStrikes(ctx, tx, map[string]bool{botID: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Second crash
|
||||
tx, err = db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = updateCrashStrikes(ctx, tx, map[string]bool{botID: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
strikes, cooldown := getBotStrikes(t, db, botID)
|
||||
if strikes != 2 {
|
||||
t.Errorf("strikes after 2 crashes = %d, want 2", strikes)
|
||||
}
|
||||
if cooldown != nil {
|
||||
t.Errorf("cooldown after 2 crashes = %v, want nil", cooldown)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrashStrikes_ThreeCrashesTriggerCooldown tests that 3 consecutive crashes trigger the 30-min cooldown.
|
||||
func TestCrashStrikes_ThreeCrashesTriggerCooldown(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
defer db.Close()
|
||||
setupTestSchema(t, db)
|
||||
|
||||
botID := fmt.Sprintf("b_%s", t.Name())
|
||||
insertTestBot(t, db, botID, 0, nil)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
for crashNum := range 3 {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = updateCrashStrikes(ctx, tx, map[string]bool{botID: true})
|
||||
if err != nil {
|
||||
t.Fatalf("crash %d: %v", crashNum+1, err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
strikes, cooldown := getBotStrikes(t, db, botID)
|
||||
if strikes != 3 {
|
||||
t.Errorf("strikes after 3 crashes = %d, want 3", strikes)
|
||||
}
|
||||
if cooldown == nil {
|
||||
t.Fatal("cooldown should be set after 3 consecutive crashes, got nil")
|
||||
}
|
||||
|
||||
// Cooldown should be approximately NOW() + 30min (allow 2 second tolerance)
|
||||
expectedMin := time.Now().Add(CrashCooldownDuration).Add(-2 * time.Second)
|
||||
expectedMax := time.Now().Add(CrashCooldownDuration).Add(2 * time.Second)
|
||||
if cooldown.Before(expectedMin) || cooldown.After(expectedMax) {
|
||||
t.Errorf("cooldown = %v, want approximately %v", cooldown, time.Now().Add(CrashCooldownDuration))
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrashStrikes_SuccessResetsStrikes tests that a successful match resets the strike counter.
|
||||
func TestCrashStrikes_SuccessResetsStrikes(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
defer db.Close()
|
||||
setupTestSchema(t, db)
|
||||
|
||||
botID := fmt.Sprintf("b_%s", t.Name())
|
||||
insertTestBot(t, db, botID, 2, nil) // 2 strikes already
|
||||
|
||||
ctx := context.Background()
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Successful match resets strikes
|
||||
err = updateCrashStrikes(ctx, tx, map[string]bool{botID: false})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
strikes, cooldown := getBotStrikes(t, db, botID)
|
||||
if strikes != 0 {
|
||||
t.Errorf("strikes after successful match = %d, want 0", strikes)
|
||||
}
|
||||
if cooldown != nil {
|
||||
t.Errorf("cooldown after successful match = %v, want nil", cooldown)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrashStrikes_InterleavedResets tests that a success between crashes resets the counter.
|
||||
func TestCrashStrikes_InterleavedResets(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
defer db.Close()
|
||||
setupTestSchema(t, db)
|
||||
|
||||
botID := fmt.Sprintf("b_%s", t.Name())
|
||||
insertTestBot(t, db, botID, 0, nil)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Crash twice
|
||||
for range 2 {
|
||||
tx, _ := db.BeginTx(ctx, nil)
|
||||
_ = updateCrashStrikes(ctx, tx, map[string]bool{botID: true})
|
||||
_ = tx.Commit()
|
||||
}
|
||||
|
||||
// Succeed (resets strikes to 0)
|
||||
tx, _ := db.BeginTx(ctx, nil)
|
||||
_ = updateCrashStrikes(ctx, tx, map[string]bool{botID: false})
|
||||
_ = tx.Commit()
|
||||
|
||||
// Crash once more — should be strike 1, not 3
|
||||
tx, _ = db.BeginTx(ctx, nil)
|
||||
_ = updateCrashStrikes(ctx, tx, map[string]bool{botID: true})
|
||||
_ = tx.Commit()
|
||||
|
||||
strikes, cooldown := getBotStrikes(t, db, botID)
|
||||
if strikes != 1 {
|
||||
t.Errorf("strikes after crash-success-crash = %d, want 1", strikes)
|
||||
}
|
||||
if cooldown != nil {
|
||||
t.Errorf("cooldown after crash-success-crash = %v, want nil", cooldown)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrashStrikes_CooldownExtendsOnRepeatedCrash tests that crashing again while on cooldown
|
||||
// extends the cooldown (re-triggers the 30-min timer).
|
||||
func TestCrashStrikes_CooldownExtendsOnRepeatedCrash(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
defer db.Close()
|
||||
setupTestSchema(t, db)
|
||||
|
||||
botID := fmt.Sprintf("b_%s", t.Name())
|
||||
insertTestBot(t, db, botID, 0, nil)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Accumulate 3 strikes to trigger initial cooldown
|
||||
for range 3 {
|
||||
tx, _ := db.BeginTx(ctx, nil)
|
||||
_ = updateCrashStrikes(ctx, tx, map[string]bool{botID: true})
|
||||
_ = tx.Commit()
|
||||
}
|
||||
|
||||
_, firstCooldown := getBotStrikes(t, db, botID)
|
||||
if firstCooldown == nil {
|
||||
t.Fatal("expected initial cooldown to be set")
|
||||
}
|
||||
|
||||
// Wait a tiny bit, then crash again — cooldown should extend
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
tx, _ := db.BeginTx(ctx, nil)
|
||||
_ = updateCrashStrikes(ctx, tx, map[string]bool{botID: true})
|
||||
_ = tx.Commit()
|
||||
|
||||
strikes, secondCooldown := getBotStrikes(t, db, botID)
|
||||
if strikes != 4 {
|
||||
t.Errorf("strikes after 4th crash = %d, want 4", strikes)
|
||||
}
|
||||
if secondCooldown == nil {
|
||||
t.Fatal("expected cooldown to still be set after 4th crash")
|
||||
}
|
||||
// Cooldown should have been extended (later than the first one)
|
||||
if !secondCooldown.After(*firstCooldown) {
|
||||
t.Errorf("cooldown should have been extended: first=%v, second=%v", firstCooldown, secondCooldown)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrashStrikes_MultipleBots tests updating crash strikes for multiple bots in one call.
|
||||
func TestCrashStrikes_MultipleBots(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
defer db.Close()
|
||||
setupTestSchema(t, db)
|
||||
|
||||
crashBot := fmt.Sprintf("b_%s_crash", t.Name())
|
||||
okBot := fmt.Sprintf("b_%s_ok", t.Name())
|
||||
insertTestBot(t, db, crashBot, 0, nil)
|
||||
insertTestBot(t, db, okBot, 1, nil)
|
||||
|
||||
ctx := context.Background()
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = updateCrashStrikes(ctx, tx, map[string]bool{
|
||||
crashBot: true,
|
||||
okBot: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
crashStrikes, crashCooldown := getBotStrikes(t, db, crashBot)
|
||||
okStrikes, okCooldown := getBotStrikes(t, db, okBot)
|
||||
|
||||
if crashStrikes != 1 {
|
||||
t.Errorf("crashed bot strikes = %d, want 1", crashStrikes)
|
||||
}
|
||||
if crashCooldown != nil {
|
||||
t.Errorf("crashed bot cooldown after 1 strike = %v, want nil", crashCooldown)
|
||||
}
|
||||
if okStrikes != 0 {
|
||||
t.Errorf("ok bot strikes = %d, want 0 (reset)", okStrikes)
|
||||
}
|
||||
if okCooldown != nil {
|
||||
t.Errorf("ok bot cooldown = %v, want nil", okCooldown)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrashStrikes_EmptyMap tests that calling updateCrashStrikes with an empty map is a no-op.
|
||||
func TestCrashStrikes_EmptyMap(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
err := updateCrashStrikes(ctx, nil, map[string]bool{})
|
||||
if err != nil {
|
||||
t.Errorf("expected nil error for empty map, got: %v", err)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue