package lagoon import ( "bytes" "context" "encoding/json" "fmt" "strings" "testing" "time" "git.golem15.com/golem15/summercms/modules/bonfire" "git.golem15.com/golem15/summercms/modules/party" "github.com/go-gormigrate/gormigrate/v2" "gorm.io/gorm" ) // pendingPlugins are two plugins with one and two migrations. func pendingPlugins() (alpha, beta migPlugin) { table := func(name string) *gormigrate.Migration { return &gormigrate.Migration{ ID: "202610210001_create_" + name, Migrate: func(tx *gorm.DB) error { return tx.Exec(`CREATE TABLE ` + name + ` (id BIGSERIAL PRIMARY KEY)`).Error }, Rollback: func(tx *gorm.DB) error { return tx.Exec(`DROP TABLE IF EXISTS ` + name).Error }, } } seed := &gormigrate.Migration{ ID: "202610210002_seed_pending_beta", Migrate: func(tx *gorm.DB) error { return tx.Exec(`INSERT INTO pending_beta DEFAULT VALUES`).Error }, Rollback: func(tx *gorm.DB) error { return tx.Exec(`DELETE FROM pending_beta`).Error }, } return migPlugin{id: "demo.alpha", migrations: []*gormigrate.Migration{table("pending_alpha")}}, migPlugin{id: "demo.beta", migrations: []*gormigrate.Migration{table("pending_beta"), seed}} } // pluginRefs returns the refs of pending that belong to plugin IDs (the // framework sets come first and are left out). func pluginRefs(pending []MigrationRef) []string { var out []string for _, r := range pending { if strings.HasPrefix(r.Plugin, "demo.") { out = append(out, r.Plugin+":"+r.ID) } } return out } // runLagoon runs one runtime command on a fresh app (a non-terminal stdin // holding "n", which a question would consume) and returns stdout, stderr, // the exit code and what is left of stdin. func runLagoon(t *testing.T, env, dsn string, plugins []party.Plugin, args ...string) (string, string, int, int) { t.Helper() app := envApp(t, env, dsn, "SUMMER_STORAGE__UPLOADS__BUCKET_URL=file://"+t.TempDir()) in := strings.NewReader("n\n") var out, errW bytes.Buffer root, err := bonfire.NewRootIO("acme", RuntimeCommands(app, plugins), in, &out, &errW) if err != nil { t.Fatal(err) } code := bonfire.Execute(context.Background(), root, args) return out.String(), errW.String(), code, in.Len() } // TestPendingMigrations: with no history every migration is pending, // framework sets first, then plugins in order; applied IDs drop out, a // partly applied set keeps only the rest, and a missing history table // makes its whole set pending. func TestPendingMigrations(t *testing.T) { db, _ := dedicatedDB(t, "lagoon_pending") gdb, err := Use(t.Context(), db) if err != nil { t.Fatal(err) } alpha, beta := pendingPlugins() plugins := []party.Plugin{alpha, beta} all, err := PendingMigrations(gdb, plugins) if err != nil { t.Fatal(err) } want := []string{"demo.alpha:202610210001_create_pending_alpha", "demo.beta:202610210001_create_pending_beta", "demo.beta:202610210002_seed_pending_beta"} if got := pluginRefs(all); strings.Join(got, ",") != strings.Join(want, ",") { t.Fatalf("plugin refs = %v, want %v", got, want) } if len(all) <= len(want) || strings.HasPrefix(all[0].Plugin, "demo.") { t.Fatalf("framework sets do not come first: %v", all) } for _, ref := range all[:len(all)-len(want)] { if strings.HasPrefix(ref.Plugin, "demo.") { t.Fatalf("a plugin ref sits among the framework sets: %v", all) } } // Framework and alpha applied: only beta is pending, its history table // still missing. if err := Migrate(gdb, []party.Plugin{alpha}); err != nil { t.Fatal(err) } if gdb.Migrator().HasTable("summer_migrations_demo_beta") { t.Fatal("beta's history table exists before beta migrated") } some, err := PendingMigrations(gdb, plugins) if err != nil { t.Fatal(err) } if strings.Join(pluginRefs(some), ",") != strings.Join(want[1:], ",") || len(some) != 2 { t.Fatalf("after alpha: %v, want only beta's two", some) } // beta applied, then its seed removed from the history: one pending. if err := Migrate(gdb, plugins); err != nil { t.Fatal(err) } if err := gdb.Exec(`DELETE FROM summer_migrations_demo_beta WHERE id = ?`, "202610210002_seed_pending_beta").Error; err != nil { t.Fatal(err) } partly, err := PendingMigrations(gdb, plugins) if err != nil { t.Fatal(err) } if len(partly) != 1 || partly[0] != (MigrationRef{Plugin: "demo.beta", ID: "202610210002_seed_pending_beta"}) { t.Fatalf("partly applied = %v", partly) } if _, err := PendingMigrations(nil, plugins); err == nil { t.Fatal("a nil db did not fail") } } // TestMigratePretend: migrate --pretend lists every pending migration and // applies nothing; migrate in production off a terminal then applies them // without asking (stdin unread), and both report nothing left after. func TestMigratePretend(t *testing.T) { db, dsn := dedicatedDB(t, "lagoon_pretend") gdb, err := Use(t.Context(), db) if err != nil { t.Fatal(err) } alpha, beta := pendingPlugins() plugins := []party.Plugin{alpha, beta} before, err := PendingMigrations(gdb, plugins) if err != nil { t.Fatal(err) } out, errOut, code, left := runLagoon(t, "production", dsn, plugins, "migrate", "--pretend") if code != bonfire.ExitOK || left != 2 { t.Fatalf("--pretend: exit %d, stdin left %d\n%s%s", code, left, out, errOut) } for _, ref := range before { if !strings.Contains(out, ref.Plugin+": "+ref.ID+"\n") { t.Errorf("--pretend lacks %s: %s:\n%s", ref.Plugin, ref.ID, out) } } if !strings.Contains(out, fmt.Sprintf("%d migrations would run", len(before))) { t.Errorf("--pretend lacks the count:\n%s", out) } after, err := PendingMigrations(gdb, plugins) if err != nil || len(after) != len(before) || gdb.Migrator().HasTable("pending_alpha") { t.Fatalf("--pretend applied something: %v, %v", after, err) } // -q keeps the data lines (the IDs) and drops the count line. out, _, code, _ = runLagoon(t, "production", dsn, plugins, "migrate", "--pretend", "-q") if code != bonfire.ExitOK || !strings.Contains(out, "demo.beta: 202610210002_seed_pending_beta\n") || strings.Contains(out, "would run") { t.Errorf("--pretend -q:\n%s", out) } out, errOut, code, left = runLagoon(t, "production", dsn, plugins, "migrate") if code != bonfire.ExitOK || left != 2 || strings.Contains(out, "[y/N]") { t.Fatalf("production migrate off a terminal: exit %d, stdin left %d\n%s%s", code, left, out, errOut) } if !strings.Contains(out, fmt.Sprintf("Applied %d migrations", len(before))) || !strings.Contains(out, "demo.beta: 2 applied") { t.Errorf("migrate result lines:\n%s", out) } if !gdb.Migrator().HasTable("pending_beta") { t.Fatal("migrate did not apply beta") } out, _, _, _ = runLagoon(t, "production", dsn, plugins, "migrate", "--pretend") if !strings.Contains(out, "0 migrations would run") { t.Errorf("--pretend after migrate:\n%s", out) } out, _, _, _ = runLagoon(t, "production", dsn, plugins, "migrate") if !strings.Contains(out, "Nothing to migrate") { t.Errorf("second migrate:\n%s", out) } } // TestRollbackPretend: migrate:rollback --pretend names the migration that // would roll back and changes nothing; the real rollback in production off // a terminal proceeds without asking. func TestRollbackPretend(t *testing.T) { db, dsn := dedicatedDB(t, "lagoon_rollback_pretend") gdb, err := Use(t.Context(), db) if err != nil { t.Fatal(err) } alpha, beta := pendingPlugins() plugins := []party.Plugin{alpha, beta} if err := Migrate(gdb, []party.Plugin{alpha}); err != nil { t.Fatal(err) } out, _, code, _ := runLagoon(t, "production", dsn, plugins, "migrate:rollback", "--plugin", "demo.beta", "--pretend") if code != bonfire.ExitOK || !strings.Contains(out, "demo.beta has no applied migration to roll back") { t.Fatalf("pretend without history: exit %d\n%s", code, out) } if err := Migrate(gdb, plugins); err != nil { t.Fatal(err) } out, _, code, _ = runLagoon(t, "production", dsn, plugins, "migrate:rollback", "--plugin", "demo.beta", "--pretend") if code != bonfire.ExitOK || !strings.Contains(out, "Would roll back 202610210002_seed_pending_beta of demo.beta") { t.Fatalf("pretend: exit %d\n%s", code, out) } var n int64 if err := gdb.Table("summer_migrations_demo_beta").Count(&n).Error; err != nil || n != 2 { t.Fatalf("pretend changed the history: %d rows, %v", n, err) } out, errOut, code, left := runLagoon(t, "production", dsn, plugins, "migrate:rollback", "--plugin", "demo.beta") if code != bonfire.ExitOK || left != 2 || !strings.Contains(out, "Rolled back 202610210002_seed_pending_beta of demo.beta") { t.Fatalf("rollback: exit %d, stdin left %d\n%s%s", code, left, out, errOut) } if err := gdb.Table("summer_migrations_demo_beta").Count(&n).Error; err != nil || n != 1 { t.Fatalf("rollback left %d history rows, %v", n, err) } } // TestDeferredPurgePretend: deferred:purge --pretend counts the bindings // older than --days and deletes none; the real purge in production off a // terminal proceeds without asking. func TestDeferredPurgePretend(t *testing.T) { db, dsn := dedicatedDB(t, "lagoon_purge_pretend") gdb, err := Use(t.Context(), db) if err != nil { t.Fatal(err) } if err := Migrate(gdb, nil); err != nil { t.Fatal(err) } old := time.Now().Add(-10 * 24 * time.Hour) for i, created := range []time.Time{old, old, time.Now()} { row := DeferredBinding{MasterType: "acme.blog.post", MasterField: "cover", SlaveType: "acme.unknown", SlaveID: fmt.Sprint(i + 1), SessionKey: "session", IsBind: true, BackendUserID: 1, CreatedAt: created, UpdatedAt: created} if err := gdb.Create(&row).Error; err != nil { t.Fatal(err) } } out, errOut, code, _ := runLagoon(t, "production", dsn, nil, "deferred:purge", "--days", "5", "--pretend") if code != bonfire.ExitOK || !strings.Contains(out, "2 deferred bindings older than 5 days would be purged") { t.Fatalf("pretend: exit %d\n%s%s", code, out, errOut) } var n int64 if err := gdb.Model(&DeferredBinding{}).Count(&n).Error; err != nil || n != 3 { t.Fatalf("pretend deleted bindings: %d left, %v", n, err) } out, errOut, code, left := runLagoon(t, "production", dsn, nil, "deferred:purge", "--days", "5") if code != bonfire.ExitOK || left != 2 || !strings.Contains(out, "older than 5 days") || strings.Contains(out, "[y/N]") { t.Fatalf("purge: exit %d, stdin left %d\n%s%s", code, left, out, errOut) } } // TestMigrateStatusJSON: migrate:status --json lists every declared // migration of the plugins with its table and applied flag, under -q too. func TestMigrateStatusJSON(t *testing.T) { db, dsn := dedicatedDB(t, "lagoon_status_json") gdb, err := Use(t.Context(), db) if err != nil { t.Fatal(err) } alpha, beta := pendingPlugins() plugins := []party.Plugin{alpha, beta} if err := Migrate(gdb, []party.Plugin{alpha}); err != nil { t.Fatal(err) } for _, args := range [][]string{{"migrate:status", "--json"}, {"migrate:status", "--json", "-q"}} { out, errOut, code, _ := runLagoon(t, "development", dsn, plugins, args...) if code != bonfire.ExitOK { t.Fatalf("%v: exit %d\n%s", args, code, errOut) } var list []statusJSON if err := json.Unmarshal([]byte(out), &list); err != nil { t.Fatalf("%v: not JSON: %v\n%s", args, err, out) } want := []statusJSON{ {Plugin: "demo.alpha", Table: "summer_migrations_demo_alpha", ID: "202610210001_create_pending_alpha", Applied: true}, {Plugin: "demo.beta", Table: "summer_migrations_demo_beta", ID: "202610210001_create_pending_beta"}, {Plugin: "demo.beta", Table: "summer_migrations_demo_beta", ID: "202610210002_seed_pending_beta"}, } if fmt.Sprint(list) != fmt.Sprint(want) { t.Errorf("%v: status = %+v, want %+v", args, list, want) } if !strings.Contains(out, `"applied": false`) { t.Errorf("%v: pending entries lack \"applied\": false:\n%s", args, out) } } }