mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-03-21 23:22:34 +01:00
Move all direct DB access from handlers_rengoku.go (11 calls) and handlers_mail.go (10 calls) into dedicated repository types, continuing the established extraction pattern. RengokuRepository provides UpsertScore and GetRanking, replacing a 3-call check/insert/update sequence and an 8-case switch of nearly identical queries respectively. MailRepository provides SendMail, SendMailTx, GetListForCharacter, GetByID, MarkRead, MarkDeleted, SetLocked, and MarkItemReceived. The old Mail.Send(), Mail.MarkRead(), GetMailListForCharacter, and GetMailByID free functions are removed. Guild handlers that sent mail via Mail.Send(s, ...) now call mailRepo directly.
347 lines
9.5 KiB
Go
347 lines
9.5 KiB
Go
package channelserver
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
|
|
"erupe-ce/server/channelserver/compression/nullcomp"
|
|
"github.com/jmoiron/sqlx"
|
|
_ "github.com/lib/pq"
|
|
)
|
|
|
|
// 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
|
|
func SetupTestDB(t *testing.T) *sqlx.DB {
|
|
t.Helper()
|
|
|
|
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 {
|
|
t.Skipf("Failed to connect to test database: %v. Run: docker compose -f docker/docker-compose.test.yml up -d", err)
|
|
return nil
|
|
}
|
|
|
|
// Test connection
|
|
if err := db.Ping(); err != nil {
|
|
_ = db.Close()
|
|
t.Skipf("Test database not available: %v. Run: docker compose -f docker/docker-compose.test.yml up -d", err)
|
|
return nil
|
|
}
|
|
|
|
// Clean the database before tests
|
|
CleanTestDB(t, db)
|
|
|
|
// Apply schema
|
|
ApplyTestSchema(t, db)
|
|
|
|
return db
|
|
}
|
|
|
|
// CleanTestDB drops all tables to ensure a clean state
|
|
func CleanTestDB(t *testing.T, db *sqlx.DB) {
|
|
t.Helper()
|
|
|
|
// Drop all tables in the public schema
|
|
_, err := db.Exec(`
|
|
DO $$ DECLARE
|
|
r RECORD;
|
|
BEGIN
|
|
FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = 'public') LOOP
|
|
EXECUTE 'DROP TABLE IF EXISTS ' || quote_ident(r.tablename) || ' CASCADE';
|
|
END LOOP;
|
|
END $$;
|
|
`)
|
|
if err != nil {
|
|
t.Logf("Warning: Failed to clean database: %v", err)
|
|
}
|
|
}
|
|
|
|
// ApplyTestSchema applies the database schema from init.sql using pg_restore
|
|
func ApplyTestSchema(t *testing.T, db *sqlx.DB) {
|
|
t.Helper()
|
|
|
|
// Find the project root (where schemas/ directory is located)
|
|
projectRoot := findProjectRoot(t)
|
|
schemaPath := filepath.Join(projectRoot, "schemas", "init.sql")
|
|
|
|
// Get the connection config
|
|
config := DefaultTestDBConfig()
|
|
|
|
// Use pg_restore to load the schema dump
|
|
// The init.sql file is a pg_dump custom format, so we need pg_restore
|
|
cmd := exec.Command("pg_restore",
|
|
"-h", config.Host,
|
|
"-p", config.Port,
|
|
"-U", config.User,
|
|
"-d", config.DBName,
|
|
"--no-owner",
|
|
"--no-acl",
|
|
"-c", // clean (drop) before recreating
|
|
schemaPath,
|
|
)
|
|
cmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", config.Password))
|
|
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
// pg_restore may error on first run (no tables to drop), that's usually ok
|
|
t.Logf("pg_restore output: %s", string(output))
|
|
// Check if it's a fatal error
|
|
if !strings.Contains(string(output), "does not exist") {
|
|
t.Logf("pg_restore error (may be non-fatal): %v", err)
|
|
}
|
|
}
|
|
|
|
// Apply the 9.2 update schema (init.sql bootstraps to 9.1.0)
|
|
applyUpdateSchema(t, db, projectRoot)
|
|
|
|
// Apply patch schemas in order
|
|
applyPatchSchemas(t, db, projectRoot)
|
|
}
|
|
|
|
// applyUpdateSchema applies the 9.2 update schema that bridges init.sql (v9.1.0) to v9.2.0.
|
|
// It runs each statement individually to tolerate partial failures (e.g. role references).
|
|
func applyUpdateSchema(t *testing.T, db *sqlx.DB, projectRoot string) {
|
|
t.Helper()
|
|
|
|
updatePath := filepath.Join(projectRoot, "schemas", "update-schema", "9.2-update.sql")
|
|
updateSQL, err := os.ReadFile(updatePath)
|
|
if err != nil {
|
|
t.Logf("Warning: Could not read 9.2 update schema: %v", err)
|
|
return
|
|
}
|
|
|
|
// Strip the outer BEGIN/END transaction wrapper so we can run statements individually.
|
|
content := string(updateSQL)
|
|
content = strings.Replace(content, "BEGIN;", "", 1)
|
|
// Remove trailing END; (last occurrence)
|
|
if idx := strings.LastIndex(content, "END;"); idx >= 0 {
|
|
content = content[:idx] + content[idx+4:]
|
|
}
|
|
|
|
// Split on semicolons and execute each statement, tolerating errors from
|
|
// role references or already-applied changes.
|
|
for _, stmt := range strings.Split(content, ";") {
|
|
stmt = strings.TrimSpace(stmt)
|
|
if stmt == "" {
|
|
continue
|
|
}
|
|
if _, err := db.Exec(stmt); err != nil {
|
|
// Silently ignore — these are expected for role mismatches, already-applied changes, etc.
|
|
}
|
|
}
|
|
}
|
|
|
|
// applyPatchSchemas applies all patch schema files in numeric order
|
|
func applyPatchSchemas(t *testing.T, db *sqlx.DB, projectRoot string) {
|
|
t.Helper()
|
|
|
|
patchDir := filepath.Join(projectRoot, "schemas", "patch-schema")
|
|
entries, err := os.ReadDir(patchDir)
|
|
if err != nil {
|
|
t.Logf("Warning: Could not read patch-schema directory: %v", err)
|
|
return
|
|
}
|
|
|
|
// Sort patch files numerically
|
|
var patchFiles []string
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") {
|
|
patchFiles = append(patchFiles, entry.Name())
|
|
}
|
|
}
|
|
sort.Strings(patchFiles)
|
|
|
|
// Apply each patch in its own transaction
|
|
for _, filename := range patchFiles {
|
|
patchPath := filepath.Join(patchDir, filename)
|
|
patchSQL, err := os.ReadFile(patchPath)
|
|
if err != nil {
|
|
t.Logf("Warning: Failed to read patch file %s: %v", filename, err)
|
|
continue
|
|
}
|
|
|
|
// Start a new transaction for each patch
|
|
tx, err := db.Begin()
|
|
if err != nil {
|
|
t.Logf("Warning: Failed to start transaction for patch %s: %v", filename, err)
|
|
continue
|
|
}
|
|
|
|
_, err = tx.Exec(string(patchSQL))
|
|
if err != nil {
|
|
_ = tx.Rollback()
|
|
t.Logf("Warning: Failed to apply patch %s: %v", filename, err)
|
|
// Continue with other patches even if one fails
|
|
} else {
|
|
_ = tx.Commit()
|
|
}
|
|
}
|
|
}
|
|
|
|
// findProjectRoot finds the project root directory by looking for the schemas directory
|
|
func findProjectRoot(t *testing.T) string {
|
|
t.Helper()
|
|
|
|
// Start from current directory and walk up
|
|
dir, err := os.Getwd()
|
|
if err != nil {
|
|
t.Fatalf("Failed to get working directory: %v", err)
|
|
}
|
|
|
|
for {
|
|
schemasPath := filepath.Join(dir, "schemas")
|
|
if stat, err := os.Stat(schemasPath); err == nil && stat.IsDir() {
|
|
return dir
|
|
}
|
|
|
|
parent := filepath.Dir(dir)
|
|
if parent == dir {
|
|
t.Fatal("Could not find project root (schemas directory not found)")
|
|
}
|
|
dir = parent
|
|
}
|
|
}
|
|
|
|
// TeardownTestDB closes the database connection
|
|
func TeardownTestDB(t *testing.T, db *sqlx.DB) {
|
|
t.Helper()
|
|
if db != nil {
|
|
_ = db.Close()
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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)
|
|
}
|