package migrations_test import ( "context" "database/sql" "fmt" "os" "regexp" "strings" "testing" "time" mysql "github.com/go-sql-driver/mysql" "kra/internal/modules" ) func cleanupSQL(t *testing.T) string { t.Helper() raw, err := os.ReadFile("cleanup-legacy.sql") if err != nil { t.Fatal(err) } return string(raw) } func cleanupManifest(t *testing.T, source, name string) []string { t.Helper() block := regexp.MustCompile(`(?s)INSERT INTO ` + regexp.QuoteMeta(name) + `\([^\n]+\) VALUES\s*(.*?);`).FindStringSubmatch(source) if len(block) != 2 { t.Fatalf("missing manifest %s", name) } matches := regexp.MustCompile(`\('([a-z0-9_]+)'`).FindAllStringSubmatch(block[1], -1) var values []string seen := map[string]bool{} for _, match := range matches { if seen[match[1]] { t.Fatalf("duplicate %s entry %s", name, match[1]) } seen[match[1]] = true values = append(values, match[1]) } return values } func TestCleanupManifestMatchesMigrationAndRetainsCurrentTables(t *testing.T) { source := cleanupSQL(t) targets := cleanupManifest(t, source, "_sw_cleanup_targets_20261106") required := cleanupManifest(t, source, "_sw_cleanup_required_tables_20261106") versions := cleanupManifest(t, source, "_sw_cleanup_required_versions_20261106") if len(targets) != 79 || len(required) != 76 { t.Fatalf("cleanup manifest changed: %d targets, %d required", len(targets), len(required)) } set := map[string]bool{} for _, name := range targets { if strings.HasPrefix(name, "tab_") || name == "sys_business_upgrade_state" { t.Fatalf("current data included: %s", name) } set[name] = true } for _, name := range required { if set[name] { t.Fatalf("required table selected for deletion: %s", name) } } legacy, err := os.ReadFile("legacy-framework.sql") if err != nil { t.Fatal(err) } for _, pattern := range []string{"TO `(legacy_sw_20261103_[a-z0-9_]+)`", "CREATE TABLE `(bak_sw2kra_20261103_[a-z0-9_]+)`"} { for _, match := range regexp.MustCompile(pattern).FindAllStringSubmatch(string(legacy), -1) { if !set[match[1]] { t.Fatalf("migration archive omitted: %s", match[1]) } } } known := map[string]bool{} for _, id := range versions { known[id] = true } steps := modules.Catalog().MigrationSteps() if len(steps) != len(versions) { t.Fatal("refresh the cleanup version gate for the current catalog") } for _, step := range steps { if !known[step.ID] { t.Fatalf("missing required migration %s", step.ID) } } if !strings.Contains(source, "SET @sw_cleanup_execute = 0;") { t.Fatal("cleanup must default to preview") } } // DELIMITER belongs to the mysql client. Keep one connection for the routine, // session variables, advisory lock and temporary manifests. func runCleanupSQL(ctx context.Context, conn *sql.Conn, source, database string, execute bool) error { source = strings.Replace(source, "USE `sw10`;", "USE `"+database+"`;", 1) if execute { source = strings.Replace(source, "SET @sw_cleanup_execute = 0;", "SET @sw_cleanup_execute = 1;", 1) } delimiter, buffer := ";", "" for _, line := range strings.Split(source, "\n") { trimmed := strings.TrimSpace(line) if strings.HasPrefix(trimmed, "DELIMITER ") { delimiter = strings.TrimSpace(strings.TrimPrefix(trimmed, "DELIMITER ")) continue } buffer += line + "\n" if strings.HasSuffix(trimmed, delimiter) { statement := strings.TrimSuffix(strings.TrimSpace(buffer), delimiter) if _, err := conn.ExecContext(ctx, statement); err != nil { return err } buffer = "" } } return nil } func TestMySQLCleanupPreflightPreviewDeletionAndRetry(t *testing.T) { dsn := os.Getenv("SW_TEST_MYSQL_DSN") if dsn == "" { t.Skip("set SW_TEST_MYSQL_DSN to an isolated local MySQL") } cfg, err := mysql.ParseDSN(dsn) if err != nil { t.Fatal(err) } if cfg.Net != "tcp" || !strings.HasPrefix(cfg.Addr, "127.0.0.1:") { t.Fatal("cleanup tests require a local isolated MySQL") } cfg.DBName = "" admin, err := sql.Open("mysql", cfg.FormatDSN()) if err != nil { t.Fatal(err) } name := fmt.Sprintf("sw_test_cleanup_%d", time.Now().UnixNano()) if _, err := admin.Exec("CREATE DATABASE `" + name + "` CHARACTER SET utf8mb4"); err != nil { admin.Close() t.Fatal(err) } t.Cleanup(func() { _, err := admin.Exec("DROP DATABASE `" + name + "`") if err != nil { t.Error(err) } admin.Close() }) ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() conn, err := admin.Conn(ctx) if err != nil { t.Fatal(err) } defer conn.Close() exec := func(query string, args ...any) { t.Helper() if _, err := conn.ExecContext(ctx, query, args...); err != nil { t.Fatal(err) } } exec("USE `" + name + "`") source := cleanupSQL(t) targets := cleanupManifest(t, source, "_sw_cleanup_targets_20261106") required := cleanupManifest(t, source, "_sw_cleanup_required_tables_20261106") versions := cleanupManifest(t, source, "_sw_cleanup_required_versions_20261106") for _, table := range append(targets, required...) { switch table { case "sw_kra_state", "sw_kra_checks", "sys_schema_migrations", "sys_business_upgrade_state", "sys_authorities": continue } exec("CREATE TABLE `" + table + "` (id BIGINT PRIMARY KEY) ENGINE=InnoDB") exec("INSERT INTO `" + table + "` VALUES (1)") } exec("CREATE TABLE sys_authorities(authority_id BIGINT PRIMARY KEY,business_level TINYINT UNSIGNED,deleted_at DATETIME,UNIQUE KEY uk_authority_business_level(business_level))") exec("INSERT INTO sys_authorities VALUES(101,1,NULL),(102,2,NULL),(103,3,NULL)") exec("CREATE TABLE sys_business_upgrade_state(name VARCHAR(64) PRIMARY KEY,version VARCHAR(64),phase VARCHAR(32),verified_at DATETIME,verified_migrations VARCHAR(64),cleanup_backup_path TEXT,cleanup_sha256 VARCHAR(64))") exec("INSERT INTO sys_business_upgrade_state VALUES('legacy-sw','20261106_automatic_v1','cleanup',NOW(),REPEAT('a',64),'verified-backup.sql.gz',REPEAT('b',64))") exec("CREATE TABLE sw_kra_state(id INT PRIMARY KEY,version VARCHAR(80),phase VARCHAR(40),completed_at DATETIME)") exec("INSERT INTO sw_kra_state VALUES(1,'20261103_framework_migration_v5','completed',NOW())") exec("CREATE TABLE sw_kra_checks(stage VARCHAR(32),check_name VARCHAR(160),expected_value BIGINT,actual_value BIGINT,passed TINYINT)") for stage, count := range map[string]int{"backup": 52, "migration": 24} { for i := 0; i < count; i++ { exec("INSERT INTO sw_kra_checks VALUES(?,?,?,?,?)", stage, fmt.Sprintf("check_%d", i), 1, 1, 1) } } exec("CREATE TABLE sys_schema_migrations(id VARCHAR(255) PRIMARY KEY)") for _, id := range versions { exec("INSERT INTO sys_schema_migrations VALUES(?)", id) } exec("CREATE TABLE bak_sw2kra_20261103_not_owned(id INT)") count := func(query string, args ...any) int { t.Helper() var n int if err := conn.QueryRowContext(ctx, query, args...).Scan(&n); err != nil { t.Fatal(err) } return n } has := func(table string) bool { return count("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=? AND table_name=?", name, table) == 1 } assertTargets := func() { t.Helper() for _, table := range targets { if !has(table) { t.Fatalf("table deleted before all checks passed: %s", table) } } } fails := func(want string) { t.Helper() err := runCleanupSQL(ctx, conn, source, name, true) if err == nil || !strings.Contains(err.Error(), want) { t.Fatalf("want rejection %q, got %v", want, err) } assertTargets() } if err := runCleanupSQL(ctx, conn, source, name, false); err != nil { t.Fatal(err) } assertTargets() exec("UPDATE sw_kra_state SET phase='data_validated'") fails("has not completed") exec("UPDATE sw_kra_state SET phase='completed'") exec("UPDATE sw_kra_checks SET passed=0 WHERE stage='backup' AND check_name='check_0'") fails("verification records") exec("UPDATE sw_kra_checks SET passed=1") const attachments = "202611030007_business_alert_attachments" exec("DELETE FROM sys_schema_migrations WHERE id=?", attachments) fails("incremental migrations") exec("INSERT INTO sys_schema_migrations VALUES(?)", attachments) exec("RENAME TABLE sys_alert_rule TO user_saved_rule") fails("Required current table") exec("RENAME TABLE user_saved_rule TO sys_alert_rule") exec("CREATE TABLE sys_user(id INT)") fails("Unarchived legacy") exec("DROP TABLE sys_user") exec("CREATE TABLE retained_reference(id BIGINT,FOREIGN KEY(id) REFERENCES legacy_sw_20261103_sys_user(id))") fails("Foreign keys") exec("DROP TABLE retained_reference") exec("CREATE VIEW retained_view AS SELECT * FROM legacy_sw_20261103_sys_user") fails("views referencing") exec("DROP VIEW retained_view") exec("CREATE TRIGGER retained_trigger AFTER INSERT ON sys_users FOR EACH ROW INSERT INTO sj_job(id) VALUES(NEW.id)") fails("triggers referencing") exec("DROP TRIGGER retained_trigger") exec("CREATE PROCEDURE retained_routine() SELECT COUNT(*) FROM sj_job") fails("routines referencing") exec("DROP PROCEDURE retained_routine") exec("CREATE EVENT retained_event ON SCHEDULE EVERY 1 YEAR STARTS CURRENT_TIMESTAMP + INTERVAL 1 YEAR DISABLE DO INSERT INTO sj_job(id) VALUES(2)") fails("events referencing") exec("DROP EVENT retained_event") exec("SET SESSION foreign_key_checks=0") fails("foreign_key_checks") exec("SET SESSION foreign_key_checks=1") other, err := admin.Conn(ctx) if err != nil { t.Fatal(err) } var lock string if err := conn.QueryRowContext(ctx, "SELECT CONCAT('sw_to_kra:',MD5(DATABASE()))").Scan(&lock); err != nil { t.Fatal(err) } var locked int if err := other.QueryRowContext(ctx, "SELECT GET_LOCK(?,0)", lock).Scan(&locked); err != nil || locked != 1 { t.Fatalf("lock: %d %v", locked, err) } fails("already running") if _, err := other.ExecContext(ctx, "DO RELEASE_LOCK(?)", lock); err != nil { t.Fatal(err) } other.Close() oldTimeout := count("SELECT @@SESSION.lock_wait_timeout") if err := runCleanupSQL(ctx, conn, source, name, true); err != nil { t.Fatal(err) } for _, table := range targets { if has(table) { t.Fatalf("old table retained: %s", table) } } for _, table := range required { if !has(table) { t.Fatalf("current table deleted: %s", table) } } if !has("bak_sw2kra_20261103_not_owned") || count("SELECT COUNT(*) FROM sys_business_upgrade_state") != 1 || count("SELECT COUNT(*) FROM sys_users") != 1 || count("SELECT COUNT(*) FROM tab_user") != 1 { t.Fatal("non-target data changed") } if count("SELECT @@SESSION.lock_wait_timeout") != oldTimeout { t.Fatal("session lock timeout not restored") } if err := runCleanupSQL(ctx, conn, source, name, true); err != nil { t.Fatalf("retry after deletion: %v", err) } }