Files
go-admin/cmd/migrate/install_test.go
T
zhangwenjian 37aece9791 test✅: share one sys_app fixture, and count through the helper that checks
Two tests in this package each wrote out what an installed sys_app row looks
like, field for field, and a third inlined the same Create with a different
status. One appRow(t, db, code, status) now covers all three, so a new NOT
NULL column on SysApp is one edit rather than three.

One assertion counted with a bare db.Model(...).Count(&n) and dropped the
error that call returns. A failing query leaves n at zero, which is exactly
what that assertion wanted to see - so the test would have passed on a broken
query. The package already had a count helper that fails on the error, and
this now uses it.

Also a cycle reached from outside itself. The existing case walks straight
into its own cycle from the first code, so the path trimming had nothing to do
and replacing it with the untrimmed path left the test green - the trimming
was never covered. With a requiring b, b requiring c and c requiring b, the
untrimmed report names a as part of a cycle it is not in, and the test goes
red.
2026-09-11 08:21:35 +08:00

650 lines
21 KiB
Go

package migrate
import (
"errors"
"strings"
"testing"
"github.com/glebarez/sqlite"
"github.com/go-admin-team/go-admin-core/v2/sdk/contract/app"
"gorm.io/gorm"
"gorm.io/gorm/logger"
adminmodels "go-admin/app/admin/models"
"go-admin/cmd/migrate/migration"
)
func newInstallDB(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(&adminmodels.SysApp{}); err != nil {
t.Fatalf("automigrate sys_app: %v", err)
}
return db
}
// fakeEngine stands in for the migration engine. The real one is a
// package-level singleton with no exported constructor, so a test taking it
// would share one registry with every other test in this process.
type fakeEngine struct {
entries []migration.StatusEntry
// failWith, when set, is what MigrateApp returns instead of applying.
failWith error
calls []string
}
func (f *fakeEngine) SetDb(*gorm.DB) {}
func (f *fakeEngine) Status() ([]migration.StatusEntry, error) {
out := make([]migration.StatusEntry, len(f.entries))
copy(out, f.entries)
return out, nil
}
func (f *fakeEngine) MigrateApp(code string) error {
f.calls = append(f.calls, code)
if f.failWith != nil {
return f.failWith
}
for i := range f.entries {
if f.entries[i].AppCode == code && f.entries[i].Registered {
f.entries[i].Applied = true
}
}
return nil
}
func orderManifest(version string) app.Manifest {
return app.Manifest{
Code: "order",
Name: "Orders",
Version: version,
Description: "order management",
Author: "go-admin",
// No dependency by default: these tests are about installing, and a
// declared requirement would make every one of them set up a second
// application first. requiresInstalled has its own tests below.
Requires: nil,
Pricing: "free",
License: "MIT",
}
}
// appRow writes one sys_app row: what another application looks like to the
// installer, in whichever state the caller is testing against.
func appRow(t *testing.T, db *gorm.DB, code string, status int) {
t.Helper()
if err := db.Create(&adminmodels.SysApp{
AppCode: code, Name: code, Version: "1.0.0", Status: status,
}).Error; err != nil {
t.Fatalf("seeding %q with status %d: %v", code, status, err)
}
}
func loadRow(t *testing.T, db *gorm.DB, code string) adminmodels.SysApp {
t.Helper()
var row adminmodels.SysApp
if err := db.Where("app_code = ?", code).First(&row).Error; err != nil {
t.Fatalf("sys_app has no row for %q: %v", code, err)
}
return row
}
// A1: a first install records the app, at the version the manifest declares,
// with every descriptive column copied from it.
func TestInstallRecordsAFirstInstall(t *testing.T) {
db := newInstallDB(t)
eng := &fakeEngine{entries: []migration.StatusEntry{
{Version: "order-1786800001000", AppCode: "order", Registered: true},
{Version: "order-1786800002000", AppCode: "order", Registered: true},
{Version: "crm-1786800001000", AppCode: "crm", Registered: true},
}}
rep, err := install(db, eng, orderManifest("1.0.0"))
if err != nil {
t.Fatalf("install: %v", err)
}
if rep.NoOp {
t.Error("a first install reported nothing to do")
}
if got, want := len(rep.Applied), 2; got != want {
t.Errorf("applied %v, want %d versions", rep.Applied, want)
}
// Only this app's migrations, not every pending one in the process.
if len(eng.calls) != 1 || eng.calls[0] != "order" {
t.Errorf("MigrateApp calls = %v", eng.calls)
}
row := loadRow(t, db, "order")
if row.Status != adminmodels.AppInstalled {
t.Errorf("status = %d, want installed", row.Status)
}
if row.Version != "1.0.0" {
t.Errorf("version = %q", row.Version)
}
if row.InstalledAt == nil {
t.Error("installed_at was not set")
}
if row.Name != "Orders" || row.Author != "go-admin" || row.Description != "order management" {
t.Errorf("descriptive columns not copied from the manifest: %+v", row)
}
if row.Pricing != "free" || row.License != "MIT" {
t.Errorf("the reserved fields were not carried through: %+v", row)
}
}
// A2: installing the same version again is a no-op, and says so.
func TestInstallIsANoOpAtTheSameVersion(t *testing.T) {
db := newInstallDB(t)
eng := &fakeEngine{entries: []migration.StatusEntry{
{Version: "order-1786800001000", AppCode: "order", Registered: true},
}}
if _, err := install(db, eng, orderManifest("1.0.0")); err != nil {
t.Fatalf("first install: %v", err)
}
before := loadRow(t, db, "order")
rep, err := install(db, eng, orderManifest("1.0.0"))
if err != nil {
t.Fatalf("second install: %v", err)
}
if !rep.NoOp {
t.Error("installing the same version again was not reported as a no-op")
}
if len(eng.calls) != 1 {
t.Errorf("the engine was driven again: %v", eng.calls)
}
after := loadRow(t, db, "order")
if !after.UpdatedAt.Equal(before.UpdatedAt) {
t.Error("a no-op rewrote the row")
}
var n int64
db.Model(&adminmodels.SysApp{}).Count(&n)
if n != 1 {
t.Errorf("sys_app has %d rows, want 1", n)
}
}
// A no-op is only a no-op when nothing is outstanding. A row that says
// installed while a migration of its has never run is the case sys_app must
// not be believed over sys_migration.
func TestInstallRunsWhenTheRowSaysInstalledButAMigrationIsPending(t *testing.T) {
db := newInstallDB(t)
eng := &fakeEngine{entries: []migration.StatusEntry{
{Version: "order-1786800001000", AppCode: "order", Registered: true, Applied: true},
}}
if _, err := install(db, eng, orderManifest("1.0.0")); err != nil {
t.Fatalf("first install: %v", err)
}
// A second version of the same app appears - the app was rebuilt with
// one more migration file, without its version changing.
eng.entries = append(eng.entries, migration.StatusEntry{
Version: "order-1786800002000", AppCode: "order", Registered: true,
})
rep, err := install(db, eng, orderManifest("1.0.0"))
if err != nil {
t.Fatalf("install: %v", err)
}
if rep.NoOp {
t.Fatal("an outstanding migration was reported as nothing to do")
}
if len(rep.Applied) != 1 || rep.Applied[0] != "order-1786800002000" {
t.Errorf("applied = %v", rep.Applied)
}
}
// A9: an upgrade is in place. installed_at is the first install's, not this
// one's.
func TestInstallUpgradesInPlaceAndKeepsTheFirstInstallTime(t *testing.T) {
db := newInstallDB(t)
eng := &fakeEngine{entries: []migration.StatusEntry{
{Version: "order-1786800001000", AppCode: "order", Registered: true},
}}
if _, err := install(db, eng, orderManifest("1.0.0")); err != nil {
t.Fatalf("first install: %v", err)
}
first := loadRow(t, db, "order")
if first.InstalledAt == nil {
t.Fatal("installed_at was not set by the first install")
}
eng.entries = append(eng.entries, migration.StatusEntry{
Version: "order-1786800002000", AppCode: "order", Registered: true,
})
rep, err := install(db, eng, orderManifest("2.0.0"))
if err != nil {
t.Fatalf("upgrade: %v", err)
}
if rep.Previous != "1.0.0" {
t.Errorf("previous = %q, want 1.0.0", rep.Previous)
}
row := loadRow(t, db, "order")
if row.Version != "2.0.0" {
t.Errorf("version = %q, want 2.0.0", row.Version)
}
if row.Status != adminmodels.AppInstalled {
t.Errorf("status = %d, want installed", row.Status)
}
if !row.InstalledAt.Equal(*first.InstalledAt) {
t.Errorf("installed_at moved from %v to %v; an upgrade keeps the first install's time",
first.InstalledAt, row.InstalledAt)
}
}
// A10: a downgrade is refused, and refused before anything is written.
func TestInstallRefusesADowngrade(t *testing.T) {
db := newInstallDB(t)
eng := &fakeEngine{entries: []migration.StatusEntry{
{Version: "order-1786800001000", AppCode: "order", Registered: true},
}}
if _, err := install(db, eng, orderManifest("2.0.0")); err != nil {
t.Fatalf("first install: %v", err)
}
before := loadRow(t, db, "order")
_, err := install(db, eng, orderManifest("1.0.0"))
if err == nil {
t.Fatal("a downgrade was accepted")
}
if !strings.Contains(err.Error(), "downgrade") {
t.Errorf("error = %q, it has to say what it refused", err)
}
after := loadRow(t, db, "order")
if after.Version != before.Version || after.Status != before.Status {
t.Errorf("the refused downgrade still wrote to the row: %+v -> %+v", before, after)
}
}
// A5: a failing migration leaves a row that says so, and says where.
func TestInstallRecordsAFailure(t *testing.T) {
db := newInstallDB(t)
boom := errors.New("the seed hit a duplicate")
eng := &fakeEngine{
entries: []migration.StatusEntry{
{Version: "order-1786800001000", AppCode: "order", Registered: true},
},
failWith: &migration.VersionFailure{Version: "order-1786800001000", Err: boom},
}
_, err := install(db, eng, orderManifest("1.0.0"))
if err == nil {
t.Fatal("a failed install reported success")
}
if !errors.Is(err, boom) {
t.Errorf("the cause is not reachable: %v", err)
}
row := loadRow(t, db, "order")
if row.Status != adminmodels.AppFailed {
t.Errorf("status = %d, want failed", row.Status)
}
if row.FailedVersion != "order-1786800001000" {
t.Errorf("failed_version = %q", row.FailedVersion)
}
if !strings.Contains(row.LastError, "duplicate") {
t.Errorf("last_error = %q", row.LastError)
}
if row.InstalledAt != nil {
t.Error("installed_at was set by an install that failed")
}
}
// A failed install is retried by running it again - not by any special
// command, and without the previous attempt's diagnostics surviving into a
// row that now says installed.
func TestInstallResumesAfterAFailure(t *testing.T) {
db := newInstallDB(t)
eng := &fakeEngine{
entries: []migration.StatusEntry{
{Version: "order-1786800001000", AppCode: "order", Registered: true},
},
failWith: &migration.VersionFailure{Version: "order-1786800001000", Err: errors.New("boom")},
}
if _, err := install(db, eng, orderManifest("1.0.0")); err == nil {
t.Fatal("the first attempt did not fail")
}
eng.failWith = nil
rep, err := install(db, eng, orderManifest("1.0.0"))
if err != nil {
t.Fatalf("retry: %v", err)
}
if rep.NoOp {
t.Error("a failed row was treated as installed")
}
row := loadRow(t, db, "order")
if row.Status != adminmodels.AppInstalled {
t.Errorf("status = %d, want installed", row.Status)
}
if row.FailedVersion != "" || row.LastError != "" {
t.Errorf("the previous failure survived onto a row that now says installed: %q / %q",
row.FailedVersion, row.LastError)
}
if row.InstalledAt == nil {
t.Error("installed_at was not set by the attempt that succeeded")
}
}
// A row stuck at installing - the process was killed partway - is not
// installed, and must not be mistaken for it.
func TestInstallRetriesARowStuckAtInstalling(t *testing.T) {
db := newInstallDB(t)
if err := db.Create(&adminmodels.SysApp{
AppCode: "order", Name: "Orders", Version: "1.0.0",
Status: adminmodels.AppInstalling,
}).Error; err != nil {
t.Fatalf("seed: %v", err)
}
eng := &fakeEngine{entries: []migration.StatusEntry{
{Version: "order-1786800001000", AppCode: "order", Registered: true, Applied: true},
}}
rep, err := install(db, eng, orderManifest("1.0.0"))
if err != nil {
t.Fatalf("install: %v", err)
}
if rep.NoOp {
t.Fatal("a row stuck at installing was reported as already installed")
}
if row := loadRow(t, db, "order"); row.Status != adminmodels.AppInstalled {
t.Errorf("status = %d, want installed", row.Status)
}
}
func TestInstallRejectsTheFrameworkCode(t *testing.T) {
db := newInstallDB(t)
m := orderManifest("1.0.0")
m.Code = migration.FrameworkAppCode
_, err := install(db, &fakeEngine{}, m)
if err == nil {
t.Fatal("the framework was installed as an application")
}
if !strings.Contains(err.Error(), "migrate") {
t.Errorf("error = %q, it should point at the command that does this", err)
}
}
func TestInstallRefusesAnUnparseableRecordedVersion(t *testing.T) {
db := newInstallDB(t)
if err := db.Create(&adminmodels.SysApp{
AppCode: "order", Name: "Orders", Version: "v1.0", Status: adminmodels.AppInstalled,
}).Error; err != nil {
t.Fatalf("seed: %v", err)
}
_, err := install(db, &fakeEngine{}, orderManifest("1.0.0"))
if err == nil {
t.Fatal("an unparseable recorded version was compared anyway")
}
row := loadRow(t, db, "order")
if row.Status != adminmodels.AppInstalled || row.Version != "v1.0" {
t.Errorf("the row was overwritten before the comparison failed: %+v", row)
}
}
// A7: the report has to say the code is not running yet. Menus appearing is
// exactly what makes an operator think it is.
func TestReportInstallSaysTheCodeIsNotRunningYet(t *testing.T) {
var out strings.Builder
reportInstall(&out, installReport{Code: "order", Version: "1.0.0", Applied: []string{"order-1786800001000"}})
got := out.String()
if !strings.Contains(got, "rebuild") || !strings.Contains(got, "restart") {
t.Errorf("the report does not say the binary has to be rebuilt: %q", got)
}
if !strings.Contains(got, "order-1786800001000") {
t.Errorf("the report does not name what it applied: %q", got)
}
}
func TestReportInstallOnANoOp(t *testing.T) {
var out strings.Builder
reportInstall(&out, installReport{Code: "order", Version: "1.0.0", NoOp: true})
if !strings.Contains(out.String(), "already installed") {
t.Errorf("output = %q", out.String())
}
}
// last_error is a varchar(255) declared in characters. A message that is
// partly Chinese would be cut mid-rune by a byte-wise truncation and stored
// as an invalid sequence.
func TestTruncateCutsRunesNotBytes(t *testing.T) {
s := strings.Repeat("迁", 300)
got := truncate(s, 255)
if n := len([]rune(got)); n != 255 {
t.Errorf("kept %d runes, want 255", n)
}
if !strings.HasPrefix(s, got) {
t.Error("truncation did not cut at a rune boundary")
}
if short := truncate("ok", 255); short != "ok" {
t.Errorf("a short message was altered: %q", short)
}
}
func TestInstallNeedsSysApp(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.Fatalf("open sqlite: %v", err)
}
_, err = install(db, &fakeEngine{}, orderManifest("1.0.0"))
if err == nil {
t.Fatal("install ran against a database with no sys_app")
}
if !strings.Contains(err.Error(), "migrate") {
t.Errorf("error = %q, it should say what to run first", err)
}
}
// The code written to sys_app and handed to the engine is the normalized one.
// A manifest whose Code was typed with different case or stray spaces has to
// land on the same identity migration.ForApp and seed.SeedMenus already use,
// or the row and the migrations it stands for are filed under two names.
func TestInstallNormalizesTheAppCode(t *testing.T) {
db := newInstallDB(t)
eng := &fakeEngine{entries: []migration.StatusEntry{
{Version: "order-1786800001000", AppCode: "order", Registered: true},
}}
m := orderManifest("1.0.0")
m.Code = " Order "
rep, err := install(db, eng, m)
if err != nil {
t.Fatalf("install: %v", err)
}
if rep.Code != "order" {
t.Errorf("reported code = %q, want order", rep.Code)
}
if len(eng.calls) != 1 || eng.calls[0] != "order" {
t.Errorf("the engine was asked for %v, want [order]", eng.calls)
}
// The row has to be findable by the normalized code, which is what every
// other table in this batch is keyed by.
row := loadRow(t, db, "order")
if row.AppCode != "order" {
t.Errorf("app_code = %q", row.AppCode)
}
if len(rep.Applied) != 1 {
t.Errorf("applied = %v; the normalized code has to match what Status reports", rep.Applied)
}
}
// The manifest's dependency list is stored as it was declared, in the CSV
// shape sys_app.requires carries.
func TestInstallStoresTheDeclaredRequires(t *testing.T) {
db := newInstallDB(t)
appRow(t, db, "crm", adminmodels.AppInstalled)
appRow(t, db, "billing", adminmodels.AppInstalled)
eng := &fakeEngine{entries: []migration.StatusEntry{
{Version: "order-1786800001000", AppCode: "order", Registered: true},
}}
m := orderManifest("1.0.0")
m.Requires = []string{"crm", "billing"}
if _, err := install(db, eng, m); err != nil {
t.Fatalf("install: %v", err)
}
if row := loadRow(t, db, "order"); row.Requires != "crm,billing" {
t.Errorf("requires = %q, want the manifest's list as CSV", row.Requires)
}
}
// An application is not installed for you because something else names it.
// "Install this" would otherwise mean "and everything it happens to name, and
// everything those name".
func TestInstallRefusesWhenADependencyIsNotInstalled(t *testing.T) {
db := newInstallDB(t)
eng := &fakeEngine{entries: []migration.StatusEntry{
{Version: "order-1786800001000", AppCode: "order", Registered: true},
}}
m := orderManifest("1.0.0")
m.Requires = []string{"crm"}
_, err := install(db, eng, m)
if err == nil {
t.Fatal("an application with an uninstalled dependency was installed")
}
if !strings.Contains(err.Error(), "crm") || !strings.Contains(err.Error(), "not installed") {
t.Errorf("error = %q, it has to name what is missing and why", err)
}
if len(eng.calls) != 0 {
t.Errorf("the engine ran anyway: %v", eng.calls)
}
// Refused before phase A, so a refusal leaves nothing behind.
if n := count(t, db, "sys_app", "app_code = ?", "order"); n != 0 {
t.Errorf("a refused install wrote %d sys_app row(s)", n)
}
}
// A dependency whose own install failed or never finished is not a dependency
// that is there, and the two say which they are - one sends you to install it,
// the other to look at why.
func TestInstallRefusesWhenADependencyIsNotFinished(t *testing.T) {
for _, tc := range []struct {
name string
status int
want string
}{
{"failed", adminmodels.AppFailed, "its install failed"},
{"installing", adminmodels.AppInstalling, "did not finish"},
} {
t.Run(tc.name, func(t *testing.T) {
db := newInstallDB(t)
appRow(t, db, "crm", tc.status)
m := orderManifest("1.0.0")
m.Requires = []string{"crm"}
_, err := install(db, &fakeEngine{}, m)
if err == nil {
t.Fatal("the dependency was accepted")
}
if !strings.Contains(err.Error(), tc.want) {
t.Errorf("error = %q, want it to say %q", err, tc.want)
}
})
}
}
func TestInstallAcceptsASatisfiedDependency(t *testing.T) {
db := newInstallDB(t)
appRow(t, db, "crm", adminmodels.AppInstalled)
eng := &fakeEngine{entries: []migration.StatusEntry{
{Version: "order-1786800001000", AppCode: "order", Registered: true},
}}
m := orderManifest("1.0.0")
m.Requires = []string{"crm"}
if _, err := install(db, eng, m); err != nil {
t.Fatalf("install: %v", err)
}
if row := loadRow(t, db, "order"); row.Status != adminmodels.AppInstalled {
t.Errorf("status = %d, want installed", row.Status)
}
}
func TestDependencyCycleIsRefused(t *testing.T) {
manifests := map[string]app.Manifest{
"a": {Code: "a", Requires: []string{"b"}},
"b": {Code: "b", Requires: []string{"c"}},
"c": {Code: "c", Requires: []string{"a"}},
}
err := refuseOnDependencyCycle(manifests)
if err == nil {
t.Fatal("a cycle was accepted")
}
// The error is the cycle, not the walk that reached it.
if !strings.Contains(err.Error(), "a -> b -> c -> a") {
t.Errorf("error = %q", err)
}
}
// A cycle between two applications neither of which is being installed is
// still an authoring mistake, and the day somebody installs into it is the
// worse time to find out.
func TestDependencyCycleIsRefusedEvenAwayFromTheTarget(t *testing.T) {
manifests := map[string]app.Manifest{
"order": {Code: "order"},
"x": {Code: "x", Requires: []string{"y"}},
"y": {Code: "y", Requires: []string{"x"}},
}
if err := refuseOnDependencyCycle(manifests); err == nil {
t.Fatal("a cycle away from the target was accepted")
}
}
func TestDependencyGraphWithoutACycle(t *testing.T) {
manifests := map[string]app.Manifest{
"a": {Code: "a", Requires: []string{"b", "c"}},
"b": {Code: "b", Requires: []string{"c"}},
"c": {Code: "c"},
// Naming something that is not registered is not a cycle. Whether it
// is installed is a question for the database, at install time.
"d": {Code: "d", Requires: []string{"nowhere"}},
}
if err := refuseOnDependencyCycle(manifests); err != nil {
t.Errorf("a graph with no cycle was refused: %v", err)
}
}
// An application that names itself.
func TestDependencyCycleOfOne(t *testing.T) {
manifests := map[string]app.Manifest{"a": {Code: "a", Requires: []string{"a"}}}
err := refuseOnDependencyCycle(manifests)
if err == nil {
t.Fatal("an application requiring itself was accepted")
}
if !strings.Contains(err.Error(), "a -> a") {
t.Errorf("error = %q", err)
}
}
// The cycle reached from outside it. a is not part of anything circular; b
// and c are. Reporting the walk instead of the cycle would name a as well,
// and sending somebody to look at an application that is not involved is
// the whole reason the path is trimmed.
func TestDependencyCycleReportsOnlyTheCycleItReached(t *testing.T) {
manifests := map[string]app.Manifest{
"a": {Code: "a", Requires: []string{"b"}},
"b": {Code: "b", Requires: []string{"c"}},
"c": {Code: "c", Requires: []string{"b"}},
}
err := refuseOnDependencyCycle(manifests)
if err == nil {
t.Fatal("a cycle was accepted")
}
if !strings.Contains(err.Error(), "b -> c -> b") {
t.Errorf("error = %q, want just the cycle", err)
}
if strings.Contains(err.Error(), "a ->") {
t.Errorf("the walk that reached the cycle was reported as part of it: %q", err)
}
}