Skip to content

Commit 32bd21b

Browse files
committed
fix(cli): keep default status output unchanged; test central migrate routing
1 parent 1d6db7b commit 32bd21b

2 files changed

Lines changed: 231 additions & 12 deletions

File tree

cli/app_runner_commands.go

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ func buildMigrateUpCommand() Command {
123123
if app.CentralMigrationsEnabled() {
124124
cm, ok := app.CentralMigrator()
125125
if !ok {
126-
ctx.Info("CentralMigrations is enabled but no CentralMigrator was contributed to the container (is grove registered?)")
126+
ctx.Info("migrations applied during startup; no CentralMigrator registered for status reporting")
127127
return nil
128128
}
129129

@@ -139,9 +139,9 @@ func buildMigrateUpCommand() Command {
139139
totalPending += len(g.Pending)
140140
}
141141
if totalPending == 0 {
142-
ctx.Info(fmt.Sprintf("No pending migrations (%d already applied)", totalApplied))
142+
ctx.Success(fmt.Sprintf("All migrations applied (%d total)", totalApplied))
143143
} else {
144-
ctx.Success(fmt.Sprintf("%d migration(s) applied, %d pending", totalApplied, totalPending))
144+
ctx.Info(fmt.Sprintf("%d migration(s) applied, %d still pending", totalApplied, totalPending))
145145
}
146146
return nil
147147
}
@@ -281,13 +281,11 @@ func buildMigrateDownCommand() Command {
281281
)
282282
}
283283

284-
// renderMigrationGroups renders a slice of MigrationGroupInfo to the command
285-
// context using the standard Version/Name/Status/Applied-At table format.
286-
// It is shared by the per-extension and central status paths.
287-
func renderMigrationGroups(ctx CommandContext, groupLabel string, groups []*forge.MigrationGroupInfo) {
288-
ctx.Println("")
289-
ctx.Println(fmt.Sprintf("%s:", Bold(groupLabel)))
290-
284+
// renderMigrationGroups renders only the per-group section (group header +
285+
// Version/Name/Status/Applied-At table) for each group in the slice.
286+
// Callers are responsible for printing their own top-level section header
287+
// before calling this helper.
288+
func renderMigrationGroups(ctx CommandContext, groups []*forge.MigrationGroupInfo) {
291289
for _, g := range groups {
292290
ctx.Println(fmt.Sprintf(" Group: %s", Bold(g.Name)))
293291

@@ -352,7 +350,9 @@ func buildMigrateStatusCommand() Command {
352350
return nil
353351
}
354352

355-
renderMigrationGroups(ctx, "Migrations", groups)
353+
ctx.Println("")
354+
ctx.Println(fmt.Sprintf("%s:", Bold("Central migrations")))
355+
renderMigrationGroups(ctx, groups)
356356
return nil
357357
}
358358

@@ -376,7 +376,9 @@ func buildMigrateStatusCommand() Command {
376376
continue
377377
}
378378

379-
renderMigrationGroups(ctx, fmt.Sprintf("%s Migrations (%s %s)", Bold(ext.Name()), ext.Name(), ext.Version()), groups)
379+
ctx.Println("")
380+
ctx.Println(fmt.Sprintf("%s Migrations (%s %s):", Bold(ext.Name()), ext.Name(), ext.Version()))
381+
renderMigrationGroups(ctx, groups)
380382
}
381383
return nil
382384
})

cli/app_runner_test.go

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package cli
22

