Files
go-admin/cmd/migrate/migration/init_test.go
zhangwenjian d8529289cf fix🐛: fold the host's GetFilename into the contract's
The host kept its own copy of the version-naming rule, byte-identical to
the one in contract/migration: slice the leading 13 characters, no check.
Two copies of a convention that applications also have to follow is two
things to keep in step, and the copies had already stopped matching - core
now rejects a name that carries no timestamp, and this one still accepted
"add_orders.go" and registered a migration under that string as its
version, which nothing would ever match and nothing would report.

Delegate instead, so there is one implementation of the rule and an app's
migration and a host migration derive their version the same way.

The test pins the reject case, not just the happy path: a re-divergence
that only sliced would still pass the happy path.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-05 10:58:39 +08:00

583 lines
18 KiB
Go

package migration
import (
"bytes"
"log"
"os"
"strings"
"testing"
"time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
contractmigration "github.com/go-admin-team/go-admin-core/v2/sdk/contract/migration"
common "go-admin/common/models"
)
// withContractRegistry points contractSnapshot at an isolated
// *contractmigration.Registry for the duration of one test, instead of
// go-admin-core's single process-wide one - see contractSnapshot's doc
// comment for why that indirection exists. Restored on cleanup so other
// tests in this package keep seeing an empty contract registry regardless of
// run order.
func withContractRegistry(t *testing.T) *contractmigration.Registry {
t.Helper()
reg := contractmigration.NewRegistry()
orig := contractSnapshot
contractSnapshot = reg.Snapshot
t.Cleanup(func() { contractSnapshot = orig })
return reg
}
func newTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err = db.AutoMigrate(&common.Migration{}); err != nil {
t.Fatalf("automigrate: %v", err)
}
return db
}
// recordFor is what an app's migration is expected to do: write its own
// completion row, with the version it was handed and the app code it was told
// it belongs to.
func recordFor(db *gorm.DB, version, appCode string) error {
return db.Create(&common.Migration{Version: version, AppCode: appCode}).Error
}
func rowsByVersion(t *testing.T, db *gorm.DB) map[string]common.Migration {
t.Helper()
var rows []common.Migration
if err := db.Find(&rows).Error; err != nil {
t.Fatalf("read sys_migration: %v", err)
}
out := make(map[string]common.Migration, len(rows))
for _, r := range rows {
out[r.Version] = r
}
return out
}
// Acceptance 9: a migration registered through ForApp("x") lands in
// sys_migration with app_code "x".
//
// The registry cannot write that row for the migration, because the row is the
// migration's own last statement inside its own transaction. So the only thing
// that can make this true is handing the code to the function - which is why
// AppMigrationFunc takes three parameters.
func TestForAppRecordsItsAppCode(t *testing.T) {
db := newTestDB(t)
m := newMigration()
m.SetDb(db)
m.ForApp("x").SetVersion("1786800001000", func(db *gorm.DB, version, appCode string) error {
return recordFor(db, version, appCode)
})
m.Migrate()
rows := rowsByVersion(t, db)
row, ok := rows["x-1786800001000"]
if !ok {
t.Fatalf("no row for x-1786800001000; got %v", rows)
}
if row.AppCode != "x" {
t.Errorf("app_code = %q, want %q", row.AppCode, "x")
}
}
// The framework path is untouched: same signature, and an empty app code, which
// is what the column defaults to and what every row written before this field
// existed reads back as.
func TestSetVersionStillRecordsTheFrameworkAsEmpty(t *testing.T) {
db := newTestDB(t)
m := newMigration()
m.SetDb(db)
m.SetVersion("1786700009000", func(db *gorm.DB, version string) error {
return db.Create(&common.Migration{Version: version}).Error
})
m.Migrate()
rows := rowsByVersion(t, db)
row, ok := rows["1786700009000"]
if !ok {
t.Fatalf("no row for 1786700009000; got %v", rows)
}
if row.AppCode != "" {
t.Errorf("app_code = %q, want empty (framework)", row.AppCode)
}
}
// Acceptance 12: --app x runs x's migrations and touches nothing else.
func TestMigrateAppRunsOnlyThatApp(t *testing.T) {
db := newTestDB(t)
m := newMigration()
m.SetDb(db)
ran := map[string]bool{}
m.SetVersion("1786700009000", func(db *gorm.DB, version string) error {
ran["core"] = true
return db.Create(&common.Migration{Version: version}).Error
})
m.ForApp("x").SetVersion("1786800001000", func(db *gorm.DB, version, appCode string) error {
ran["x"] = true
return recordFor(db, version, appCode)
})
m.ForApp("y").SetVersion("1786800002000", func(db *gorm.DB, version, appCode string) error {
ran["y"] = true
return recordFor(db, version, appCode)
})
m.MigrateApp("x")
if !ran["x"] {
t.Error("x did not run")
}
if ran["y"] || ran["core"] {
t.Errorf("MigrateApp(x) also ran %v", ran)
}
rows := rowsByVersion(t, db)
if len(rows) != 1 {
t.Fatalf("sys_migration has %d rows, want 1: %v", len(rows), rows)
}
}
// "core" is what status prints for the framework, so --app core has to select
// it. The stored code is the empty string; AppFilter is the translation.
func TestMigrateAppCoreSelectsTheFramework(t *testing.T) {
db := newTestDB(t)
m := newMigration()
m.SetDb(db)
ran := map[string]bool{}
m.SetVersion("1786700009000", func(db *gorm.DB, version string) error {
ran["core"] = true
return db.Create(&common.Migration{Version: version}).Error
})
m.ForApp("x").SetVersion("1786800001000", func(db *gorm.DB, version, appCode string) error {
ran["x"] = true
return recordFor(db, version, appCode)
})
m.MigrateApp(FrameworkAppCode)
if !ran["core"] {
t.Error("framework migration did not run")
}
if ran["x"] {
t.Error("--app core also ran x")
}
}
// Zero-argument Migrate keeps meaning "everything", which is what every
// existing caller relies on.
func TestMigrateRunsEveryApp(t *testing.T) {
db := newTestDB(t)
m := newMigration()
m.SetDb(db)
var order []string
m.SetVersion("1786700009000", func(db *gorm.DB, version string) error {
order = append(order, version)
return db.Create(&common.Migration{Version: version}).Error
})
m.ForApp("bbb").SetVersion("1786800001000", func(db *gorm.DB, version, appCode string) error {
order = append(order, version)
return recordFor(db, version, appCode)
})
m.ForApp("aaa").SetVersion("1786800002000", func(db *gorm.DB, version, appCode string) error {
order = append(order, version)
return recordFor(db, version, appCode)
})
m.Migrate()
// Namespacing puts every framework migration - bare digits - ahead of every
// app migration, and orders apps by code rather than by whose timestamp
// happened to be smaller. aaa's file is the newer of the two and still runs
// first. Cross-app order is not promised, but this is the order, and it is
// the one to notice changed.
want := []string{"1786700009000", "aaa-1786800002000", "bbb-1786800001000"}
if len(order) != len(want) {
t.Fatalf("ran %v, want %v", order, want)
}
for i := range want {
if order[i] != want[i] {
t.Fatalf("ran %v, want %v", order, want)
}
}
}
// Two apps minting the same millisecond timestamp used to mean one of them was
// read as already applied and silently skipped. The namespace prefix is what
// makes that impossible without changing the primary key.
func TestNamespacingKeepsTwoAppsWithTheSameTimestampApart(t *testing.T) {
db := newTestDB(t)
m := newMigration()
m.SetDb(db)
const sameTimestamp = "1786800001000"
ran := 0
for _, app := range []string{"crm", "oms"} {
m.ForApp(app).SetVersion(sameTimestamp, func(db *gorm.DB, version, appCode string) error {
ran++
return recordFor(db, version, appCode)
})
}
m.Migrate()
if ran != 2 {
t.Errorf("ran %d migrations, want 2", ran)
}
rows := rowsByVersion(t, db)
for _, want := range []string{"crm-" + sameTimestamp, "oms-" + sameTimestamp} {
if _, ok := rows[want]; !ok {
t.Errorf("missing %s; got %v", want, rows)
}
}
}
func TestNamespacedKeyLeavesFrameworkVersionsBare(t *testing.T) {
if got := namespacedKey("", "1786700009000"); got != "1786700009000" {
t.Errorf("framework version was rewritten to %q", got)
}
if got := namespacedKey("crm", "1786800001000"); got != "crm-1786800001000" {
t.Errorf("namespacedKey = %q", got)
}
}
// An app code differing only in case would group as two apps in status and sort
// before every lower-case one, for no reason a reader could guess.
func TestForAppNormalisesTheCode(t *testing.T) {
m := newMigration()
if got := m.ForApp(" CRM ").AppCode(); got != "crm" {
t.Errorf("AppCode = %q, want crm", got)
}
}
func TestForAppRejectsReservedCodes(t *testing.T) {
for _, code := range []string{"", " ", FrameworkAppCode, "CORE"} {
t.Run("code="+code, func(t *testing.T) {
defer func() {
if recover() == nil {
t.Errorf("ForApp(%q) did not panic", code)
}
}()
newMigration().ForApp(code)
})
}
}
func TestStatusReportsPendingAppliedAndOrphaned(t *testing.T) {
db := newTestDB(t)
m := newMigration()
m.SetDb(db)
applied := time.Date(2026, 8, 25, 14, 3, 11, 0, time.UTC)
if err := db.Create(&common.Migration{Version: "1786700009000", ApplyTime: applied}).Error; err != nil {
t.Fatal(err)
}
// Recorded, but nothing registers it any more.
if err := db.Create(&common.Migration{Version: "gone-1786800000000", ApplyTime: applied, AppCode: "gone"}).Error; err != nil {
t.Fatal(err)
}
m.SetVersion("1786700009000", func(db *gorm.DB, version string) error { return nil })
m.ForApp("crm").SetVersion("1786800001000", func(db *gorm.DB, version, appCode string) error { return nil })
entries, err := m.Status()
if err != nil {
t.Fatal(err)
}
byVersion := map[string]StatusEntry{}
for _, e := range entries {
byVersion[e.Version] = e
}
if e := byVersion["1786700009000"]; !e.Applied || !e.Registered || e.AppCode != "" {
t.Errorf("framework entry = %+v", e)
} else if e.ApplyTime == nil || !e.ApplyTime.Equal(applied) {
t.Errorf("framework apply time = %v, want %v", e.ApplyTime, applied)
}
if e := byVersion["crm-1786800001000"]; e.Applied || !e.Registered || e.AppCode != "crm" {
t.Errorf("crm entry = %+v", e)
}
if e := byVersion["gone-1786800000000"]; !e.Applied || e.Registered || e.AppCode != "gone" {
t.Errorf("orphaned entry = %+v", e)
}
}
// Acceptance 11 rests on this: status and --dry-run both go through Status, and
// Status must not create the table it reads.
func TestStatusDoesNotCreateItsTable(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatal(err)
}
m := newMigration()
m.SetDb(db)
m.ForApp("crm").SetVersion("1786800001000", func(db *gorm.DB, version, appCode string) error { return nil })
entries, err := m.Status()
if err != nil {
t.Fatalf("Status on a database with no sys_migration: %v", err)
}
if len(entries) != 1 || entries[0].Applied {
t.Errorf("entries = %+v, want one pending", entries)
}
if db.Migrator().HasTable(&common.Migration{}) {
t.Error("Status created sys_migration; it must only read")
}
}
// The completion row is the migration's own last statement, inside its own
// transaction. A migration that fails must leave no record of having run, or
// the next run skips it and the schema stays half-changed with nothing to say
// so.
func TestFailedMigrationLeavesNoRecord(t *testing.T) {
db := newTestDB(t)
m := newMigration()
m.SetDb(db)
m.ForApp("crm").SetVersion("1786800001000", func(db *gorm.DB, version, appCode string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := recordFor(tx, version, appCode); err != nil {
return err
}
return errTestMigrationFailed
})
})
// run() calls log.Fatal on failure, which would take the test binary with
// it, so drive the registered function directly - the point here is the
// transaction boundary, not the scheduler.
entry := m.version["crm-1786800001000"]
if err := entry.fn(db, "crm-1786800001000"); err == nil {
t.Fatal("migration reported success")
}
if rows := rowsByVersion(t, db); len(rows) != 0 {
t.Errorf("sys_migration has %v after a failed migration", rows)
}
}
var errTestMigrationFailed = &testError{"boom"}
type testError struct{ s string }
func (e *testError) Error() string { return e.s }
// A mistyped --app used to select nothing and print "no migrations to apply",
// which reads as "already up to date" - the command reports success and does
// nothing, which is the failure mode this whole batch exists to remove.
func TestMigrateAppOnAnUnknownCodeSaysSo(t *testing.T) {
db := newTestDB(t)
m := newMigration()
m.SetDb(db)
m.SetVersion("1786700009000", func(db *gorm.DB, version string) error {
return db.Create(&common.Migration{Version: version}).Error
})
m.ForApp("crm").SetVersion("1786800001000", func(db *gorm.DB, version, appCode string) error {
return recordFor(db, version, appCode)
})
var buf bytes.Buffer
log.SetOutput(&buf)
t.Cleanup(func() { log.SetOutput(os.Stderr) })
m.MigrateApp("crmm")
if !strings.Contains(buf.String(), `no migrations are registered for app "crmm"`) {
t.Errorf("output = %q", buf.String())
}
if !strings.Contains(buf.String(), "registered: core, crm") {
t.Errorf("the message must list what is registered; got %q", buf.String())
}
if rows := rowsByVersion(t, db); len(rows) != 0 {
t.Errorf("a typo ran %v", rows)
}
}
// This is the acceptance test for PRD 006's host-wiring gap: a migration
// registered through contract/migration.ForApp - the only door open to a
// third-party application - must actually run, be recorded under its app
// code, and show up in AppCodes/Status/--app the same as one registered
// through the host's own m.ForApp. Before mergedEntries existed, m.Migrate()
// never looked at contract/migration's registry at all, so this compiled,
// registered, and silently never ran.
func TestMergedEntriesRunsAContractRegisteredAppMigration(t *testing.T) {
reg := withContractRegistry(t)
db := newTestDB(t)
m := newMigration()
m.SetDb(db)
ran := false
reg.ForApp("order").SetVersion("1793800000000", func(db *gorm.DB, version, appCode string) error {
ran = true
return recordFor(db, version, appCode)
})
m.Migrate()
if !ran {
t.Fatal("contract-registered migration did not run")
}
rows := rowsByVersion(t, db)
row, ok := rows["order-1793800000000"]
if !ok {
t.Fatalf("no row for order-1793800000000; got %v", rows)
}
if row.AppCode != "order" {
t.Errorf("app_code = %q, want %q", row.AppCode, "order")
}
}
// migrate status and --dry-run both read Status; a contract-registered
// migration has to appear there under its app code exactly like a
// host-registered one, both before and after it is applied.
func TestMergedEntriesStatusIncludesContractRegisteredMigrations(t *testing.T) {
reg := withContractRegistry(t)
db := newTestDB(t)
m := newMigration()
m.SetDb(db)
reg.ForApp("order").SetVersion("1793800000000", func(db *gorm.DB, version, appCode string) error {
return recordFor(db, version, appCode)
})
entries, err := m.Status()
if err != nil {
t.Fatal(err)
}
byVersion := map[string]StatusEntry{}
for _, e := range entries {
byVersion[e.Version] = e
}
e, ok := byVersion["order-1793800000000"]
if !ok || !e.Registered || e.Applied || e.AppCode != "order" {
t.Fatalf("pending contract entry = %+v (ok=%v)", e, ok)
}
m.Migrate()
entries, err = m.Status()
if err != nil {
t.Fatal(err)
}
byVersion = map[string]StatusEntry{}
for _, e := range entries {
byVersion[e.Version] = e
}
if e := byVersion["order-1793800000000"]; !e.Applied {
t.Errorf("applied contract entry = %+v", e)
}
}
// AppCodes feeds both --app's typo detection (appRegistrationError) and the
// group headings status prints; a contract-registered app has to appear
// there or a real "go-admin migrate --app order" would be told the app does
// not exist.
func TestMergedEntriesAppCodesIncludesContractRegisteredApps(t *testing.T) {
reg := withContractRegistry(t)
m := newMigration()
m.SetVersion("1786700009000", func(db *gorm.DB, version string) error { return nil })
reg.ForApp("order").SetVersion("1793800000000", func(db *gorm.DB, version, appCode string) error { return nil })
got := m.AppCodes()
want := []string{"core", "order"}
if len(got) != len(want) {
t.Fatalf("AppCodes = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("AppCodes = %v, want %v", got, want)
}
}
}
// --app order has to actually run only order's migrations - the same
// per-app isolation MigrateApp already gives host-registered apps - even
// though order is registered in a different registry entirely.
func TestMergedEntriesMigrateAppRunsOnlyThatContractApp(t *testing.T) {
reg := withContractRegistry(t)
db := newTestDB(t)
m := newMigration()
m.SetDb(db)
ran := map[string]bool{}
m.SetVersion("1786700009000", func(db *gorm.DB, version string) error {
ran["core"] = true
return db.Create(&common.Migration{Version: version}).Error
})
reg.ForApp("order").SetVersion("1793800000000", func(db *gorm.DB, version, appCode string) error {
ran["order"] = true
return recordFor(db, version, appCode)
})
m.MigrateApp("order")
if !ran["order"] {
t.Error("order did not run")
}
if ran["core"] {
t.Errorf("MigrateApp(order) also ran %v", ran)
}
}
// A host-registered key is not supposed to collide with a namespaced
// contract key (see mergedEntries' doc comment), but if it somehow did, the
// host's own registration must win rather than a third-party application
// silently overwriting a framework migration under the same key.
func TestMergedEntriesHostRegistrationWinsOnKeyCollision(t *testing.T) {
reg := withContractRegistry(t)
db := newTestDB(t)
m := newMigration()
m.SetDb(db)
hostRan, contractRan := false, false
m.ForApp("dup").SetVersion("1786800001000", func(db *gorm.DB, version, appCode string) error {
hostRan = true
return recordFor(db, version, appCode)
})
reg.ForApp("dup").SetVersion("1786800001000", func(db *gorm.DB, version, appCode string) error {
contractRan = true
return recordFor(db, version, appCode)
})
m.Migrate()
if !hostRan {
t.Error("host registration did not run")
}
if contractRan {
t.Error("contract registration ran; host registration should have won the collision")
}
}
// GetFilename must stay the same rule the contract package applies, since an
// application registering through contract/migration names its files by that
// convention and has to land on the same version string. Pinning the reject
// case is what catches a re-divergence: a local copy that only sliced would
// return "add_orders.go" here and register a migration under a key that never
// matches anything.
func TestGetFilenameDelegatesToTheContractRule(t *testing.T) {
if got := GetFilename("version/1786700001000_demo_menu.go"); got != "1786700001000" {
t.Fatalf("GetFilename = %q, want %q", got, "1786700001000")
}
defer func() {
if recover() == nil {
t.Fatal("a file name carrying no version did not panic")
}
}()
GetFilename("version/add_orders.go")
}