package swmigration import ( "compress/gzip" "context" "database/sql" "fmt" "io" "log/slog" "os" "path/filepath" "strings" "testing" "time" driver "github.com/go-sql-driver/mysql" "gorm.io/driver/mysql" "gorm.io/gorm" "gorm.io/gorm/logger" assets "kra/deploy/migrations" ) func backupTestDB(t *testing.T) *gorm.DB { t.Helper() dsn := os.Getenv("SW_TEST_MYSQL_DSN") if dsn == "" { t.Skip("isolated MySQL required") } cfg, err := driver.ParseDSN(dsn) if err != nil { t.Fatal(err) } if cfg.Net != "tcp" || !strings.HasPrefix(cfg.Addr, "127.0.0.1:") { t.Fatal("local MySQL required") } cfg.DBName = "" cfg.ParseTime = true admin, err := sql.Open("mysql", cfg.FormatDSN()) if err != nil { t.Fatal(err) } name := fmt.Sprintf("sw_test_backup_%d", time.Now().UnixNano()) if _, err := admin.Exec("CREATE DATABASE `" + name + "` CHARACTER SET utf8mb4"); err != nil { t.Fatal(err) } cfg.DBName = name db, err := gorm.Open(mysql.Open(cfg.FormatDSN()), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) if err != nil { t.Fatal(err) } pool, _ := db.DB() pool.SetMaxOpenConns(1) t.Cleanup(func() { pool.Close() _, err := admin.Exec("DROP DATABASE `" + name + "`") if err != nil { t.Error(err) } admin.Close() }) return db } func TestMySQLBackupRoundTripPreservesStorageValues(t *testing.T) { source, target := backupTestDB(t), backupTestDB(t) for _, statement := range []string{ "SET SESSION time_zone='+08:00'", "CREATE TABLE sys_fixture(id BIGINT UNSIGNED PRIMARY KEY,txt LONGTEXT,raw VARBINARY(16),amount DECIMAL(30,6),payload JSON,flags BIT(9),stamp TIMESTAMP(6) NULL,clock DATETIME(6),computed DECIMAL(31,6) GENERATED ALWAYS AS(amount*2) VIRTUAL)", "INSERT INTO sys_fixture(id,txt,raw,amount,payload,flags,stamp,clock) VALUES(18446744073709551615,'δΈ­ζ–‡πŸ˜€ quotes '' slash \\\\ line',X'00FF27225C','123456789012345678901234.123456','{\"id\":\"9007199254740993\"}',b'100000001','2026-02-03 04:05:06.123456','2026-02-03 04:05:06.123456'),(1,'',X'',NULL,NULL,b'0',NULL,NULL)", "CREATE TABLE tab_user(id INT PRIMARY KEY)", "INSERT INTO tab_user VALUES(1)", "CREATE TABLE tab_promote(id BIGINT PRIMARY KEY)", "INSERT INTO tab_promote VALUES(9007199254740993)", } { if err := source.Exec(statement).Error; err != nil { t.Fatal(err) } } if err := target.Exec("CREATE TABLE tab_user(id INT PRIMARY KEY)").Error; err != nil { t.Fatal(err) } target.Exec("INSERT INTO tab_user VALUES(77)") root := t.TempDir() path, digest, err := backupDatabase(context.Background(), source, root, "test-db", "source", nil, slog.New(slog.NewTextHandler(io.Discard, nil))) if err != nil { t.Fatal(err) } if err := verifyBackup(path, digest); err != nil { t.Fatal(err) } file, err := os.Open(path) if err != nil { t.Fatal(err) } reader, err := gzip.NewReader(file) if err != nil { t.Fatal(err) } raw, err := io.ReadAll(reader) reader.Close() file.Close() if err != nil { t.Fatal(err) } statements, err := assets.Statements(string(raw)) if err != nil { t.Fatal(err) } for _, statement := range statements { if err := target.Exec(statement).Error; err != nil { t.Fatal(err) } } query := "SELECT CAST(id AS CHAR) AS id,txt,HEX(raw) AS raw,CAST(amount AS CHAR) AS amount,CAST(payload AS CHAR) AS payload,CAST(flags AS UNSIGNED) AS flags,UNIX_TIMESTAMP(stamp) AS stamp,CAST(clock AS CHAR) AS clock,CAST(computed AS CHAR) AS computed FROM sys_fixture ORDER BY id" var before, after []map[string]any if err := source.Raw(query).Scan(&before).Error; err != nil { t.Fatal(err) } if err := target.Raw(query).Scan(&after).Error; err != nil { t.Fatal(err) } if fmt.Sprint(before) != fmt.Sprint(after) { t.Fatalf("SQL backup round trip changed storage values: before=%v after=%v", before, after) } var id uint64 target.Table("tab_promote").Select("id").Scan(&id) if id != 9007199254740993 { t.Fatal("large promote ID changed") } target.Table("tab_user").Select("id").Scan(&id) if id != 77 { t.Fatal("schema-only raw table was overwritten") } var timezone string source.Raw("SELECT @@SESSION.time_zone").Scan(&timezone) if timezone != "+08:00" { t.Fatalf("backup leaked connection time zone: %s", timezone) } appendFile, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0600) if err != nil { t.Fatal(err) } appendFile.WriteString("corrupt") appendFile.Close() if err := verifyBackup(path, digest); err == nil { t.Fatal("modified backup checksum accepted") } cancelled, cancel := context.WithCancel(context.Background()) cancel() if _, _, err := backupDatabase(cancelled, source, root, "test-db", "cancelled", nil, slog.New(slog.NewTextHandler(io.Discard, nil))); err == nil { t.Fatal("cancelled backup succeeded") } partial, _ := filepath.Glob(filepath.Join(root, "*.partial")) if len(partial) != 0 { t.Fatalf("partial backup left behind: %v", partial) } }