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()) } }