|
1 | 1 | package cli |
2 | 2 |
|
3 | 3 | import ( |
| 4 | + "bytes" |
4 | 5 | "context" |
| 6 | + "sync" |
5 | 7 | "testing" |
6 | 8 | "time" |
7 | 9 |
|
@@ -528,3 +530,218 @@ func (e *commandProviderExt) Stop(_ context.Context) error { return nil } |
528 | 530 | func (e *commandProviderExt) Health(_ context.Context) error { return nil } |
529 | 531 | func (e *commandProviderExt) Dependencies() []string { return nil } |
530 | 532 | 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 := ¢ralMockApp{ |
| 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 := ¢ralMockApp{ |
| 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