package mysql import ( "context" "fmt" "os" "path/filepath" "strings" "testing" "time" "gorm.io/driver/sqlite" "gorm.io/gorm" "wucher/internal/config" "wucher/internal/domain/auth" "wucher/internal/domain/base" facility "wucher/internal/domain/facility" filemanager "wucher/internal/domain/file_manager" flightdata "wucher/internal/domain/flight_data" "wucher/internal/domain/helicopter" "wucher/internal/domain/mission" "wucher/internal/domain/transient" ) func findMySQLDSNForTest() (string, bool) { if v := strings.TrimSpace(os.Getenv("MYSQL_DSN")); v != "" { return v, true } dir, err := os.Getwd() if err != nil { return "", false } for i := 0; i < 8; i++ { envPath := filepath.Join(dir, ".env") raw, err := os.ReadFile(envPath) if err == nil { lines := strings.Split(string(raw), "\n") for _, line := range lines { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { continue } if strings.HasPrefix(line, "MYSQL_DSN=") { v := strings.TrimSpace(strings.TrimPrefix(line, "MYSQL_DSN=")) v = strings.Trim(v, `"'`) if v != "" { return v, true } } } } parent := filepath.Dir(dir) if parent == dir { break } dir = parent } return "", false } func openDBTestSQLite(t *testing.T) *gorm.DB { t.Helper() dsn := fmt.Sprintf("file:db_test_%d?mode=memory&cache=shared", time.Now().UnixNano()) db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{ NowFunc: func() time.Time { return time.Now().UTC() }, }) if err != nil { t.Fatalf("open sqlite: %v", err) } return db } func TestConnect(t *testing.T) { t.Run("missing dsn", func(t *testing.T) { if _, err := Connect(config.MySQLConfig{}); err == nil { t.Fatalf("expected missing dsn error") } }) t.Run("invalid dsn", func(t *testing.T) { _, err := Connect(config.MySQLConfig{ DSN: "root:pass@tcp(127.0.0.1:1)/wucher?loc=%", }) if err == nil { t.Fatalf("expected connect error for invalid DSN") } }) t.Run("success", func(t *testing.T) { dsn, ok := findMySQLDSNForTest() if !ok { t.Skip("MYSQL_DSN not found in env or .env") } cfg := config.MySQLConfig{ DSN: dsn, MaxOpenConns: 7, MaxIdleConns: 5, ConnMaxLifetime: 2 * time.Minute, ConnMaxIdleTime: time.Minute, } db, err := Connect(cfg) if err != nil { t.Skipf("skip connect success branch (mysql unavailable): %v", err) } sqlDB, err := db.DB() if err != nil { t.Fatalf("db.DB error: %v", err) } defer sqlDB.Close() if err := sqlDB.PingContext(context.Background()); err != nil { t.Fatalf("ping mysql: %v", err) } if got := sqlDB.Stats().MaxOpenConnections; got != cfg.MaxOpenConns { t.Fatalf("unexpected max open conns, got=%d want=%d", got, cfg.MaxOpenConns) } }) } func TestAutoMigrate(t *testing.T) { t.Run("success and base category backfill", func(t *testing.T) { db := openDBTestSQLite(t) if err := db.Exec(`CREATE TABLE helicopters ( id BLOB PRIMARY KEY, designation TEXT, identifier TEXT, report_sequence INTEGER DEFAULT 0, type TEXT, counter INTEGER DEFAULT 0, maintenance TEXT )`).Error; err != nil { t.Fatalf("create legacy helicopters table: %v", err) } if err := db.Exec(`CREATE TABLE bases ( id BLOB PRIMARY KEY, base TEXT )`).Error; err != nil { t.Fatalf("create legacy bases table: %v", err) } if err := db.Exec(`INSERT INTO bases(id, base) VALUES (X'00112233445566778899aabbccddeeff', 'Legacy Base')`).Error; err != nil { t.Fatalf("seed legacy bases row: %v", err) } if err := AutoMigrate(db); err != nil { t.Fatalf("auto migrate: %v", err) } if db.Migrator().HasColumn(&helicopter.Helicopter{}, "maintenance") { t.Fatalf("expected deprecated helicopters.maintenance dropped") } if db.Migrator().HasColumn(&helicopter.Helicopter{}, "counter") { t.Fatalf("expected deprecated helicopters.counter dropped") } if !db.Migrator().HasColumn(&helicopter.Helicopter{}, "foto_attachment_id") { t.Fatalf("expected helicopters.foto_attachment_id migrated") } if !db.Migrator().HasTable(&base.BaseCategory{}) { t.Fatalf("expected base_categories table migrated") } if !db.Migrator().HasColumn(&base.Base{}, "base_category_id") { t.Fatalf("expected bases.base_category_id migrated") } if !db.Migrator().HasColumn(&base.Base{}, "latitude") { t.Fatalf("expected bases.latitude migrated") } if !db.Migrator().HasColumn(&base.Base{}, "longitude") { t.Fatalf("expected bases.longitude migrated") } if !db.Migrator().HasColumn(&base.Base{}, "default_shift_start") { t.Fatalf("expected bases.default_shift_start migrated") } if !db.Migrator().HasColumn(&base.Base{}, "default_shift_end") { t.Fatalf("expected bases.default_shift_end migrated") } if !db.Migrator().HasColumn(&base.Base{}, "foto_attachment_id") { t.Fatalf("expected bases.foto_attachment_id migrated") } if !db.Migrator().HasColumn(&base.Base{}, "deleted_at") { t.Fatalf("expected bases.deleted_at migrated") } if !db.Migrator().HasColumn(&base.Base{}, "deleted_by") { t.Fatalf("expected bases.deleted_by migrated") } if !db.Migrator().HasTable(&base.BaseOperationalShiftTime{}) { t.Fatalf("expected base_operational_shift_times table migrated") } if !db.Migrator().HasColumn(&base.BaseOperationalShiftTime{}, "base_id") { t.Fatalf("expected base_operational_shift_times.base_id migrated") } if !db.Migrator().HasColumn(&base.BaseOperationalShiftTime{}, "date_start") { t.Fatalf("expected base_operational_shift_times.date_start migrated") } if !db.Migrator().HasColumn(&base.BaseOperationalShiftTime{}, "date_end") { t.Fatalf("expected base_operational_shift_times.date_end migrated") } if !db.Migrator().HasColumn(&base.BaseOperationalShiftTime{}, "start_time_type") { t.Fatalf("expected base_operational_shift_times.start_time_type migrated") } if !db.Migrator().HasColumn(&base.BaseOperationalShiftTime{}, "end_time_type") { t.Fatalf("expected base_operational_shift_times.end_time_type migrated") } if !db.Migrator().HasColumn(&mission.Mission{}, "start_time") { t.Fatalf("expected missions.start_time migrated") } if !db.Migrator().HasColumn(&mission.Mission{}, "end_time") { t.Fatalf("expected missions.end_time migrated") } var missingCategory int64 if err := db.Table("bases"). Where("base_category_id IS NULL OR LENGTH(base_category_id) = 0"). Count(&missingCategory).Error; err != nil { t.Fatalf("count missing base category: %v", err) } if missingCategory != 0 { t.Fatalf("expected all bases to have category, missing=%d", missingCategory) } var seededCategories int64 if err := db.Table("base_categories"). Where("`key` IN (?, ?)", base.CategoryKeyRegular, base.CategoryKeyHEMS). Count(&seededCategories).Error; err != nil { t.Fatalf("count seeded categories: %v", err) } if seededCategories != 2 { t.Fatalf("expected regular+hems seeded categories, got=%d", seededCategories) } if !db.Migrator().HasTable(&auth.User{}) { t.Fatalf("expected core tables migrated") } if !db.Migrator().HasTable(&auth.UserRole{}) { t.Fatalf("expected user_roles table migrated") } if !db.Migrator().HasTable(&transient.TokenEntry{}) || !db.Migrator().HasTable(&transient.QueueIdempotencyRecord{}) { t.Fatalf("expected transient runtime tables migrated") } if !db.Migrator().HasTable(&filemanager.Folder{}) || !db.Migrator().HasTable(&filemanager.File{}) { t.Fatalf("expected file manager tables migrated") } if !db.Migrator().HasColumn(&auth.Permission{}, "requires_pin") { t.Fatalf("expected permissions.requires_pin migrated") } }) t.Run("facility category backfill", func(t *testing.T) { db := openDBTestSQLite(t) if err := db.AutoMigrate(&facility.Facility{}); err != nil { t.Fatalf("auto migrate facilities: %v", err) } for _, category := range []string{ "fuel-truck", "heslo-sling", "logging-sling", "hec-sling", "hems-hec-sling", } { if err := db.Create(&facility.Facility{Category: category, Name: category, Type: "Sling"}).Error; err != nil { t.Fatalf("seed facility category %q: %v", category, err) } } if err := backfillFacilityCategories(db); err != nil { t.Fatalf("backfillFacilityCategories: %v", err) } expected := []string{ "heslo-rope-label", "heslo-rope-length", "heslo-hook", "hec-rope-label", "hec-rope-length", } for _, category := range expected { var count int64 if err := db.Table("facilities").Where("category = ?", category).Count(&count).Error; err != nil { t.Fatalf("count category %q: %v", category, err) } if count != 1 { t.Fatalf("expected one row for category %q, got %d", category, count) } } }) t.Run("auto migrate error when db closed", func(t *testing.T) { db := openDBTestSQLite(t) sqlDB, err := db.DB() if err != nil { t.Fatalf("db.DB: %v", err) } _ = sqlDB.Close() if err := AutoMigrate(db); err == nil { t.Fatalf("expected auto migrate error on closed DB") } }) t.Run("drop deprecated maintenance from legacy composite key schema", func(t *testing.T) { db := openDBTestSQLite(t) // Keep maintenance as part of PK to force drop-column failure on SQLite. if err := db.Exec(`CREATE TABLE helicopters ( id BLOB NOT NULL, maintenance TEXT NOT NULL, designation TEXT, identifier TEXT, type TEXT, report_sequence INTEGER DEFAULT 0, PRIMARY KEY (id, maintenance) )`).Error; err != nil { t.Fatalf("create legacy helicopters table: %v", err) } if err := AutoMigrate(db); err != nil { t.Fatalf("unexpected auto migrate error: %v", err) } }) t.Run("ensure flight data status column on legacy table", func(t *testing.T) { db := openDBTestSQLite(t) if err := db.Exec(`CREATE TABLE flight_data ( id BLOB PRIMARY KEY )`).Error; err != nil { t.Fatalf("create legacy flight_data table: %v", err) } if err := ensureFlightDataStatusColumn(db); err != nil { t.Fatalf("ensure status column: %v", err) } if !db.Migrator().HasColumn(&flightdata.FlightData{}, "status") { t.Fatal("expected flight_data.status column to exist") } }) }