diff options
| -rw-r--r-- | internal/cli/cli_test.go | 436 | ||||
| -rw-r--r-- | internal/shell/shell_internal_test.go | 106 | ||||
| -rw-r--r-- | internal/store/store_test.go | 177 |
3 files changed, 719 insertions, 0 deletions
diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go new file mode 100644 index 0000000..250601c --- /dev/null +++ b/internal/cli/cli_test.go @@ -0,0 +1,436 @@ +// Package cli (internal tests) exercise pure package-level helpers and +// dispatch paths that do not require a full CLI setup (no cipher, no store, +// no terminal). +package cli + +import ( + "bytes" + "context" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "codeberg.org/snonux/foostore/internal/clipboard" + "codeberg.org/snonux/foostore/internal/config" + "codeberg.org/snonux/foostore/internal/crypto" + "codeberg.org/snonux/foostore/internal/git" + "codeberg.org/snonux/foostore/internal/shell" + "codeberg.org/snonux/foostore/internal/store" +) + +// testCLI creates a fully wired CLI backed by temporary directories and a real +// cipher so that dispatch paths that touch the store can be exercised. +// No git repo is initialised, so tests must not attempt commits. +func testCLI(t *testing.T) (*CLI, *config.Config) { + t.Helper() + + dataDir := t.TempDir() + exportDir := t.TempDir() + keyDir := t.TempDir() + keyFile := filepath.Join(keyDir, "key") + if err := os.WriteFile(keyFile, []byte("testkey1234567890"), 0o600); err != nil { + t.Fatalf("write key file: %v", err) + } + + cfg := &config.Config{ + DataDir: dataDir, + ExportDir: exportDir, + KeyFile: keyFile, + KeyLength: 32, + AddToIV: "Hello world", + } + + ciph, err := crypto.NewCipher(keyFile, 32, "testpin", "Hello world") + if err != nil { + t.Fatalf("NewCipher: %v", err) + } + + g := git.New(dataDir) + st, err := store.New(cfg, ciph, g) + if err != nil { + t.Fatalf("store.New: %v", err) + } + + // No-op completion function; no readline instance needed for dispatch tests. + sh, err := shell.New(func(string) []string { return nil }) + if err != nil { + t.Logf("shell.New: %v (non-TTY, skipping test)", err) + t.Skip("shell.New requires a TTY") + } + t.Cleanup(func() { sh.Close() }) + + return &CLI{ + cfg: cfg, + st: st, + g: g, + clip: clipboard.New("", ""), + sh: sh, + cipher: ciph, + }, cfg +} + +// captureStdout redirects os.Stdout to a pipe, calls fn, then returns what was +// written and restores the original Stdout. +func captureStdout(fn func()) string { + orig := os.Stdout + r, w, err := os.Pipe() + if err != nil { + return "" + } + os.Stdout = w + fn() + w.Close() + os.Stdout = orig + var buf bytes.Buffer + _, _ = io.Copy(&buf, r) + return buf.String() +} + +// captureStderr redirects os.Stderr to a pipe, calls fn, then returns what was +// written and restores the original Stderr. +func captureStderr(fn func()) string { + orig := os.Stderr + r, w, err := os.Pipe() + if err != nil { + return "" + } + os.Stderr = w + fn() + w.Close() + os.Stderr = orig + var buf bytes.Buffer + _, _ = io.Copy(&buf, r) + return buf.String() +} + +// TestLogMsg verifies that logMsg writes "> <msg>\n" to stdout. +func TestLogMsg(t *testing.T) { + got := captureStdout(func() { logMsg("hello world") }) + want := "> hello world\n" + if got != want { + t.Errorf("logMsg output = %q; want %q", got, want) + } +} + +// TestWarn verifies that warn writes "WARN <msg>\n" to stderr. +func TestWarn(t *testing.T) { + got := captureStderr(func() { warn("something bad") }) + want := "WARN something bad\n" + if got != want { + t.Errorf("warn output = %q; want %q", got, want) + } +} + +// TestPrintHelp verifies that printHelp writes a non-empty help text to stdout +// containing key command names. +func TestPrintHelp(t *testing.T) { + got := captureStdout(func() { printHelp() }) + for _, cmd := range []string{"ls", "cat", "add", "import", "sync", "version"} { + if !strings.Contains(got, cmd) { + t.Errorf("printHelp output missing %q; full output:\n%s", cmd, got) + } + } +} + +// TestShredFileCli verifies that shredFile removes a temporary file. +// It uses a temp file so no live data is affected. +func TestShredFileCli(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "todelete.txt") + if err := os.WriteFile(target, []byte("sensitive"), 0o600); err != nil { + t.Fatalf("writing temp file: %v", err) + } + + ctx := t.Context() + if err := shredFile(ctx, target); err != nil { + t.Fatalf("shredFile: %v", err) + } + + if _, err := os.Stat(target); err == nil { + t.Errorf("file %q still exists after shredFile", target) + } +} + +// ---- dispatch helpers ------------------------------------------------------- +// The following tests use a bare &CLI{} because the tested code paths in +// dispatchSimple never dereference any struct fields — they only call package- +// level helpers (logMsg, warn, printHelp) or read c.lastResult (zero value ""). + +// TestDispatch_noopCommands runs the stateless dispatchSimple branches and +// confirms they all return exit code 0. +func TestDispatch_noopCommands(t *testing.T) { + ctx := context.Background() + c := &CLI{} + + for _, cmd := range []string{"version", "commands", "help", "shell", "exit", "last"} { + t.Run(cmd, func(t *testing.T) { + // Capture stdout/stderr to avoid noise in test output. + _ = captureStdout(func() { + _ = captureStderr(func() { + ec := c.dispatch(ctx, []string{cmd}) + if ec != 0 { + t.Errorf("dispatch(%q) = %d; want 0", cmd, ec) + } + }) + }) + }) + } +} + +// TestDispatch_version_output confirms that dispatch("version") emits the +// version string to stdout. +func TestDispatch_version_output(t *testing.T) { + ctx := context.Background() + c := &CLI{} + out := captureStdout(func() { c.dispatch(ctx, []string{"version"}) }) + if !strings.Contains(out, "foostore") { + t.Errorf("version output %q does not contain 'foostore'", out) + } +} + +// TestDispatch_commands_output confirms that dispatch("commands") lists all +// commands to stdout, one per line. +func TestDispatch_commands_output(t *testing.T) { + ctx := context.Background() + c := &CLI{} + out := captureStdout(func() { c.dispatch(ctx, []string{"commands"}) }) + for _, cmd := range []string{"ls", "cat", "add", "sync", "import"} { + if !strings.Contains(out, cmd) { + t.Errorf("commands output missing %q; output:\n%s", cmd, out) + } + } +} + +// ---- cmd* error paths ------------------------------------------------------- +// The error paths for missing arguments do not access any struct fields. + +// TestCmdAdd_missingArgs verifies cmdAdd returns exit code 1 when no +// description argument is supplied. +func TestCmdAdd_missingArgs(t *testing.T) { + c := &CLI{} + ec := c.cmdAdd(context.Background(), []string{"add"}) + if ec != 1 { + t.Errorf("cmdAdd with no args = %d; want 1", ec) + } +} + +// TestCmdImport_missingArgs verifies cmdImport returns exit code 1 when no +// file argument is supplied. +func TestCmdImport_missingArgs(t *testing.T) { + c := &CLI{} + ec := c.cmdImport(context.Background(), []string{"import"}) + if ec != 1 { + t.Errorf("cmdImport with no args = %d; want 1", ec) + } +} + +// TestCmdImportR_missingArgs verifies cmdImportR returns exit code 1 when no +// directory argument is supplied. +func TestCmdImportR_missingArgs(t *testing.T) { + c := &CLI{} + ec := c.cmdImportR(context.Background(), []string{"import_r"}) + if ec != 1 { + t.Errorf("cmdImportR with no args = %d; want 1", ec) + } +} + +// ---- store-backed dispatch tests -------------------------------------------- +// These tests use testCLI() which provides a real but empty store. + +// TestDispatch_ls_empty confirms that dispatch("ls") on an empty store +// returns exit code 0 and prints "0 entries". +func TestDispatch_ls_empty(t *testing.T) { + c, _ := testCLI(t) + out := captureStdout(func() { + ec := c.dispatch(context.Background(), []string{"ls"}) + if ec != 0 { + t.Errorf("dispatch(ls) = %d; want 0", ec) + } + }) + if !strings.Contains(out, "0 entries") { + t.Errorf("ls output %q does not contain '0 entries'", out) + } +} + +// TestDispatch_shred_empty confirms that dispatch("shred") on an empty export +// dir returns exit code 0. +func TestDispatch_shred_empty(t *testing.T) { + c, _ := testCLI(t) + ec := c.dispatch(context.Background(), []string{"shred"}) + if ec != 0 { + t.Errorf("dispatch(shred) = %d; want 0", ec) + } +} + +// TestDispatch_emptyArgv confirms that dispatch with an empty argv on an empty +// store returns exit code 0 (no fzf entries, immediate return). +func TestDispatch_emptyArgv(t *testing.T) { + c, _ := testCLI(t) + ec := c.dispatch(context.Background(), []string{}) + if ec != 0 { + t.Errorf("dispatch([]) = %d; want 0", ec) + } +} + +// TestDispatch_search_empty confirms that dispatch("search", "foo") returns +// exit code 0 when there are no matching entries. +func TestDispatch_search_empty(t *testing.T) { + c, _ := testCLI(t) + ec := c.dispatch(context.Background(), []string{"search", "foo"}) + if ec != 0 { + t.Errorf("dispatch(search,foo) = %d; want 0", ec) + } +} + +// TestDispatch_cat_empty confirms that dispatch("cat", "foo") returns exit +// code 0 when there are no matching entries. +func TestDispatch_cat_empty(t *testing.T) { + c, _ := testCLI(t) + ec := c.dispatch(context.Background(), []string{"cat", "foo"}) + if ec != 0 { + t.Errorf("dispatch(cat,foo) = %d; want 0", ec) + } +} + +// TestDispatch_rm_empty confirms that dispatch("rm", "foo") returns exit code +// 0 when there are no matching entries (nothing to remove). +func TestDispatch_rm_empty(t *testing.T) { + c, _ := testCLI(t) + ec := c.dispatch(context.Background(), []string{"rm", "foo"}) + if ec != 0 { + t.Errorf("dispatch(rm,foo) = %d; want 0", ec) + } +} + +// TestDispatch_unknownCommand confirms that an unrecognised command is treated +// as a search term and returns exit code 0 on an empty store. +func TestDispatch_unknownCommand(t *testing.T) { + c, _ := testCLI(t) + ec := c.dispatch(context.Background(), []string{"completelyunknown"}) + if ec != 0 { + t.Errorf("dispatch(unknown) = %d; want 0", ec) + } +} + +// ---- readPIN ---------------------------------------------------------------- + +// TestReadPIN_envVar verifies that readPIN returns the $PIN value immediately +// without touching the terminal. +func TestReadPIN_envVar(t *testing.T) { + t.Setenv("PIN", "s3cret") + pin, err := readPIN() + if err != nil { + t.Fatalf("readPIN: %v", err) + } + if pin != "s3cret" { + t.Errorf("readPIN = %q; want %q", pin, "s3cret") + } +} + +// ---- completionFn ----------------------------------------------------------- + +// TestCompletionFn_commandsOnly verifies that completionFn (with $PIN unset) +// returns matching command names and nothing else. +func TestCompletionFn_commandsOnly(t *testing.T) { + t.Setenv("PIN", "") // ensure no store walk + c, _ := testCLI(t) + + results := c.completionFn("sy") + if len(results) != 1 || results[0] != "sync" { + t.Errorf("completionFn(sy) = %v; want [sync]", results) + } + + results = c.completionFn("") + if len(results) != len(CommandList) { + t.Errorf("completionFn('') returned %d results; want %d", len(results), len(CommandList)) + } +} + +// ---- makeActionFn ----------------------------------------------------------- + +// TestMakeActionFn_nil verifies that makeActionFn returns nil for actions +// handled directly by the store (cat, export, pathexport). +func TestMakeActionFn_nil(t *testing.T) { + c, _ := testCLI(t) + ctx := context.Background() + + for _, action := range []store.Action{store.ActionCat, store.ActionExport, store.ActionPathExport} { + fn := c.makeActionFn(ctx, action) + if fn != nil { + t.Errorf("makeActionFn(%v) = non-nil; want nil (store handles it internally)", action) + } + } +} + +// ---- remaining dispatchSearch branches -------------------------------------- + +// TestDispatch_searchActions exercises all SearchActions entries on an empty +// store, confirming each returns exit code 0 without panicking. +func TestDispatch_searchActions(t *testing.T) { + c, _ := testCLI(t) + ctx := context.Background() + + // These commands go through dispatchSearch/cmdSearchAction. + // With an empty store they find no matching entries and return 0. + for _, cmd := range []string{"paste", "export", "pathexport", "open", "edit", "get"} { + t.Run(cmd, func(t *testing.T) { + ec := c.dispatch(ctx, []string{cmd, "nonexistent"}) + if ec != 0 { + t.Errorf("dispatch(%q, nonexistent) = %d; want 0", cmd, ec) + } + }) + } +} + +// ---- add/import/import_r with real args (error paths) ---------------------- + +// TestDispatch_add_noStdinData calls dispatch("add", "desc") when stdin is +// empty (as in a non-interactive test run). cmdAdd should detect no data and +// return exit code 1. +func TestDispatch_add_noStdinData(t *testing.T) { + c, _ := testCLI(t) + // Redirect stdin to /dev/null so scanner.Scan returns false immediately. + null, err := os.Open(os.DevNull) + if err != nil { + t.Fatalf("open /dev/null: %v", err) + } + defer null.Close() + origStdin := os.Stdin + os.Stdin = null + defer func() { os.Stdin = origStdin }() + + _ = captureStdout(func() { + _ = captureStderr(func() { + ec := c.dispatch(context.Background(), []string{"add", "my/desc"}) + if ec != 1 { + t.Errorf("dispatch(add,desc) with empty stdin = %d; want 1", ec) + } + }) + }) +} + +// TestDispatch_import_missingFile calls dispatch("import", "/no/such/file") +// which should return exit code 1 after failing to read the source file. +func TestDispatch_import_missingFile(t *testing.T) { + c, _ := testCLI(t) + _ = captureStderr(func() { + ec := c.dispatch(context.Background(), []string{"import", "/no/such/file.txt"}) + if ec != 1 { + t.Errorf("dispatch(import, missing) = %d; want 1", ec) + } + }) +} + +// TestDispatch_importR_missingDir calls dispatch("import_r", "/no/such/dir") +// which should return exit code 1 after failing to walk the directory. +func TestDispatch_importR_missingDir(t *testing.T) { + c, _ := testCLI(t) + _ = captureStderr(func() { + ec := c.dispatch(context.Background(), []string{"import_r", "/no/such/dir"}) + if ec != 1 { + t.Errorf("dispatch(import_r, missing) = %d; want 1", ec) + } + }) +} diff --git a/internal/shell/shell_internal_test.go b/internal/shell/shell_internal_test.go new file mode 100644 index 0000000..73284d7 --- /dev/null +++ b/internal/shell/shell_internal_test.go @@ -0,0 +1,106 @@ +// package shell (internal test) exercises unexported types that cannot be +// reached from the external shell_test package. +package shell + +import ( + "strings" + "testing" +) + +// TestPrefixCompleterDo exercises the Do method of prefixCompleter against a +// fixed candidate list. No TTY is required because the method is pure logic. +func TestPrefixCompleterDo(t *testing.T) { + candidates := []string{"add", "edit", "export", "ls", "search"} + + p := &prefixCompleter{ + fn: func(prefix string) []string { + var out []string + for _, c := range candidates { + if strings.HasPrefix(c, prefix) { + out = append(out, c) + } + } + return out + }, + } + + cases := []struct { + name string + line string // full line up to cursor + wantSuffix []string + wantLen int + }{ + { + // Empty input: all candidates returned; each suffix is the full word + space. + name: "empty prefix", + line: "", + wantSuffix: []string{"add ", "edit ", "export ", "ls ", "search "}, + wantLen: 0, + }, + { + // "e" prefix: edit, export. + name: "single char prefix", + line: "e", + wantSuffix: []string{"dit ", "xport "}, + wantLen: 1, + }, + { + // "ex" prefix: only export. + name: "two char prefix", + line: "ex", + wantSuffix: []string{"port "}, + wantLen: 2, + }, + { + // "z" prefix: no matches. + name: "no match", + line: "z", + wantSuffix: nil, + wantLen: 1, + }, + { + // Line has a space: prefix is the word after the last space. + // "cat e" → prefix is "e", completions for "e". + name: "prefix after space", + line: "cat e", + wantSuffix: []string{"dit ", "xport "}, + wantLen: 1, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + line := []rune(tc.line) + pos := len(line) + newLine, length := p.Do(line, pos) + + if length != tc.wantLen { + t.Errorf("length = %d; want %d", length, tc.wantLen) + } + if len(newLine) != len(tc.wantSuffix) { + t.Errorf("len(newLine) = %d; want %d (%v vs %v)", + len(newLine), len(tc.wantSuffix), toStrings(newLine), tc.wantSuffix) + return + } + // Build a set for order-independent comparison. + got := make(map[string]bool, len(newLine)) + for _, r := range newLine { + got[string(r)] = true + } + for _, want := range tc.wantSuffix { + if !got[want] { + t.Errorf("missing suffix %q in completions %v", want, toStrings(newLine)) + } + } + }) + } +} + +// toStrings converts a [][]rune to []string for readable error output. +func toStrings(runes [][]rune) []string { + out := make([]string, len(runes)) + for i, r := range runes { + out[i] = string(r) + } + return out +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 9670a01..47d4692 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -745,3 +745,180 @@ func TestCommitIndexSkipsExisting(t *testing.T) { t.Errorf("index file was overwritten: got %q; want %q", got, sentinel) } } + +// --- TestSearchActionPathExport ---------------------------------------------- + +// TestSearchActionPathExport verifies that ActionPathExport preserves the full +// description path (not just the basename) when writing to the export dir. +func TestSearchActionPathExport(t *testing.T) { + ctx, store, cfg, _, _ := testSetup(t) + initGitRepo(t, cfg.DataDir) + + wantContent := "path export content\n" + if err := store.Add(ctx, "docs/subdir/report.txt", wantContent); err != nil { + t.Fatalf("Add: %v", err) + } + + results, err := store.Search(ctx, "report.txt", ActionPathExport, nil) + if err != nil { + t.Fatalf("Search ActionPathExport: %v", err) + } + if len(results) != 1 { + t.Fatalf("expected 1 result; got %d", len(results)) + } + + // ActionPathExport uses the full description as the export path. + exportedPath := filepath.Join(cfg.ExportDir, "docs/subdir/report.txt") + got, err := os.ReadFile(exportedPath) + if err != nil { + t.Fatalf("reading exported file at full path: %v", err) + } + if string(got) != wantContent { + t.Errorf("exported content = %q; want %q", got, wantContent) + } +} + +// --- TestSearchActionWithCallback -------------------------------------------- + +// TestSearchActionWithCallback verifies that Search passes the correct Index and +// Data to the actionFn for actions that delegate to the caller (ActionPaste etc.). +func TestSearchActionWithCallback(t *testing.T) { + ctx, store, cfg, _, _ := testSetup(t) + initGitRepo(t, cfg.DataDir) + + wantContent := "callback content\n" + if err := store.Add(ctx, "cb/entry.txt", wantContent); err != nil { + t.Fatalf("Add: %v", err) + } + + var gotDesc string + var gotContent string + actionFn := func(_ context.Context, idx *Index, d *Data) error { + gotDesc = idx.Description + gotContent = string(d.Content) + return nil + } + + // ActionPaste falls through to the default/actionFn branch of applyAction. + results, err := store.Search(ctx, "cb/entry.txt", ActionPaste, actionFn) + if err != nil { + t.Fatalf("Search with callback: %v", err) + } + if len(results) != 1 { + t.Fatalf("expected 1 result; got %d", len(results)) + } + if gotDesc != "cb/entry.txt" { + t.Errorf("callback got description %q; want %q", gotDesc, "cb/entry.txt") + } + if gotContent != wantContent { + t.Errorf("callback got content %q; want %q", gotContent, wantContent) + } +} + +// --- TestSearchActionNilCallback --------------------------------------------- + +// TestSearchActionNilCallback confirms that passing a nil actionFn for a +// callback-delegated action (ActionPaste) is handled gracefully without panic. +func TestSearchActionNilCallback(t *testing.T) { + ctx, store, cfg, _, _ := testSetup(t) + initGitRepo(t, cfg.DataDir) + + if err := store.Add(ctx, "nil/cb.txt", "data"); err != nil { + t.Fatalf("Add: %v", err) + } + + // nil actionFn: applyAction must return nil without calling anything. + _, err := store.Search(ctx, "nil/cb.txt", ActionPaste, nil) + if err != nil { + t.Fatalf("Search with nil callback: %v", err) + } +} + +// --- TestFzfEmpty ------------------------------------------------------------ + +// TestFzfEmpty calls Fzf on an empty store and confirms it returns ("", nil) +// without attempting to launch fzf. +func TestFzfEmpty(t *testing.T) { + ctx, store, _, _, _ := testSetup(t) + // No entries added — Fzf must return immediately. + result, err := store.Fzf(ctx) + if err != nil { + t.Fatalf("Fzf on empty store: %v", err) + } + if result != "" { + t.Errorf("Fzf on empty store = %q; want empty string", result) + } +} + +// --- TestRemoveInteractiveInvalidThenDecline --------------------------------- + +// TestRemoveInteractiveInvalidThenDecline exercises the retry loop in +// confirmAndRemove: an unrecognised answer causes a re-prompt; "n" then exits. +func TestRemoveInteractiveInvalidThenDecline(t *testing.T) { + ctx, store, cfg, _, g := testSetup(t) + initGitRepo(t, cfg.DataDir) + + if err := store.Add(ctx, "retry/entry", "data"); err != nil { + t.Fatalf("Add: %v", err) + } + if err := g.Commit(ctx); err != nil { + t.Fatalf("Commit: %v", err) + } + + // "maybe\n" is not "y" or "n" → retry; "n\n" then declines. + input := strings.NewReader("maybe\nn\n") + if err := store.Remove(ctx, "retry/entry", input); err != nil { + t.Fatalf("Remove: %v", err) + } + + // Entry must still be present because deletion was declined. + count := 0 + if err := store.WalkIndexes(ctx, "", func(*Index) error { count++; return nil }); err != nil { + t.Fatalf("WalkIndexes: %v", err) + } + if count != 1 { + t.Errorf("expected 1 entry after decline; got %d", count) + } +} + +// --- TestImportForceOverwrite ------------------------------------------------ + +// TestImportForceOverwrite confirms that force=true overwrites an existing entry +// while force=false (the default) skips it silently. +func TestImportForceOverwrite(t *testing.T) { + ctx, store, cfg, c, _ := testSetup(t) + initGitRepo(t, cfg.DataDir) + + srcDir := t.TempDir() + src := filepath.Join(srcDir, "f.txt") + if err := os.WriteFile(src, []byte("original\n"), 0o600); err != nil { + t.Fatalf("write src: %v", err) + } + + // First import. + if err := store.Import(ctx, src, "force/f.txt", false); err != nil { + t.Fatalf("first Import: %v", err) + } + + // Overwrite source with new content. + if err := os.WriteFile(src, []byte("updated\n"), 0o600); err != nil { + t.Fatalf("write updated src: %v", err) + } + + // Second import with force=true must overwrite. + if err := store.Import(ctx, src, "force/f.txt", true); err != nil { + t.Fatalf("second Import (force): %v", err) + } + + var idx *Index + if err := store.WalkIndexes(ctx, "", func(i *Index) error { idx = i; return nil }); err != nil { + t.Fatalf("WalkIndexes: %v", err) + } + d, err := loadData(ctx, filepath.Join(cfg.DataDir, idx.DataFile), c) + if err != nil { + t.Fatalf("loadData: %v", err) + } + if string(d.Content) != "updated\n" { + t.Errorf("content after force import = %q; want %q", d.Content, "updated\n") + } +} |
