diff --git a/src/control/cmd/ddb/command_completers_test.go b/src/control/cmd/ddb/command_completers_test.go index 834fbfb048a..70f7b131d54 100644 --- a/src/control/cmd/ddb/command_completers_test.go +++ b/src/control/cmd/ddb/command_completers_test.go @@ -1,3 +1,9 @@ +// +// (C) Copyright 2026 Hewlett Packard Enterprise Development LP +// +// SPDX-License-Identifier: BSD-2-Clause-Patent +// + package main import ( @@ -18,7 +24,7 @@ func createFile(t *testing.T, filePath string) { fd, err := os.Create(filePath) if err != nil { - t.Fatalf("Failed to create test vos file %s: %v", filePath, err) + t.Fatalf("failed to create test vos file %s: %v", filePath, err) } fd.Close() } @@ -27,14 +33,14 @@ func createDirAll(t *testing.T, dirPath string) { t.Helper() if err := os.MkdirAll(dirPath, 0755); err != nil { - t.Fatalf("Failed to create test pool directory %s: %v", dirPath, err) + t.Fatalf("failed to create test pool directory %s: %v", dirPath, err) } } -func testSetup(t *testing.T) (tmpDir string, teardown func()) { +func testSetup(t *testing.T) string { t.Helper() - tmpDir, teardown = test.CreateTestDir(t) + tmpDir := t.TempDir() for _, dir := range testPoolDirs { createDirAll(t, filepath.Join(tmpDir, dir)) @@ -51,12 +57,11 @@ func testSetup(t *testing.T) (tmpDir string, teardown func()) { createDirAll(t, filepath.Join(tmpDir, "bar", "baz")) createFile(t, filepath.Join(tmpDir, "bar", "baz", "no_vos")) - return + return tmpDir } -func TestListVosFiles(t *testing.T) { - tmpDir, teardown := testSetup(t) - t.Cleanup(teardown) +func TestDdb_listVosFiles(t *testing.T) { + tmpDir := testSetup(t) for name, tc := range map[string]struct { args string @@ -118,12 +123,12 @@ func TestListVosFiles(t *testing.T) { } { t.Run(name, func(t *testing.T) { results := listVosFiles(tc.args) - test.AssertStringsEqual(t, tc.expRes, results, "listDirVos results do not match expected") + test.AssertStringsEqual(t, tc.expRes, results, "unexpected listVosFiles results") }) } } -func TestFilterSuggestions(t *testing.T) { +func TestDdb_filterSuggestions(t *testing.T) { // The test cases are designed to cover various prefix scenarios. // It should notably cover the case where the prefix is a single character that matches the // second character of a suggestion, which is a special case in the appendSuggestion @@ -169,7 +174,7 @@ func TestFilterSuggestions(t *testing.T) { } { t.Run(name, func(t *testing.T) { results := filterSuggestions(tc.prefix, initialSuggestions, additionalSuggestions) - test.AssertStringsEqual(t, tc.expRes, results, "filterSuggestions results do not match expected") + test.AssertStringsEqual(t, tc.expRes, results, "unexpected filterSuggestions results") }) } } diff --git a/src/control/cmd/ddb/ddb_commands_test.go b/src/control/cmd/ddb/ddb_commands_test.go new file mode 100644 index 00000000000..a01c5c16d05 --- /dev/null +++ b/src/control/cmd/ddb/ddb_commands_test.go @@ -0,0 +1,395 @@ +// +// (C) Copyright 2026 Hewlett Packard Enterprise Development LP +// +// SPDX-License-Identifier: BSD-2-Clause-Patent +// + +//go:build test_stubs + +package main + +import ( + "fmt" + "os" + "path" + "path/filepath" + "strings" + "testing" + + "github.com/daos-stack/daos/src/control/common/test" +) + +func TestDdb_HelpCmds(t *testing.T) { + for name, tc := range map[string]struct { + cmdStr string + helpSubStr string + }{ + "help for 'ls' command": { + cmdStr: "ls", + helpSubStr: "Usage:\n ls [flags] [path]\n", + }, + "help for 'open' command": { + cmdStr: "open", + helpSubStr: "Usage:\n open [flags] path\n", + }, + // TODO(follow-up PR): Add help tests for the remaining commands. + } { + t.Run(name, func(t *testing.T) { + ctx := newTestContext(t) + + // Create a temporary config file with the help command + tmpCfgDir := t.TempDir() + tmpCfgFile := path.Join(tmpCfgDir, "ddb-cmd_file.txt") + if err := os.WriteFile(tmpCfgFile, []byte(fmt.Sprintf("%s --help", tc.cmdStr)), 0644); err != nil { + t.Fatalf("failed to write temp config file: %v", err) + } + + // Run the help command with a command file + args := test.JoinArgs(nil, "--cmd_file="+tmpCfgFile) + stdoutCmdFile, err := captureStdout(func() error { + return runDdb(ctx, args) + }) + if err != nil { + t.Fatalf("unexpected error when running '%s --help' via command file: want nil, got %v", tc.cmdStr, err) + } + test.AssertTrue(t, strings.Contains(stdoutCmdFile, tc.helpSubStr), + fmt.Sprintf("expected stdout to contain %q: got\n%s", tc.helpSubStr, stdoutCmdFile)) + + // Run the help command with a command line + args = test.JoinArgs(nil, tc.cmdStr, "--help") + stdoutCmdLine, err := captureStdout(func() error { + return runDdb(ctx, args) + }) + if err != nil { + t.Fatalf("unexpected error when running '%s --help' via command line: want nil, got %v", tc.cmdStr, err) + } + test.AssertTrue(t, strings.Contains(stdoutCmdLine, tc.helpSubStr), + fmt.Sprintf("expected stdout to contain %q: got\n%s", tc.helpSubStr, stdoutCmdLine)) + + // Compare command line and command file outputs + test.AssertEqual(t, stdoutCmdFile, stdoutCmdLine, + fmt.Sprintf("unexpected help output mismatch between command file and command line for '%s'", tc.cmdStr)) + }) + } +} + +func TestDdb_Cmds(t *testing.T) { + // Helper factories for command stub functions — declared here to avoid + // anonymous functions nested inside the test table. + + lsFnChecking := func(t *testing.T, wantPath string, wantRecursive, wantDetails bool) func(string, bool, bool) error { + return func(path string, recursive, details bool) error { + fmt.Println("ls called") + test.CmpAny(t, "path", wantPath, path) + test.CmpAny(t, "recursive", wantRecursive, recursive) + test.CmpAny(t, "details", wantDetails, details) + return nil + } + } + + openFnChecking := func(t *testing.T, wantPath, wantDbPath string, wantWriteMode bool) func(string, string, bool) error { + return func(path, dbPath string, writeMode bool) error { + fmt.Println("open called") + test.CmpAny(t, "path", wantPath, path) + test.CmpAny(t, "db_path", wantDbPath, dbPath) + test.CmpAny(t, "write_mode", wantWriteMode, writeMode) + return nil + } + } + + featureFnCheckingShow := func(t *testing.T, wantShow bool) func(string, string, string, string, bool) error { + return func(_, _, _, _ string, show bool) error { + fmt.Println("feature called") + test.CmpAny(t, "show", wantShow, show) + return nil + } + } + + dtxAggrFnChecking := func(t *testing.T, wantPath string, wantCmtTime uint64, wantCmtDate string) func(string, uint64, string) error { + return func(path string, cmtTime uint64, cmtDate string) error { + fmt.Println("dtx_aggr called") + test.CmpAny(t, "path", wantPath, path) + test.CmpAny(t, "cmtTime", wantCmtTime, cmtTime) + test.CmpAny(t, "cmtDate", wantCmtDate, cmtDate) + return nil + } + } + + for name, tc := range map[string]struct { + args []string + setup func(*testing.T) + expStdout []string + expErr error + // skipCmdLine skips the command-line sub-test with a message. Use when + // a flag is shared between the CLI layer and the grumble command: go-flags + // consumes it before grumble can see it, making a clean command-line test + // impossible for that particular flag. + skipCmdLine string + }{ + "ls invalid options": { + args: []string{"ls", "--bar"}, + expErr: ddbTestErr("invalid flag: --bar"), + }, + "ls default": { + args: []string{"ls"}, + setup: func(t *testing.T) { + ddb_run_ls_Fn = lsFnChecking(t, "", false, false) + }, + expStdout: []string{"ls called"}, + }, + "ls path": { + args: []string{"ls", "/[0]"}, + setup: func(t *testing.T) { + ddb_run_ls_Fn = lsFnChecking(t, "/[0]", false, false) + }, + expStdout: []string{"ls called"}, + }, + "ls long recursive opt": { + args: []string{"ls", "--recursive"}, + setup: func(t *testing.T) { + ddb_run_ls_Fn = lsFnChecking(t, "", true, false) + }, + expStdout: []string{"ls called"}, + }, + "ls short details opt": { + args: []string{"ls", "-d"}, + setup: func(t *testing.T) { + ddb_run_ls_Fn = lsFnChecking(t, "", false, true) + }, + expStdout: []string{"ls called"}, + }, + "ls details long opt": { + args: []string{"ls", "--details"}, + setup: func(t *testing.T) { + ddb_run_ls_Fn = lsFnChecking(t, "", false, true) + }, + expStdout: []string{"ls called"}, + }, + + // --- open command --- + // Note: the -w/--write_mode and -p/--db_path flags of the grumble 'open' + // command share names with CLI-level flags that are consumed by go-flags + // before reaching grumble in command-line mode. The command-line test for + // those flags would silently test wrong values. They are correctly exercised + // in command-file mode; see TestRun for CLI-level flag coverage. + "open default": { + args: []string{"open", "/path/to/vos-0"}, + setup: func(t *testing.T) { + ddb_run_open_Fn = openFnChecking(t, "/path/to/vos-0", "", false) + }, + expStdout: []string{"open called"}, + }, + "open write mode": { + args: []string{"open", "-w", "/path/to/vos-0"}, + skipCmdLine: "-w is consumed by the CLI write_mode flag before reaching grumble", + setup: func(t *testing.T) { + ddb_run_open_Fn = openFnChecking(t, "/path/to/vos-0", "", true) + }, + expStdout: []string{"open called"}, + }, + "open with db path": { + args: []string{"open", "-p", "/sysdb", "/path/to/vos-0"}, + skipCmdLine: "-p is consumed by the CLI db_path flag before reaching grumble", + setup: func(t *testing.T) { + ddb_run_open_Fn = openFnChecking(t, "/path/to/vos-0", "/sysdb", false) + }, + expStdout: []string{"open called"}, + }, + + // --- feature command --- + // feature --show: verifies the show flag is forwarded to the C layer. + "feature show": { + args: []string{"feature", "--show"}, + setup: func(t *testing.T) { + ddb_run_feature_Fn = featureFnCheckingShow(t, true) + }, + expStdout: []string{"feature called"}, + }, + // feature --enable: verifies that the enable string reaches ddb_feature_string2flags. + "feature enable": { + args: []string{"feature", "--enable=myflag"}, + setup: func(t *testing.T) { + var capturedFlag string + ddb_feature_string2flags_Fn = func(s string) (uint64, uint64, error) { + capturedFlag = s + return 0, 0, nil + } + ddb_run_feature_Fn = func(path, dbPath, enable, disable string, show bool) error { + fmt.Println("feature called") + test.CmpAny(t, "enable flag string", "myflag", capturedFlag) + return nil + } + }, + expStdout: []string{"feature called"}, + }, + // feature --disable: verifies that the disable string reaches ddb_feature_string2flags. + "feature disable": { + args: []string{"feature", "--disable=otherflag"}, + setup: func(t *testing.T) { + var capturedFlag string + ddb_feature_string2flags_Fn = func(s string) (uint64, uint64, error) { + capturedFlag = s + return 0, 0, nil + } + ddb_run_feature_Fn = func(path, dbPath, enable, disable string, show bool) error { + fmt.Println("feature called") + test.CmpAny(t, "disable flag string", "otherflag", capturedFlag) + return nil + } + }, + expStdout: []string{"feature called"}, + }, + + // --- dtx_aggr command --- + // The Run handler in ddb_commands.go enforces that exactly one of --cmt_time or + // --cmt_date is provided. These tests exercise that Go-layer validation. + "dtx_aggr both cmt_time and cmt_date": { + args: []string{"dtx_aggr", "--cmt_time=0", "--cmt_date=2024-01-01"}, + expErr: ddbTestErr("mutually exclusive"), + }, + "dtx_aggr neither cmt_time nor cmt_date": { + args: []string{"dtx_aggr"}, + expErr: ddbTestErr("has to be defined"), + }, + "dtx_aggr cmt_time": { + args: []string{"dtx_aggr", "--cmt_time=1000"}, + setup: func(t *testing.T) { + ddb_run_dtx_aggr_Fn = dtxAggrFnChecking(t, "", 1000, "") + }, + expStdout: []string{"dtx_aggr called"}, + }, + "dtx_aggr cmt_date": { + args: []string{"dtx_aggr", "--cmt_date=2024-01-01"}, + setup: func(t *testing.T) { + ddb_run_dtx_aggr_Fn = dtxAggrFnChecking(t, "", 0, "2024-01-01") + }, + expStdout: []string{"dtx_aggr called"}, + }, + "dtx_aggr with path": { + args: []string{"dtx_aggr", "--cmt_time=0", "[0]"}, + setup: func(t *testing.T) { + ddb_run_dtx_aggr_Fn = dtxAggrFnChecking(t, "[0]", 0, "") + }, + expStdout: []string{"dtx_aggr called"}, + }, + + // --- close command --- + "close": { + args: []string{"close"}, + setup: func(t *testing.T) { + ddb_run_close_Fn = func() error { + fmt.Println("close called") + return nil + } + }, + expStdout: []string{"close called"}, + }, + + // --- version command --- + "version": { + args: []string{"version"}, + setup: func(t *testing.T) { + ddb_run_version_Fn = func() error { + fmt.Println("version called") + return nil + } + }, + expStdout: []string{"version called"}, + }, + + // TODO(follow-up PR): Add TestCmds cases for the remaining commands. + // Each new test case follows the same pattern as the cases above: set the + // corresponding ddb_run__Fn hook in setup() to verify argument passing, + // then add the case to this table. + // Commands still to be covered: superblock_dump, value_dump, rm, + // value_load, ilog_dump, ilog_commit, ilog_clear, dtx_dump, dtx_cmt_clear, + // smd_sync, vea_dump, vea_update, dtx_act_commit, dtx_act_abort, rm_pool, + // dtx_act_discard_invalid, dev_list, dev_replace, dtx_stat, prov_mem. + } { + t.Run(name, func(t *testing.T) { + checkCmd := func(t *testing.T, stdout string, err error) { + t.Helper() + test.CmpErr(t, tc.expErr, err) + if tc.expErr != nil { + return + } + for _, msg := range tc.expStdout { + test.AssertTrue(t, strings.Contains(stdout, msg), + fmt.Sprintf("expected stdout to contain %q: got\n%s", msg, stdout)) + } + } + + t.Run("command-line", func(t *testing.T) { + if tc.skipCmdLine != "" { + t.Skipf("skipping command-line mode: %s", tc.skipCmdLine) + } + ctx := newTestContext(t) + if tc.setup != nil { + tc.setup(t) + } + stdout, err := captureStdout(func() error { + return runDdb(ctx, tc.args) + }) + checkCmd(t, stdout, err) + }) + + t.Run("command-file", func(t *testing.T) { + tmpDir := t.TempDir() + cmdFile := filepath.Join(tmpDir, "cmds.txt") + cmdLine := strings.Join(tc.args, " ") + if err := os.WriteFile(cmdFile, []byte(cmdLine), 0644); err != nil { + t.Fatalf("failed to write command file: %v", err) + } + ctx := newTestContext(t) + if tc.setup != nil { + tc.setup(t) + } + stdout, err := captureStdout(func() error { + return runDdb(ctx, []string{"--cmd_file=" + cmdFile}) + }) + checkCmd(t, stdout, err) + }) + }) + } +} + +func TestDdb_ManPage(t *testing.T) { + // Expected sections and commands present in every man page rendering. + expSections := []string{ + manArgsHeader, + manCmdsHeader, + manPathSection[:20], + manMdOnSsdSection[:20], + manLoggingSection[:20], + ".B ls\n", + ".B open\n", + } + + // manpage to stdout: must contain all section headers and known commands. + ctx := newTestContext(t) + stdout, err := captureStdout(func() error { + return runDdb(ctx, []string{"manpage"}) + }) + test.CmpErr(t, nil, err) + test.AssertStringContains(t, stdout, expSections...) + + // --output flag: man page is written to a file, stdout is empty. + tmpDir := t.TempDir() + outFile := filepath.Join(tmpDir, "ddb.groff") + + ctx = newTestContext(t) + stdout, err = captureStdout(func() error { + return runDdb(ctx, []string{"manpage", "--output=" + outFile}) + }) + if err != nil { + t.Fatalf("unexpected error when running 'manpage --output': want nil, got %v", err) + } + test.AssertTrue(t, stdout == "", + fmt.Sprintf("expected empty stdout when --output is set: got\n%s", stdout)) + + content, err := os.ReadFile(outFile) + if err != nil { + t.Fatalf("failed to read output file: %v", err) + } + test.AssertStringContains(t, string(content), expSections...) +} diff --git a/src/control/cmd/ddb/main.go b/src/control/cmd/ddb/main.go index 67cc415069b..97e2603b76a 100644 --- a/src/control/cmd/ddb/main.go +++ b/src/control/cmd/ddb/main.go @@ -365,37 +365,45 @@ func run(ctx *DdbContext, log *logging.LeveledLogger, opts cliOptions, parser *f return result } -func main() { - // Set the traceback level such that a crash results in - // a coredump (when ulimit -c is set appropriately). - debug.SetTraceback("crash") - - // Must be called before any write to stdout. - if err := logging.DisableCStdoutBuffering(); err != nil { - exitWithError(err) - } - - ctx := &DdbContext{} - opts, parser, err := parseOpts(os.Args[1:], ctx) +// runDdb contains the core ddb execution logic. It is separate from main() so +// that it can be tested without triggering os.Exit. main() handles only +// OS-level setup (traceback, stdout buffering) and calls exitWithError on +// failure; runDdb returns errors instead. +func runDdb(ctx *DdbContext, args []string) error { + opts, parser, err := parseOpts(args, ctx) if errors.Is(err, errHelpRequested) { - return + return nil } if err != nil { - exitWithError(err) + return err } if opts.Version { fmt.Printf("ddb version %s\n", build.DaosVersion) - return + return nil } - log, err := newLogger(opts) - if err != nil { - exitWithError(errors.Wrap(err, loggerInitErr)) + var log *logging.LeveledLogger + if log, err = newLogger(opts); err != nil { + return errors.Wrap(err, loggerInitErr) } log.Debug("Logging facilities initialized") - if err = run(ctx, log, opts, parser); err != nil { + return run(ctx, log, opts, parser) +} + +func main() { + // Set the traceback level such that a crash results in + // a coredump (when ulimit -c is set appropriately). + debug.SetTraceback("crash") + + // Must be called before any write to stdout. + if err := logging.DisableCStdoutBuffering(); err != nil { + exitWithError(err) + } + + ctx := &DdbContext{} + if err := runDdb(ctx, os.Args[1:]); err != nil { exitWithError(err) } } diff --git a/src/control/cmd/ddb/main_test.go b/src/control/cmd/ddb/main_test.go new file mode 100644 index 00000000000..03c761b94b8 --- /dev/null +++ b/src/control/cmd/ddb/main_test.go @@ -0,0 +1,616 @@ +// +// (C) Copyright 2026 Hewlett Packard Enterprise Development LP +// +// SPDX-License-Identifier: BSD-2-Clause-Patent +// + +//go:build test_stubs + +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/daos-stack/daos/src/control/common/test" + "github.com/daos-stack/daos/src/control/logging" + "github.com/daos-stack/daos/src/control/server/engine" +) + +func TestDdb_parseOpts(t *testing.T) { + for name, tc := range map[string]struct { + args []string + checkFunc func(opts *cliOptions) error + expStdout []string + expErr error + }{ + "General help message": { + args: []string{"--help"}, + expStdout: []string{ + "Usage:\n ddb [OPTIONS] [ddb_command] [ddb_command_args...]\n", + "VOS Paths:\n", + "Available Commands:\n", + }, + expErr: errHelpRequested, + }, + "General help message with opt": { + args: []string{"-w", "--help"}, + expStdout: []string{ + "Usage:\n ddb [OPTIONS] [ddb_command] [ddb_command_args...]\n", + "VOS Paths:\n", + "Available Commands:\n", + }, + expErr: errHelpRequested, + }, + "Unknown commands with help": { + args: []string{"foo", "--help"}, + expErr: errUnknownCmd, + }, + "Unknown commands with help and opt": { + args: []string{"-w", "foo", "--help"}, + expErr: errUnknownCmd, + }, + "Default option values": { + args: []string{"ls", "-d", "-r"}, + checkFunc: func(opts *cliOptions) error { + if opts.Debug != "" { + return fmt.Errorf("expected Debug to be empty, got %q", opts.Debug) + } + if opts.WriteMode { + return fmt.Errorf("expected WriteMode to be false") + } + if opts.CmdFile != "" { + return fmt.Errorf("expected CmdFile to be empty") + } + if opts.SysdbPath != "" { + return fmt.Errorf("expected SysdbPath to be empty") + } + if opts.VosPath != "" { + return fmt.Errorf("expected VosPath to be empty") + } + if opts.Args.RunCmd != "ls" { + return fmt.Errorf("expected RunCmd to be 'ls', got %q", opts.Args.RunCmd) + } + if opts.Args.RunCmdArgs[0] != "-d" { + return fmt.Errorf("expected first RunCmdArgs to be '-d', got %q", opts.Args.RunCmdArgs[0]) + } + if opts.Args.RunCmdArgs[1] != "-r" { + return fmt.Errorf("expected second RunCmdArgs to be '-r', got %q", opts.Args.RunCmdArgs[1]) + } + return nil + }, + }, + "Short miss vos path error": { + args: []string{"-p", "/foo", "ls"}, + expErr: ddbTestErr(vosPathMissErr), + }, + "Long miss vos path error": { + args: []string{"--db_path=/bar", "ls"}, + expErr: ddbTestErr(vosPathMissErr), + }, + "Short cmd args error": { + args: []string{"-f", "/foo/bar.cmd", "ls"}, + expErr: ddbTestErr(runCmdArgsErr), + }, + "Long cmd args error": { + args: []string{"--cmd_file=/foo/bar.cmd", "ls"}, + expErr: ddbTestErr(runCmdArgsErr), + }, + "Short vos path miss error": { + args: []string{"-p", "/foo"}, + expErr: ddbTestErr(vosPathMissErr), + }, + "Long vos path miss error": { + args: []string{"--db_path=/foo"}, + expErr: ddbTestErr(vosPathMissErr), + }, + "Long debug option": { + args: []string{"--debug=DEBUG", "ls"}, + checkFunc: func(opts *cliOptions) error { + if opts.Debug != "DEBUG" { + return fmt.Errorf("expected Debug to be 'DEBUG', got %q", opts.Debug) + } + return nil + }, + }, + "Short write option": { + args: []string{"-w", "ls"}, + checkFunc: func(opts *cliOptions) error { + if !opts.WriteMode { + return fmt.Errorf("expected WriteMode to be true") + } + return nil + }, + }, + "Long write option": { + args: []string{"--write_mode", "ls"}, + checkFunc: func(opts *cliOptions) error { + if !opts.WriteMode { + return fmt.Errorf("expected WriteMode to be true") + } + return nil + }, + }, + "Short vos path option": { + args: []string{"-s", "/foo/vos-0", "ls"}, + checkFunc: func(opts *cliOptions) error { + if opts.VosPath != "/foo/vos-0" { + return fmt.Errorf("expected VosPath to be '/foo/vos-0', got %q", opts.VosPath) + } + return nil + }, + }, + "Long vos path option": { + args: []string{"--vos_path=/foo/vos-0", "ls"}, + checkFunc: func(opts *cliOptions) error { + if opts.VosPath != "/foo/vos-0" { + return fmt.Errorf("expected VosPath to be '/foo/vos-0', got %q", opts.VosPath) + } + return nil + }, + }, + "Short db path option": { + args: []string{"-s", "/foo/vos-0", "-p", "/bar", "ls"}, + checkFunc: func(opts *cliOptions) error { + if opts.VosPath != "/foo/vos-0" { + return fmt.Errorf("expected VosPath to be '/foo/vos-0', got %q", opts.VosPath) + } + if opts.SysdbPath != "/bar" { + return fmt.Errorf("expected SysdbPath to be '/bar', got %q", opts.SysdbPath) + } + return nil + }, + }, + "Long db path option": { + args: []string{"--vos_path=/foo/vos-0", "--db_path=/bar", "ls"}, + checkFunc: func(opts *cliOptions) error { + if opts.VosPath != "/foo/vos-0" { + return fmt.Errorf("expected VosPath to be '/foo/vos-0', got %q", opts.VosPath) + } + if opts.SysdbPath != "/bar" { + return fmt.Errorf("expected SysdbPath to be '/bar', got %q", opts.SysdbPath) + } + return nil + }, + }, + "Short version option": { + args: []string{"-v"}, + checkFunc: func(opts *cliOptions) error { + if !opts.Version { + return fmt.Errorf("expected Version to be true") + } + return nil + }, + }, + "Long version option": { + args: []string{"--version"}, + checkFunc: func(opts *cliOptions) error { + if !opts.Version { + return fmt.Errorf("expected Version to be true") + } + return nil + }, + }, + } { + t.Run(name, func(t *testing.T) { + ctx := newTestContext(t) + + opts, stdout, err := runCmdToStdout(ctx, tc.args) + test.CmpErr(t, tc.expErr, err) + + for _, msg := range tc.expStdout { + test.AssertTrue(t, strings.Contains(stdout, msg), + fmt.Sprintf("expected stdout to contain %q: got\n%s", msg, stdout)) + } + + if tc.expErr != nil { + return + } + + if tc.checkFunc != nil { + if err := tc.checkFunc(&opts); err != nil { + t.Fatal(err) + } + } + }) + } +} + +// openFnChecking returns a ddb_run_open_Fn stub that verifies the vos path and +// sysdb path arguments passed to the open call. called is set to true when the +// stub is invoked, allowing the caller to assert that open was called. +func openFnChecking(t *testing.T, wantPath, wantDbPath string, called *bool) func(string, string, bool) error { + return func(path, dbPath string, _ bool) error { + *called = true + test.CmpAny(t, "vos path", wantPath, path) + test.CmpAny(t, "sysdb path", wantDbPath, dbPath) + return nil + } +} + +// openFnCheckingWriteMode returns a ddb_run_open_Fn stub that verifies the +// write_mode argument passed to the open call. called is set to true when the +// stub is invoked, allowing the caller to assert that open was called. +func openFnCheckingWriteMode(t *testing.T, wantWriteMode bool, called *bool) func(string, string, bool) error { + return func(_ string, _ string, writeMode bool) error { + *called = true + test.CmpAny(t, "write_mode", wantWriteMode, writeMode) + return nil + } +} + +// openFnMustNotBeCalled is a ddb_run_open_Fn stub that fails the test if +// the open function is called at all (used to verify no-auto-open behavior). +func openFnMustNotBeCalled(_ string, _ string, _ bool) error { + return fmt.Errorf("open should not have been called") +} + +// openFnAllowedOnce returns a ddb_run_open_Fn stub that allows the open +// function to be called exactly once (used to verify the 'open' command +// itself calls open but the CLI does not pre-open). +func openFnAllowedOnce() func(string, string, bool) error { + count := 0 + return func(_ string, _ string, _ bool) error { + count++ + if count > 1 { + return fmt.Errorf("open pre-opened by CLI (called %d times)", count) + } + return nil + } +} + +// openFnFailing is a ddb_run_open_Fn stub that always returns an error, +// used to verify that open failures are propagated correctly. +func openFnFailing(_ string, _ string, _ bool) error { + return fmt.Errorf("simulated open failure") +} + +// TestRun covers the non-interactive command-line execution paths of runDdb(). +// Interactive mode is intentionally not tested: it delegates entirely to +// grumble's app.Run(), which requires a real terminal and is hard to automate. +func TestDdb_runDdb(t *testing.T) { + for name, tc := range map[string]struct { + args []string + setup func(*testing.T) + expStdout []string + expErr error + }{ + "Version output": { + args: []string{"-v"}, + expStdout: []string{"ddb version"}, + }, + "Long version output": { + args: []string{"--version"}, + expStdout: []string{"ddb version"}, + }, + "Unknown command": { + args: []string{"foo"}, + expErr: errUnknownCmd, + }, + "Unknown command with write option": { + args: []string{"-w", "foo"}, + expErr: errUnknownCmd, + }, + "Open called with short vos path and db path": { + args: []string{"-s", "/foo/vos-0", "-p", "/bar", "ls"}, + setup: func(t *testing.T) { + var called bool + ddb_run_open_Fn = openFnChecking(t, "/foo/vos-0", "/bar", &called) + t.Cleanup(func() { test.AssertTrue(t, called, "open was not called") }) + }, + }, + "Open called with long vos path and db path": { + args: []string{"--vos_path=/foo/vos-0", "--db_path=/bar", "ls"}, + setup: func(t *testing.T) { + var called bool + ddb_run_open_Fn = openFnChecking(t, "/foo/vos-0", "/bar", &called) + t.Cleanup(func() { test.AssertTrue(t, called, "open was not called") }) + }, + }, + "Open called with write mode": { + args: []string{"-w", "-s", "/foo/vos-0", "ls"}, + setup: func(t *testing.T) { + var called bool + ddb_run_open_Fn = openFnCheckingWriteMode(t, true, &called) + t.Cleanup(func() { test.AssertTrue(t, called, "open was not called") }) + }, + }, + "No auto-open for feature command": { + // noAutoOpen is keyed on opts.Args.RunCmd which is empty in command-file + // mode, so this case only applies to command-line mode. + args: []string{"-s", "/foo/vos-0", "feature", "--show"}, + setup: func(t *testing.T) { + ddb_run_open_Fn = openFnMustNotBeCalled + }, + }, + "No auto-open for open command": { + // The CLI should NOT pre-open when the 'open' command is issued; only the + // command itself should call ctx.Open (exactly once). + // Only valid for command-line mode (see note above). + args: []string{"-s", "/foo/vos-0", "open", "/foo/vos-0"}, + setup: func(t *testing.T) { + ddb_run_open_Fn = openFnAllowedOnce() + }, + }, + "No auto-open for smd_sync": { + args: []string{"-s", "/foo/vos-0", "smd_sync"}, + setup: func(t *testing.T) { + ddb_run_open_Fn = openFnMustNotBeCalled + }, + }, + "Init failure": { + args: []string{"ls"}, + expErr: ddbTestErr(ctxInitErr), + setup: func(t *testing.T) { + ddb_init_RC = -1 // non-zero triggers DER_UNKNOWN + }, + }, + "Open failure": { + args: []string{"-s", "/foo/vos-0", "ls"}, + expErr: ddbTestErr("Error opening VOS path"), + setup: func(t *testing.T) { + ddb_run_open_Fn = openFnFailing + }, + }, + } { + t.Run(name, func(t *testing.T) { + ctx := newTestContext(t) + if tc.setup != nil { + tc.setup(t) + } + stdout, err := captureStdout(func() error { + return runDdb(ctx, tc.args) + }) + test.CmpErr(t, tc.expErr, err) + if tc.expErr == nil { + test.AssertStringContains(t, stdout, tc.expStdout...) + } + }) + } +} + +// TestRunCommandFile covers command-file execution paths of runDdb(). +// It exercises the same behavior as TestRun for cases where command-file +// mode produces the same result as command-line mode. +func TestDdb_runDdbCommandFile(t *testing.T) { + for name, tc := range map[string]struct { + flags []string // CLI flags (before --cmd_file) + cmdLine string // line written to the temporary command file + setup func(*testing.T) + expStdout []string + expErr error + }{ + "Unknown command": { + cmdLine: "foo", + expErr: errUnknownCmd, + }, + "Unknown command with write option": { + flags: []string{"-w"}, + cmdLine: "foo", + expErr: errUnknownCmd, + }, + "Open called with short vos path and db path": { + flags: []string{"-s", "/foo/vos-0", "-p", "/bar"}, + cmdLine: "ls", + setup: func(t *testing.T) { + var called bool + ddb_run_open_Fn = openFnChecking(t, "/foo/vos-0", "/bar", &called) + t.Cleanup(func() { test.AssertTrue(t, called, "open was not called") }) + }, + }, + "Open called with long vos path and db path": { + flags: []string{"--vos_path=/foo/vos-0", "--db_path=/bar"}, + cmdLine: "ls", + setup: func(t *testing.T) { + var called bool + ddb_run_open_Fn = openFnChecking(t, "/foo/vos-0", "/bar", &called) + t.Cleanup(func() { test.AssertTrue(t, called, "open was not called") }) + }, + }, + "Open called with write mode": { + flags: []string{"-w", "-s", "/foo/vos-0"}, + cmdLine: "ls", + setup: func(t *testing.T) { + var called bool + ddb_run_open_Fn = openFnCheckingWriteMode(t, true, &called) + t.Cleanup(func() { test.AssertTrue(t, called, "open was not called") }) + }, + }, + } { + t.Run(name, func(t *testing.T) { + tmpDir := t.TempDir() + cmdFile := filepath.Join(tmpDir, "cmds.txt") + if err := os.WriteFile(cmdFile, []byte(tc.cmdLine), 0644); err != nil { + t.Fatalf("failed to write command file: %v", err) + } + ctx := newTestContext(t) + if tc.setup != nil { + tc.setup(t) + } + args := append(tc.flags, "--cmd_file="+cmdFile) + stdout, err := captureStdout(func() error { + return runDdb(ctx, args) + }) + test.CmpErr(t, tc.expErr, err) + if tc.expErr == nil { + test.AssertStringContains(t, stdout, tc.expStdout...) + } + }) + } +} + +// TestRunMultiLineCommandFile verifies that runFileCmds iterates over multiple +// lines in a command file, executing each one in sequence. +func TestDdb_runFileCmds(t *testing.T) { + ctx := newTestContext(t) + var lsCalled, versionCalled bool + ddb_run_ls_Fn = func(path string, recursive bool, details bool) error { + lsCalled = true + return nil + } + ddb_run_version_Fn = func() error { + versionCalled = true + return nil + } + + tmpDir := t.TempDir() + cmdFile := filepath.Join(tmpDir, "cmds.txt") + if err := os.WriteFile(cmdFile, []byte("ls\nversion\n"), 0644); err != nil { + t.Fatalf("failed to write command file: %v", err) + } + + if err := runDdb(ctx, []string{"--cmd_file=" + cmdFile}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + test.AssertTrue(t, lsCalled, "ls was not called") + test.AssertTrue(t, versionCalled, "version was not called") +} + +func TestDdb_strToLogLevels(t *testing.T) { + for name, tc := range map[string]struct { + input string + expCliLevel logging.LogLevel + expEngineLevel engine.LogLevel + expErr bool + }{ + "TRACE": {input: "TRACE", expCliLevel: logging.LogLevelTrace, expEngineLevel: engine.LogLevelDbug}, + "DEBUG": {input: "DEBUG", expCliLevel: logging.LogLevelDebug, expEngineLevel: engine.LogLevelDbug}, + "DBUG": {input: "DBUG", expCliLevel: logging.LogLevelDebug, expEngineLevel: engine.LogLevelDbug}, + "INFO": {input: "INFO", expCliLevel: logging.LogLevelInfo, expEngineLevel: engine.LogLevelInfo}, + "NOTE": {input: "NOTE", expCliLevel: logging.LogLevelNotice, expEngineLevel: engine.LogLevelNote}, + "NOTICE": {input: "NOTICE", expCliLevel: logging.LogLevelNotice, expEngineLevel: engine.LogLevelNote}, + "WARN": {input: "WARN", expCliLevel: logging.LogLevelNotice, expEngineLevel: engine.LogLevelWarn}, + "ERROR": {input: "ERROR", expCliLevel: logging.LogLevelError, expEngineLevel: engine.LogLevelErr}, + "ERR": {input: "ERR", expCliLevel: logging.LogLevelError, expEngineLevel: engine.LogLevelErr}, + "CRIT": {input: "CRIT", expCliLevel: logging.LogLevelError, expEngineLevel: engine.LogLevelCrit}, + "ALRT": {input: "ALRT", expCliLevel: logging.LogLevelError, expEngineLevel: engine.LogLevelAlrt}, + "FATAL": {input: "FATAL", expCliLevel: logging.LogLevelError, expEngineLevel: engine.LogLevelEmrg}, + "EMRG": {input: "EMRG", expCliLevel: logging.LogLevelError, expEngineLevel: engine.LogLevelEmrg}, + "EMIT": {input: "EMIT", expCliLevel: logging.LogLevelError, expEngineLevel: engine.LogLevelEmit}, + "lowercase debug": { + input: "debug", expCliLevel: logging.LogLevelDebug, expEngineLevel: engine.LogLevelDbug, + }, + "invalid level": {input: "INVALID", expErr: true}, + "empty string": {input: "", expErr: true}, + } { + t.Run(name, func(t *testing.T) { + cliLevel, engineLevel, err := strToLogLevels(tc.input) + if tc.expErr { + if err == nil { + t.Fatalf("expected an error for input %q: got nil", tc.input) + } + return + } + if err != nil { + t.Fatalf("unexpected error for input %q: %v", tc.input, err) + } + test.AssertTrue(t, cliLevel == tc.expCliLevel, + fmt.Sprintf("unexpected CLI log level for input %q: want %v, got %v", tc.input, tc.expCliLevel, cliLevel)) + test.AssertTrue(t, engineLevel == tc.expEngineLevel, + fmt.Sprintf("unexpected engine log level for input %q: want %v, got %v", tc.input, tc.expEngineLevel, engineLevel)) + }) + } +} + +// TestNewLogger verifies the newLogger code paths: default level, explicit debug +// level, invalid level, and all three LogDir branches (valid dir, non-existent +// path, path that is a file rather than a directory). +func TestDdb_newLogger(t *testing.T) { + t.Run("no LogDir default level", func(t *testing.T) { + log, err := newLogger(cliOptions{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if log == nil { + t.Fatal("expected non-nil logger") + } + }) + + t.Run("explicit valid debug level", func(t *testing.T) { + log, err := newLogger(cliOptions{Debug: "DEBUG"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if log == nil { + t.Fatal("expected non-nil logger") + } + }) + + t.Run("invalid debug level", func(t *testing.T) { + _, err := newLogger(cliOptions{Debug: "INVALID"}) + if err == nil { + t.Fatal("expected error for invalid log level, got nil") + } + }) + + t.Run("valid LogDir", func(t *testing.T) { + tmpDir := t.TempDir() + log, err := newLogger(cliOptions{LogDir: tmpDir}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if log == nil { + t.Fatal("expected non-nil logger") + } + }) + + t.Run("non-existent LogDir", func(t *testing.T) { + _, err := newLogger(cliOptions{LogDir: "/non/existent/path"}) + if err == nil { + t.Fatal("expected error for non-existent log dir, got nil") + } + }) + + t.Run("LogDir is a file", func(t *testing.T) { + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "not-a-dir") + if err := os.WriteFile(tmpFile, []byte(""), 0644); err != nil { + t.Fatalf("failed to create temp file: %v", err) + } + _, err := newLogger(cliOptions{LogDir: tmpFile}) + if err == nil { + t.Fatal("expected error when LogDir is a file, got nil") + } + }) +} + +// TestClosePoolIfOpen verifies that closePoolIfOpen only calls Close when the +// pool is actually open, and that it tolerates a Close error (log only, no panic). +func TestDdb_closePoolIfOpen(t *testing.T) { + log := logging.NewCommandLineLogger() + + t.Run("pool not open, close not called", func(t *testing.T) { + ctx := newTestContext(t) + ddb_pool_is_open_RC = false + ddb_run_close_Fn = func() error { + t.Fatal("Close should not be called when pool is not open") + return nil + } + closePoolIfOpen(ctx, log) + }) + + t.Run("pool open, close called", func(t *testing.T) { + ctx := newTestContext(t) + ddb_pool_is_open_RC = true + closeCalled := false + ddb_run_close_Fn = func() error { + closeCalled = true + return nil + } + closePoolIfOpen(ctx, log) + test.AssertTrue(t, closeCalled, "expected Close to have been called when pool is open") + }) + + t.Run("pool open, close returns error", func(t *testing.T) { + ctx := newTestContext(t) + ddb_pool_is_open_RC = true + ddb_run_close_Fn = func() error { + return fmt.Errorf("close failed") + } + // Should not panic; the error is only logged. + closePoolIfOpen(ctx, log) + }) +} diff --git a/src/control/cmd/ddb/test_helpers.go b/src/control/cmd/ddb/test_helpers.go new file mode 100644 index 00000000000..3a171fec36c --- /dev/null +++ b/src/control/cmd/ddb/test_helpers.go @@ -0,0 +1,72 @@ +// +// (C) Copyright 2026 Hewlett Packard Enterprise Development LP +// +// SPDX-License-Identifier: BSD-2-Clause-Patent +// + +//go:build test_stubs + +package main + +import ( + "bytes" + "io" + "os" + "testing" +) + +type ddbTestErr string + +func (dte ddbTestErr) Error() string { + return string(dte) +} + +const ( + errUnknownCmd = ddbTestErr("Unknown command:") +) + +// newTestContext creates a fresh DdbContext for use in tests, resetting all +// stub variables to their zero values to ensure test isolation. +func newTestContext(t *testing.T) *DdbContext { + t.Helper() + resetDdbStubs() + return &DdbContext{} +} + +// captureStdout replaces os.Stdout with a pipe, runs fn, restores os.Stdout, +// and returns the captured output along with any error from fn. +func captureStdout(fn func() error) (output string, err error) { + var result bytes.Buffer + r, w, _ := os.Pipe() + done := make(chan struct{}) + go func() { + _, _ = io.Copy(&result, r) + close(done) + }() + stdout := os.Stdout + os.Stdout = w + // Deferred so cleanup and output capture always run, even if fn() exits + // via runtime.Goexit() (e.g., t.Fatalf called from a stub via test.CmpAny). + defer func() { + w.Close() + <-done + os.Stdout = stdout + output = result.String() + }() + + err = fn() + return +} + +// runCmdToStdout calls parseOpts with the given args and captures stdout +// output. Returns the parsed options, stdout output, and error. +func runCmdToStdout(ctx *DdbContext, args []string) (cliOptions, string, error) { + var opts cliOptions + stdout, err := captureStdout(func() error { + var e error + opts, _, e = parseOpts(args, ctx) + return e + }) + + return opts, stdout, err +} diff --git a/src/control/common/test/utils.go b/src/control/common/test/utils.go index 5b731550b9c..3f149baa41e 100644 --- a/src/control/common/test/utils.go +++ b/src/control/common/test/utils.go @@ -1,6 +1,6 @@ // // (C) Copyright 2018-2024 Intel Corporation. -// (C) Copyright 2025 Hewlett Packard Enterprise Development LP +// (C) Copyright 2025-2026 Hewlett Packard Enterprise Development LP // (C) Copyright 2025 Google LLC // // SPDX-License-Identifier: BSD-2-Clause-Patent @@ -150,6 +150,16 @@ func CmpAny(t *testing.T, desc string, want, got any, cmpOpts ...cmp.Option) { } } +// AssertStringContains asserts that s contains each of the provided substrings. +func AssertStringContains(t *testing.T, s string, substrings ...string) { + t.Helper() + for _, sub := range substrings { + if !strings.Contains(s, sub) { + t.Fatalf("expected string to contain %q: got\n%s", sub, s) + } + } +} + // SplitFile separates file content into contiguous sections separated by // a blank line. func SplitFile(path string) (sections [][]string, err error) {