From c2ed7e82abc79e355092c5752e6625479a9c70cb Mon Sep 17 00:00:00 2001 From: TheZero0-ctrl Date: Mon, 16 Mar 2026 13:30:14 +0545 Subject: [PATCH 1/3] feat: implement db:create and db:drop command --- go.mod | 1 + go.sum | 2 + internal/app/bootstrap.go | 2 + internal/app/executor.go | 16 +++ internal/cli/root.go | 15 ++- internal/domain/command/builtin.go | 37 +++++++ internal/domain/db/config.go | 97 +++++++++++++++++++ internal/domain/db/planner.go | 70 +++++++++++++ internal/domain/generate/newapp/config.go | 3 +- internal/domain/generate/newapp/planner.go | 5 +- .../templates/default/v1/cmd/api/main.go.tmpl | 74 +++++++++++--- .../default/v1/config/database.toml.tmpl | 17 ++++ .../newapp/templates/default/v1/manifest.json | 1 + .../{generate/newapp => params}/params.go | 2 +- internal/domain/plan/plan.go | 1 + 15 files changed, 322 insertions(+), 21 deletions(-) create mode 100644 internal/domain/db/config.go create mode 100644 internal/domain/db/planner.go create mode 100644 internal/domain/generate/newapp/templates/default/v1/config/database.toml.tmpl rename internal/domain/{generate/newapp => params}/params.go (84%) diff --git a/go.mod b/go.mod index 8ff96cc..24b88f1 100644 --- a/go.mod +++ b/go.mod @@ -6,5 +6,6 @@ require github.com/spf13/cobra v1.10.2 require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/spf13/pflag v1.0.9 // indirect ) diff --git a/go.sum b/go.sum index a6ee3e0..668b35e 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index ba31ea1..61a28d4 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -13,6 +13,8 @@ func NewDefaultRegistry() (*command.Registry, error) { command.NewNewCommand(), command.NewGenerateCommand(), command.NewDestroyCommand(), + command.NewDBCreateCommand(), + command.NewDBDropCommand(), } { if err := reg.Register(cmd); err != nil { return nil, err diff --git a/internal/app/executor.go b/internal/app/executor.go index f1de2d5..465bc08 100644 --- a/internal/app/executor.go +++ b/internal/app/executor.go @@ -132,6 +132,22 @@ func (e *Executor) executeOp(ctx context.Context, op plan.Operation, flags comma } return Entry{Status: "ERROR", Message: fmt.Sprintf("%s is non-empty", op.Path)}, conflictError{message: fmt.Sprintf("conflict: target directory %s is non-empty (use --force)", op.Path)} + case plan.OpEnsureExists: + exists, err := e.fs.Exists(op.Path) + + if err != nil { + return Entry{Status: "ERROR", Message: fmt.Sprintf("CHECK %s", op.Path)}, err + } + + if !exists { + msg := op.Message + if msg == "" { + msg = fmt.Sprintf("required path %s not found", op.Path) + } + return Entry{Status: "ERROR", Message: msg}, conflictError{message: msg} + } + + return Entry{Status: "INFO", Message: fmt.Sprintf("found %s", op.Path)}, nil case plan.OpMkdir: perm := op.Perm if perm == 0 { diff --git a/internal/cli/root.go b/internal/cli/root.go index 8e9f5d9..cb5e1d4 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -74,6 +74,8 @@ func newRootCommand(executor *app.Executor, registry *command.Registry, stdout, module := "" skipGit := false skipTidy := false + dsn := "" + env := "" cobraCmd := &cobra.Command{ Use: spec.Use, @@ -82,10 +84,14 @@ func newRootCommand(executor *app.Executor, registry *command.Registry, stdout, RunE: func(c *cobra.Command, args []string) error { params := map[string]string{} - if spec.ID == "new" { + switch spec.ID { + case "new": params["module"] = module params["skip-git"] = fmt.Sprintf("%t", skipGit) params["skip-tidy"] = fmt.Sprintf("%t", skipTidy) + case "db:create", "db:drop": + params["dsn"] = dsn + params["env"] = env } input := command.Input{ @@ -110,11 +116,16 @@ func newRootCommand(executor *app.Executor, registry *command.Registry, stdout, }, } - if spec.ID == "new" { + switch spec.ID { + case "new": cobraCmd.Flags().StringVar(&module, "module", "", "Explicit Go module path") cobraCmd.Flags().BoolVar(&skipGit, "skip-git", false, "Skip git init") cobraCmd.Flags().BoolVar(&skipTidy, "skip-tidy", false, "Skip go mod tidy") + case "db:create", "db:drop": + cobraCmd.Flags().StringVar(&dsn, "dsn", "", "Database connection string") + cobraCmd.Flags().StringVar(&env, "env", "", "Environment to use") } + root.AddCommand(cobraCmd) } diff --git a/internal/domain/command/builtin.go b/internal/domain/command/builtin.go index 2ada817..df15cd5 100644 --- a/internal/domain/command/builtin.go +++ b/internal/domain/command/builtin.go @@ -3,6 +3,7 @@ package command import ( "context" + "goforge/internal/domain/db" "goforge/internal/domain/generate/newapp" "goforge/internal/domain/plan" ) @@ -66,3 +67,39 @@ func NewDestroyCommand() Command { return NewStatic(spec, nil, planner) } + +func NewDBCreateCommand() Command { + spec := Spec{ + ID: "db:create", + Use: "db:create", + Short: "Create database", + } + + validate := func(input Input) error { + return db.ValidateCreate(input.Args, input) + } + + planner := func(ctx context.Context, input Input) (plan.Plan, error) { + return db.PlanCreate(ctx, input.Args, input) + } + + return NewStatic(spec, validate, planner) +} + +func NewDBDropCommand() Command { + spec := Spec{ + ID: "db:drop", + Use: "db:drop", + Short: "Drop database", + } + + validate := func(input Input) error { + return db.ValidateDrop(input.Args, input) + } + + planner := func(ctx context.Context, input Input) (plan.Plan, error) { + return db.PlanDrop(ctx, input.Args, input) + } + + return NewStatic(spec, validate, planner) +} diff --git a/internal/domain/db/config.go b/internal/domain/db/config.go new file mode 100644 index 0000000..60ff3e7 --- /dev/null +++ b/internal/domain/db/config.go @@ -0,0 +1,97 @@ +package db + +import ( + "fmt" + "goforge/internal/domain/params" + "net/url" + "os" + "path" + "path/filepath" + "strings" + + "github.com/pelletier/go-toml/v2" +) + +type Config struct { + DSN string `json:"dsn"` + AdminDSN string + DatabaseName string + Skip bool + Force bool +} + +func ParseConfig(p params.Params) (Config, error) { + dsn := strings.TrimSpace(p.Param("dsn")) + + if dsn == "" { + env := strings.TrimSpace(p.Param("env")) + + if env == "" { + env = "development" + } + + fileDSN, err := loadDSNFromConfig(env) + + if err != nil { + return Config{}, err + } + + dsn = fileDSN + } + + u, err := url.Parse(dsn) + + if err != nil { + return Config{}, fmt.Errorf("invalid DSN: %w", err) + } + + if u.Scheme != "postgres" && u.Scheme != "postgresql" { + return Config{}, fmt.Errorf("unsupported DSN scheme %q (only postgres/postgresql supported)", u.Scheme) + } + + dbName := strings.TrimPrefix(path.Clean(u.Path), "/") + if dbName == "" || dbName == "." { + return Config{}, fmt.Errorf("dsn must include target database name in path") + } + + adminURL := *u + adminURL.Path = "/postgres" + + return Config{ + DSN: dsn, + AdminDSN: adminURL.String(), + Skip: p.BoolParam("skip"), + Force: p.BoolParam("force"), + DatabaseName: dbName, + }, nil +} + +func loadDSNFromConfig(env string) (string, error) { + configPath := filepath.Join("config", "database.toml") + + data, err := os.ReadFile(configPath) + + if err != nil { + return "", err + } + + var configByEnv map[string]Config + + if err := toml.Unmarshal(data, &configByEnv); err != nil { + return "", err + } + + dbConfig, ok := configByEnv[env] + + if !ok { + return "", fmt.Errorf("no database config found for env %s", env) + } + + dsn := dbConfig.DSN + + if dsn == "" { + return "", fmt.Errorf("no database DSN found for env %s", env) + } + + return dsn, nil +} diff --git a/internal/domain/db/planner.go b/internal/domain/db/planner.go new file mode 100644 index 0000000..2f92fb1 --- /dev/null +++ b/internal/domain/db/planner.go @@ -0,0 +1,70 @@ +package db + +import ( + "context" + "fmt" + "goforge/internal/domain/params" + "goforge/internal/domain/plan" +) + +func ValidateCreate(args []string, p params.Params) error { + if len(args) != 0 { + return fmt.Errorf("db:create does not accept positional arguments") + } + + _, err := ParseConfig(p) + + return err +} + +func ValidateDrop(args []string, p params.Params) error { + if len(args) != 0 { + return fmt.Errorf("db:create does not accept positional arguments") + } + + _, err := ParseConfig(p) + + return err +} + +func PlanCreate(_ context.Context, _ []string, p params.Params) (plan.Plan, error) { + cfg, err := ParseConfig(p) + + if err != nil { + return plan.Plan{}, err + } + + ops := []plan.Operation{ + {Type: plan.OpEnsureExists, Path: "config/database.toml", Message: "missing config/database.toml"}, + } + + create := fmt.Sprintf("CREATE DATABASE \"%s\";", cfg.DatabaseName) + ops = append(ops, plan.Operation{Type: plan.OpRun, Cmd: []string{"psql", cfg.AdminDSN, "-v", "ON_ERROR_STOP=1", "-c", create}}) + + return plan.Plan{ + CommandID: "db:create", + Description: "Create database", + Ops: ops, + }, nil +} + +func PlanDrop(ctx context.Context, args []string, p params.Params) (plan.Plan, error) { + cfg, err := ParseConfig(p) + + if err != nil { + return plan.Plan{}, err + } + + ops := []plan.Operation{ + {Type: plan.OpEnsureExists, Path: "config/database.toml", Message: "missing config/database.toml"}, + } + + drop := fmt.Sprintf("DROP DATABASE IF EXISTS \"%s\";", cfg.DatabaseName) + ops = append(ops, plan.Operation{Type: plan.OpRun, Cmd: []string{"psql", cfg.AdminDSN, "-v", "ON_ERROR_STOP=1", "-c", drop}}) + + return plan.Plan{ + CommandID: "db:drop", + Description: "Drop database", + Ops: ops, + }, nil +} diff --git a/internal/domain/generate/newapp/config.go b/internal/domain/generate/newapp/config.go index 21dfb07..bdc7a5b 100644 --- a/internal/domain/generate/newapp/config.go +++ b/internal/domain/generate/newapp/config.go @@ -2,6 +2,7 @@ package newapp import ( "fmt" + "goforge/internal/domain/params" "regexp" "strings" ) @@ -18,7 +19,7 @@ type Config struct { SkipTidy bool } -func ParseConfig(args []string, p Params) (Config, error) { +func ParseConfig(args []string, p params.Params) (Config, error) { if len(args) != 1 { return Config{}, fmt.Errorf("new requires exactly one argument: ") } diff --git a/internal/domain/generate/newapp/planner.go b/internal/domain/generate/newapp/planner.go index 114cac9..7611d22 100644 --- a/internal/domain/generate/newapp/planner.go +++ b/internal/domain/generate/newapp/planner.go @@ -4,15 +4,16 @@ import ( "context" "path/filepath" + "goforge/internal/domain/params" "goforge/internal/domain/plan" ) -func Validate(args []string, p Params) error { +func Validate(args []string, p params.Params) error { _, err := ParseConfig(args, p) return err } -func Plan(_ context.Context, args []string, p Params) (plan.Plan, error) { +func Plan(_ context.Context, args []string, p params.Params) (plan.Plan, error) { cfg, err := ParseConfig(args, p) if err != nil { diff --git a/internal/domain/generate/newapp/templates/default/v1/cmd/api/main.go.tmpl b/internal/domain/generate/newapp/templates/default/v1/cmd/api/main.go.tmpl index f32b846..0357d54 100644 --- a/internal/domain/generate/newapp/templates/default/v1/cmd/api/main.go.tmpl +++ b/internal/domain/generate/newapp/templates/default/v1/cmd/api/main.go.tmpl @@ -8,10 +8,12 @@ import ( "log/slog" "net/http" "os" + "path/filepath" "time" _ "github.com/lib/pq" - "{{ .ModulePath }}/internal/data" + "github.com/pelletier/go-toml/v2" + "tempgo/internal/data" ) const version = "1.0.0" @@ -19,12 +21,7 @@ const version = "1.0.0" type config struct { port int env string - db struct { - dsn string - maxOpenConns int - maxIdleConns int - maxIdleTime time.Duration - } + db databaseConfig } type application struct { @@ -33,20 +30,30 @@ type application struct { models data.Models } +type databaseConfig struct { + DSN string `toml:"dsn"` + MaxOpenConns int `toml:"max_open_conns"` + MaxIdleConns int `toml:"max_idle_conns"` + MaxIdleTime string `toml:"max_idle_time"` +} + func main() { var cfg config flag.IntVar(&cfg.port, "port", 3000, "API server port") flag.StringVar(&cfg.env, "env", "development", "Environment (development|staging|production)") - flag.StringVar(&cfg.db.dsn, "db-dsn", "postgres://{{ .NormalizeAppName}}_development:password@localhost/{{ .NormalizeAppName}}?sslmode=disable", "PostgreSQL DSN") - flag.IntVar(&cfg.db.maxOpenConns, "db-max-open-conns", 25, "PostgreSQL max open connections") - flag.IntVar(&cfg.db.maxIdleConns, "db-max-idle-conns", 25, "PostgreSQL max idle connections") - flag.DurationVar(&cfg.db.maxIdleTime, "db-max-idle-time", 15*time.Minute, "PostgreSQL max connection idle time") flag.Parse() logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + dbConfig, err := loadDBConfig(cfg.env) + if err != nil { + logger.Error(err.Error()) + os.Exit(1) + } + cfg.db = dbConfig + db, err := openDB(cfg) if err != nil { @@ -80,16 +87,53 @@ func main() { os.Exit(1) } +func loadDBConfig(env string) (databaseConfig, error) { + configPath := filepath.Join("config", "database.toml") + data, err := os.ReadFile(configPath) + if err != nil { + return databaseConfig{}, fmt.Errorf("read %s: %w", configPath, err) + } + + var configByEnv map[string]databaseConfig + if err := toml.Unmarshal(data, &configByEnv); err != nil { + return databaseConfig{}, fmt.Errorf("parse %s: %w", configPath, err) + } + + dbConfig, ok := configByEnv[env] + if !ok { + return databaseConfig{}, fmt.Errorf("unknown env %q in %s", env, configPath) + } + + if dbConfig.DSN == "" { + return databaseConfig{}, fmt.Errorf("empty DSN for %q in %s", env, configPath) + } + if dbConfig.MaxOpenConns == 0 { + return databaseConfig{}, fmt.Errorf("empty max_open_conns for %q in %s", env, configPath) + } + if dbConfig.MaxIdleConns == 0 { + return databaseConfig{}, fmt.Errorf("empty max_idle_conns for %q in %s", env, configPath) + } + if dbConfig.MaxIdleTime == "" { + return databaseConfig{}, fmt.Errorf("empty max_idle_time for %q in %s", env, configPath) + } + + return dbConfig, nil +} + func openDB(cfg config) (*sql.DB, error) { - db, err := sql.Open("postgres", cfg.db.dsn) + db, err := sql.Open("postgres", cfg.db.DSN) if err != nil { return nil, err } - db.SetMaxOpenConns(cfg.db.maxOpenConns) - db.SetMaxIdleConns(cfg.db.maxIdleConns) - db.SetConnMaxIdleTime(cfg.db.maxIdleTime) + db.SetMaxOpenConns(cfg.db.MaxOpenConns) + db.SetMaxIdleConns(cfg.db.MaxIdleConns) + maxIdleTime, err := time.ParseDuration(cfg.db.MaxIdleTime) + if err != nil { + return nil, err + } + db.SetConnMaxIdleTime(maxIdleTime) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) diff --git a/internal/domain/generate/newapp/templates/default/v1/config/database.toml.tmpl b/internal/domain/generate/newapp/templates/default/v1/config/database.toml.tmpl new file mode 100644 index 0000000..efde0f8 --- /dev/null +++ b/internal/domain/generate/newapp/templates/default/v1/config/database.toml.tmpl @@ -0,0 +1,17 @@ +[development] +dsn = "postgres://postgres@localhost/{{ .NormalizeAppName }}?sslmode=disable" +max_open_conns = 25 +max_idle_conns = 25 +max_idle_time = "15m" + +[test] +dsn = "postgres://postgres@localhost/{{ .NormalizeAppName }}_test?sslmode=disable" +max_open_conns = 25 +max_idle_conns = 25 +max_idle_time = "15m" + +[production] +dsn = "postgres://postgres@localhost/{{ .NormalizeAppName }}_production?sslmode=disable" +max_open_conns = 25 +max_idle_conns = 25 +max_idle_time = "15m" diff --git a/internal/domain/generate/newapp/templates/default/v1/manifest.json b/internal/domain/generate/newapp/templates/default/v1/manifest.json index 37b5f1b..83cec62 100644 --- a/internal/domain/generate/newapp/templates/default/v1/manifest.json +++ b/internal/domain/generate/newapp/templates/default/v1/manifest.json @@ -11,6 +11,7 @@ "cmd/api/middleware.go.tmpl", "internal/data/models.go.tmpl", "internal/validator/validator.go.tmpl", + "config/database.toml.tmpl", "README.md.tmpl", ".gitignore.tmpl", "Makefile.tmpl", diff --git a/internal/domain/generate/newapp/params.go b/internal/domain/params/params.go similarity index 84% rename from internal/domain/generate/newapp/params.go rename to internal/domain/params/params.go index f9dc336..a918aa8 100644 --- a/internal/domain/generate/newapp/params.go +++ b/internal/domain/params/params.go @@ -1,4 +1,4 @@ -package newapp +package params type Params interface { Param(key string) string diff --git a/internal/domain/plan/plan.go b/internal/domain/plan/plan.go index 9d2436a..ba6fec9 100644 --- a/internal/domain/plan/plan.go +++ b/internal/domain/plan/plan.go @@ -7,6 +7,7 @@ type OperationType string const ( OpNote OperationType = "note" OpEnsureEmptyDir OperationType = "ensure_empty_dir" + OpEnsureExists OperationType = "ensure_exists" OpMkdir OperationType = "mkdir" OpWriteFile OperationType = "write_file" OpRun OperationType = "run" From 053fc9cd0aa0a658ea46e9c2c6f6ab192b5043b0 Mon Sep 17 00:00:00 2001 From: TheZero0-ctrl Date: Mon, 16 Mar 2026 13:53:43 +0545 Subject: [PATCH 2/3] lint --- internal/app/executor.go | 18 +++++++++--------- internal/domain/command/builtin.go | 12 ++++++------ internal/domain/db/config.go | 16 ++++++++-------- 3 files changed, 23 insertions(+), 23 deletions(-) diff --git a/internal/app/executor.go b/internal/app/executor.go index 465bc08..d9563b8 100644 --- a/internal/app/executor.go +++ b/internal/app/executor.go @@ -139,15 +139,15 @@ func (e *Executor) executeOp(ctx context.Context, op plan.Operation, flags comma return Entry{Status: "ERROR", Message: fmt.Sprintf("CHECK %s", op.Path)}, err } - if !exists { - msg := op.Message - if msg == "" { - msg = fmt.Sprintf("required path %s not found", op.Path) - } - return Entry{Status: "ERROR", Message: msg}, conflictError{message: msg} - } - - return Entry{Status: "INFO", Message: fmt.Sprintf("found %s", op.Path)}, nil + if !exists { + msg := op.Message + if msg == "" { + msg = fmt.Sprintf("required path %s not found", op.Path) + } + return Entry{Status: "ERROR", Message: msg}, conflictError{message: msg} + } + + return Entry{Status: "INFO", Message: fmt.Sprintf("found %s", op.Path)}, nil case plan.OpMkdir: perm := op.Perm if perm == 0 { diff --git a/internal/domain/command/builtin.go b/internal/domain/command/builtin.go index df15cd5..0e4e644 100644 --- a/internal/domain/command/builtin.go +++ b/internal/domain/command/builtin.go @@ -70,9 +70,9 @@ func NewDestroyCommand() Command { func NewDBCreateCommand() Command { spec := Spec{ - ID: "db:create", - Use: "db:create", - Short: "Create database", + ID: "db:create", + Use: "db:create", + Short: "Create database", } validate := func(input Input) error { @@ -88,9 +88,9 @@ func NewDBCreateCommand() Command { func NewDBDropCommand() Command { spec := Spec{ - ID: "db:drop", - Use: "db:drop", - Short: "Drop database", + ID: "db:drop", + Use: "db:drop", + Short: "Drop database", } validate := func(input Input) error { diff --git a/internal/domain/db/config.go b/internal/domain/db/config.go index 60ff3e7..3bbd250 100644 --- a/internal/domain/db/config.go +++ b/internal/domain/db/config.go @@ -13,11 +13,11 @@ import ( ) type Config struct { - DSN string `json:"dsn"` - AdminDSN string + DSN string `json:"dsn"` + AdminDSN string DatabaseName string - Skip bool - Force bool + Skip bool + Force bool } func ParseConfig(p params.Params) (Config, error) { @@ -58,10 +58,10 @@ func ParseConfig(p params.Params) (Config, error) { adminURL.Path = "/postgres" return Config{ - DSN: dsn, - AdminDSN: adminURL.String(), - Skip: p.BoolParam("skip"), - Force: p.BoolParam("force"), + DSN: dsn, + AdminDSN: adminURL.String(), + Skip: p.BoolParam("skip"), + Force: p.BoolParam("force"), DatabaseName: dbName, }, nil } From e7f5062e15df6c36020641b54e70ef700eb30ab3 Mon Sep 17 00:00:00 2001 From: TheZero0-ctrl Date: Mon, 16 Mar 2026 13:59:25 +0545 Subject: [PATCH 3/3] add test for the db create and db drop --- internal/domain/db/planner_test.go | 96 ++++++++++++++++++++++++++++++ test/e2e/db_test.go | 43 +++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 internal/domain/db/planner_test.go create mode 100644 test/e2e/db_test.go diff --git a/internal/domain/db/planner_test.go b/internal/domain/db/planner_test.go new file mode 100644 index 0000000..904362b --- /dev/null +++ b/internal/domain/db/planner_test.go @@ -0,0 +1,96 @@ +package db + +import ( + "context" + "testing" + + "goforge/internal/domain/plan" +) + +type testParams struct { + values map[string]string +} + +func (p testParams) Param(key string) string { + return p.values[key] +} + +func (p testParams) BoolParam(key string) bool { + return p.values[key] == "true" +} + +func TestPlanCreateIncludesExpectedOperations(t *testing.T) { + t.Parallel() + + planned, err := PlanCreate(context.Background(), nil, testParams{values: map[string]string{ + "dsn": "postgres://localhost:5432/demo_app?sslmode=disable", + }}) + if err != nil { + t.Fatalf("plan create: %v", err) + } + + if planned.CommandID != "db:create" { + t.Fatalf("expected command id db:create, got %q", planned.CommandID) + } + + if len(planned.Ops) != 2 { + t.Fatalf("expected 2 ops, got %d", len(planned.Ops)) + } + + if planned.Ops[0].Type != plan.OpEnsureExists || planned.Ops[0].Path != "config/database.toml" { + t.Fatalf("unexpected first op: %+v", planned.Ops[0]) + } + + if planned.Ops[1].Type != plan.OpRun { + t.Fatalf("expected second op to be run, got %q", planned.Ops[1].Type) + } + + want := []string{"psql", "postgres://localhost:5432/postgres?sslmode=disable", "-v", "ON_ERROR_STOP=1", "-c", "CREATE DATABASE \"demo_app\";"} + if len(planned.Ops[1].Cmd) != len(want) { + t.Fatalf("unexpected create command length: %v", planned.Ops[1].Cmd) + } + + for i := range want { + if planned.Ops[1].Cmd[i] != want[i] { + t.Fatalf("unexpected create command: %v", planned.Ops[1].Cmd) + } + } +} + +func TestPlanDropIncludesExpectedOperations(t *testing.T) { + t.Parallel() + + planned, err := PlanDrop(context.Background(), nil, testParams{values: map[string]string{ + "dsn": "postgres://localhost:5432/demo_app?sslmode=disable", + }}) + if err != nil { + t.Fatalf("plan drop: %v", err) + } + + if planned.CommandID != "db:drop" { + t.Fatalf("expected command id db:drop, got %q", planned.CommandID) + } + + if len(planned.Ops) != 2 { + t.Fatalf("expected 2 ops, got %d", len(planned.Ops)) + } + + if planned.Ops[0].Type != plan.OpEnsureExists || planned.Ops[0].Path != "config/database.toml" { + t.Fatalf("unexpected first op: %+v", planned.Ops[0]) + } + + if planned.Ops[1].Type != plan.OpRun { + t.Fatalf("expected second op to be run, got %q", planned.Ops[1].Type) + } + + want := []string{"psql", "postgres://localhost:5432/postgres?sslmode=disable", "-v", "ON_ERROR_STOP=1", "-c", "DROP DATABASE IF EXISTS \"demo_app\";"} + if len(planned.Ops[1].Cmd) != len(want) { + t.Fatalf("unexpected drop command length: %v", planned.Ops[1].Cmd) + } + + for i := range want { + if planned.Ops[1].Cmd[i] != want[i] { + t.Fatalf("unexpected drop command: %v", planned.Ops[1].Cmd) + } + } +} diff --git a/test/e2e/db_test.go b/test/e2e/db_test.go new file mode 100644 index 0000000..37197c6 --- /dev/null +++ b/test/e2e/db_test.go @@ -0,0 +1,43 @@ +//go:build e2e + +package e2e_test + +import ( + "testing" + + "goforge/test/testutil/e2e" +) + +func TestDBCreateCommandE2EDryRunPrintsCreatePlan(t *testing.T) { + repoRoot := e2e.RepoRoot(t) + binary := e2e.BuildBinary(t, repoRoot) + workspace := t.TempDir() + + result := e2e.Run(t, binary, workspace, "--dry-run", "db:create", "--dsn", "postgres://localhost:5432/demo_app?sslmode=disable") + if result.ExitCode != 0 { + t.Fatalf("expected exit code 0, got %d\nstdout:\n%s\nstderr:\n%s", result.ExitCode, result.Stdout, result.Stderr) + } + + e2e.AssertContains(t, result.Stdout, "INFO DRY-RUN Create database") + e2e.AssertContains(t, result.Stdout, "CREATE DATABASE \"demo_app\";") + if result.Stderr != "" { + t.Fatalf("expected empty stderr, got %q", result.Stderr) + } +} + +func TestDBDropCommandE2EDryRunPrintsDropPlan(t *testing.T) { + repoRoot := e2e.RepoRoot(t) + binary := e2e.BuildBinary(t, repoRoot) + workspace := t.TempDir() + + result := e2e.Run(t, binary, workspace, "--dry-run", "db:drop", "--dsn", "postgres://localhost:5432/demo_app?sslmode=disable") + if result.ExitCode != 0 { + t.Fatalf("expected exit code 0, got %d\nstdout:\n%s\nstderr:\n%s", result.ExitCode, result.Stdout, result.Stderr) + } + + e2e.AssertContains(t, result.Stdout, "INFO DRY-RUN Drop database") + e2e.AssertContains(t, result.Stdout, "DROP DATABASE IF EXISTS \"demo_app\";") + if result.Stderr != "" { + t.Fatalf("expected empty stderr, got %q", result.Stderr) + } +}