Files
Erupe/server/channelserver/testhelpers_db.go
Houmgaor f9d4252860 test(repos): add SQL integration tests for 17 untested repo files
Add 148 integration tests exercising actual SQL against PostgreSQL for
all previously untested repository files. Includes 6 new fixture helpers
in testhelpers_db.go and CI PostgreSQL service configuration.

Discovered and documented existing RecordPurchase SQL bug (ambiguous
column reference in ON CONFLICT clause).
2026-02-24 16:57:47 +01:00

355 lines
10 KiB
Go

package channelserver
import (
"fmt"
"os"
"strings"
"sync"
"testing"
"time"
"erupe-ce/server/channelserver/compression/nullcomp"
"erupe-ce/server/migrations"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"go.uber.org/zap"
)
var (
testDBOnce sync.Once
testDB *sqlx.DB
testDBSetupFailed bool
)
// TestDBConfig holds the configuration for the test database
type TestDBConfig struct {
Host string
Port string
User string
Password string
DBName string
}
// DefaultTestDBConfig returns the default test database configuration
// that matches docker-compose.test.yml
func DefaultTestDBConfig() *TestDBConfig {
return &TestDBConfig{
Host: getEnv("TEST_DB_HOST", "localhost"),
Port: getEnv("TEST_DB_PORT", "5433"),
User: getEnv("TEST_DB_USER", "test"),
Password: getEnv("TEST_DB_PASSWORD", "test"),
DBName: getEnv("TEST_DB_NAME", "erupe_test"),
}
}
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
// SetupTestDB creates a connection to the test database and applies the schema.
// The schema is applied only once per test binary via sync.Once. Subsequent calls
// only TRUNCATE data for test isolation, avoiding expensive pg_restore + patch cycles.
func SetupTestDB(t *testing.T) *sqlx.DB {
t.Helper()
testDBOnce.Do(func() {
config := DefaultTestDBConfig()
connStr := fmt.Sprintf(
"host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
config.Host, config.Port, config.User, config.Password, config.DBName,
)
db, err := sqlx.Open("postgres", connStr)
if err != nil {
testDBSetupFailed = true
return
}
if err := db.Ping(); err != nil {
_ = db.Close()
testDBSetupFailed = true
return
}
// Clean the database and apply schema once
CleanTestDB(t, db)
ApplyTestSchema(t, db)
testDB = db
})
if testDBSetupFailed || testDB == nil {
t.Skipf("Test database not available. Run: docker compose -f docker/docker-compose.test.yml up -d")
return nil
}
// Truncate all data for test isolation (schema stays intact)
truncateAllTables(t, testDB)
return testDB
}
// CleanTestDB drops all objects in the public schema to ensure a clean state
func CleanTestDB(t *testing.T, db *sqlx.DB) {
t.Helper()
// Drop and recreate the public schema to remove all objects (tables, types, sequences, etc.)
_, err := db.Exec(`DROP SCHEMA public CASCADE; CREATE SCHEMA public;`)
if err != nil {
t.Logf("Warning: Failed to clean database: %v", err)
}
}
// ApplyTestSchema applies the database schema using the embedded migration system.
func ApplyTestSchema(t *testing.T, db *sqlx.DB) {
t.Helper()
logger, _ := zap.NewDevelopment()
_, err := migrations.Migrate(db, logger.Named("test-migrations"))
if err != nil {
t.Fatalf("Failed to apply schema migrations: %v", err)
}
}
// truncateAllTables truncates all tables in the public schema for test isolation.
// It retries on deadlock, which can occur when a previous test's goroutines still
// hold connections with in-flight DB operations.
func truncateAllTables(t *testing.T, db *sqlx.DB) {
t.Helper()
rows, err := db.Query("SELECT tablename FROM pg_tables WHERE schemaname = 'public'")
if err != nil {
t.Fatalf("Failed to list tables for truncation: %v", err)
}
defer func() { _ = rows.Close() }()
var tables []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
t.Fatalf("Failed to scan table name: %v", err)
}
tables = append(tables, name)
}
if len(tables) == 0 {
return
}
stmt := "TRUNCATE " + strings.Join(tables, ", ") + " CASCADE"
const maxRetries = 3
for attempt := 1; attempt <= maxRetries; attempt++ {
_, err := db.Exec(stmt)
if err == nil {
return
}
if attempt < maxRetries {
time.Sleep(50 * time.Millisecond)
continue
}
t.Fatalf("Failed to truncate tables after %d attempts: %v", maxRetries, err)
}
}
// TeardownTestDB is a no-op. The shared DB connection is reused across tests
// and closed automatically at process exit.
func TeardownTestDB(t *testing.T, db *sqlx.DB) {
t.Helper()
}
// CreateTestUser creates a test user and returns the user ID
func CreateTestUser(t *testing.T, db *sqlx.DB, username string) uint32 {
t.Helper()
var userID uint32
err := db.QueryRow(`
INSERT INTO users (username, password, rights)
VALUES ($1, 'test_password_hash', 0)
RETURNING id
`, username).Scan(&userID)
if err != nil {
t.Fatalf("Failed to create test user: %v", err)
}
return userID
}
// CreateTestCharacter creates a test character and returns the character ID
func CreateTestCharacter(t *testing.T, db *sqlx.DB, userID uint32, name string) uint32 {
t.Helper()
// Create minimal valid savedata (needs to be large enough for the game to parse)
// The name is at offset 88, and various game mode pointers extend up to ~147KB for ZZ mode
// We need at least 150KB to accommodate all possible pointer offsets
saveData := make([]byte, 150000) // Large enough for all game modes
copy(saveData[88:], append([]byte(name), 0x00)) // Name at offset 88 with null terminator
// Import the nullcomp package for compression
compressed, err := nullcomp.Compress(saveData)
if err != nil {
t.Fatalf("Failed to compress savedata: %v", err)
}
var charID uint32
err = db.QueryRow(`
INSERT INTO characters (user_id, is_female, is_new_character, name, unk_desc_string, gr, hr, weapon_type, last_login, savedata, decomyset, savemercenary)
VALUES ($1, false, false, $2, '', 0, 0, 0, 0, $3, '', '')
RETURNING id
`, userID, name, compressed).Scan(&charID)
if err != nil {
t.Fatalf("Failed to create test character: %v", err)
}
return charID
}
// CreateTestGuild creates a test guild with the given leader and returns the guild ID
func CreateTestGuild(t *testing.T, db *sqlx.DB, leaderCharID uint32, name string) uint32 {
t.Helper()
tx, err := db.Begin()
if err != nil {
t.Fatalf("Failed to begin transaction: %v", err)
}
var guildID uint32
err = tx.QueryRow(
"INSERT INTO guilds (name, leader_id) VALUES ($1, $2) RETURNING id",
name, leaderCharID,
).Scan(&guildID)
if err != nil {
_ = tx.Rollback()
t.Fatalf("Failed to create test guild: %v", err)
}
_, err = tx.Exec(
"INSERT INTO guild_characters (guild_id, character_id) VALUES ($1, $2)",
guildID, leaderCharID,
)
if err != nil {
_ = tx.Rollback()
t.Fatalf("Failed to add leader to guild: %v", err)
}
if err := tx.Commit(); err != nil {
t.Fatalf("Failed to commit guild creation: %v", err)
}
return guildID
}
// CreateTestSignSession creates a sign session and returns the session ID.
func CreateTestSignSession(t *testing.T, db *sqlx.DB, userID uint32, token string) uint32 {
t.Helper()
var id uint32
err := db.QueryRow(
`INSERT INTO sign_sessions (user_id, token) VALUES ($1, $2) RETURNING id`,
userID, token,
).Scan(&id)
if err != nil {
t.Fatalf("Failed to create test sign session: %v", err)
}
return id
}
// CreateTestServer creates a server entry for testing.
func CreateTestServer(t *testing.T, db *sqlx.DB, serverID uint16) {
t.Helper()
_, err := db.Exec(
`INSERT INTO servers (server_id, current_players) VALUES ($1, 0)`,
serverID,
)
if err != nil {
t.Fatalf("Failed to create test server: %v", err)
}
}
// CreateTestUserBinary creates a user_binary row for the given character ID.
func CreateTestUserBinary(t *testing.T, db *sqlx.DB, charID uint32) {
t.Helper()
_, err := db.Exec(`INSERT INTO user_binary (id) VALUES ($1)`, charID)
if err != nil {
t.Fatalf("Failed to create test user_binary: %v", err)
}
}
// CreateTestGachaShop creates a gacha shop entry and returns its ID.
func CreateTestGachaShop(t *testing.T, db *sqlx.DB, name string, gachaType int) uint32 {
t.Helper()
var id uint32
err := db.QueryRow(
`INSERT INTO gacha_shop (name, gacha_type, min_gr, min_hr, url_banner, url_feature, url_thumbnail, wide, recommended, hidden)
VALUES ($1, $2, 0, 0, '', '', '', false, false, false) RETURNING id`,
name, gachaType,
).Scan(&id)
if err != nil {
t.Fatalf("Failed to create test gacha shop: %v", err)
}
return id
}
// CreateTestGachaEntry creates a gacha entry and returns its ID.
func CreateTestGachaEntry(t *testing.T, db *sqlx.DB, gachaID uint32, entryType int, weight int) uint32 {
t.Helper()
var id uint32
err := db.QueryRow(
`INSERT INTO gacha_entries (gacha_id, entry_type, weight, rarity, item_type, item_number, item_quantity, rolls, frontier_points, daily_limit)
VALUES ($1, $2, $3, 1, 0, 0, 0, 1, 0, 0) RETURNING id`,
gachaID, entryType, weight,
).Scan(&id)
if err != nil {
t.Fatalf("Failed to create test gacha entry: %v", err)
}
return id
}
// CreateTestGachaItem creates a gacha item for an entry.
func CreateTestGachaItem(t *testing.T, db *sqlx.DB, entryID uint32, itemType uint8, itemID uint16, quantity uint16) {
t.Helper()
_, err := db.Exec(
`INSERT INTO gacha_items (entry_id, item_type, item_id, quantity) VALUES ($1, $2, $3, $4)`,
entryID, itemType, itemID, quantity,
)
if err != nil {
t.Fatalf("Failed to create test gacha item: %v", err)
}
}
// SetTestDB assigns a database to a Server and initializes all repositories.
// Use this in integration tests instead of setting s.server.db directly.
func SetTestDB(s *Server, db *sqlx.DB) {
s.db = db
s.charRepo = NewCharacterRepository(db)
s.guildRepo = NewGuildRepository(db)
s.userRepo = NewUserRepository(db)
s.gachaRepo = NewGachaRepository(db)
s.houseRepo = NewHouseRepository(db)
s.festaRepo = NewFestaRepository(db)
s.towerRepo = NewTowerRepository(db)
s.rengokuRepo = NewRengokuRepository(db)
s.mailRepo = NewMailRepository(db)
s.stampRepo = NewStampRepository(db)
s.distRepo = NewDistributionRepository(db)
s.sessionRepo = NewSessionRepository(db)
s.eventRepo = NewEventRepository(db)
s.achievementRepo = NewAchievementRepository(db)
s.shopRepo = NewShopRepository(db)
s.cafeRepo = NewCafeRepository(db)
s.goocooRepo = NewGoocooRepository(db)
s.divaRepo = NewDivaRepository(db)
s.miscRepo = NewMiscRepository(db)
s.scenarioRepo = NewScenarioRepository(db)
s.mercenaryRepo = NewMercenaryRepository(db)
}