mirror of
https://github.com/go-admin-team/go-admin.git
synced 2026-09-22 10:33:13 +00:00
run() called log.Fatalf on the first migration that failed, which ended the process from inside the migration engine. Nothing above it could record what happened - an installer needs to write down which version an attempt stopped on - and no test could exercise a failing migration at all without taking the test binary with it, which is why the one test that covers a failed migration drove the registered function directly and left the scheduler uncovered. run(), Migrate() and MigrateApp() now return an error, and the exit moved to the command layer where the exit code is the command's business. Two of those errors say more than "it failed". A migration that fails comes back as a *VersionFailure naming the version, because an installer records that as a diagnostic snapshot - the authoritative answer to where a retry resumes is always recomputed from sys_migration, never read back, and asking the database what is still pending answers a different question that merely has the same answer most of the time. An app code nothing registered under is now an error rather than a log line, so an installer asking for one app by name cannot be told that installing an app that does not exist succeeded; the command layer still rejects a typo before any database work. exitOnError is what makes the command exit non-zero, and it covers more than it replaces. Every path out of migrateModel used to return without an exit code: an unreachable tenant database or a failed AutoMigrate printed a line and exited 0, so a caller that migrates before starting a server - the deploy workflow does exactly that - carried on onto a schema that had not been brought forward. A failing migration function was the only failure reported, and only as a side effect of the log.Fatalf this commit removes. Each of these was checked by degrading it and watching the named assertion go red: returning nil instead of the failure, naming the first version rather than the one that failed, accepting an unregistered app code, and not exiting. One gap is left open deliberately. Go allows a call whose only result is an error to stand as a statement, so `migration.Migrate.Migrate()` still compiles while dropping what it returns - `go build` passed while migrateModel was doing exactly that during this change. Both call sites now return the value, which the compiler does check, but nothing guards against the statement form coming back. A checksilent rule was considered and dropped: that tool parses without type information, so it could only match the method name, and a guard that fires on any type with a Migrate method is noise.
645 lines
20 KiB
Go
645 lines
20 KiB
Go
package migration
|
|
|
|
import (
|
|
"errors"
|
|
"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)
|
|
})
|
|
if err := m.Migrate(); err != nil {
|
|
t.Fatalf("m.Migrate(): %v", err)
|
|
}
|
|
|
|
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
|
|
})
|
|
if err := m.Migrate(); err != nil {
|
|
t.Fatalf("m.Migrate(): %v", err)
|
|
}
|
|
|
|
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)
|
|
})
|
|
|
|
if err := m.MigrateApp("x"); err != nil {
|
|
t.Fatalf("m.MigrateApp(\"x\"): %v", err)
|
|
}
|
|
|
|
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)
|
|
})
|
|
|
|
if err := m.MigrateApp(FrameworkAppCode); err != nil {
|
|
t.Fatalf("m.MigrateApp(FrameworkAppCode): %v", err)
|
|
}
|
|
|
|
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)
|
|
})
|
|
|
|
if err := m.Migrate(); err != nil {
|
|
t.Fatalf("m.Migrate(): %v", err)
|
|
}
|
|
|
|
// 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)
|
|
})
|
|
}
|
|
if err := m.Migrate(); err != nil {
|
|
t.Fatalf("m.Migrate(): %v", err)
|
|
}
|
|
|
|
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
|
|
})
|
|
})
|
|
|
|
// Driven through the scheduler, not by calling the registered function
|
|
// directly. That workaround was here because run() called log.Fatal and
|
|
// would have taken the test binary with it, which also meant nothing
|
|
// covered what the scheduler does with a failure.
|
|
if err := m.MigrateApp("crm"); 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)
|
|
}
|
|
}
|
|
|
|
// An installer records which version an attempt got stuck on. It gets that
|
|
// from the error rather than by asking the database what is still pending,
|
|
// which is a different question - see VersionFailure.
|
|
func TestRunReportsWhichVersionFailed(t *testing.T) {
|
|
db := newTestDB(t)
|
|
m := newMigration()
|
|
m.SetDb(db)
|
|
|
|
// Two versions, and the first one succeeds: the failure has to name the
|
|
// one that actually failed, which a report that just names the app, or
|
|
// the first version it looked at, would get wrong.
|
|
m.ForApp("crm").SetVersion("1786800001000", func(db *gorm.DB, version, appCode string) error {
|
|
return recordFor(db, version, appCode)
|
|
})
|
|
m.ForApp("crm").SetVersion("1786800002000", func(db *gorm.DB, version, appCode string) error {
|
|
return errTestMigrationFailed
|
|
})
|
|
|
|
err := m.MigrateApp("crm")
|
|
if err == nil {
|
|
t.Fatal("MigrateApp reported success")
|
|
}
|
|
var vf *VersionFailure
|
|
if !errors.As(err, &vf) {
|
|
t.Fatalf("error is %T, want *VersionFailure: %v", err, err)
|
|
}
|
|
if vf.Version != "crm-1786800002000" {
|
|
t.Errorf("failed version = %q, want crm-1786800002000", vf.Version)
|
|
}
|
|
if !errors.Is(err, errTestMigrationFailed) {
|
|
t.Errorf("the cause is not reachable through the wrapper: %v", err)
|
|
}
|
|
// The one that succeeded before it stays recorded: a retry must not run
|
|
// it again.
|
|
rows := rowsByVersion(t, db)
|
|
if _, ok := rows["crm-1786800001000"]; !ok {
|
|
t.Errorf("the migration that succeeded was not recorded: %v", rows)
|
|
}
|
|
if _, ok := rows["crm-1786800002000"]; ok {
|
|
t.Errorf("the migration that failed was recorded: %v", 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)
|
|
})
|
|
|
|
// Reported as an error rather than a log line, so an installer asking
|
|
// for one app by name cannot be told that installing an app nothing
|
|
// registered succeeded.
|
|
err := m.MigrateApp("crmm")
|
|
if err == nil {
|
|
t.Fatal("a typo reported success")
|
|
}
|
|
if !strings.Contains(err.Error(), `no migrations are registered for app "crmm"`) {
|
|
t.Errorf("error = %q", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "registered: core, crm") {
|
|
t.Errorf("the message must list what is registered; got %q", err)
|
|
}
|
|
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)
|
|
})
|
|
|
|
if err := m.Migrate(); err != nil {
|
|
t.Fatalf("m.Migrate(): %v", err)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
if err := m.Migrate(); err != nil {
|
|
t.Fatalf("m.Migrate(): %v", err)
|
|
}
|
|
|
|
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)
|
|
})
|
|
|
|
if err := m.MigrateApp("order"); err != nil {
|
|
t.Fatalf("m.MigrateApp(\"order\"): %v", err)
|
|
}
|
|
|
|
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)
|
|
})
|
|
|
|
if err := m.Migrate(); err != nil {
|
|
t.Fatalf("m.Migrate(): %v", err)
|
|
}
|
|
|
|
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")
|
|
}
|