mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-03-21 23:22:34 +01:00
Add zero-dependency SQLite mode so users can run Erupe without
PostgreSQL. A transparent db.DB wrapper auto-translates PostgreSQL
SQL ($N placeholders, now(), ::casts, ILIKE, public. prefix,
TRUNCATE) for SQLite at runtime — all 28 repo files use the wrapper
with no per-query changes needed.
Setup wizard gains two new steps: quest file detection with download
link, and gameplay presets (solo/small/community/rebalanced). The API
server gets a /dashboard endpoint with auto-refreshing stats.
CI release workflow now builds and pushes Docker images to GHCR
alongside binary artifacts on tag push.
Key changes:
- common/db: DB/Tx wrapper with 6 SQL translation rules
- server/migrations/sqlite: full SQLite schema (0001-0005)
- config: Database.Driver field ("postgres" or "sqlite")
- main.go: SQLite connection with WAL mode, single writer
- server/setup: quest check + preset selection steps
- server/api: /dashboard with live stats
- .github/workflows: Docker in release, deduplicate docker.yml
336 lines
8.2 KiB
Go
336 lines
8.2 KiB
Go
package migrations
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"testing"
|
|
|
|
"github.com/jmoiron/sqlx"
|
|
_ "github.com/lib/pq"
|
|
"go.uber.org/zap"
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
func testDB(t *testing.T) *sqlx.DB {
|
|
t.Helper()
|
|
|
|
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")
|
|
|
|
connStr := fmt.Sprintf(
|
|
"host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
|
|
host, port, user, password, dbName,
|
|
)
|
|
|
|
db, err := sqlx.Open("postgres", connStr)
|
|
if err != nil {
|
|
t.Skipf("Test database not available: %v", err)
|
|
return nil
|
|
}
|
|
|
|
if err := db.Ping(); err != nil {
|
|
_ = db.Close()
|
|
t.Skipf("Test database not available: %v", err)
|
|
return nil
|
|
}
|
|
|
|
// Clean slate
|
|
_, err = db.Exec("DROP SCHEMA public CASCADE; CREATE SCHEMA public;")
|
|
if err != nil {
|
|
t.Fatalf("Failed to clean database: %v", err)
|
|
}
|
|
|
|
return db
|
|
}
|
|
|
|
func getEnv(key, defaultValue string) string {
|
|
if value := os.Getenv(key); value != "" {
|
|
return value
|
|
}
|
|
return defaultValue
|
|
}
|
|
|
|
func TestMigrateEmptyDB(t *testing.T) {
|
|
db := testDB(t)
|
|
defer func() { _ = db.Close() }()
|
|
|
|
logger, _ := zap.NewDevelopment()
|
|
|
|
applied, err := Migrate(db, logger)
|
|
if err != nil {
|
|
t.Fatalf("Migrate failed: %v", err)
|
|
}
|
|
if applied != 5 {
|
|
t.Errorf("expected 5 migrations applied, got %d", applied)
|
|
}
|
|
|
|
ver, err := Version(db)
|
|
if err != nil {
|
|
t.Fatalf("Version failed: %v", err)
|
|
}
|
|
if ver != 5 {
|
|
t.Errorf("expected version 5, got %d", ver)
|
|
}
|
|
}
|
|
|
|
func TestMigrateAlreadyMigrated(t *testing.T) {
|
|
db := testDB(t)
|
|
defer func() { _ = db.Close() }()
|
|
|
|
logger, _ := zap.NewDevelopment()
|
|
|
|
// First run
|
|
_, err := Migrate(db, logger)
|
|
if err != nil {
|
|
t.Fatalf("First Migrate failed: %v", err)
|
|
}
|
|
|
|
// Second run should apply 0
|
|
applied, err := Migrate(db, logger)
|
|
if err != nil {
|
|
t.Fatalf("Second Migrate failed: %v", err)
|
|
}
|
|
if applied != 0 {
|
|
t.Errorf("expected 0 migrations on second run, got %d", applied)
|
|
}
|
|
}
|
|
|
|
func TestMigrateExistingDBWithoutSchemaVersion(t *testing.T) {
|
|
db := testDB(t)
|
|
defer func() { _ = db.Close() }()
|
|
|
|
logger, _ := zap.NewDevelopment()
|
|
|
|
// Simulate an existing database that has the full 0001 schema applied
|
|
// but no schema_version tracking yet (pre-migration-system installs).
|
|
// First, run all migrations normally to get the real schema...
|
|
_, err := Migrate(db, logger)
|
|
if err != nil {
|
|
t.Fatalf("Initial Migrate failed: %v", err)
|
|
}
|
|
// ...then drop schema_version to simulate the pre-tracking state.
|
|
_, err = db.Exec("DROP TABLE schema_version")
|
|
if err != nil {
|
|
t.Fatalf("Failed to drop schema_version: %v", err)
|
|
}
|
|
|
|
// Migrate should detect existing DB and auto-mark baseline,
|
|
// then apply remaining migrations (0002-0005).
|
|
applied, err := Migrate(db, logger)
|
|
if err != nil {
|
|
t.Fatalf("Migrate failed: %v", err)
|
|
}
|
|
// Baseline (0001) is auto-marked, then 0002-0005 are applied = 4
|
|
if applied != 4 {
|
|
t.Errorf("expected 4 migrations applied (baseline auto-marked, 0002-0005 applied), got %d", applied)
|
|
}
|
|
|
|
ver, err := Version(db)
|
|
if err != nil {
|
|
t.Fatalf("Version failed: %v", err)
|
|
}
|
|
if ver != 5 {
|
|
t.Errorf("expected version 5, got %d", ver)
|
|
}
|
|
}
|
|
|
|
func TestVersionEmptyDB(t *testing.T) {
|
|
db := testDB(t)
|
|
defer func() { _ = db.Close() }()
|
|
|
|
ver, err := Version(db)
|
|
if err != nil {
|
|
t.Fatalf("Version failed: %v", err)
|
|
}
|
|
if ver != 0 {
|
|
t.Errorf("expected version 0 on empty DB, got %d", ver)
|
|
}
|
|
}
|
|
|
|
func TestApplySeedData(t *testing.T) {
|
|
db := testDB(t)
|
|
defer func() { _ = db.Close() }()
|
|
|
|
logger, _ := zap.NewDevelopment()
|
|
|
|
// Apply schema first
|
|
_, err := Migrate(db, logger)
|
|
if err != nil {
|
|
t.Fatalf("Migrate failed: %v", err)
|
|
}
|
|
|
|
count, err := ApplySeedData(db, logger)
|
|
if err != nil {
|
|
t.Fatalf("ApplySeedData failed: %v", err)
|
|
}
|
|
if count == 0 {
|
|
t.Error("expected at least 1 seed file applied, got 0")
|
|
}
|
|
}
|
|
|
|
func TestParseVersion(t *testing.T) {
|
|
tests := []struct {
|
|
filename string
|
|
want int
|
|
wantErr bool
|
|
}{
|
|
{"0001_init.sql", 1, false},
|
|
{"0002_add_users.sql", 2, false},
|
|
{"0100_big_change.sql", 100, false},
|
|
{"bad.sql", 0, true},
|
|
}
|
|
for _, tt := range tests {
|
|
got, err := parseVersion(tt.filename)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("parseVersion(%q) error = %v, wantErr %v", tt.filename, err, tt.wantErr)
|
|
continue
|
|
}
|
|
if got != tt.want {
|
|
t.Errorf("parseVersion(%q) = %d, want %d", tt.filename, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestReadMigrations(t *testing.T) {
|
|
migrations, err := readMigrations(false)
|
|
if err != nil {
|
|
t.Fatalf("readMigrations failed: %v", err)
|
|
}
|
|
if len(migrations) == 0 {
|
|
t.Fatal("expected at least 1 migration, got 0")
|
|
}
|
|
if migrations[0].version != 1 {
|
|
t.Errorf("first migration version = %d, want 1", migrations[0].version)
|
|
}
|
|
if migrations[0].filename != "0001_init.sql" {
|
|
t.Errorf("first migration filename = %q, want 0001_init.sql", migrations[0].filename)
|
|
}
|
|
}
|
|
|
|
func TestReadMigrations_Sorted(t *testing.T) {
|
|
migrations, err := readMigrations(false)
|
|
if err != nil {
|
|
t.Fatalf("readMigrations failed: %v", err)
|
|
}
|
|
for i := 1; i < len(migrations); i++ {
|
|
if migrations[i].version <= migrations[i-1].version {
|
|
t.Errorf("migrations not sorted: version %d at index %d follows version %d at index %d",
|
|
migrations[i].version, i, migrations[i-1].version, i-1)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestReadMigrations_AllHaveSQL(t *testing.T) {
|
|
migrations, err := readMigrations(false)
|
|
if err != nil {
|
|
t.Fatalf("readMigrations failed: %v", err)
|
|
}
|
|
for _, m := range migrations {
|
|
if m.sql == "" {
|
|
t.Errorf("migration %s has empty SQL", m.filename)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestReadMigrations_BaselineIsLargest(t *testing.T) {
|
|
migrations, err := readMigrations(false)
|
|
if err != nil {
|
|
t.Fatalf("readMigrations failed: %v", err)
|
|
}
|
|
if len(migrations) < 2 {
|
|
t.Skip("not enough migrations to compare sizes")
|
|
}
|
|
// The baseline (0001_init.sql) should be the largest migration.
|
|
baselineLen := len(migrations[0].sql)
|
|
for _, m := range migrations[1:] {
|
|
if len(m.sql) > baselineLen {
|
|
t.Errorf("migration %s (%d bytes) is larger than baseline (%d bytes)",
|
|
m.filename, len(m.sql), baselineLen)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestReadMigrations_SQLite(t *testing.T) {
|
|
migrations, err := readMigrations(true)
|
|
if err != nil {
|
|
t.Fatalf("readMigrations(sqlite) failed: %v", err)
|
|
}
|
|
if len(migrations) != 5 {
|
|
t.Fatalf("expected 5 SQLite migrations, got %d", len(migrations))
|
|
}
|
|
if migrations[0].filename != "0001_init.sql" {
|
|
t.Errorf("first SQLite migration = %q, want 0001_init.sql", migrations[0].filename)
|
|
}
|
|
for _, m := range migrations {
|
|
if m.sql == "" {
|
|
t.Errorf("SQLite migration %s has empty SQL", m.filename)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSQLiteMigrateInMemory(t *testing.T) {
|
|
db, err := sqlx.Open("sqlite", ":memory:?_pragma=foreign_keys(1)")
|
|
if err != nil {
|
|
t.Fatalf("Failed to open in-memory SQLite: %v", err)
|
|
}
|
|
defer func() { _ = db.Close() }()
|
|
|
|
logger, _ := zap.NewDevelopment()
|
|
applied, err := Migrate(db, logger)
|
|
if err != nil {
|
|
t.Fatalf("Migrate to SQLite failed: %v", err)
|
|
}
|
|
if applied != 5 {
|
|
t.Errorf("expected 5 migrations applied, got %d", applied)
|
|
}
|
|
|
|
ver, err := Version(db)
|
|
if err != nil {
|
|
t.Fatalf("Version failed: %v", err)
|
|
}
|
|
if ver != 5 {
|
|
t.Errorf("expected version 5, got %d", ver)
|
|
}
|
|
|
|
// Verify a few key tables exist
|
|
for _, table := range []string{"users", "characters", "guilds", "guild_characters", "sign_sessions"} {
|
|
var count int
|
|
err := db.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?", table).Scan(&count)
|
|
if err != nil {
|
|
t.Errorf("Failed to check table %s: %v", table, err)
|
|
}
|
|
if count != 1 {
|
|
t.Errorf("Table %s not found in SQLite schema", table)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseVersion_Comprehensive(t *testing.T) {
|
|
tests := []struct {
|
|
filename string
|
|
want int
|
|
wantErr bool
|
|
}{
|
|
{"0001_init.sql", 1, false},
|
|
{"0002_add_users.sql", 2, false},
|
|
{"0100_big_change.sql", 100, false},
|
|
{"9999_final.sql", 9999, false},
|
|
{"bad.sql", 0, true},
|
|
{"noseparator", 0, true},
|
|
{"abc_description.sql", 0, true},
|
|
}
|
|
for _, tt := range tests {
|
|
got, err := parseVersion(tt.filename)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("parseVersion(%q) error = %v, wantErr %v", tt.filename, err, tt.wantErr)
|
|
continue
|
|
}
|
|
if got != tt.want {
|
|
t.Errorf("parseVersion(%q) = %d, want %d", tt.filename, got, tt.want)
|
|
}
|
|
}
|
|
}
|