package restic import ( "context" "fmt" "os" "path/filepath" "strings" "testing" ) // setupScriptBin writes a small shell script to a temp directory, // makes it executable, and returns its path. scriptBody is the // complete script content (without the shebang line — that's added // automatically). func setupScriptBin(t *testing.T, scriptBody string) string { t.Helper() dir := t.TempDir() p := filepath.Join(dir, "restic") content := "#!/bin/sh\n" + scriptBody + "\n" if err := os.WriteFile(p, []byte(content), 0o755); err != nil { t.Fatalf("setupScriptBin: %v", err) } return p } // captureLines returns a LineHandler that appends "stream:line" into // the returned slice pointer (safe for single-goroutine test use). func captureLines() (*[]string, LineHandler) { var lines []string h := func(stream, line string, _ any) { lines = append(lines, fmt.Sprintf("%s:%s", stream, line)) } return &lines, h } // --- B1: RunPrune + B2: RunCheck --- func TestRunPruneInvokesPrune(t *testing.T) { // Shell script that echoes its args; "prune" should appear in output. bin := setupScriptBin(t, `echo "$@"`) env := Env{Bin: bin} lines, h := captureLines() if err := env.RunPrune(context.Background(), h); err != nil { t.Fatalf("RunPrune returned error: %v", err) } for _, l := range *lines { if strings.Contains(l, "prune") { return } } t.Fatalf("expected 'prune' in captured output; got: %v", *lines) } // --- B2: RunCheck --- func TestRunCheckLockSniff(t *testing.T) { cases := []struct { name string stderrLine string wantLocked bool }{ {"stale lock", "Found stale lock from PID 1234", true}, {"already locked", "repository is already locked exclusively", true}, {"benign mention", "subdir/locked-file ok", false}, {"empty", "", false}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { // Script emits the line on stderr, then exits 0. script := fmt.Sprintf(`printf '%%s\n' %q >&2`, c.stderrLine) bin := setupScriptBin(t, script) env := Env{Bin: bin} res, err := env.RunCheck(context.Background(), 0, nil) if err != nil { t.Fatalf("RunCheck returned unexpected error: %v", err) } if res.LockPresent != c.wantLocked { t.Fatalf("LockPresent: got %v, want %v (line: %q)", res.LockPresent, c.wantLocked, c.stderrLine) } if res.ErrorsFound { t.Fatal("expected ErrorsFound=false") } }) } } func TestRunCheckErrorsFoundOnExit1(t *testing.T) { bin := setupScriptBin(t, `exit 1`) env := Env{Bin: bin} res, err := env.RunCheck(context.Background(), 0, nil) if err != nil { t.Fatalf("RunCheck returned unexpected error (should have absorbed exit 1): %v", err) } if !res.ErrorsFound { t.Fatal("expected ErrorsFound=true for exit 1") } } func TestRunCheckSubsetArg(t *testing.T) { bin := setupScriptBin(t, `echo "$@"`) env := Env{Bin: bin} lines, h := captureLines() if _, err := env.RunCheck(context.Background(), 25, h); err != nil { t.Fatalf("RunCheck: %v", err) } want := "--read-data-subset 25%" for _, l := range *lines { if strings.Contains(l, want) { return } } t.Fatalf("expected %q in captured output; got: %v", want, *lines) } // --- B3: RunUnlock + RunStats --- func TestRunUnlockInvokesUnlock(t *testing.T) { bin := setupScriptBin(t, `echo "$@"`) env := Env{Bin: bin} lines, h := captureLines() if err := env.RunUnlock(context.Background(), h); err != nil { t.Fatalf("RunUnlock: %v", err) } for _, l := range *lines { if strings.Contains(l, "unlock") { return } } t.Fatalf("expected 'unlock' in captured output; got: %v", *lines) } func TestRunStatsParsesJSON(t *testing.T) { bin := setupScriptBin(t, `echo '{"total_size":1234,"total_uncompressed_size":5678,"snapshots_count":3,"total_file_count":100,"total_blob_count":50}'`) env := Env{Bin: bin} stats, err := env.RunStats(context.Background(), nil) if err != nil { t.Fatalf("RunStats: %v", err) } if stats.TotalSize != 1234 { t.Fatalf("TotalSize: got %d, want 1234", stats.TotalSize) } if stats.TotalUncompressed != 5678 { t.Fatalf("TotalUncompressed: got %d, want 5678", stats.TotalUncompressed) } if stats.SnapshotsCount != 3 { t.Fatalf("SnapshotsCount: got %d, want 3", stats.SnapshotsCount) } if stats.TotalFileCount != 100 { t.Fatalf("TotalFileCount: got %d, want 100", stats.TotalFileCount) } if stats.TotalBlobCount != 50 { t.Fatalf("TotalBlobCount: got %d, want 50", stats.TotalBlobCount) } } func TestRunStatsErrorsWithoutJSON(t *testing.T) { bin := setupScriptBin(t, `echo "no json here"`) env := Env{Bin: bin} _, err := env.RunStats(context.Background(), nil) if err == nil { t.Fatal("expected error when no JSON in output") } if !strings.Contains(err.Error(), "no JSON in output") { t.Fatalf("unexpected error: %v", err) } } func TestRunStatsZeroSnapshots(t *testing.T) { // Confirms RunStats succeeds and returns a valid *RepoStats when the // repo has no snapshots (snapshots_count=0). A regression that // re-added a "SnapshotsCount > 0" guard would return an error here. bin := setupScriptBin(t, `echo '{"total_size":0,"total_uncompressed_size":0,"snapshots_count":0,"total_file_count":0,"total_blob_count":0}'`) env := Env{Bin: bin} stats, err := env.RunStats(context.Background(), nil) if err != nil { t.Fatalf("RunStats with zero snapshots returned unexpected error: %v", err) } if stats == nil { t.Fatal("expected non-nil *RepoStats, got nil") } if stats.SnapshotsCount != 0 { t.Fatalf("SnapshotsCount: got %d, want 0", stats.SnapshotsCount) } }