diff options
| author | Paul Buetow <paul@buetow.org> | 2026-02-22 20:21:34 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-02-22 20:21:34 +0200 |
| commit | 3373b7e90c4e1ff1abcebe0594316131f546dfa8 (patch) | |
| tree | 8acc26309fce0048f156e5cec2acca9a5d891628 | |
| parent | 528a9c0888f1db2449d30bf9c0c8b55fcd3c645c (diff) | |
Add unit tests to reach 63.3% coverage (task 335)
- internal/shell/shell_internal_test.go (new): tests prefixCompleter.Do
with table-driven cases covering empty prefix, partial match, no match,
and multi-word line (prefix after space)
- internal/cli/cli_test.go (new): tests pure helpers (logMsg, warn,
printHelp, shredFile), dispatch paths with real store (ls, search, cat,
rm, shred, get, paste, export, pathexport, open, edit, unknown command,
empty argv), error paths for cmdAdd/cmdImport/cmdImportR, readPIN env-var
path, completionFn commands-only path, makeActionFn nil cases
- internal/store/store_test.go: added TestSearchActionPathExport,
TestSearchActionWithCallback, TestSearchActionNilCallback, TestFzfEmpty,
TestRemoveInteractiveInvalidThenDecline, TestImportForceOverwrite
Total coverage: 44.8% → 63.3%
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| -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") + } +} |
