mirror of
https://github.com/go-admin-team/go-admin.git
synced 2026-09-26 03:47:43 +00:00
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.
285 lines
9.3 KiB
Go
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())
|
|
}
|
|
}
|