mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-03-22 07:32:32 +01:00
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).
355 lines
10 KiB
Go
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)
|
|
}
|