Files
go-admin/cmd/migrate/status_test.go
T
zhangwenjian 25344aa572 feat✨: let migrate status say what sys_app knows
The migration rows answer "did this run". They cannot answer "is this
application installed", and the difference is not academic: an install that
stopped partway leaves every migration reading applied and a row saying the
install never finished. Until now nothing printed that row.

    [order]  1.0.0 failed at order-1793800000000
      applied   order-1793800000000  2026-09-10 21:02:07

The application list is the union of the two sources rather than either one.
Reading it from sys_app alone would drop an application whose migrations ran
under plain `migrate`, which records no row; reading it from the migration
rows alone drops one whose code has been taken out of the binary, which is
when somebody most wants to see it named - that one now gets a group of its
own, empty, saying why.

A database from before sys_app existed prints exactly what it printed before.
`migrate status` has to keep working on a database that has not been migrated
at all, which is when it is most wanted.

Four degradations turn the new assertions red: dropping the sys_app-only
applications from the listing, printing no summary, not narrowing sys_app by
--app, and reporting an unfinished install as an installed one.
2026-09-10 21:30:03 +08:00

285 lines
9.3 KiB
Go

package migrate
import (
"bytes"
"strings"
"testing"
"time"
adminmodels "go-admin/app/admin/models"
"go-admin/cmd/migrate/migration"
)
func at(s string) *time.Time {
t, err := time.Parse(applyTimeLayout, s)
if err != nil {
panic(err)
}
return &t
}
// The order Status returns: version strings sorted as ASCII.
func sampleEntries() []migration.StatusEntry {
return []migration.StatusEntry{
{Version: "1786700001000", AppCode: "", Registered: true, Applied: true, ApplyTime: at("2026-08-20 10:00:00")},
{Version: "1786700005000", AppCode: "", Registered: true},
{Version: "crm-1786800001000", AppCode: "crm", Registered: true, Applied: true, ApplyTime: at("2026-08-25 14:03:11")},
{Version: "crm-1786800002000", AppCode: "crm", Registered: true},
}
}
func TestPrintStatusGroupsByApp(t *testing.T) {
var buf bytes.Buffer
if err := printStatus(&buf, sampleEntries(), nil, ""); err != nil {
t.Fatal(err)
}
got := buf.String()
for _, want := range []string{
"[core]",
"[crm]",
"applied 1786700001000 2026-08-20 10:00:00",
"pending 1786700005000",
"applied crm-1786800001000 2026-08-25 14:03:11",
"pending crm-1786800002000",
"2 applied, 2 pending across 2 app(s)",
} {
if !strings.Contains(got, want) {
t.Errorf("output missing %q:\n%s", want, got)
}
}
// The framework heads the list, because that is the order a full run
// executes in.
if strings.Index(got, "[core]") > strings.Index(got, "[crm]") {
t.Errorf("core is not listed first:\n%s", got)
}
}
// A row nobody registers any more is neither applied-and-current nor pending.
// Calling it applied would say the migration is in this binary, which is what
// sends someone looking for a file that was deleted.
func TestPrintStatusMarksOrphanedRows(t *testing.T) {
entries := append(sampleEntries(), migration.StatusEntry{
Version: "gone-1786800000000", AppCode: "gone", Applied: true, ApplyTime: at("2026-08-01 09:00:00"),
})
var buf bytes.Buffer
if err := printStatus(&buf, entries, nil, ""); err != nil {
t.Fatal(err)
}
got := buf.String()
if !strings.Contains(got, "orphaned gone-1786800000000") {
t.Errorf("orphaned row not marked:\n%s", got)
}
if !strings.Contains(got, "nothing in this binary registers them") {
t.Errorf("orphaned rows need an explanation:\n%s", got)
}
if !strings.Contains(got, "2 applied, 2 pending") {
t.Errorf("orphaned rows must not be counted as applied:\n%s", got)
}
}
func TestPrintStatusFiltersByApp(t *testing.T) {
var buf bytes.Buffer
if err := printStatus(&buf, sampleEntries(), nil, "crm"); err != nil {
t.Fatal(err)
}
got := buf.String()
if strings.Contains(got, "[core]") {
t.Errorf("--app crm listed the framework:\n%s", got)
}
if !strings.Contains(got, "across 1 app(s)") {
t.Errorf("output = %s", got)
}
}
// status prints [core]; --app core has to mean the same thing.
func TestPrintStatusAppCoreSelectsTheFramework(t *testing.T) {
var buf bytes.Buffer
if err := printStatus(&buf, sampleEntries(), nil, migration.FrameworkAppCode); err != nil {
t.Fatal(err)
}
got := buf.String()
if strings.Contains(got, "[crm]") {
t.Errorf("--app core listed crm:\n%s", got)
}
if !strings.Contains(got, "[core]") {
t.Errorf("--app core listed nothing:\n%s", got)
}
}
func TestPrintStatusOnAnEmptyRegistry(t *testing.T) {
var buf bytes.Buffer
if err := printStatus(&buf, nil, nil, ""); err != nil {
t.Fatal(err)
}
if !strings.Contains(buf.String(), "no migrations registered and none recorded") {
t.Errorf("output = %s", buf.String())
}
}
func TestPrintPendingListsOnlyPendingInOrder(t *testing.T) {
var buf bytes.Buffer
if err := printPending(&buf, sampleEntries(), ""); err != nil {
t.Fatal(err)
}
got := buf.String()
if !strings.Contains(got, "dry-run: nothing will be written") {
t.Errorf("dry-run must say it writes nothing:\n%s", got)
}
if strings.Contains(got, "1786700001000\n") || strings.Contains(got, "crm-1786800001000") {
t.Errorf("dry-run listed already applied migrations:\n%s", got)
}
if !strings.Contains(got, "[core] 1786700005000") || !strings.Contains(got, "[crm] crm-1786800002000") {
t.Errorf("dry-run is missing pending migrations:\n%s", got)
}
if !strings.Contains(got, "2 migration(s) pending") {
t.Errorf("output = %s", got)
}
if strings.Index(got, "1786700005000") > strings.Index(got, "crm-1786800002000") {
t.Errorf("dry-run order does not match run order:\n%s", got)
}
}
// An orphaned row is applied and unregistered; a dry run must not offer to
// apply it, because a real run cannot.
func TestPrintPendingSkipsOrphanedRows(t *testing.T) {
entries := []migration.StatusEntry{
{Version: "gone-1786800000000", AppCode: "gone", Applied: true, ApplyTime: at("2026-08-01 09:00:00")},
}
var buf bytes.Buffer
if err := printPending(&buf, entries, ""); err != nil {
t.Fatal(err)
}
if !strings.Contains(buf.String(), "nothing to apply") {
t.Errorf("output = %s", buf.String())
}
}
func TestPrintPendingFiltersByApp(t *testing.T) {
var buf bytes.Buffer
if err := printPending(&buf, sampleEntries(), "CRM"); err != nil {
t.Fatal(err)
}
got := buf.String()
if strings.Contains(got, "[core]") {
t.Errorf("--app CRM listed the framework:\n%s", got)
}
if !strings.Contains(got, "1 migration(s) pending") {
t.Errorf("output = %s", got)
}
}
func appRows(rows ...adminmodels.SysApp) map[string]adminmodels.SysApp {
out := make(map[string]adminmodels.SysApp, len(rows))
for _, r := range rows {
out[r.AppCode] = r
}
return out
}
// The migration rows say every one of an application's migrations ran. Only
// sys_app can say the install that ran them never finished.
func TestPrintStatusShowsWhatSysAppSays(t *testing.T) {
apps := appRows(
adminmodels.SysApp{AppCode: "crm", Version: "1.2.0", Status: adminmodels.AppInstalled},
)
var buf bytes.Buffer
if err := printStatus(&buf, sampleEntries(), apps, ""); err != nil {
t.Fatal(err)
}
got := buf.String()
if !strings.Contains(got, "[crm] 1.2.0 installed") {
t.Errorf("the header does not carry what sys_app says:\n%s", got)
}
// The framework is not an application and has no row.
if strings.Contains(got, "[core] ") {
t.Errorf("the framework was given an application summary:\n%s", got)
}
}
func TestPrintStatusNamesAFailedInstallAndWhereItStopped(t *testing.T) {
apps := appRows(adminmodels.SysApp{
AppCode: "crm", Version: "1.2.0", Status: adminmodels.AppFailed,
FailedVersion: "crm-1786800002000",
})
var buf bytes.Buffer
if err := printStatus(&buf, sampleEntries(), apps, ""); err != nil {
t.Fatal(err)
}
if !strings.Contains(buf.String(), "[crm] 1.2.0 failed at crm-1786800002000") {
t.Errorf("output:\n%s", buf.String())
}
}
// A process killed partway leaves this, and nothing else records it.
func TestPrintStatusNamesAnInstallThatDidNotFinish(t *testing.T) {
apps := appRows(adminmodels.SysApp{AppCode: "crm", Version: "1.2.0", Status: adminmodels.AppInstalling})
var buf bytes.Buffer
if err := printStatus(&buf, sampleEntries(), apps, ""); err != nil {
t.Fatal(err)
}
if !strings.Contains(buf.String(), "did not finish installing") {
t.Errorf("output:\n%s", buf.String())
}
}
// An application whose code was taken out of the binary after its migration
// records were removed has nothing left but a sys_app row. The migration rows
// cannot report it at all.
func TestPrintStatusListsAnAppWithNoMigrationsAtAll(t *testing.T) {
apps := appRows(adminmodels.SysApp{AppCode: "billing", Version: "3.0.0", Status: adminmodels.AppInstalled})
var buf bytes.Buffer
if err := printStatus(&buf, sampleEntries(), apps, ""); err != nil {
t.Fatal(err)
}
got := buf.String()
if !strings.Contains(got, "[billing] 3.0.0 installed") {
t.Errorf("an application only sys_app knows about was not listed:\n%s", got)
}
if !strings.Contains(got, "no migrations registered in this binary and none recorded") {
t.Errorf("the empty group needs to say why it is empty:\n%s", got)
}
if !strings.Contains(got, "across 3 app(s)") {
t.Errorf("the count does not include it:\n%s", got)
}
}
// --app narrows both halves, or the report names one application and
// summarises another.
func TestPrintStatusFilterAppliesToSysAppToo(t *testing.T) {
apps := appRows(
adminmodels.SysApp{AppCode: "crm", Version: "1.2.0", Status: adminmodels.AppInstalled},
adminmodels.SysApp{AppCode: "billing", Version: "3.0.0", Status: adminmodels.AppInstalled},
)
var buf bytes.Buffer
if err := printStatus(&buf, sampleEntries(), apps, "crm"); err != nil {
t.Fatal(err)
}
got := buf.String()
if strings.Contains(got, "billing") {
t.Errorf("--app crm listed billing:\n%s", got)
}
if !strings.Contains(got, "across 1 app(s)") {
t.Errorf("output:\n%s", got)
}
}
// A database from before sys_app existed. The listing is what it always was,
// rather than an error or an empty report.
func TestPrintStatusWithoutSysApp(t *testing.T) {
var withRows, without bytes.Buffer
if err := printStatus(&without, sampleEntries(), nil, ""); err != nil {
t.Fatal(err)
}
if err := printStatus(&withRows, sampleEntries(), map[string]adminmodels.SysApp{}, ""); err != nil {
t.Fatal(err)
}
if without.String() != withRows.String() {
t.Errorf("an empty sys_app and no sys_app print differently:\n%s\n---\n%s", without.String(), withRows.String())
}
if !strings.Contains(without.String(), "[crm]\n") {
t.Errorf("the header carries a summary it has no row for:\n%s", without.String())
}
}