diff --git a/cmd/root_test.go b/cmd/root_test.go index 5d84271..faeccee 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -11,172 +11,133 @@ import ( ) func TestPrintRestore(t *testing.T) { - t.Run("live restore without backups", func(t *testing.T) { - var buf bytes.Buffer - info := service.RestoreInfo{ - Restored: []string{"a", "b"}, - } - if err := cmd.PrintRestore(&buf, info, false); err != nil { - t.Fatal(err) - } - - out := buf.String() - if !strings.Contains(out, "Restored 2 path(s)") { - t.Errorf("unexpected header: %s", out) - } - if !strings.Contains(out, " a\n") || !strings.Contains(out, " b\n") { - t.Errorf("missing paths: %s", out) - } - if strings.Contains(out, "Backed up") { - t.Error("unexpected backup section") - } - }) - - t.Run("dry run with backups", func(t *testing.T) { - var buf bytes.Buffer - info := service.RestoreInfo{ - Restored: []string{"a"}, - BackedUp: []string{"b", "c"}, - } - if err := cmd.PrintRestore(&buf, info, true); err != nil { - t.Fatal(err) - } + t.Parallel() + + tests := []struct { + name string + info service.RestoreInfo + dryRun bool + want []string + unwant []string + }{ + { + name: "live_restore_without_backups", + info: service.RestoreInfo{ + Restored: []string{"a", "b"}, + }, + dryRun: false, + want: []string{"Restored 2 path(s)", " a\n", " b\n"}, + unwant: []string{"Backed up"}, + }, + { + name: "dry_run_with_backups", + info: service.RestoreInfo{ + Restored: []string{"a"}, + BackedUp: []string{"b", "c"}, + }, + dryRun: true, + want: []string{"Would restore 1 path(s)", "Would back up 2 conflicting path(s)", " b\n", " c\n"}, + }, + } - out := buf.String() - if !strings.Contains(out, "Would restore 1 path(s)") { - t.Errorf("unexpected header: %s", out) - } - if !strings.Contains(out, "Would back up 2 conflicting path(s)") { - t.Errorf("unexpected backup header: %s", out) - } - if !strings.Contains(out, " b\n") || !strings.Contains(out, " c\n") { - t.Errorf("missing backup paths: %s", out) - } - }) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + if err := cmd.PrintRestore(&buf, tt.info, tt.dryRun); err != nil { + t.Fatal(err) + } + out := buf.String() + for _, w := range tt.want { + if !strings.Contains(out, w) { + t.Errorf("expected output to contain %q, got:\n%s", w, out) + } + } + for _, u := range tt.unwant { + if strings.Contains(out, u) { + t.Errorf("expected output NOT to contain %q, got:\n%s", u, out) + } + } + }) + } } func TestPrintDoctor(t *testing.T) { - t.Run("minimal report", func(t *testing.T) { - var buf bytes.Buffer - report := service.DoctorReport{ - Mode: "check", - } - if err := cmd.PrintDoctor(&buf, report); err != nil { - t.Fatal(err) - } - if !strings.Contains(buf.String(), "Mode: check") { - t.Errorf("unexpected output: %s", buf.String()) - } - }) - - t.Run("marker missing", func(t *testing.T) { - var buf bytes.Buffer - report := service.DoctorReport{ - Mode: "check", - MarkerMissing: true, - } - if err := cmd.PrintDoctor(&buf, report); err != nil { - t.Fatal(err) - } - if !strings.Contains(buf.String(), "Repo marker missing") { - t.Error("expected marker missing message") - } - }) - - t.Run("marker fixed", func(t *testing.T) { - var buf bytes.Buffer - report := service.DoctorReport{ - Mode: "fix", - MarkerMissing: true, - MarkerFixed: true, - } - if err := cmd.PrintDoctor(&buf, report); err != nil { - t.Fatal(err) - } - if !strings.Contains(buf.String(), "Repo marker was added") { - t.Error("expected marker fixed message") - } - }) - - t.Run("collisions", func(t *testing.T) { - var buf bytes.Buffer - report := service.DoctorReport{ - Mode: "check", - Collisions: []service.OwnershipCollision{ - {Path: "foo", Scopes: []string{"common", "host1"}}, + t.Parallel() + + tests := []struct { + name string + report service.DoctorReport + want []string + }{ + { + name: "minimal_report", + report: service.DoctorReport{Mode: "check"}, + want: []string{"Mode: check"}, + }, + { + name: "marker_missing", + report: service.DoctorReport{Mode: "check", MarkerMissing: true}, + want: []string{"Repo marker missing"}, + }, + { + name: "marker_fixed", + report: service.DoctorReport{Mode: "fix", MarkerMissing: true, MarkerFixed: true}, + want: []string{"Repo marker was added"}, + }, + { + name: "collisions", + report: service.DoctorReport{ + Mode: "check", + Collisions: []service.OwnershipCollision{ + {Path: "foo", Scopes: []string{"common", "host1"}}, + }, }, - } - if err := cmd.PrintDoctor(&buf, report); err != nil { - t.Fatal(err) - } - out := buf.String() - if !strings.Contains(out, "Ownership collisions:") { - t.Error("expected collisions header") - } - if !strings.Contains(out, " foo -> common, host1") { - t.Errorf("unexpected collision line: %s", out) - } - }) - - t.Run("empty scopes", func(t *testing.T) { - var buf bytes.Buffer - report := service.DoctorReport{ - Mode: "check", - EmptyScopes: []string{"host1", "host2"}, - } - if err := cmd.PrintDoctor(&buf, report); err != nil { - t.Fatal(err) - } - if !strings.Contains(buf.String(), "Empty host scopes:") { - t.Error("expected empty scopes header") - } - }) - - t.Run("pruned scopes", func(t *testing.T) { - var buf bytes.Buffer - report := service.DoctorReport{ - Mode: "fix", - PrunedScopes: []string{"host1"}, - } - if err := cmd.PrintDoctor(&buf, report); err != nil { - t.Fatal(err) - } - if !strings.Contains(buf.String(), "Pruned empty host scopes:") { - t.Error("expected pruned scopes header") - } - }) - - t.Run("broken symlink skipped", func(t *testing.T) { - var buf bytes.Buffer - report := service.DoctorReport{ - Mode: "check", - BrokenSymlinkFixSkipped: true, - } - if err := cmd.PrintDoctor(&buf, report); err != nil { - t.Fatal(err) - } - if !strings.Contains(buf.String(), "Broken symlink repair was skipped") { - t.Error("expected skipped message") - } - }) + want: []string{"Ownership collisions:", " foo -> common, host1"}, + }, + { + name: "empty_scopes", + report: service.DoctorReport{Mode: "check", EmptyScopes: []string{"host1", "host2"}}, + want: []string{"Empty host scopes:"}, + }, + { + name: "pruned_scopes", + report: service.DoctorReport{Mode: "fix", PrunedScopes: []string{"host1"}}, + want: []string{"Pruned empty host scopes:"}, + }, + { + name: "broken_symlink_skipped", + report: service.DoctorReport{Mode: "check", BrokenSymlinkFixSkipped: true}, + want: []string{"Broken symlink repair was skipped"}, + }, + { + name: "broken_symlink_fixed", + report: service.DoctorReport{Mode: "fix", BrokenSymlinkFix: true}, + want: []string{"Broken symlinks repaired"}, + }, + } - t.Run("broken symlink fixed", func(t *testing.T) { - var buf bytes.Buffer - report := service.DoctorReport{ - Mode: "fix", - BrokenSymlinkFix: true, - } - if err := cmd.PrintDoctor(&buf, report); err != nil { - t.Fatal(err) - } - if !strings.Contains(buf.String(), "Broken symlinks repaired") { - t.Error("expected fixed message") - } - }) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + if err := cmd.PrintDoctor(&buf, tt.report); err != nil { + t.Fatal(err) + } + out := buf.String() + for _, w := range tt.want { + if !strings.Contains(out, w) { + t.Errorf("expected output to contain %q, got:\n%s", w, out) + } + } + }) + } } func TestNewRootCommand(t *testing.T) { - t.Run("version includes build info", func(t *testing.T) { + // Note: Not parallel due to shared package-level version state. + + t.Run("version_includes_build_info", func(t *testing.T) { cmd.SetVersion("1.0.0", "2024-01-01") root := cmd.NewRootCommand() if !strings.Contains(root.Version, "1.0.0") { @@ -187,7 +148,7 @@ func TestNewRootCommand(t *testing.T) { } }) - t.Run("all subcommands registered", func(t *testing.T) { + t.Run("all_subcommands_registered", func(t *testing.T) { root := cmd.NewRootCommand() want := []string{ "init", "clone", "add", "move", "remove", "forget", @@ -205,88 +166,64 @@ func TestNewRootCommand(t *testing.T) { } }) - t.Run("move flags are mutually exclusive", func(t *testing.T) { - root := cmd.NewRootCommand() - root.SetArgs([]string{"move", "foo", "--to-common", "--to-host", "h"}) - - err := root.Execute() - if err == nil { - t.Fatal("expected error for mutually exclusive flags") - } - - // Cobra's mutual exclusion message varies by version; check for key terms - msg := err.Error() - if !strings.Contains(msg, "to-common") && !strings.Contains(msg, "to-host") && !strings.Contains(msg, "mutually exclusive") { - t.Errorf("unexpected error message: %s", msg) - } - }) - - t.Run("list flags are mutually exclusive", func(t *testing.T) { - root := cmd.NewRootCommand() - root.SetArgs([]string{"list", "--all", "--host", "h"}) - - err := root.Execute() - if err == nil { - t.Fatal("expected error for mutually exclusive flags") - } - - // Cobra's mutual exclusion message varies by version; check for key terms - msg := err.Error() - if !strings.Contains(msg, "all") && !strings.Contains(msg, "host") && !strings.Contains(msg, "mutually exclusive") { - t.Errorf("unexpected error message: %s", msg) - } - }) + tests := []struct { + name string + args []string + }{ + { + name: "move_flags_are_mutually_exclusive", + args: []string{"move", "foo", "--to-common", "--to-host", "h"}, + }, + { + name: "list_flags_are_mutually_exclusive", + args: []string{"list", "--all", "--host", "h"}, + }, + { + name: "doctor_flags_are_mutually_exclusive", + args: []string{"doctor", "--all", "--host", "h"}, + }, + { + name: "format_flags_are_mutually_exclusive", + args: []string{"format", "--v1", "--v2"}, + }, + } - t.Run("doctor flags are mutually exclusive", func(t *testing.T) { - root := cmd.NewRootCommand() - root.SetArgs([]string{"doctor", "--all", "--host", "h"}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + root := cmd.NewRootCommand() + root.SetArgs(tt.args) + err := root.Execute() + if err == nil { + t.Fatal("expected error for mutually exclusive flags") + } + msg := err.Error() + if !strings.Contains(msg, "mutually exclusive") && !strings.Contains(msg, "none of the others") { + t.Errorf("unexpected error message: %s", msg) + } + }) + } +} - err := root.Execute() - if err == nil { - t.Fatal("expected error for mutually exclusive flags") - } +func TestInitCommand(t *testing.T) { + t.Run("initializes_repo", func(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", "") - // Cobra's mutual exclusion message varies by version; check for key terms - msg := err.Error() - if !strings.Contains(msg, "all") && !strings.Contains(msg, "host") && !strings.Contains(msg, "mutually exclusive") { - t.Errorf("unexpected error message: %s", msg) - } - }) + repoPath := filepath.Join(home, ".config", "lnk") - t.Run("list flags are mutually exclusive", func(t *testing.T) { + var buf bytes.Buffer root := cmd.NewRootCommand() - root.SetArgs([]string{"format", "--v1", "--v2"}) + root.SetOut(&buf) + root.SetErr(&buf) + root.SetArgs([]string{"--repo", repoPath, "init"}) - err := root.Execute() - if err == nil { - t.Fatal("expected error for mutually exclusive flags") + if err := root.Execute(); err != nil { + t.Fatalf("Execute: %v\noutput: %s", err, buf.String()) } - // Cobra's mutual exclusion message varies by version; check for key terms - msg := err.Error() - if !strings.Contains(msg, "v1") && !strings.Contains(msg, "v2") && !strings.Contains(msg, "mutually exclusive") { - t.Errorf("unexpected error message: %s", msg) + if !strings.Contains(buf.String(), "Initialized repo at") { + t.Errorf("unexpected output: %s", buf.String()) } }) } -func TestInitCommand(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - t.Setenv("XDG_CONFIG_HOME", "") - - repoPath := filepath.Join(home, ".config", "lnk") - - var buf bytes.Buffer - root := cmd.NewRootCommand() - root.SetOut(&buf) - root.SetErr(&buf) - root.SetArgs([]string{"--repo", repoPath, "init"}) - - if err := root.Execute(); err != nil { - t.Fatalf("Execute: %v\noutput: %s", err, buf.String()) - } - - if !strings.Contains(buf.String(), "Initialized repo at") { - t.Errorf("unexpected output: %s", buf.String()) - } -} diff --git a/internal/bootstrapper/bootstrapper_test.go b/internal/bootstrapper/bootstrapper_test.go index d184c9e..5a718f9 100644 --- a/internal/bootstrapper/bootstrapper_test.go +++ b/internal/bootstrapper/bootstrapper_test.go @@ -25,37 +25,62 @@ func (f *fakeGit) IsGitRepository() bool { return f.isRepo } // --- tests --- func TestRunner_FindScript(t *testing.T) { - t.Run("returns error when not a git repo", func(t *testing.T) { - tmp := t.TempDir() - r := bootstrapper.New(tmp, &fakeGit{isRepo: false}) + t.Parallel() - _, err := r.FindScript() - if !errors.Is(err, lnkerror.ErrNotInitialized) { - t.Fatalf("expected ErrNotInitialized, got %v", err) - } - }) + tests := []struct { + name string + isRepo bool + write string // script content to write, empty = none + want string + wantErr error + }{ + { + name: "returns_error_when_not_a_git_repo", + isRepo: false, + wantErr: lnkerror.ErrNotInitialized, + }, + { + name: "finds_bootstrap_sh", + isRepo: true, + write: "#!/bin/bash\necho ok", + want: "bootstrap.sh", + }, + } - t.Run("finds bootstrap.sh", func(t *testing.T) { - tmp := t.TempDir() - // create a fake bootstrap.sh - script := filepath.Join(tmp, "bootstrap.sh") - if err := os.WriteFile(script, []byte("#!/bin/bash\necho ok"), 0644); err != nil { - t.Fatal(err) - } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + tmp := t.TempDir() + if tt.write != "" { + script := filepath.Join(tmp, "bootstrap.sh") + if err := os.WriteFile(script, []byte(tt.write), 0644); err != nil { + t.Fatal(err) + } + } - r := bootstrapper.New(tmp, &fakeGit{isRepo: true}) - name, err := r.FindScript() - if err != nil { - t.Fatal(err) - } - if name != "bootstrap.sh" { - t.Fatalf("expected bootstrap.sh, got %s", name) - } - }) + r := bootstrapper.New(tmp, &fakeGit{isRepo: tt.isRepo}) + name, err := r.FindScript() + if tt.wantErr != nil { + if !errors.Is(err, tt.wantErr) { + t.Fatalf("expected %v, got %v", tt.wantErr, err) + } + return + } + if err != nil { + t.Fatal(err) + } + if name != tt.want { + t.Fatalf("expected %s, got %s", tt.want, name) + } + }) + } } func TestRunner_RunScript(t *testing.T) { - t.Run("executes script successfully", func(t *testing.T) { + t.Parallel() + + t.Run("executes_script_successfully", func(t *testing.T) { + t.Parallel() tmp := t.TempDir() script := filepath.Join(tmp, "bootstrap.sh") os.WriteFile(script, []byte("#!/bin/bash\necho hello"), 0755) diff --git a/internal/filemanager/filemanager_test.go b/internal/filemanager/filemanager_test.go index ffd5f50..3abc311 100644 --- a/internal/filemanager/filemanager_test.go +++ b/internal/filemanager/filemanager_test.go @@ -27,7 +27,7 @@ func (f *fakeFileInfo) Size() int64 { return f.size } func (f *fakeFileInfo) Mode() os.FileMode { return f.mode } func (f *fakeFileInfo) ModTime() time.Time { return f.modTime } func (f *fakeFileInfo) IsDir() bool { return f.dir } -func (f *fakeFileInfo) Sys() interface{} { return nil } +func (f *fakeFileInfo) Sys() any { return nil } type moveCall struct{ src, dst string } @@ -71,7 +71,7 @@ func (f *fakeFileSystem) ValidateSymlinkForRemove(absPath, repoPath string) erro type fakeTracker struct { lnkFileNameFunc func() (string, error) hostStoragePathFunc func() (string, error) - hostStorageRelPathFunc func() (string, error) // NEW + hostStorageRelPathFunc func() (string, error) addManagedItemFunc func(path string) error removeManagedItemFunc func(path string) error getManagedItemsFunc func() ([]string, error) @@ -122,204 +122,192 @@ func (f *fakeTracker) GetManagedItems() ([]string, error) { // ---------- tests ---------- func TestManager_AddMultiple(t *testing.T) { - t.Run("empty paths returns empty result", func(t *testing.T) { - fm := filemanager.New("repo", "host", &fakeFileSystem{}, &fakeTracker{}) - result, err := fm.AddMultiple(nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(result.StagePaths) != 0 { - t.Errorf("expected empty StagePaths, got %v", result.StagePaths) - } - if len(result.Rollback) != 0 { - t.Errorf("expected empty Rollback, got %d", len(result.Rollback)) - } - }) - - t.Run("validation failure returns error", func(t *testing.T) { - fs := &fakeFileSystem{ - validateFileInfoFunc: func(path string) (os.FileInfo, error) { - return nil, errors.New("not a regular file") + t.Parallel() + + tests := []struct { + name string + paths []filemanager.FileToTrack + fsSetup func(t *testing.T, storage string) *fakeFileSystem + trkSetup func(t *testing.T, storage string) *fakeTracker + wantErr bool + errMsg string + wantStages int + wantRollbk int + wantMoves int + }{ + { + name: "empty_paths_returns_empty_result", + paths: nil, + fsSetup: func(t *testing.T, s string) *fakeFileSystem { return &fakeFileSystem{} }, + trkSetup: func(t *testing.T, s string) *fakeTracker { return &fakeTracker{} }, + wantStages: 0, + wantRollbk: 0, + }, + { + name: "validation_failure_returns_error", + paths: []filemanager.FileToTrack{ + {AbsPath: "/foo", RelativePath: "foo"}, }, - } - fm := filemanager.New("repo", "host", fs, &fakeTracker{}) - _, err := fm.AddMultiple([]filemanager.FileToTrack{ - {AbsPath: "/foo", RelativePath: "foo"}, - }) - if err == nil { - t.Fatal("expected error") - } - if !strings.Contains(err.Error(), "validation failed") { - t.Errorf("unexpected error message: %v", err) - } - }) - - t.Run("success returns stage paths and rollback", func(t *testing.T) { - tmp := t.TempDir() - storage := filepath.Join(tmp, "storage") - - fs := &fakeFileSystem{ - validateFileInfoFunc: func(path string) (os.FileInfo, error) { - return &fakeFileInfo{name: filepath.Base(path), mode: 0644}, nil + fsSetup: func(t *testing.T, s string) *fakeFileSystem { + return &fakeFileSystem{ + validateFileInfoFunc: func(path string) (os.FileInfo, error) { + return nil, errors.New("not a regular file") + }, + } }, - moveFunc: func(src, dst string, info os.FileInfo) error { return nil }, - createSymlinkFunc: func(target, link string) error { return nil }, - } - - trk := &fakeTracker{ - lnkFileNameFunc: func() (string, error) { - return filepath.Join("repo", ".lnk"), nil + trkSetup: func(t *testing.T, s string) *fakeTracker { return &fakeTracker{} }, + wantErr: true, + errMsg: "validation failed", + }, + { + name: "success_returns_stage_paths_and_rollback", + paths: []filemanager.FileToTrack{ + {AbsPath: "/foo", RelativePath: "foo"}, }, - hostStoragePathFunc: func() (string, error) { return storage, nil }, - hostStorageRelPathFunc: func() (string, error) { return "storage", nil }, // NEW - addManagedItemFunc: func(path string) error { return nil }, - } - - fm := filemanager.New("repo", "host", fs, trk) - result, err := fm.AddMultiple([]filemanager.FileToTrack{ - {AbsPath: "/foo", RelativePath: "foo"}, - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - // StagePaths now contains relative paths, not absolute - wantPaths := []string{filepath.Join("storage", "foo"), filepath.Join("repo", ".lnk")} - if len(result.StagePaths) != len(wantPaths) { - t.Fatalf("expected %d stage paths, got %d: %v", len(wantPaths), len(result.StagePaths), result.StagePaths) - } - if result.StagePaths[0] != wantPaths[0] { - t.Errorf("stage path[0] = %q, want %q", result.StagePaths[0], wantPaths[0]) - } - if result.StagePaths[1] != wantPaths[1] { - t.Errorf("stage path[1] = %q, want %q", result.StagePaths[1], wantPaths[1]) - } - if len(result.Rollback) != 1 { - t.Errorf("expected 1 rollback action, got %d", len(result.Rollback)) - } - }) - - t.Run("rollback on move failure", func(t *testing.T) { - tmp := t.TempDir() - storage := filepath.Join(tmp, "storage") - - fs := &fakeFileSystem{ - validateFileInfoFunc: func(path string) (os.FileInfo, error) { - return &fakeFileInfo{name: filepath.Base(path), mode: 0644}, nil + fsSetup: func(t *testing.T, s string) *fakeFileSystem { + return &fakeFileSystem{ + validateFileInfoFunc: func(path string) (os.FileInfo, error) { + return &fakeFileInfo{name: filepath.Base(path), mode: 0644}, nil + }, + moveFunc: func(src, dst string, info os.FileInfo) error { return nil }, + createSymlinkFunc: func(target, link string) error { return nil }, + } }, - moveFunc: func(src, dst string, info os.FileInfo) error { - if strings.Contains(dst, "file2") { - return errors.New("disk full") + trkSetup: func(t *testing.T, s string) *fakeTracker { + return &fakeTracker{ + lnkFileNameFunc: func() (string, error) { return filepath.Join("repo", ".lnk"), nil }, + hostStoragePathFunc: func() (string, error) { return s, nil }, + hostStorageRelPathFunc: func() (string, error) { return "storage", nil }, + addManagedItemFunc: func(path string) error { return nil }, } - return nil }, - createSymlinkFunc: func(target, link string) error { return nil }, - } - - trk := &fakeTracker{ - lnkFileNameFunc: func() (string, error) { return ".lnk", nil }, - hostStoragePathFunc: func() (string, error) { return storage, nil }, - hostStorageRelPathFunc: func() (string, error) { return "storage", nil }, - addManagedItemFunc: func(path string) error { return nil }, - } - - fm := filemanager.New("repo", "host", fs, trk) - _, err := fm.AddMultiple([]filemanager.FileToTrack{ - {AbsPath: "/file1", RelativePath: "file1"}, - {AbsPath: "/file2", RelativePath: "file2"}, - }) - if err == nil { - t.Fatal("expected error") - } - - // 1. move file1 forward - // 2. move file2 forward (fails) - // 3. rollback file1 (move back) - if len(fs.moveCalls) != 3 { - t.Fatalf("expected 3 move calls, got %d: %v", len(fs.moveCalls), fs.moveCalls) - } - if fs.moveCalls[2].src != filepath.Join(storage, "file1") { - t.Errorf("rollback src = %q, want %q", fs.moveCalls[2].src, filepath.Join(storage, "file1")) - } - if fs.moveCalls[2].dst != "/file1" { - t.Errorf("rollback dst = %q, want %q", fs.moveCalls[2].dst, "/file1") - } - }) - - t.Run("rollback on symlink failure", func(t *testing.T) { - tmp := t.TempDir() - storage := filepath.Join(tmp, "storage") - - fs := &fakeFileSystem{ - validateFileInfoFunc: func(path string) (os.FileInfo, error) { - return &fakeFileInfo{name: filepath.Base(path), mode: 0644}, nil + wantStages: 2, + wantRollbk: 1, + }, + { + name: "rollback_on_move_failure", + paths: []filemanager.FileToTrack{ + {AbsPath: "/file1", RelativePath: "file1"}, + {AbsPath: "/file2", RelativePath: "file2"}, }, - moveFunc: func(src, dst string, info os.FileInfo) error { return nil }, - createSymlinkFunc: func(target, link string) error { return errors.New("permission denied") }, - } - - trk := &fakeTracker{ - lnkFileNameFunc: func() (string, error) { return ".lnk", nil }, - hostStoragePathFunc: func() (string, error) { return storage, nil }, - hostStorageRelPathFunc: func() (string, error) { return "storage", nil }, - addManagedItemFunc: func(path string) error { return nil }, - } - - fm := filemanager.New("repo", "host", fs, trk) - _, err := fm.AddMultiple([]filemanager.FileToTrack{ - {AbsPath: "/foo", RelativePath: "foo"}, - }) - if err == nil { - t.Fatal("expected error") - } - if !strings.Contains(err.Error(), "symlink") { - t.Errorf("unexpected error: %v", err) - } - // forward move + rollback move - if len(fs.moveCalls) != 2 { - t.Errorf("expected 2 move calls, got %d", len(fs.moveCalls)) - } - }) - - t.Run("rollback on tracker failure", func(t *testing.T) { - tmp := t.TempDir() - storage := filepath.Join(tmp, "storage") - - fs := &fakeFileSystem{ - validateFileInfoFunc: func(path string) (os.FileInfo, error) { - return &fakeFileInfo{name: filepath.Base(path), mode: 0644}, nil + fsSetup: func(t *testing.T, s string) *fakeFileSystem { + return &fakeFileSystem{ + validateFileInfoFunc: func(path string) (os.FileInfo, error) { + return &fakeFileInfo{name: filepath.Base(path), mode: 0644}, nil + }, + moveFunc: func(src, dst string, info os.FileInfo) error { + if strings.Contains(dst, "file2") { + return errors.New("disk full") + } + return nil + }, + createSymlinkFunc: func(target, link string) error { return nil }, + } }, - moveFunc: func(src, dst string, info os.FileInfo) error { return nil }, - createSymlinkFunc: func(target, link string) error { return nil }, - } - - trk := &fakeTracker{ - lnkFileNameFunc: func() (string, error) { return ".lnk", nil }, - hostStoragePathFunc: func() (string, error) { return storage, nil }, - hostStorageRelPathFunc: func() (string, error) { return "storage", nil }, - addManagedItemFunc: func(path string) error { - return errors.New("tracker locked") + trkSetup: func(t *testing.T, s string) *fakeTracker { + return &fakeTracker{ + lnkFileNameFunc: func() (string, error) { return ".lnk", nil }, + hostStoragePathFunc: func() (string, error) { return s, nil }, + hostStorageRelPathFunc: func() (string, error) { return "storage", nil }, + addManagedItemFunc: func(path string) error { return nil }, + } }, - } + wantErr: true, + wantMoves: 3, // 2 forward + 1 rollback + }, + { + name: "rollback_on_symlink_failure", + paths: []filemanager.FileToTrack{ + {AbsPath: "/foo", RelativePath: "foo"}, + }, + fsSetup: func(t *testing.T, s string) *fakeFileSystem { + return &fakeFileSystem{ + validateFileInfoFunc: func(path string) (os.FileInfo, error) { + return &fakeFileInfo{name: filepath.Base(path), mode: 0644}, nil + }, + moveFunc: func(src, dst string, info os.FileInfo) error { return nil }, + createSymlinkFunc: func(target, link string) error { return errors.New("permission denied") }, + } + }, + trkSetup: func(t *testing.T, s string) *fakeTracker { + return &fakeTracker{ + lnkFileNameFunc: func() (string, error) { return ".lnk", nil }, + hostStoragePathFunc: func() (string, error) { return s, nil }, + hostStorageRelPathFunc: func() (string, error) { return "storage", nil }, + addManagedItemFunc: func(path string) error { return nil }, + } + }, + wantErr: true, + errMsg: "symlink", + wantMoves: 2, // forward + rollback + }, + { + name: "rollback_on_tracker_failure", + paths: []filemanager.FileToTrack{ + {AbsPath: "/foo", RelativePath: "foo"}, + }, + fsSetup: func(t *testing.T, s string) *fakeFileSystem { + return &fakeFileSystem{ + validateFileInfoFunc: func(path string) (os.FileInfo, error) { + return &fakeFileInfo{name: filepath.Base(path), mode: 0644}, nil + }, + moveFunc: func(src, dst string, info os.FileInfo) error { return nil }, + createSymlinkFunc: func(target, link string) error { return nil }, + } + }, + trkSetup: func(t *testing.T, s string) *fakeTracker { + return &fakeTracker{ + lnkFileNameFunc: func() (string, error) { return ".lnk", nil }, + hostStoragePathFunc: func() (string, error) { return s, nil }, + hostStorageRelPathFunc: func() (string, error) { return "storage", nil }, + addManagedItemFunc: func(path string) error { + return errors.New("tracker locked") + }, + } + }, + wantErr: true, + errMsg: "tracking file", + wantMoves: 2, + }, + } - fm := filemanager.New("repo", "host", fs, trk) - _, err := fm.AddMultiple([]filemanager.FileToTrack{ - {AbsPath: "/foo", RelativePath: "foo"}, + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + tmp := t.TempDir() + storage := filepath.Join(tmp, "storage") + fsys := tt.fsSetup(t, storage) + trk := tt.trkSetup(t, storage) + fm := filemanager.New("repo", "host", fsys, trk) + result, err := fm.AddMultiple(tt.paths) + if tt.wantErr { + if err == nil { + t.Fatal("expected error") + } + if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) { + t.Errorf("error = %v, want containing %q", err, tt.errMsg) + } + if tt.wantMoves > 0 && len(fsys.moveCalls) != tt.wantMoves { + t.Errorf("expected %d move calls, got %d: %v", tt.wantMoves, len(fsys.moveCalls), fsys.moveCalls) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.StagePaths) != tt.wantStages { + t.Errorf("expected %d stage paths, got %d: %v", tt.wantStages, len(result.StagePaths), result.StagePaths) + } + if len(result.Rollback) != tt.wantRollbk { + t.Errorf("expected %d rollback actions, got %d", tt.wantRollbk, len(result.Rollback)) + } }) - if err == nil { - t.Fatal("expected error") - } - if !strings.Contains(err.Error(), "tracking file") { - t.Errorf("unexpected error: %v", err) - } - // forward move + rollback move - if len(fs.moveCalls) != 2 { - t.Errorf("expected 2 move calls, got %d", len(fs.moveCalls)) - } - }) + } } func TestManager_RollbackAll(t *testing.T) { + t.Parallel() + callOrder := []int{} actions := []func() error{ func() error { callOrder = append(callOrder, 1); return nil }, @@ -342,9 +330,10 @@ func TestManager_RollbackAll(t *testing.T) { } func TestManager_Remove(t *testing.T) { - // ... other subtests stay the same ... + t.Parallel() - t.Run("success removes symlink and returns restore function", func(t *testing.T) { + t.Run("success_removes_symlink_and_returns_restore_function", func(t *testing.T) { + t.Parallel() tmp := t.TempDir() repoPath := filepath.Join(tmp, "repo") os.MkdirAll(repoPath, 0755) @@ -355,9 +344,8 @@ func TestManager_Remove(t *testing.T) { link := filepath.Join(tmp, "link") os.Symlink(target, link) - fs := &fakeFileSystem{ + fsys := &fakeFileSystem{ validateSymlinkFunc: func(absPath, repoPath string) error { return nil }, - // Make the fake actually move files so we can verify end-to-end state moveFunc: func(src, dst string, info os.FileInfo) error { return os.Rename(src, dst) }, @@ -368,7 +356,7 @@ func TestManager_Remove(t *testing.T) { removeManagedItemFunc: func(path string) error { return nil }, } - fm := filemanager.New(repoPath, "host", fs, trk) + fm := filemanager.New(repoPath, "host", fsys, trk) result, err := fm.Remove(filemanager.FileToTrack{AbsPath: link, RelativePath: "link"}) if err != nil { t.Fatalf("unexpected error: %v", err) diff --git a/internal/fs/filesystem_test.go b/internal/fs/filesystem_test.go index 45dc54a..2510591 100644 --- a/internal/fs/filesystem_test.go +++ b/internal/fs/filesystem_test.go @@ -12,142 +12,178 @@ import ( ) func TestFileSystem_ValidateFileInfoForAdd(t *testing.T) { - fsys := fs.New() - - t.Run("file not found", func(t *testing.T) { - _, err := fsys.ValidateFileInfoForAdd("/nonexistent/path") - if err == nil { - t.Fatal("expected error") - } - if !errors.Is(err, fs.ErrFileNotExists) { - t.Errorf("expected ErrFileNotExists, got %v", err) - } - }) - - t.Run("regular file", func(t *testing.T) { - tmp := t.TempDir() - path := filepath.Join(tmp, "file.txt") - os.WriteFile(path, []byte("hello"), 0644) + t.Parallel() - info, err := fsys.ValidateFileInfoForAdd(path) - if err != nil { - t.Fatal(err) - } - if info.Name() != "file.txt" { - t.Errorf("unexpected name: %s", info.Name()) - } - }) - - t.Run("directory", func(t *testing.T) { - tmp := t.TempDir() - - info, err := fsys.ValidateFileInfoForAdd(tmp) - if err != nil { - t.Fatal(err) - } - if !info.IsDir() { - t.Error("expected directory") - } - }) - - t.Run("symlink rejected", func(t *testing.T) { - tmp := t.TempDir() - target := filepath.Join(tmp, "target") - link := filepath.Join(tmp, "link") - os.WriteFile(target, []byte("x"), 0644) - os.Symlink(target, link) + fsys := fs.New() - _, err := fsys.ValidateFileInfoForAdd(link) - if err == nil { - t.Fatal("expected error for symlink") - } - if !errors.Is(err, fs.ErrUnsupportedType) { - t.Errorf("expected ErrUnsupportedType, got %v", err) - } - }) + tests := []struct { + name string + setup func(t *testing.T) string + wantErr error + isDir bool + }{ + { + name: "file_not_found", + setup: func(t *testing.T) string { + return "/nonexistent/path" + }, + wantErr: fs.ErrFileNotExists, + }, + { + name: "regular_file", + setup: func(t *testing.T) string { + tmp := t.TempDir() + path := filepath.Join(tmp, "file.txt") + os.WriteFile(path, []byte("hello"), 0644) + return path + }, + wantErr: nil, + }, + { + name: "directory", + setup: func(t *testing.T) string { + return t.TempDir() + }, + wantErr: nil, + isDir: true, + }, + { + name: "symlink_rejected", + setup: func(t *testing.T) string { + tmp := t.TempDir() + target := filepath.Join(tmp, "target") + link := filepath.Join(tmp, "link") + os.WriteFile(target, []byte("x"), 0644) + os.Symlink(target, link) + return link + }, + wantErr: fs.ErrUnsupportedType, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + path := tt.setup(t) + info, err := fsys.ValidateFileInfoForAdd(path) + if tt.wantErr != nil { + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, tt.wantErr) { + t.Errorf("expected %v, got %v", tt.wantErr, err) + } + return + } + if err != nil { + t.Fatal(err) + } + if tt.isDir && !info.IsDir() { + t.Error("expected directory") + } + }) + } } func TestFileSystem_ValidateSymlinkForRemove(t *testing.T) { - fsys := fs.New() - - t.Run("not a symlink", func(t *testing.T) { - tmp := t.TempDir() - path := filepath.Join(tmp, "file.txt") - os.WriteFile(path, []byte("x"), 0644) - - err := fsys.ValidateSymlinkForRemove(path, tmp) - if !errors.Is(err, lnkerror.ErrNotManaged) { - t.Fatalf("expected ErrNotManaged, got %v", err) - } - }) - - t.Run("nonexistent path", func(t *testing.T) { - err := fsys.ValidateSymlinkForRemove("/nonexistent", "/repo") - if err == nil { - t.Fatal("expected error") - } - if !errors.Is(err, fs.ErrFileNotExists) { - t.Errorf("expected ErrFileNotExists, got %v", err) - } - }) - - t.Run("symlink outside repo", func(t *testing.T) { - tmp := t.TempDir() - repo := filepath.Join(tmp, "repo") - os.MkdirAll(repo, 0755) - - outside := filepath.Join(tmp, "outside") - link := filepath.Join(tmp, "link") - os.WriteFile(outside, []byte("x"), 0644) - os.Symlink(outside, link) - - err := fsys.ValidateSymlinkForRemove(link, repo) - if !errors.Is(err, lnkerror.ErrNotManaged) { - t.Fatalf("expected ErrNotManaged, got %v", err) - } - }) - - t.Run("symlink inside repo", func(t *testing.T) { - tmp := t.TempDir() - repo := filepath.Join(tmp, "repo") - storage := filepath.Join(repo, "storage") - os.MkdirAll(storage, 0755) - - target := filepath.Join(storage, "file.txt") - link := filepath.Join(tmp, "link") - os.WriteFile(target, []byte("x"), 0644) - os.Symlink(target, link) - - err := fsys.ValidateSymlinkForRemove(link, repo) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - }) - - t.Run("relative symlink inside repo", func(t *testing.T) { - tmp := t.TempDir() - repo := filepath.Join(tmp, "repo") - storage := filepath.Join(repo, "storage") - os.MkdirAll(storage, 0755) + t.Parallel() - target := filepath.Join(storage, "file.txt") - link := filepath.Join(repo, "link.txt") - os.WriteFile(target, []byte("x"), 0644) - // relative symlink: link.txt -> storage/file.txt - rel, _ := filepath.Rel(repo, target) - os.Symlink(rel, link) + fsys := fs.New() - err := fsys.ValidateSymlinkForRemove(link, repo) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - }) + tests := []struct { + name string + setup func(t *testing.T) (path, repo string) + wantErr error + }{ + { + name: "not_a_symlink", + setup: func(t *testing.T) (string, string) { + tmp := t.TempDir() + path := filepath.Join(tmp, "file.txt") + os.WriteFile(path, []byte("x"), 0644) + return path, tmp + }, + wantErr: lnkerror.ErrNotManaged, + }, + { + name: "nonexistent_path", + setup: func(t *testing.T) (string, string) { + return "/nonexistent", "/repo" + }, + wantErr: fs.ErrFileNotExists, + }, + { + name: "symlink_outside_repo", + setup: func(t *testing.T) (string, string) { + tmp := t.TempDir() + repo := filepath.Join(tmp, "repo") + os.MkdirAll(repo, 0755) + outside := filepath.Join(tmp, "outside") + link := filepath.Join(tmp, "link") + os.WriteFile(outside, []byte("x"), 0644) + os.Symlink(outside, link) + return link, repo + }, + wantErr: lnkerror.ErrNotManaged, + }, + { + name: "symlink_inside_repo", + setup: func(t *testing.T) (string, string) { + tmp := t.TempDir() + repo := filepath.Join(tmp, "repo") + storage := filepath.Join(repo, "storage") + os.MkdirAll(storage, 0755) + target := filepath.Join(storage, "file.txt") + link := filepath.Join(tmp, "link") + os.WriteFile(target, []byte("x"), 0644) + os.Symlink(target, link) + return link, repo + }, + wantErr: nil, + }, + { + name: "relative_symlink_inside_repo", + setup: func(t *testing.T) (string, string) { + tmp := t.TempDir() + repo := filepath.Join(tmp, "repo") + storage := filepath.Join(repo, "storage") + os.MkdirAll(storage, 0755) + target := filepath.Join(storage, "file.txt") + link := filepath.Join(repo, "link.txt") + os.WriteFile(target, []byte("x"), 0644) + rel, _ := filepath.Rel(repo, target) + os.Symlink(rel, link) + return link, repo + }, + wantErr: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + path, repo := tt.setup(t) + err := fsys.ValidateSymlinkForRemove(path, repo) + if tt.wantErr != nil { + if !errors.Is(err, tt.wantErr) { + t.Fatalf("expected %v, got %v", tt.wantErr, err) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } } func TestFileSystem_MoveFile(t *testing.T) { + t.Parallel() + fsys := fs.New() - t.Run("moves file and creates parent dirs", func(t *testing.T) { + t.Run("moves_file_and_creates_parent_dirs", func(t *testing.T) { + t.Parallel() tmp := t.TempDir() src := filepath.Join(tmp, "src.txt") dst := filepath.Join(tmp, "nested", "dst.txt") @@ -172,9 +208,12 @@ func TestFileSystem_MoveFile(t *testing.T) { } func TestFileSystem_MoveDirectory(t *testing.T) { + t.Parallel() + fsys := fs.New() - t.Run("moves directory with contents", func(t *testing.T) { + t.Run("moves_directory_with_contents", func(t *testing.T) { + t.Parallel() tmp := t.TempDir() src := filepath.Join(tmp, "src") dst := filepath.Join(tmp, "moved", "dst") @@ -196,45 +235,60 @@ func TestFileSystem_MoveDirectory(t *testing.T) { } func TestFileSystem_Move(t *testing.T) { - fsys := fs.New() - - t.Run("delegates to MoveFile for files", func(t *testing.T) { - tmp := t.TempDir() - src := filepath.Join(tmp, "file.txt") - dst := filepath.Join(tmp, "moved.txt") - os.WriteFile(src, []byte("x"), 0644) + t.Parallel() - info, _ := os.Stat(src) - err := fsys.Move(src, dst, info) - if err != nil { - t.Fatal(err) - } - if _, err := os.Stat(dst); err != nil { - t.Fatal(err) - } - }) - - t.Run("delegates to MoveDirectory for dirs", func(t *testing.T) { - tmp := t.TempDir() - src := filepath.Join(tmp, "dir") - dst := filepath.Join(tmp, "moved", "dir") - os.MkdirAll(src, 0755) + fsys := fs.New() - info, _ := os.Stat(src) - err := fsys.Move(src, dst, info) - if err != nil { - t.Fatal(err) - } - if _, err := os.Stat(dst); err != nil { - t.Fatal(err) - } - }) + tests := []struct { + name string + setup func(t *testing.T) (src, dst string, info os.FileInfo) + }{ + { + name: "delegates_to_MoveFile_for_files", + setup: func(t *testing.T) (string, string, os.FileInfo) { + tmp := t.TempDir() + src := filepath.Join(tmp, "file.txt") + dst := filepath.Join(tmp, "moved.txt") + os.WriteFile(src, []byte("x"), 0644) + info, _ := os.Stat(src) + return src, dst, info + }, + }, + { + name: "delegates_to_MoveDirectory_for_dirs", + setup: func(t *testing.T) (string, string, os.FileInfo) { + tmp := t.TempDir() + src := filepath.Join(tmp, "dir") + dst := filepath.Join(tmp, "moved", "dir") + os.MkdirAll(src, 0755) + info, _ := os.Stat(src) + return src, dst, info + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + src, dst, info := tt.setup(t) + err := fsys.Move(src, dst, info) + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(dst); err != nil { + t.Fatal(err) + } + }) + } } func TestFileSystem_CreateSymlink(t *testing.T) { + t.Parallel() + fsys := fs.New() - t.Run("creates relative symlink", func(t *testing.T) { + t.Run("creates_relative_symlink", func(t *testing.T) { + t.Parallel() tmp := t.TempDir() target := filepath.Join(tmp, "storage", "file.txt") link := filepath.Join(tmp, "link.txt") @@ -262,67 +316,67 @@ func TestFileSystem_CreateSymlink(t *testing.T) { } func TestRemoveEmptyDirs(t *testing.T) { - t.Run("removes nested empty directories", func(t *testing.T) { - tmp := t.TempDir() - empty1 := filepath.Join(tmp, "a", "b", "c") - empty2 := filepath.Join(tmp, "a", "b") - keep := filepath.Join(tmp, "a", "keep") - os.MkdirAll(empty1, 0755) - os.MkdirAll(keep, 0755) - os.WriteFile(filepath.Join(keep, "file.txt"), []byte("x"), 0644) - - err := fs.RemoveEmptyDirs(tmp) - if err != nil { - t.Fatal(err) - } - - if _, err := os.Stat(empty1); !os.IsNotExist(err) { - t.Error("expected empty1 to be removed") - } - if _, err := os.Stat(empty2); !os.IsNotExist(err) { - t.Error("expected empty2 to be removed") - } - if _, err := os.Stat(keep); err != nil { - t.Errorf("expected keep to exist: %v", err) - } - }) - - t.Run("keeps non-empty root", func(t *testing.T) { - tmp := t.TempDir() - os.WriteFile(filepath.Join(tmp, "file.txt"), []byte("x"), 0644) - - err := fs.RemoveEmptyDirs(tmp) - if err != nil { - t.Fatal(err) - } - if _, err := os.Stat(tmp); err != nil { - t.Errorf("expected root to exist: %v", err) - } - }) - - t.Run("removes sibling empty dirs", func(t *testing.T) { - tmp := t.TempDir() - emptyA := filepath.Join(tmp, "emptyA") - emptyB := filepath.Join(tmp, "emptyB") - full := filepath.Join(tmp, "full") - os.MkdirAll(emptyA, 0755) - os.MkdirAll(emptyB, 0755) - os.MkdirAll(full, 0755) - os.WriteFile(filepath.Join(full, "f.txt"), []byte("x"), 0644) - - err := fs.RemoveEmptyDirs(tmp) - if err != nil { - t.Fatal(err) - } - - if _, err := os.Stat(emptyA); !os.IsNotExist(err) { - t.Error("expected emptyA to be removed") - } - if _, err := os.Stat(emptyB); !os.IsNotExist(err) { - t.Error("expected emptyB to be removed") - } - if _, err := os.Stat(full); err != nil { - t.Errorf("expected full to exist: %v", err) - } - }) + t.Parallel() + + tests := []struct { + name string + setup func(t *testing.T) (root string, removed []string, kept []string) + }{ + { + name: "removes_nested_empty_directories", + setup: func(t *testing.T) (string, []string, []string) { + tmp := t.TempDir() + empty1 := filepath.Join(tmp, "a", "b", "c") + empty2 := filepath.Join(tmp, "a", "b") + keep := filepath.Join(tmp, "a", "keep") + os.MkdirAll(empty1, 0755) + os.MkdirAll(keep, 0755) + os.WriteFile(filepath.Join(keep, "file.txt"), []byte("x"), 0644) + return tmp, []string{empty1, empty2}, []string{keep} + }, + }, + { + name: "keeps_non_empty_root", + setup: func(t *testing.T) (string, []string, []string) { + tmp := t.TempDir() + os.WriteFile(filepath.Join(tmp, "file.txt"), []byte("x"), 0644) + return tmp, nil, []string{tmp} + }, + }, + { + name: "removes_sibling_empty_dirs", + setup: func(t *testing.T) (string, []string, []string) { + tmp := t.TempDir() + emptyA := filepath.Join(tmp, "emptyA") + emptyB := filepath.Join(tmp, "emptyB") + full := filepath.Join(tmp, "full") + os.MkdirAll(emptyA, 0755) + os.MkdirAll(emptyB, 0755) + os.MkdirAll(full, 0755) + os.WriteFile(filepath.Join(full, "f.txt"), []byte("x"), 0644) + return tmp, []string{emptyA, emptyB}, []string{full} + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + root, removed, kept := tt.setup(t) + err := fs.RemoveEmptyDirs(root) + if err != nil { + t.Fatal(err) + } + for _, p := range removed { + if _, err := os.Stat(p); !os.IsNotExist(err) { + t.Errorf("expected %q to be removed", p) + } + } + for _, p := range kept { + if _, err := os.Stat(p); err != nil { + t.Errorf("expected %q to exist: %v", p, err) + } + } + }) + } } diff --git a/internal/git/git_test.go b/internal/git/git_test.go index 403a192..4461db1 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -31,7 +31,6 @@ func configureGit(t *testing.T, g *git.Git) { } } -// newBareRemote creates a bare git repo to act as a remote. func newBareRemote(t *testing.T) string { t.Helper() remote := t.TempDir() @@ -41,7 +40,6 @@ func newBareRemote(t *testing.T) string { return remote } -// pushToRemote sets up a source repo, commits, and pushes to remote. func pushToRemote(t *testing.T, remote string) string { t.Helper() src := t.TempDir() @@ -74,7 +72,10 @@ func pushToRemote(t *testing.T, remote string) string { // ---------- tests ---------- func TestGit_Init(t *testing.T) { - t.Run("initializes git repository", func(t *testing.T) { + t.Parallel() + + t.Run("initializes_git_repository", func(t *testing.T) { + t.Parallel() tmp := t.TempDir() g := git.New(tmp) @@ -93,7 +94,10 @@ func TestGit_Init(t *testing.T) { } func TestGit_EnsureGitConfigOnce(t *testing.T) { - t.Run("configures git user", func(t *testing.T) { + t.Parallel() + + t.Run("configures_git_user", func(t *testing.T) { + t.Parallel() tmp := t.TempDir() g := initRepo(t, tmp) configured := false @@ -116,38 +120,64 @@ func TestGit_EnsureGitConfigOnce(t *testing.T) { } func TestGit_Commit(t *testing.T) { - t.Run("commits staged changes", func(t *testing.T) { - tmp := t.TempDir() - g := initRepo(t, tmp) - configureGit(t, g) - - os.WriteFile(filepath.Join(tmp, "file.txt"), []byte("hello"), 0644) - if err := g.AddAll(); err != nil { - t.Fatal(err) - } - - if err := g.Commit("initial"); err != nil { - t.Fatal(err) - } - }) - - t.Run("fails without staged changes", func(t *testing.T) { - tmp := t.TempDir() - g := initRepo(t, tmp) - configureGit(t, g) + t.Parallel() + + tests := []struct { + name string + setup func(t *testing.T, tmp string, g *git.Git) + wantErr bool + errMsg string + }{ + { + name: "commits_staged_changes", + setup: func(t *testing.T, tmp string, g *git.Git) { + configureGit(t, g) + os.WriteFile(filepath.Join(tmp, "file.txt"), []byte("hello"), 0644) + if err := g.AddAll(); err != nil { + t.Fatal(err) + } + }, + wantErr: false, + }, + { + name: "fails_without_staged_changes", + setup: func(t *testing.T, tmp string, g *git.Git) { + configureGit(t, g) + }, + wantErr: true, + errMsg: "git operation failed", + }, + } - err := g.Commit("empty") - if err == nil { - t.Fatal("expected error") - } - if !strings.Contains(err.Error(), "git operation failed") { - t.Errorf("unexpected error: %v", err) - } - }) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + tmp := t.TempDir() + g := initRepo(t, tmp) + tt.setup(t, tmp, g) + + err := g.Commit("test commit") + if tt.wantErr { + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), tt.errMsg) { + t.Errorf("unexpected error: %v", err) + } + return + } + if err != nil { + t.Fatal(err) + } + }) + } } func TestGit_HasChanges(t *testing.T) { - t.Run("detects dirty and clean states", func(t *testing.T) { + t.Parallel() + + t.Run("detects_dirty_and_clean_states", func(t *testing.T) { + t.Parallel() tmp := t.TempDir() g := initRepo(t, tmp) @@ -172,7 +202,10 @@ func TestGit_HasChanges(t *testing.T) { } func TestGit_Diff(t *testing.T) { - t.Run("returns diff for uncommitted changes", func(t *testing.T) { + t.Parallel() + + t.Run("returns_diff_for_uncommitted_changes", func(t *testing.T) { + t.Parallel() tmp := t.TempDir() g := initRepo(t, tmp) configureGit(t, g) @@ -200,7 +233,10 @@ func TestGit_Diff(t *testing.T) { } func TestGit_AddAll(t *testing.T) { - t.Run("stages new files", func(t *testing.T) { + t.Parallel() + + t.Run("stages_new_files", func(t *testing.T) { + t.Parallel() tmp := t.TempDir() g := initRepo(t, tmp) @@ -222,272 +258,400 @@ func TestGit_AddAll(t *testing.T) { } func TestGit_GetStatus(t *testing.T) { - t.Run("local-only status without remote", func(t *testing.T) { - tmp := t.TempDir() - g := initRepo(t, tmp) - configureGit(t, g) - - // Create a commit so we have local history - os.WriteFile(filepath.Join(tmp, "file.txt"), []byte("hello"), 0644) - if err := g.AddAll(); err != nil { - t.Fatal(err) - } - if err := g.Commit("initial"); err != nil { - t.Fatal(err) - } - - status, err := g.GetStatus() - if err != nil { - t.Fatal(err) - } - if status.Ahead != 1 { - t.Errorf("expected Ahead=1, got %d", status.Ahead) - } - if status.Behind != 0 { - t.Errorf("expected Behind=0, got %d", status.Behind) - } - if status.Remote != "" { - t.Errorf("expected no remote, got %q", status.Remote) - } - if status.Dirty { - t.Error("expected clean working tree") - } - }) - - t.Run("dirty working tree", func(t *testing.T) { - tmp := t.TempDir() - g := initRepo(t, tmp) - - os.WriteFile(filepath.Join(tmp, "file.txt"), []byte("hello"), 0644) - - status, err := g.GetStatus() - if err != nil { - t.Fatal(err) - } - if !status.Dirty { - t.Error("expected dirty working tree") - } - }) - - t.Run("status with remote", func(t *testing.T) { - remote := newBareRemote(t) - _ = pushToRemote(t, remote) - - // Clone and check status - dst := filepath.Join(t.TempDir(), "clone") - g := git.New(dst) - if err := g.Clone(remote); err != nil { - t.Fatalf("Clone: %v", err) - } + t.Parallel() + + tests := []struct { + name string + setup func(t *testing.T) (g *git.Git, cleanup func()) + wantAhead int + wantBehind int + wantRemote string + wantDirty bool + }{ + { + name: "local_only_status_without_remote", + setup: func(t *testing.T) (*git.Git, func()) { + tmp := t.TempDir() + g := initRepo(t, tmp) + configureGit(t, g) + os.WriteFile(filepath.Join(tmp, "file.txt"), []byte("hello"), 0644) + if err := g.AddAll(); err != nil { + t.Fatal(err) + } + if err := g.Commit("initial"); err != nil { + t.Fatal(err) + } + return g, func() {} + }, + wantAhead: 1, + wantBehind: 0, + wantRemote: "", + wantDirty: false, + }, + { + name: "dirty_working_tree", + setup: func(t *testing.T) (*git.Git, func()) { + tmp := t.TempDir() + g := initRepo(t, tmp) + os.WriteFile(filepath.Join(tmp, "file.txt"), []byte("hello"), 0644) + return g, func() {} + }, + wantAhead: 0, + wantBehind: 0, + wantRemote: "", + wantDirty: true, + }, + { + name: "status_with_remote", + setup: func(t *testing.T) (*git.Git, func()) { + remote := newBareRemote(t) + _ = pushToRemote(t, remote) + + dst := filepath.Join(t.TempDir(), "clone") + g := git.New(dst) + if err := g.Clone(remote); err != nil { + t.Fatalf("Clone: %v", err) + } + return g, func() {} + }, + wantAhead: 0, + wantBehind: 0, + wantRemote: "origin/main", + wantDirty: false, + }, + } - status, err := g.GetStatus() - if err != nil { - t.Fatal(err) - } - if status.Ahead != 0 { - t.Errorf("expected Ahead=0 after clone, got %d", status.Ahead) - } - if status.Behind != 0 { - t.Errorf("expected Behind=0 after clone, got %d", status.Behind) - } - if status.Dirty { - t.Error("expected clean working tree after clone") - } - }) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + g, cleanup := tt.setup(t) + defer cleanup() + + status, err := g.GetStatus() + if err != nil { + t.Fatal(err) + } + if status.Ahead != tt.wantAhead { + t.Errorf("expected Ahead=%d, got %d", tt.wantAhead, status.Ahead) + } + if status.Behind != tt.wantBehind { + t.Errorf("expected Behind=%d, got %d", tt.wantBehind, status.Behind) + } + if status.Remote != tt.wantRemote { + t.Errorf("expected Remote=%q, got %q", tt.wantRemote, status.Remote) + } + if status.Dirty != tt.wantDirty { + t.Errorf("expected Dirty=%v, got %v", tt.wantDirty, status.Dirty) + } + }) + } } func TestGit_Push(t *testing.T) { - t.Run("fails without remote", func(t *testing.T) { - tmp := t.TempDir() - g := initRepo(t, tmp) - - err := g.Push() - if err == nil { - t.Fatal("expected error") - } - if !errors.Is(err, git.ErrPush) { - t.Errorf("expected ErrPush, got %v", err) - } - }) - - t.Run("pushes to remote", func(t *testing.T) { - remote := newBareRemote(t) - src := pushToRemote(t, remote) - - // Add another commit and push - g := git.New(src) - configureGit(t, g) - os.WriteFile(filepath.Join(src, "file2.txt"), []byte("world"), 0644) - if err := g.AddAll(); err != nil { - t.Fatal(err) - } - if err := g.Commit("second"); err != nil { - t.Fatal(err) - } + t.Parallel() + + tests := []struct { + name string + setup func(t *testing.T) (g *git.Git, remote string) + wantErr error + }{ + { + name: "fails_without_remote", + setup: func(t *testing.T) (*git.Git, string) { + tmp := t.TempDir() + return initRepo(t, tmp), "" + }, + wantErr: git.ErrPush, + }, + { + name: "pushes_to_remote", + setup: func(t *testing.T) (*git.Git, string) { + remote := newBareRemote(t) + src := pushToRemote(t, remote) + + // Add another commit and push + g := git.New(src) + configureGit(t, g) + os.WriteFile(filepath.Join(src, "file2.txt"), []byte("world"), 0644) + if err := g.AddAll(); err != nil { + t.Fatal(err) + } + if err := g.Commit("second"); err != nil { + t.Fatal(err) + } + return g, remote + }, + wantErr: nil, + }, + } - if err := g.Push(); err != nil { - t.Fatalf("Push: %v", err) - } - }) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + g, _ := tt.setup(t) + err := g.Push() + if tt.wantErr != nil { + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, tt.wantErr) { + t.Errorf("expected %v, got %v", tt.wantErr, err) + } + return + } + if err != nil { + t.Fatalf("Push: %v", err) + } + }) + } } func TestGit_Pull(t *testing.T) { - t.Run("fails without remote", func(t *testing.T) { - tmp := t.TempDir() - g := initRepo(t, tmp) - - err := g.Pull() - if err == nil { - t.Fatal("expected error") - } - if !errors.Is(err, git.ErrPull) { - t.Errorf("expected ErrPull, got %v", err) - } - }) - - t.Run("pulls from remote", func(t *testing.T) { - remote := newBareRemote(t) - src := pushToRemote(t, remote) - - // Clone into dst - dst := filepath.Join(t.TempDir(), "clone") - g := git.New(dst) - if err := g.Clone(remote); err != nil { - t.Fatalf("Clone: %v", err) - } - - // Add commit to source and push - gSrc := git.New(src) - configureGit(t, gSrc) - os.WriteFile(filepath.Join(src, "pulled.txt"), []byte("new"), 0644) - if err := gSrc.AddAll(); err != nil { - t.Fatal(err) - } - if err := gSrc.Commit("add pulled"); err != nil { - t.Fatal(err) - } - if err := gSrc.Push(); err != nil { - t.Fatal(err) - } - - // Pull in clone - if err := g.Pull(); err != nil { - t.Fatalf("Pull: %v", err) - } + t.Parallel() + + tests := []struct { + name string + setup func(t *testing.T) (g *git.Git, remote string) + wantErr error + }{ + { + name: "fails_without_remote", + setup: func(t *testing.T) (*git.Git, string) { + tmp := t.TempDir() + return initRepo(t, tmp), "" + }, + wantErr: git.ErrPull, + }, + { + name: "pulls_from_remote", + setup: func(t *testing.T) (*git.Git, string) { + remote := newBareRemote(t) + src := pushToRemote(t, remote) + + // Clone into dst + dst := filepath.Join(t.TempDir(), "clone") + g := git.New(dst) + if err := g.Clone(remote); err != nil { + t.Fatalf("Clone: %v", err) + } + + // Add commit to source and push + gSrc := git.New(src) + configureGit(t, gSrc) + os.WriteFile(filepath.Join(src, "pulled.txt"), []byte("new"), 0644) + if err := gSrc.AddAll(); err != nil { + t.Fatal(err) + } + if err := gSrc.Commit("add pulled"); err != nil { + t.Fatal(err) + } + if err := gSrc.Push(); err != nil { + t.Fatal(err) + } + + return g, remote + }, + wantErr: nil, + }, + } - if _, err := os.Stat(filepath.Join(dst, "pulled.txt")); err != nil { - t.Errorf("expected pulled file to exist: %v", err) - } - }) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + g, _ := tt.setup(t) + err := g.Pull() + if tt.wantErr != nil { + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, tt.wantErr) { + t.Errorf("expected %v, got %v", tt.wantErr, err) + } + return + } + if err != nil { + t.Fatalf("Pull: %v", err) + } + }) + } } func TestGit_Clone(t *testing.T) { - t.Run("clones from bare remote", func(t *testing.T) { - remote := newBareRemote(t) - pushToRemote(t, remote) - - dst := filepath.Join(t.TempDir(), "clone") - g := git.New(dst) - - if err := g.Clone(remote); err != nil { - t.Fatalf("Clone: %v", err) - } - - if !g.IsGitRepository() { - t.Error("expected cloned directory to be a git repo") - } - if _, err := os.Stat(filepath.Join(dst, "file.txt")); err != nil { - t.Errorf("expected file.txt in clone: %v", err) - } - }) - - t.Run("overwrites existing directory", func(t *testing.T) { - remote := newBareRemote(t) - pushToRemote(t, remote) - - dst := filepath.Join(t.TempDir(), "clone") - os.MkdirAll(dst, 0755) - os.WriteFile(filepath.Join(dst, "old.txt"), []byte("old"), 0644) - - g := git.New(dst) - if err := g.Clone(remote); err != nil { - t.Fatalf("Clone: %v", err) - } + t.Parallel() + + tests := []struct { + name string + setup func(t *testing.T) (remote, dst string) + }{ + { + name: "clones_from_bare_remote", + setup: func(t *testing.T) (string, string) { + remote := newBareRemote(t) + _ = pushToRemote(t, remote) + dst := filepath.Join(t.TempDir(), "clone") + return remote, dst + }, + }, + { + name: "overwrites_existing_directory", + setup: func(t *testing.T) (string, string) { + remote := newBareRemote(t) + _ = pushToRemote(t, remote) + dst := filepath.Join(t.TempDir(), "clone") + os.MkdirAll(dst, 0755) + os.WriteFile(filepath.Join(dst, "old.txt"), []byte("old"), 0644) + return remote, dst + }, + }, + } - if _, err := os.Stat(filepath.Join(dst, "old.txt")); !os.IsNotExist(err) { - t.Error("expected old file to be removed") - } - if _, err := os.Stat(filepath.Join(dst, "file.txt")); err != nil { - t.Errorf("expected file.txt in clone: %v", err) - } - }) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + remote, dst := tt.setup(t) + g := git.New(dst) + if err := g.Clone(remote); err != nil { + t.Fatalf("Clone: %v", err) + } + if !g.IsGitRepository() { + t.Error("expected cloned directory to be a git repo") + } + if _, err := os.Stat(filepath.Join(dst, "file.txt")); err != nil { + t.Errorf("expected file.txt in clone: %v", err) + } + if tt.name == "overwrites_existing_directory" { + if _, err := os.Stat(filepath.Join(dst, "old.txt")); !os.IsNotExist(err) { + t.Error("expected old file to be removed") + } + } + }) + } } func TestGit_Stage(t *testing.T) { - t.Run("stages existing file", func(t *testing.T) { - tmp := t.TempDir() - g := initRepo(t, tmp) - configureGit(t, g) - - os.WriteFile(filepath.Join(tmp, "file.txt"), []byte("hello"), 0644) + t.Parallel() + + tests := []struct { + name string + setup func(t *testing.T, tmp string, g *git.Git) + wantDirty bool + }{ + { + name: "stages_existing_file", + setup: func(t *testing.T, tmp string, g *git.Git) { + configureGit(t, g) + os.WriteFile(filepath.Join(tmp, "file.txt"), []byte("hello"), 0644) + if err := g.Stage("file.txt"); err != nil { + t.Fatalf("Stage: %v", err) + } + }, + wantDirty: true, + }, + { + name: "stages_deletion_of_removed_file", + setup: func(t *testing.T, tmp string, g *git.Git) { + configureGit(t, g) + os.WriteFile(filepath.Join(tmp, "file.txt"), []byte("hello"), 0644) + if err := g.AddAll(); err != nil { + t.Fatal(err) + } + if err := g.Commit("initial"); err != nil { + t.Fatal(err) + } + os.Remove(filepath.Join(tmp, "file.txt")) + if err := g.Stage("file.txt"); err != nil { + t.Fatalf("Stage: %v", err) + } + }, + wantDirty: true, + }, + { + name: "ignores_untracked_missing_file", + setup: func(t *testing.T, tmp string, g *git.Git) { + if err := g.Stage("nonexistent.txt"); err != nil { + t.Fatalf("Stage: %v", err) + } + }, + wantDirty: false, + }, + } - if err := g.Stage("file.txt"); err != nil { - t.Fatalf("Stage: %v", err) - } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + tmp := t.TempDir() + g := initRepo(t, tmp) + tt.setup(t, tmp, g) + + dirty, err := g.HasChanges() + if err != nil { + t.Fatal(err) + } + if dirty != tt.wantDirty { + t.Errorf("expected dirty=%v, got %v", tt.wantDirty, dirty) + } + }) + } +} - dirty, err := g.HasChanges() - if err != nil { - t.Fatal(err) - } - if !dirty { - t.Error("expected staged changes") - } - }) +func TestGit_Options(t *testing.T) { + t.Parallel() - t.Run("stages deletion of removed file", func(t *testing.T) { + t.Run("WithColor_adds_color_flag", func(t *testing.T) { + t.Parallel() tmp := t.TempDir() - g := initRepo(t, tmp) - configureGit(t, g) - - // Create, commit, then delete - os.WriteFile(filepath.Join(tmp, "file.txt"), []byte("hello"), 0644) - if err := g.AddAll(); err != nil { - t.Fatal(err) - } - if err := g.Commit("initial"); err != nil { - t.Fatal(err) - } - - os.Remove(filepath.Join(tmp, "file.txt")) - - if err := g.Stage("file.txt"); err != nil { - t.Fatalf("Stage: %v", err) - } - - dirty, err := g.HasChanges() - if err != nil { - t.Fatal(err) - } - if !dirty { - t.Error("expected deletion to be staged") - } + g := git.New(tmp, git.WithColor()) + _ = g.Init() }) - t.Run("ignores untracked missing file", func(t *testing.T) { + t.Run("WithLongTimeout_uses_long_timeout", func(t *testing.T) { + t.Parallel() tmp := t.TempDir() - g := initRepo(t, tmp) + g := git.New(tmp, git.WithLongTimeout()) + _ = g.Init() + }) +} - // File never existed and was never tracked - if err := g.Stage("nonexistent.txt"); err != nil { - t.Fatalf("Stage: %v", err) - } +func TestGit_HasStagedChanges(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setup func(t *testing.T, tmp string, g *git.Git) + wantStaged bool + }{ + { + name: "detects_staged_changes", + setup: func(t *testing.T, tmp string, g *git.Git) { + configureGit(t, g) + os.WriteFile(filepath.Join(tmp, "file.txt"), []byte("hello"), 0644) + _ = g.AddAll() + }, + wantStaged: true, + }, + { + name: "no_staged_changes", + setup: func(t *testing.T, tmp string, g *git.Git) { + configureGit(t, g) + }, + wantStaged: false, + }, + } - dirty, err := g.HasChanges() - if err != nil { - t.Fatal(err) - } - if dirty { - t.Error("expected no changes when staging untracked missing file") - } - }) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + tmp := t.TempDir() + g := initRepo(t, tmp) + tt.setup(t, tmp, g) + + staged, err := g.HasStagedChanges() + if err != nil { + t.Fatal(err) + } + if staged != tt.wantStaged { + t.Errorf("expected staged=%v, got %v", tt.wantStaged, staged) + } + }) + } } diff --git a/internal/lnkerror/error_test.go b/internal/lnkerror/error_test.go index 978c296..2cbb6d2 100644 --- a/internal/lnkerror/error_test.go +++ b/internal/lnkerror/error_test.go @@ -8,60 +8,76 @@ import ( ) func TestSentinels(t *testing.T) { - // Verify sentinels are non-nil and distinct - sentinels := []error{ - lnkerror.ErrManagedFilesExist, - lnkerror.ErrGitRepoExists, - lnkerror.ErrAlreadyManaged, - lnkerror.ErrNotManaged, - lnkerror.ErrNotInitialized, - lnkerror.ErrBootstrapNotFound, - lnkerror.ErrBootstrapFailed, - lnkerror.ErrBootstrapPerms, + t.Parallel() + + tests := []struct { + name string + err error + }{ + {"ErrManagedFilesExist", lnkerror.ErrManagedFilesExist}, + {"ErrGitRepoExists", lnkerror.ErrGitRepoExists}, + {"ErrAlreadyManaged", lnkerror.ErrAlreadyManaged}, + {"ErrNotManaged", lnkerror.ErrNotManaged}, + {"ErrNotInitialized", lnkerror.ErrNotInitialized}, + {"ErrBootstrapNotFound", lnkerror.ErrBootstrapNotFound}, + {"ErrBootstrapFailed", lnkerror.ErrBootstrapFailed}, + {"ErrBootstrapPerms", lnkerror.ErrBootstrapPerms}, } - for i, err := range sentinels { - if err == nil { - t.Fatalf("sentinel %d is nil", i) - } - for j := i + 1; j < len(sentinels); j++ { - if errors.Is(err, sentinels[j]) { - t.Fatalf("sentinel %d and %d are the same error", i, j) + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if tt.err == nil { + t.Fatalf("sentinel %s is nil", tt.name) + } + }) + } + + // Verify all sentinels are distinct. + for i := 0; i < len(tests); i++ { + for j := i + 1; j < len(tests); j++ { + if errors.Is(tests[i].err, tests[j].err) { + t.Fatalf("sentinel %s and %s are the same error", tests[i].name, tests[j].name) } } } } func TestError_Error(t *testing.T) { + t.Parallel() + tests := []struct { name string err *lnkerror.Error want string }{ { - name: "sentinel only", + name: "sentinel_only", err: lnkerror.Wrap(lnkerror.ErrNotInitialized), want: "lnk repository not initialized", }, { - name: "with path", + name: "with_path", err: lnkerror.WithPath(lnkerror.ErrNotManaged, "foo.txt"), want: "file is not managed by lnk: foo.txt", }, { - name: "with suggestion", + name: "with_suggestion", err: lnkerror.WithSuggestion(lnkerror.ErrBootstrapFailed, "check permissions"), want: "bootstrap script failed with error (check permissions)", }, { - name: "with path and suggestion", + name: "with_path_and_suggestion", err: lnkerror.WithPathAndSuggestion(lnkerror.ErrNotInitialized, "repo", "run 'lnk init'"), want: "lnk repository not initialized: repo (run 'lnk init')", }, } for _, tt := range tests { + tt := tt t.Run(tt.name, func(t *testing.T) { + t.Parallel() got := tt.err.Error() if got != tt.want { t.Errorf("Error() = %q, want %q", got, tt.want) @@ -71,6 +87,8 @@ func TestError_Error(t *testing.T) { } func TestError_Unwrap(t *testing.T) { + t.Parallel() + wrapped := lnkerror.WithPath(lnkerror.ErrNotManaged, "foo.txt") if !errors.Is(wrapped, lnkerror.ErrNotManaged) { diff --git a/internal/tracker/tracker_test.go b/internal/tracker/tracker_test.go index eb63e16..980e154 100644 --- a/internal/tracker/tracker_test.go +++ b/internal/tracker/tracker_test.go @@ -11,26 +11,29 @@ import ( ) func TestTracker_LnkFileName(t *testing.T) { + t.Parallel() + tests := []struct { - name string - host string - format tracker.RepoFormat - want string - err bool + name string + host string + format tracker.RepoFormat + want string + wantErr bool }{ - {"common v2", "common", tracker.FormatV2, ".lnk.common", false}, - {"common v1", "common", tracker.FormatV1, ".lnk", false}, - {"common unknown", "common", tracker.FormatUnknown, "", true}, - {"host v2", "myhost", tracker.FormatV2, ".lnk.myhost", false}, - {"host v1", "myhost", tracker.FormatV1, ".lnk.myhost", false}, - {"host unknown", "myhost", tracker.FormatUnknown, ".lnk.myhost", false}, + {"common_v2", "common", tracker.FormatV2, ".lnk.common", false}, + {"common_v1", "common", tracker.FormatV1, ".lnk", false}, + {"common_unknown", "common", tracker.FormatUnknown, "", true}, + {"host_v2", "myhost", tracker.FormatV2, ".lnk.myhost", false}, + {"host_v1", "myhost", tracker.FormatV1, ".lnk.myhost", false}, + {"host_unknown", "myhost", tracker.FormatUnknown, ".lnk.myhost", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() tr := tracker.New("repo", tt.host, tt.format) got, err := tr.LnkFileName() - if tt.err { + if tt.wantErr { if err == nil { t.Fatal("expected error") } @@ -47,28 +50,29 @@ func TestTracker_LnkFileName(t *testing.T) { } func TestTracker_HostStoragePath(t *testing.T) { - tmp := t.TempDir() + t.Parallel() tests := []struct { - name string - host string - format tracker.RepoFormat - want string - err bool + name string + host string + format tracker.RepoFormat + want string + wantErr bool }{ - {"common v2", "common", tracker.FormatV2, filepath.Join(tmp, "common.lnk"), false}, - {"common v1", "common", tracker.FormatV1, tmp, false}, - {"common unknown", "common", tracker.FormatUnknown, "", true}, - {"host v2", "myhost", tracker.FormatV2, filepath.Join(tmp, "myhost.lnk"), false}, - {"host v1", "myhost", tracker.FormatV1, filepath.Join(tmp, "myhost.lnk"), false}, - {"host unknown", "myhost", tracker.FormatUnknown, filepath.Join(tmp, "myhost.lnk"), false}, + {"common_v2", "common", tracker.FormatV2, filepath.Join("REPO", "common.lnk"), false}, + {"common_v1", "common", tracker.FormatV1, "REPO", false}, + {"common_unknown", "common", tracker.FormatUnknown, "", true}, + {"host_v2", "myhost", tracker.FormatV2, filepath.Join("REPO", "myhost.lnk"), false}, + {"host_v1", "myhost", tracker.FormatV1, filepath.Join("REPO", "myhost.lnk"), false}, + {"host_unknown", "myhost", tracker.FormatUnknown, filepath.Join("REPO", "myhost.lnk"), false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - tr := tracker.New(tmp, tt.host, tt.format) + t.Parallel() + tr := tracker.New("REPO", tt.host, tt.format) got, err := tr.HostStoragePath() - if tt.err { + if tt.wantErr { if err == nil { t.Fatal("expected error") } @@ -85,26 +89,29 @@ func TestTracker_HostStoragePath(t *testing.T) { } func TestTracker_HostStorageRelPath(t *testing.T) { + t.Parallel() + tests := []struct { - name string - host string - format tracker.RepoFormat - want string - err bool + name string + host string + format tracker.RepoFormat + want string + wantErr bool }{ - {"common v2", "common", tracker.FormatV2, "common.lnk", false}, - {"common v1", "common", tracker.FormatV1, ".", false}, - {"common unknown", "common", tracker.FormatUnknown, "", true}, - {"host v2", "myhost", tracker.FormatV2, "myhost.lnk", false}, - {"host v1", "myhost", tracker.FormatV1, "myhost.lnk", false}, - {"host unknown", "myhost", tracker.FormatUnknown, "myhost.lnk", false}, + {"common_v2", "common", tracker.FormatV2, "common.lnk", false}, + {"common_v1", "common", tracker.FormatV1, ".", false}, + {"common_unknown", "common", tracker.FormatUnknown, "", true}, + {"host_v2", "myhost", tracker.FormatV2, "myhost.lnk", false}, + {"host_v1", "myhost", tracker.FormatV1, "myhost.lnk", false}, + {"host_unknown", "myhost", tracker.FormatUnknown, "myhost.lnk", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() tr := tracker.New("repo", tt.host, tt.format) got, err := tr.HostStorageRelPath() - if tt.err { + if tt.wantErr { if err == nil { t.Fatal("expected error") } @@ -121,143 +128,137 @@ func TestTracker_HostStorageRelPath(t *testing.T) { } func TestTracker_GetManagedItems(t *testing.T) { - tmp := t.TempDir() - tr := tracker.New(tmp, "common", tracker.FormatV2) + t.Parallel() - t.Run("file does not exist", func(t *testing.T) { - items, err := tr.GetManagedItems() - if err != nil { - t.Fatal(err) - } - if len(items) != 0 { - t.Errorf("expected empty, got %v", items) - } - }) - - t.Run("empty file", func(t *testing.T) { - lnk := filepath.Join(tmp, ".lnk.common") - os.WriteFile(lnk, []byte(""), 0644) - - items, err := tr.GetManagedItems() - if err != nil { - t.Fatal(err) - } - if len(items) != 0 { - t.Errorf("expected empty, got %v", items) - } - }) + tests := []struct { + name string + content string + want []string + }{ + {"file_does_not_exist", "__REMOVE__", nil}, + {"empty_file", "", []string{}}, + {"parses_lines_and_trims_whitespace", "foo\n\n bar \nbaz\n", []string{"foo", "bar", "baz"}}, + } - t.Run("parses lines and trims whitespace", func(t *testing.T) { - lnk := filepath.Join(tmp, ".lnk.common") - content := "foo\n\n bar \nbaz\n" - os.WriteFile(lnk, []byte(content), 0644) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + tmp := t.TempDir() + tr := tracker.New(tmp, "common", tracker.FormatV2) + lnk := filepath.Join(tmp, ".lnk.common") + if tt.content == "__REMOVE__" { + os.Remove(lnk) + } else { + os.WriteFile(lnk, []byte(tt.content), 0644) + } - items, err := tr.GetManagedItems() - if err != nil { - t.Fatal(err) - } - want := []string{"foo", "bar", "baz"} // file order, not sorted - if !slices.Equal(items, want) { - t.Errorf("got %v, want %v", items, want) - } - }) + items, err := tr.GetManagedItems() + if err != nil { + t.Fatal(err) + } + if tt.want == nil { + if len(items) != 0 { + t.Errorf("expected empty, got %v", items) + } + } else if !slices.Equal(items, tt.want) { + t.Errorf("got %v, want %v", items, tt.want) + } + }) + } } func TestTracker_AddManagedItem(t *testing.T) { - tmp := t.TempDir() - tr := tracker.New(tmp, "common", tracker.FormatV2) - - t.Run("adds first item", func(t *testing.T) { - if err := tr.AddManagedItem("foo"); err != nil { - t.Fatal(err) - } - - items, err := tr.GetManagedItems() - if err != nil { - t.Fatal(err) - } - if !slices.Equal(items, []string{"foo"}) { - t.Errorf("got %v", items) - } - }) - - t.Run("adds second item and sorts", func(t *testing.T) { - if err := tr.AddManagedItem("baz"); err != nil { - t.Fatal(err) - } + t.Parallel() - items, err := tr.GetManagedItems() - if err != nil { - t.Fatal(err) - } - want := []string{"baz", "foo"} - if !slices.Equal(items, want) { - t.Errorf("got %v, want %v", items, want) - } - }) - - t.Run("duplicate is no-op", func(t *testing.T) { - if err := tr.AddManagedItem("foo"); err != nil { - t.Fatal(err) - } + tests := []struct { + name string + seed []string + add string + want []string + wantErr bool + }{ + {"adds_first_item", nil, "foo", []string{"foo"}, false}, + {"adds_second_item_and_sorts", []string{"foo"}, "baz", []string{"baz", "foo"}, false}, + {"duplicate_is_no_op", []string{"baz", "foo"}, "foo", []string{"baz", "foo"}, false}, + } - items, err := tr.GetManagedItems() - if err != nil { - t.Fatal(err) - } - want := []string{"baz", "foo"} - if !slices.Equal(items, want) { - t.Errorf("got %v, want %v", items, want) - } - }) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + tmp := t.TempDir() + tr := tracker.New(tmp, "common", tracker.FormatV2) + if tt.seed != nil { + if err := tr.WriteManagedItems(tt.seed); err != nil { + t.Fatal(err) + } + } + if err := tr.AddManagedItem(tt.add); err != nil { + if tt.wantErr { + return + } + t.Fatal(err) + } + items, err := tr.GetManagedItems() + if err != nil { + t.Fatal(err) + } + if !slices.Equal(items, tt.want) { + t.Errorf("got %v, want %v", items, tt.want) + } + }) + } } func TestTracker_RemoveManagedItem(t *testing.T) { - tmp := t.TempDir() - tr := tracker.New(tmp, "common", tracker.FormatV2) + t.Parallel() - // Seed tracker - if err := tr.WriteManagedItems([]string{"alpha", "beta", "gamma"}); err != nil { - t.Fatal(err) + tests := []struct { + name string + seed []string + remove string + want []string + }{ + {"removes_existing_item", []string{"alpha", "beta", "gamma"}, "beta", []string{"alpha", "gamma"}}, + {"nonexistent_item_is_no_op", []string{"alpha", "beta", "gamma"}, "delta", []string{"alpha", "beta", "gamma"}}, } - t.Run("removes existing item", func(t *testing.T) { - if err := tr.RemoveManagedItem("beta"); err != nil { - t.Fatal(err) - } - - items, err := tr.GetManagedItems() - if err != nil { - t.Fatal(err) - } - want := []string{"alpha", "gamma"} - if !slices.Equal(items, want) { - t.Errorf("got %v, want %v", items, want) - } - }) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + tmp := t.TempDir() + tr := tracker.New(tmp, "common", tracker.FormatV2) + if err := tr.WriteManagedItems(tt.seed); err != nil { + t.Fatal(err) + } + if err := tr.RemoveManagedItem(tt.remove); err != nil { + t.Fatal(err) + } + items, err := tr.GetManagedItems() + if err != nil { + t.Fatal(err) + } + if !slices.Equal(items, tt.want) { + t.Errorf("got %v, want %v", items, tt.want) + } + }) + } - t.Run("nonexistent item is no-op", func(t *testing.T) { - if err := tr.RemoveManagedItem("delta"); err != nil { + t.Run("removes_last_item", func(t *testing.T) { + t.Parallel() + tmp := t.TempDir() + tr := tracker.New(tmp, "common", tracker.FormatV2) + if err := tr.WriteManagedItems([]string{"alpha", "beta", "gamma"}); err != nil { t.Fatal(err) } - - items, err := tr.GetManagedItems() - if err != nil { - t.Fatal(err) - } - want := []string{"alpha", "gamma"} - if !slices.Equal(items, want) { - t.Errorf("got %v, want %v", items, want) - } - }) - - t.Run("removes last item", func(t *testing.T) { if err := tr.RemoveManagedItem("alpha"); err != nil { t.Fatal(err) } if err := tr.RemoveManagedItem("gamma"); err != nil { t.Fatal(err) } + if err := tr.RemoveManagedItem("beta"); err != nil { + t.Fatal(err) + } items, err := tr.GetManagedItems() if err != nil { @@ -279,37 +280,34 @@ func TestTracker_RemoveManagedItem(t *testing.T) { } func TestTracker_WriteManagedItems(t *testing.T) { - tmp := t.TempDir() - tr := tracker.New(tmp, "common", tracker.FormatV2) - - t.Run("writes items with trailing newline", func(t *testing.T) { - if err := tr.WriteManagedItems([]string{"foo", "bar"}); err != nil { - t.Fatal(err) - } + t.Parallel() - lnk := filepath.Join(tmp, ".lnk.common") - content, err := os.ReadFile(lnk) - if err != nil { - t.Fatal(err) - } - want := "foo\nbar\n" // input order, not sorted - if string(content) != want { - t.Errorf("content = %q, want %q", string(content), want) - } - }) + tests := []struct { + name string + items []string + want string + }{ + {"writes_items_with_trailing_newline", []string{"foo", "bar"}, "foo\nbar\n"}, + {"empty_slice_writes_empty_file", []string{}, ""}, + } - t.Run("empty slice writes empty file", func(t *testing.T) { - if err := tr.WriteManagedItems([]string{}); err != nil { - t.Fatal(err) - } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + tmp := t.TempDir() + tr := tracker.New(tmp, "common", tracker.FormatV2) + if err := tr.WriteManagedItems(tt.items); err != nil { + t.Fatal(err) + } - lnk := filepath.Join(tmp, ".lnk.common") - content, err := os.ReadFile(lnk) - if err != nil { - t.Fatal(err) - } - if len(content) != 0 { - t.Errorf("expected empty file, got %q", string(content)) - } - }) + lnk := filepath.Join(tmp, ".lnk.common") + content, err := os.ReadFile(lnk) + if err != nil { + t.Fatal(err) + } + if string(content) != tt.want { + t.Errorf("content = %q, want %q", string(content), tt.want) + } + }) + } }