material_manage_system/pkg/migration/runner_test.go
2026-09-15 16:08:18 +08:00

137 lines
4.8 KiB
Go

package migration
import (
"context"
"errors"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"github.com/DATA-DOG/go-sqlmock"
)
const createMigrationsTableSQL = `CREATE TABLE IF NOT EXISTS schema_migrations (
version VARCHAR(100) PRIMARY KEY,
dirty BOOLEAN NOT NULL DEFAULT TRUE,
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)`
func writeMigration(t *testing.T, name, sqlText string) string {
t.Helper()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, name), []byte(sqlText), 0o600); err != nil {
t.Fatalf("write migration: %v", err)
}
return dir
}
func expectCleanState(mock sqlmock.Sqlmock, applied ...string) {
mock.ExpectExec(regexp.QuoteMeta(createMigrationsTableSQL)).WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectQuery(regexp.QuoteMeta(`SELECT version FROM schema_migrations WHERE dirty = TRUE ORDER BY applied_at DESC LIMIT 1`)).
WillReturnRows(sqlmock.NewRows([]string{"version"}))
rows := sqlmock.NewRows([]string{"version"})
for _, version := range applied {
rows.AddRow(version)
}
mock.ExpectQuery(regexp.QuoteMeta(`SELECT version FROM schema_migrations WHERE dirty = FALSE`)).WillReturnRows(rows)
}
func TestRunAppliesEachMigrationOnce(t *testing.T) {
dir := writeMigration(t, "001_create_widgets.sql", `CREATE TABLE widgets (id BIGINT PRIMARY KEY);`)
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("open sqlmock: %v", err)
}
t.Cleanup(func() { db.Close() })
expectCleanState(mock)
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO schema_migrations (version, dirty) VALUES (?, TRUE)`)).
WithArgs("001_create_widgets.sql").WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec(regexp.QuoteMeta(`CREATE TABLE widgets (id BIGINT PRIMARY KEY)`)).
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec(regexp.QuoteMeta(`UPDATE schema_migrations SET dirty = FALSE, applied_at = CURRENT_TIMESTAMP WHERE version = ?`)).
WithArgs("001_create_widgets.sql").WillReturnResult(sqlmock.NewResult(0, 1))
if err := Run(context.Background(), db, dir); err != nil {
t.Fatalf("first run: %v", err)
}
expectCleanState(mock, "001_create_widgets.sql")
if err := Run(context.Background(), db, dir); err != nil {
t.Fatalf("second run: %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestRunLeavesFailedMigrationDirty(t *testing.T) {
dir := writeMigration(t, "002_broken.sql", `CREATE TABLE broken (id BIGINT); ALTER TABLE missing ADD value INT;`)
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("open sqlmock: %v", err)
}
t.Cleanup(func() { db.Close() })
expectCleanState(mock)
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO schema_migrations (version, dirty) VALUES (?, TRUE)`)).
WithArgs("002_broken.sql").WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec(regexp.QuoteMeta(`CREATE TABLE broken (id BIGINT)`)).WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec(regexp.QuoteMeta(`ALTER TABLE missing ADD value INT`)).WillReturnError(errors.New("missing table"))
err = Run(context.Background(), db, dir)
if err == nil {
t.Fatal("expected migration failure")
}
for _, fragment := range []string{"002_broken.sql", "statement 2", "dirty", "manual repair"} {
if !strings.Contains(strings.ToLower(err.Error()), strings.ToLower(fragment)) {
t.Fatalf("error %q does not contain %q", err, fragment)
}
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestRunStopsWhenDirtyMigrationExists(t *testing.T) {
dir := writeMigration(t, "003_should_not_run.sql", `CREATE TABLE should_not_run (id BIGINT);`)
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("open sqlmock: %v", err)
}
t.Cleanup(func() { db.Close() })
mock.ExpectExec(regexp.QuoteMeta(createMigrationsTableSQL)).WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectQuery(regexp.QuoteMeta(`SELECT version FROM schema_migrations WHERE dirty = TRUE ORDER BY applied_at DESC LIMIT 1`)).
WillReturnRows(sqlmock.NewRows([]string{"version"}).AddRow("002_broken.sql"))
err = Run(context.Background(), db, dir)
if err == nil || !strings.Contains(err.Error(), "002_broken.sql") {
t.Fatalf("expected dirty migration error, got %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestSplitStatementsPreservesSemicolonsInsideStrings(t *testing.T) {
statements, err := splitStatements("INSERT INTO notes(value) VALUES ('a;b'); -- comment\nUPDATE notes SET value = \"c;d\";")
if err != nil {
t.Fatalf("split statements: %v", err)
}
want := []string{
"INSERT INTO notes(value) VALUES ('a;b')",
"UPDATE notes SET value = \"c;d\"",
}
if len(statements) != len(want) {
t.Fatalf("got %d statements, want %d: %#v", len(statements), len(want), statements)
}
for i := range want {
if statements[i] != want[i] {
t.Fatalf("statement %d = %q, want %q", i+1, statements[i], want[i])
}
}
}