From 658f2f38e465e6b20e1d34d9d49ba3f803f294a8 Mon Sep 17 00:00:00 2001 From: Cedric Koch-Hofer Date: Tue, 19 May 2026 09:05:44 +0000 Subject: [PATCH] DAOS-18304 ddb: add Go unit tests using build-tag CGo stubs Introduce the Go test suite for the ddb CLI layer, built on top of the build-tag CGo stub infrastructure landed in #18124: - Add test_helpers.go: newTestContext(t) resets all CGo stubs via resetDdbStubs() and returns a *DdbContext ready for use in tests. Test cases set per-function _Fn hook variables directly. - All test files carry the //go:build test_stubs tag so they only compile when the stub infrastructure is present. - TestCmds: open (default, write_mode, db_path), feature (show, enable, disable), and dtx_aggr (mutual exclusion, cmt_time, cmt_date, path). Adds skipCmdLine field for flags shared between CLI and grumble layers. - TestHelpCmds: unknown-command help flow. - TestParseOpts / TestRun: CLI-level option parsing and run() dispatch, including unknown-command detection for both command-line and command-file paths. - TestNewLogger: 6 sub-cases (default level, explicit debug, invalid level, valid LogDir, non-existent LogDir, LogDir is a file). - TestClosePoolIfOpen: Close not called when already closed, called when open, Close error tolerated. Test-tag: unittest Required-githooks: yes Signed-off-by: Cedric Koch-Hofer --- .../cmd/ddb/command_completers_test.go | 23 +- src/control/cmd/ddb/ddb_commands_test.go | 470 ++++++++++++++ src/control/cmd/ddb/main_test.go | 575 ++++++++++++++++++ src/control/cmd/ddb/test_helpers.go | 122 ++++ 4 files changed, 1181 insertions(+), 9 deletions(-) create mode 100644 src/control/cmd/ddb/ddb_commands_test.go create mode 100644 src/control/cmd/ddb/main_test.go create mode 100644 src/control/cmd/ddb/test_helpers.go diff --git a/src/control/cmd/ddb/command_completers_test.go b/src/control/cmd/ddb/command_completers_test.go index 834fbfb048a..5594fae469d 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) + tmpDir := testSetup(t) for name, tc := range map[string]struct { args string @@ -118,7 +123,7 @@ 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") }) } } @@ -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..1571f9857d3 --- /dev/null +++ b/src/control/cmd/ddb/ddb_commands_test.go @@ -0,0 +1,470 @@ +// +// (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 runHelpCmd(t *testing.T, cmdStr string, helpSubStr string) { + t.Helper() + + 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", 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 := runMainFlow(ctx, args) + if err != nil { + t.Fatalf("unexpected error when running '%s --help' via command file: want nil, got %v", cmdStr, err) + } + test.AssertTrue(t, strings.Contains(stdoutCmdFile, helpSubStr), + fmt.Sprintf("expected stdout to contain %q: got\n%s", helpSubStr, stdoutCmdFile)) + + // Run the help command with a command line + args = test.JoinArgs(nil, cmdStr, "--help") + stdoutCmdLine, err := runMainFlow(ctx, args) + if err != nil { + t.Fatalf("unexpected error when running '%s --help' via command line: want nil, got %v", cmdStr, err) + } + test.AssertTrue(t, strings.Contains(stdoutCmdLine, helpSubStr), + fmt.Sprintf("expected stdout to contain %q: got\n%s", 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'", cmdStr)) +} + +func TestHelpCmds(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. + // Use runHelpCmd(t, "", "Usage:\n ") following the same pattern. + } { + t.Run(name, func(t *testing.T) { + runHelpCmd(t, tc.cmdStr, tc.helpSubStr) + }) + } +} + +func TestCmds(t *testing.T) { + for name, tc := range map[string]struct { + args []string + setup func() + 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() { + ddb_run_ls_Fn = func(path string, recursive bool, details bool) error { + fmt.Println("ls called") + if err := isArgEqual("", path, "path"); err != nil { + return err + } + if err := isArgEqual(false, recursive, "recursive"); err != nil { + return err + } + if err := isArgEqual(false, details, "details"); err != nil { + return err + } + return nil + } + }, + expStdout: []string{"ls called"}, + }, + "ls path": { + args: []string{"ls", "/[0]"}, + setup: func() { + ddb_run_ls_Fn = func(path string, recursive bool, details bool) error { + fmt.Println("ls called") + if err := isArgEqual("/[0]", path, "path"); err != nil { + return err + } + if err := isArgEqual(false, recursive, "recursive"); err != nil { + return err + } + if err := isArgEqual(false, details, "details"); err != nil { + return err + } + return nil + } + }, + expStdout: []string{"ls called"}, + }, + "ls long recursive opt": { + args: []string{"ls", "--recursive"}, + setup: func() { + ddb_run_ls_Fn = func(path string, recursive bool, details bool) error { + fmt.Println("ls called") + if err := isArgEqual("", path, "path"); err != nil { + return err + } + if err := isArgEqual(true, recursive, "recursive"); err != nil { + return err + } + if err := isArgEqual(false, details, "details"); err != nil { + return err + } + return nil + } + }, + expStdout: []string{"ls called"}, + }, + "ls short details opt": { + args: []string{"ls", "-d"}, + setup: func() { + ddb_run_ls_Fn = func(path string, recursive bool, details bool) error { + fmt.Println("ls called") + if err := isArgEqual("", path, "path"); err != nil { + return err + } + if err := isArgEqual(false, recursive, "recursive"); err != nil { + return err + } + if err := isArgEqual(true, details, "details"); err != nil { + return err + } + return nil + } + }, + expStdout: []string{"ls called"}, + }, + "ls details long opt": { + args: []string{"ls", "--details"}, + setup: func() { + ddb_run_ls_Fn = func(path string, recursive bool, details bool) error { + fmt.Println("ls called") + if err := isArgEqual("", path, "path"); err != nil { + return err + } + if err := isArgEqual(false, recursive, "recursive"); err != nil { + return err + } + if err := isArgEqual(true, details, "details"); err != nil { + return err + } + return nil + } + }, + 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() { + ddb_run_open_Fn = func(path, dbPath string, writeMode bool) error { + fmt.Println("open called") + if err := isArgEqual("/path/to/vos-0", path, "path"); err != nil { + return err + } + if err := isArgEqual("", dbPath, "db_path"); err != nil { + return err + } + if err := isArgEqual(false, writeMode, "write_mode"); err != nil { + return err + } + return nil + } + }, + 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() { + ddb_run_open_Fn = func(path, dbPath string, writeMode bool) error { + fmt.Println("open called") + if err := isArgEqual(true, writeMode, "write_mode"); err != nil { + return err + } + return nil + } + }, + 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() { + ddb_run_open_Fn = func(path, dbPath string, writeMode bool) error { + fmt.Println("open called") + if err := isArgEqual("/path/to/vos-0", path, "path"); err != nil { + return err + } + if err := isArgEqual("/sysdb", dbPath, "db_path"); err != nil { + return err + } + return nil + } + }, + 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() { + ddb_run_feature_Fn = func(path, dbPath, enable, disable string, show bool) error { + fmt.Println("feature called") + if err := isArgEqual(true, show, "show"); err != nil { + return err + } + return nil + } + }, + expStdout: []string{"feature called"}, + }, + // feature --enable: verifies that the enable string reaches ddb_feature_string2flags. + "feature enable": { + args: []string{"feature", "--enable=myflag"}, + setup: func() { + 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") + if err := isArgEqual("myflag", capturedFlag, "enable flag string"); err != nil { + return err + } + 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() { + 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") + if err := isArgEqual("otherflag", capturedFlag, "disable flag string"); err != nil { + return err + } + 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() { + ddb_run_dtx_aggr_Fn = func(path string, cmtTime uint64, cmtDate string) error { + fmt.Println("dtx_aggr called") + if err := isArgEqual(uint64(1000), cmtTime, "cmtTime"); err != nil { + return err + } + if err := isArgEqual("", cmtDate, "cmtDate"); err != nil { + return err + } + return nil + } + }, + expStdout: []string{"dtx_aggr called"}, + }, + "dtx_aggr cmt_date": { + args: []string{"dtx_aggr", "--cmt_date=2024-01-01"}, + setup: func() { + ddb_run_dtx_aggr_Fn = func(path string, cmtTime uint64, cmtDate string) error { + fmt.Println("dtx_aggr called") + if err := isArgEqual("2024-01-01", cmtDate, "cmtDate"); err != nil { + return err + } + return nil + } + }, + expStdout: []string{"dtx_aggr called"}, + }, + "dtx_aggr with path": { + args: []string{"dtx_aggr", "--cmt_time=0", "[0]"}, + setup: func() { + ddb_run_dtx_aggr_Fn = func(path string, cmtTime uint64, cmtDate string) error { + fmt.Println("dtx_aggr called") + if err := isArgEqual("[0]", path, "path"); err != nil { + return err + } + if err := isArgEqual(uint64(0), cmtTime, "cmtTime"); err != nil { + return err + } + return nil + } + }, + expStdout: []string{"dtx_aggr called"}, + }, + + // --- close command --- + "close": { + args: []string{"close"}, + setup: func() { + ddb_run_close_Fn = func() error { + fmt.Println("close called") + return nil + } + }, + expStdout: []string{"close called"}, + }, + + // --- version command --- + "version": { + args: []string{"version"}, + setup: func() { + 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() + } + stdout, err := runMainFlow(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() + } + stdout, err := runMainFlow(ctx, []string{"--cmd_file=" + cmdFile}) + checkCmd(t, stdout, err) + }) + }) + } +} + +func TestManPage(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 := runMainFlow(ctx, []string{"manpage"}) + test.CmpErr(t, nil, err) + assertContainsAll(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 = runMainFlow(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) + } + assertContainsAll(t, string(content), expSections) +} diff --git a/src/control/cmd/ddb/main_test.go b/src/control/cmd/ddb/main_test.go new file mode 100644 index 00000000000..ceca06a104d --- /dev/null +++ b/src/control/cmd/ddb/main_test.go @@ -0,0 +1,575 @@ +// +// (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 TestParseOpts(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", + }, + }, + "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", + }, + }, + "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) + 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)) + } + + if tc.checkFunc != nil { + if err := tc.checkFunc(&opts); err != nil { + t.Fatal(err) + } + } + }) + } +} + +// TestRun covers the non-interactive execution paths of run() (command-line and +// command-file modes). Interactive mode is intentionally not tested: it delegates +// entirely to grumble's app.Run(), which requires a real terminal (readline) and +// piping os.Stdin — making tests fragile and hard to maintain for little gain. +func TestRun(t *testing.T) { + for name, tc := range map[string]struct { + args []string + setup func() + expStdout []string + expErr error + // When cmdFileCmd is non-empty the test is also run in command-file mode. + // cmdFileArgs holds the CLI flags (everything except the positional command), + // and cmdFileCmd is the line written to the temporary command file. + // Note: "no auto-open" cases intentionally omit cmdFileCmd because in + // command-file mode opts.Args.RunCmd is always empty, so noAutoOpen is + // never triggered and the CLI would pre-open the pool. + cmdFileArgs []string + cmdFileCmd string + }{ + "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, + cmdFileArgs: []string{}, + cmdFileCmd: "foo", + }, + "Unknown command with write option": { + args: []string{"-w", "foo"}, + expErr: errUnknownCmd, + cmdFileArgs: []string{"-w"}, + cmdFileCmd: "foo", + }, + "Open called with short vos path and db path": { + args: []string{"-s", "/foo/vos-0", "-p", "/bar", "ls"}, + setup: func() { + ddb_run_open_Fn = func(path string, dbPath string, writeMode bool) error { + fmt.Println("Open called") + if err := isArgEqual("/foo/vos-0", path, "vos path"); err != nil { + return err + } + if err := isArgEqual("/bar", dbPath, "sysdb path"); err != nil { + return err + } + return nil + } + }, + expStdout: []string{"Open called"}, + cmdFileArgs: []string{"-s", "/foo/vos-0", "-p", "/bar"}, + cmdFileCmd: "ls", + }, + "Open called with long vos path and db path": { + args: []string{"--vos_path=/foo/vos-0", "--db_path=/bar", "ls"}, + setup: func() { + ddb_run_open_Fn = func(path string, dbPath string, writeMode bool) error { + fmt.Println("Open called") + if err := isArgEqual("/foo/vos-0", path, "vos path"); err != nil { + return err + } + if err := isArgEqual("/bar", dbPath, "sysdb path"); err != nil { + return err + } + return nil + } + }, + expStdout: []string{"Open called"}, + cmdFileArgs: []string{"--vos_path=/foo/vos-0", "--db_path=/bar"}, + cmdFileCmd: "ls", + }, + "Open called with write mode": { + args: []string{"-w", "-s", "/foo/vos-0", "ls"}, + setup: func() { + ddb_run_open_Fn = func(path string, dbPath string, writeMode bool) error { + fmt.Println("Open called") + if err := isArgEqual(true, writeMode, "write_mode"); err != nil { + return err + } + return nil + } + }, + expStdout: []string{"Open called"}, + cmdFileArgs: []string{"-w", "-s", "/foo/vos-0"}, + cmdFileCmd: "ls", + }, + "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() { + ddb_run_open_Fn = func(path string, dbPath string, writeMode bool) error { + return fmt.Errorf("open should not have been called") + } + }, + }, + "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() { + openCount := 0 + ddb_run_open_Fn = func(path string, dbPath string, writeMode bool) error { + openCount++ + if openCount > 1 { + return fmt.Errorf("open pre-opened by CLI (called %d times)", openCount) + } + return nil + } + }, + }, + "No auto-open for smd_sync": { + args: []string{"-s", "/foo/vos-0", "smd_sync"}, + setup: func() { + ddb_run_open_Fn = func(path string, dbPath string, writeMode bool) error { + return fmt.Errorf("open should not have been called") + } + }, + }, + "Init failure": { + args: []string{"ls"}, + expErr: ddbTestErr(ctxInitErr), + setup: func() { + 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() { + ddb_run_open_Fn = func(path string, dbPath string, writeMode bool) error { + return fmt.Errorf("simulated open failure") + } + }, + }, + } { + t.Run(name, func(t *testing.T) { + checkRun := 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)) + } + } + + // Command-line mode + t.Run("command-line", func(t *testing.T) { + ctx := newTestContext(t) + if tc.setup != nil { + tc.setup() + } + stdout, err := runMainFlow(ctx, tc.args) + checkRun(t, stdout, err) + }) + + // Command-file mode (only for cases that provide a command file command) + if tc.cmdFileCmd != "" { + t.Run("command-file", func(t *testing.T) { + tmpDir := t.TempDir() + cmdFile := filepath.Join(tmpDir, "cmds.txt") + if err := os.WriteFile(cmdFile, []byte(tc.cmdFileCmd), 0644); err != nil { + t.Fatalf("failed to write command file: %v", err) + } + + ctx := newTestContext(t) + if tc.setup != nil { + tc.setup() + } + args := append(tc.cmdFileArgs, "--cmd_file="+cmdFile) + stdout, err := runMainFlow(ctx, args) + checkRun(t, stdout, err) + }) + } + }) + } +} + +// TestRunMultiLineCommandFile verifies that runFileCmds iterates over multiple +// lines in a command file, executing each one in sequence. +func TestRunMultiLineCommandFile(t *testing.T) { + ctx := newTestContext(t) + ddb_run_ls_Fn = func(path string, recursive bool, details bool) error { + fmt.Println("ls called") + return nil + } + ddb_run_version_Fn = func() error { + fmt.Println("version called") + 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) + } + + stdout, err := runMainFlow(ctx, []string{"--cmd_file=" + cmdFile}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertContainsAll(t, stdout, []string{"ls called", "version called"}) +} + +func TestStrToLogLevels(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 TestNewLogger(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 TestClosePoolIfOpen(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..2f2d7e4e75e --- /dev/null +++ b/src/control/cmd/ddb/test_helpers.go @@ -0,0 +1,122 @@ +// +// (C) Copyright 2026 Hewlett Packard Enterprise Development LP +// +// SPDX-License-Identifier: BSD-2-Clause-Patent +// + +//go:build test_stubs + +package main + +import ( + "bytes" + "fmt" + "io" + "os" + "reflect" + "strings" + "testing" + + "github.com/daos-stack/daos/src/control/build" + "github.com/daos-stack/daos/src/control/common/test" + "github.com/daos-stack/daos/src/control/logging" + "github.com/pkg/errors" +) + +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) (string, error) { + var result bytes.Buffer + r, w, _ := os.Pipe() + done := make(chan struct{}) + go func() { + _, _ = io.Copy(&result, r) + close(done) + }() + stdout := os.Stdout + defer func() { os.Stdout = stdout }() + os.Stdout = w + + err := fn() + w.Close() + <-done + + return result.String(), err +} + +// runCmdToStdout calls parseOpts with the given args and captures stdout +// output. errHelpRequested is treated as a non-error (consistent with main()). +// 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 + }) + + if errors.Is(err, errHelpRequested) { + return opts, stdout, nil + } + return opts, stdout, err +} + +// runMainFlow simulates the main() execution flow without calling os.Exit. +// It calls parseOpts, handles the version flag, then calls run(). +// errHelpRequested is treated as a non-error (consistent with main()). +// Returns stdout output and any error. +func runMainFlow(ctx *DdbContext, args []string) (string, error) { + stdout, err := captureStdout(func() error { + opts, parser, e := parseOpts(args, ctx) + if errors.Is(e, errHelpRequested) { + return nil + } + if e != nil { + return e + } + + if opts.Version { + fmt.Printf("ddb version %s\n", build.DaosVersion) + return nil + } + + log := logging.NewCommandLineLogger() + return run(ctx, log, opts, parser) + }) + return stdout, err +} + +// assertContainsAll asserts that s contains each of the given substrings. +func assertContainsAll(t *testing.T, s string, substrings []string) { + t.Helper() + for _, sub := range substrings { + test.AssertTrue(t, strings.Contains(s, sub), + fmt.Sprintf("expected output to contain %q: got\n%s", sub, s)) + } +} + +func isArgEqual(want interface{}, got interface{}, wantName string) error { + if reflect.DeepEqual(want, got) { + return nil + } + + return errors.New(fmt.Sprintf("Unexpected %s argument: wanted '%+v', got '%+v'", wantName, want, got)) +}