33
import (
4+
"bytes"
45
"context"
6+
"sync"
57
"testing"
68
"time"
79

@@ -528,3 +530,218 @@ func (e *commandProviderExt) Stop(_ context.Context) error { return nil }
528530
func (e *commandProviderExt) Health(_ context.Context) error { return nil }
529531
func (e *commandProviderExt) Dependencies() []string { return nil }
530532
func (e *commandProviderExt) CLICommands() []any { return e.commands }
533+
534+
// --- Central-mode mock types ---
535+
536+
// callEvent records the name of a method call for ordering assertions.
537+
type callEvent struct {
538+
method string
539+
}
540+
541+
// centralMockApp is a mockApp variant with CentralMigrationsEnabled == true.
542+
// It records the order of SetMigrationsDisabled and Start calls so tests can
543+
// verify suppression happens before startup.
544+
type centralMockApp struct {
545+
mockApp
546+
mu sync.Mutex
547+
events []callEvent
548+
fakeCM forge.CentralMigrator
549+
hasCM bool
550+
}
551+
552+
func (a *centralMockApp) CentralMigrationsEnabled() bool { return true }
553+
554+
func (a *centralMockApp) SetMigrationsDisabled(v bool) {
555+
a.mu.Lock()
556+
defer a.mu.Unlock()
557+
if v {
558+
a.events = append(a.events, callEvent{"SetMigrationsDisabled"})
559+
}
560+
}
561+
562+
func (a *centralMockApp) Start(_ context.Context) error {
563+
a.mu.Lock()
564+
defer a.mu.Unlock()
565+
a.events = append(a.events, callEvent{"Start"})
566+
return a.startErr
567+
}
568+
569+
func (a *centralMockApp) CentralMigrator() (forge.CentralMigrator, bool) {
570+
return a.fakeCM, a.hasCM
571+
}
572+
573+
// fakeCentralMigrator records which high-level method was called.
574+
type fakeCentralMigrator struct {
575+
mu sync.Mutex
576+
calledRunAll bool
577+
calledRollback bool
578+
calledStatus bool
579+
rollbackResult *forge.MigrationResult
580+
statusGroups []*forge.MigrationGroupInfo
581+
}
582+
583+
func (f *fakeCentralMigrator) RunAll(_ context.Context) (*forge.MigrationResult, error) {
584+
f.mu.Lock()
585+
defer f.mu.Unlock()
586+
f.calledRunAll = true
587+
return &forge.MigrationResult{}, nil
588+
}
589+
590+
func (f *fakeCentralMigrator) RollbackAll(_ context.Context) (*forge.MigrationResult, error) {
591+
f.mu.Lock()
592+
defer f.mu.Unlock()
593+
f.calledRollback = true
594+
if f.rollbackResult != nil {
595+
return f.rollbackResult, nil
596+
}
597+
return &forge.MigrationResult{RolledBack: 1, Names: []string{"001_init"}}, nil
598+
}
599+
600+
func (f *fakeCentralMigrator) StatusAll(_ context.Context) ([]*forge.MigrationGroupInfo, error) {
601+
f.mu.Lock()
602+
defer f.mu.Unlock()
603+
f.calledStatus = true
604+
if f.statusGroups != nil {
605+
return f.statusGroups, nil
606+
}
607+
return []*forge.MigrationGroupInfo{
608+
{
609+
Name: "default",
610+
Applied: []*forge.MigrationInfo{{Version: "001", Name: "init", AppliedAt: "2026-01-01"}},
611+
Pending: nil,
612+
},
613+
}, nil
614+
}
615+
616+
// newCentralCLI builds a CLI wired to a centralMockApp so commands can
617+
// invoke the migrate subcommands via c.Run(args).
618+
func newCentralCLI(app *centralMockApp, out *bytes.Buffer) CLI {
619+
c := New(Config{
620+
Name: "testapp",
621+
App: app,
622+
})
623+
if out != nil {
624+
c.SetOutput(out)
625+
}
626+
migrateCmd := buildMigrateCommand()
627+
_ = c.AddCommand(migrateCmd)
628+
return c
629+
}
630+
631+
// --- Central routing tests ---
632+
633+
// TestCentralMigrateDown_SuppressionBeforeStart verifies that when central
634+
// migrations are enabled, SetMigrationsDisabled(true) is recorded BEFORE
635+
// Start in the call sequence for "migrate down".
636+
func TestCentralMigrateDown_SuppressionBeforeStart(t *testing.T) {
637+
fakeCM := &fakeCentralMigrator{}
638+
app := &centralMockApp{
639+
mockApp: mockApp{name: "test-central", logger: forge.NewNoopLogger()},
640+
fakeCM: fakeCM,
641+
hasCM: true,
642+
}
643+
644+
var out bytes.Buffer
645+
c := newCentralCLI(app, &out)
646+
647+
// Use --force to skip the interactive confirmation prompt.
648+
err := c.Run([]string{"testapp", "migrate", "down", "--force"})
649+
if err != nil {
650+
t.Fatalf("unexpected error: %v", err)
651+
}
652+
653+
app.mu.Lock()
654+
events := make([]callEvent, len(app.events))
655+
copy(events, app.events)
656+
app.mu.Unlock()
657+
658+
// Must have at least SetMigrationsDisabled and Start recorded.
659+
if len(events) < 2 {
660+
t.Fatalf("expected at least 2 call events, got %d: %v", len(events), events)
661+
}
662+
663+
// SetMigrationsDisabled must come before Start.
664+
suppressIdx := -1
665+
startIdx := -1
666+
for i, ev := range events {
667+
if ev.method == "SetMigrationsDisabled" && suppressIdx == -1 {
668+
suppressIdx = i
669+
}
670+
if ev.method == "Start" && startIdx == -1 {
671+
startIdx = i
672+
}
673+
}
674+
if suppressIdx == -1 {
675+
t.Error("SetMigrationsDisabled(true) was never called")
676+
}
677+
if startIdx == -1 {
678+
t.Error("Start was never called")
679+
}
680+
if suppressIdx != -1 && startIdx != -1 && suppressIdx >= startIdx {
681+
t.Errorf("expected SetMigrationsDisabled before Start, got indices suppress=%d start=%d", suppressIdx, startIdx)
682+
}
683+
684+
// RollbackAll must have been called on the central migrator.
685+
fakeCM.mu.Lock()
686+
rolledBack := fakeCM.calledRollback
687+
fakeCM.mu.Unlock()
688+
if !rolledBack {
689+
t.Error("expected fakeCentralMigrator.RollbackAll to be called")
690+
}
691+
}
692+
693+
// TestCentralMigrateStatus_SuppressionBeforeStart verifies that for "migrate
694+
// status" in central mode, suppression happens before Start and StatusAll is
695+
// called (not per-extension MigrationStatus).
696+
func TestCentralMigrateStatus_SuppressionBeforeStart(t *testing.T) {
697+
fakeCM := &fakeCentralMigrator{}
698+
app := &centralMockApp{
699+
mockApp: mockApp{name: "test-central", logger: forge.NewNoopLogger()},
700+
fakeCM: fakeCM,
701+
hasCM: true,
702+
// Also register a migratable extension so we can confirm it is NOT used.
703+
// (extensions field is on the embedded mockApp)
704+
}
705+
app.mockApp.extensions = []forge.Extension{&migratableExt{name: "some-ext"}}
706+
707+
var out bytes.Buffer
708+
c := newCentralCLI(app, &out)
709+
710+
err := c.Run([]string{"testapp", "migrate", "status"})
711+
if err != nil {
712+
t.Fatalf("unexpected error: %v", err)
713+
}
714+
715+
app.mu.Lock()
716+
events := make([]callEvent, len(app.events))
717+
copy(events, app.events)
718+
app.mu.Unlock()
719+
720+
suppressIdx := -1
721+
startIdx := -1
722+
for i, ev := range events {
723+
if ev.method == "SetMigrationsDisabled" && suppressIdx == -1 {
724+
suppressIdx = i
725+
}
726+
if ev.method == "Start" && startIdx == -1 {
727+
startIdx = i
728+
}
729+
}
730+
if suppressIdx == -1 {
731+
t.Error("SetMigrationsDisabled(true) was never called for status")
732+
}
733+
if startIdx == -1 {
734+
t.Error("Start was never called for status")
735+
}
736+
if suppressIdx != -1 && startIdx != -1 && suppressIdx >= startIdx {
737+
t.Errorf("suppression must precede Start: suppress=%d start=%d", suppressIdx, startIdx)
738+
}
739+
740+
// StatusAll must have been called.
741+
fakeCM.mu.Lock()
742+
statusCalled := fakeCM.calledStatus
743+
fakeCM.mu.Unlock()
744+
if !statusCalled {
745+
t.Error("expected fakeCentralMigrator.StatusAll to be called")
746+
}
747+
}

0 commit comments

Comments
 (0)