300 lines
8.8 KiB
Go
300 lines
8.8 KiB
Go
package files_test
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
|
|
"aehenamer/internal/files"
|
|
)
|
|
|
|
// makeDir creates a temporary directory containing the given files.
|
|
func makeDir(t *testing.T, names ...string) string {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
for _, name := range names {
|
|
if err := os.WriteFile(filepath.Join(dir, name), []byte(name), 0o600); err != nil {
|
|
t.Fatalf("creating %s: %v", name, err)
|
|
}
|
|
}
|
|
return dir
|
|
}
|
|
|
|
// namesOn returns the sorted file names currently in dir.
|
|
func namesOn(t *testing.T, dir string) []string {
|
|
t.Helper()
|
|
got, err := files.List(context.Background(), dir)
|
|
if err != nil {
|
|
t.Fatalf("listing %s: %v", dir, err)
|
|
}
|
|
return got
|
|
}
|
|
|
|
func TestList(t *testing.T) {
|
|
dir := makeDir(t, "b.txt", "a.txt", ".hidden")
|
|
if err := os.Mkdir(filepath.Join(dir, "sub"), 0o750); err != nil {
|
|
t.Fatalf("creating subdirectory: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, "sub", "deep.txt"), nil, 0o600); err != nil {
|
|
t.Fatalf("creating nested file: %v", err)
|
|
}
|
|
|
|
got := namesOn(t, dir)
|
|
want := []string{".hidden", "a.txt", "b.txt"}
|
|
if !slices.Equal(got, want) {
|
|
t.Errorf("List() = %v, want %v (directories and nested files must be skipped)", got, want)
|
|
}
|
|
}
|
|
|
|
func TestListErrors(t *testing.T) {
|
|
t.Run("missing directory", func(t *testing.T) {
|
|
_, err := files.List(context.Background(), filepath.Join(t.TempDir(), "nope"))
|
|
if err == nil {
|
|
t.Fatal("List() of a missing directory returned no error")
|
|
}
|
|
if !errors.Is(err, os.ErrNotExist) {
|
|
t.Errorf("error %v does not wrap os.ErrNotExist", err)
|
|
}
|
|
})
|
|
|
|
t.Run("cancelled context", func(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
if _, err := files.List(ctx, t.TempDir()); !errors.Is(err, context.Canceled) {
|
|
t.Errorf("List() = %v, want context.Canceled", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestConflicts(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
onDisk []string
|
|
directories []string
|
|
entries []files.Entry
|
|
want map[int]string
|
|
}{
|
|
{
|
|
name: "clean plan",
|
|
onDisk: []string{"a.txt", "b.txt"},
|
|
entries: []files.Entry{
|
|
{Original: "a.txt", Proposed: "1.txt"},
|
|
{Original: "b.txt", Proposed: "2.txt"},
|
|
},
|
|
want: map[int]string{},
|
|
},
|
|
{
|
|
name: "swapping names is allowed",
|
|
onDisk: []string{"a.txt", "b.txt"},
|
|
entries: []files.Entry{
|
|
{Original: "a.txt", Proposed: "b.txt"},
|
|
{Original: "b.txt", Proposed: "a.txt"},
|
|
},
|
|
want: map[int]string{},
|
|
},
|
|
{
|
|
name: "duplicate targets",
|
|
onDisk: []string{"a.txt", "b.txt"},
|
|
entries: []files.Entry{
|
|
{Original: "a.txt", Proposed: "same.txt"},
|
|
{Original: "b.txt", Proposed: "same.txt"},
|
|
},
|
|
want: map[int]string{0: "several files would get this name", 1: "several files would get this name"},
|
|
},
|
|
{
|
|
name: "empty name",
|
|
onDisk: []string{"a.txt"},
|
|
entries: []files.Entry{
|
|
{Original: "a.txt", Proposed: " "},
|
|
},
|
|
want: map[int]string{0: "new name is empty"},
|
|
},
|
|
{
|
|
name: "path separator",
|
|
onDisk: []string{"a.txt"},
|
|
entries: []files.Entry{
|
|
{Original: "a.txt", Proposed: "sub/a.txt"},
|
|
},
|
|
want: map[int]string{0: "new name contains a path separator"},
|
|
},
|
|
{
|
|
name: "directory reference",
|
|
onDisk: []string{"a.txt"},
|
|
entries: []files.Entry{
|
|
{Original: "a.txt", Proposed: ".."},
|
|
},
|
|
want: map[int]string{0: "new name is a directory reference"},
|
|
},
|
|
{
|
|
name: "would overwrite a file that is not part of the plan",
|
|
onDisk: []string{"a.txt", "keep.txt"},
|
|
entries: []files.Entry{
|
|
{Original: "a.txt", Proposed: "keep.txt"},
|
|
},
|
|
want: map[int]string{0: "a filesystem entry with this name already exists"},
|
|
},
|
|
{
|
|
name: "would collide with an existing directory",
|
|
onDisk: []string{"a.txt"},
|
|
directories: []string{"archive"},
|
|
entries: []files.Entry{
|
|
{Original: "a.txt", Proposed: "archive"},
|
|
},
|
|
want: map[int]string{0: "a filesystem entry with this name already exists"},
|
|
},
|
|
{
|
|
name: "unchanged names never conflict with themselves",
|
|
onDisk: []string{"a.txt"},
|
|
entries: []files.Entry{
|
|
{Original: "a.txt", Proposed: "a.txt"},
|
|
},
|
|
want: map[int]string{},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
dir := makeDir(t, tt.onDisk...)
|
|
for _, name := range tt.directories {
|
|
if err := os.Mkdir(filepath.Join(dir, name), 0o750); err != nil {
|
|
t.Fatalf("creating directory %s: %v", name, err)
|
|
}
|
|
}
|
|
got := files.Conflicts(context.Background(), dir, tt.entries)
|
|
if len(got) != len(tt.want) {
|
|
t.Fatalf("Conflicts() = %v, want %v", got, tt.want)
|
|
}
|
|
for index, reason := range tt.want {
|
|
if got[index] != reason {
|
|
t.Errorf("entry %d: got %q, want %q", index, got[index], reason)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRename(t *testing.T) {
|
|
t.Run("renames and skips unchanged files", func(t *testing.T) {
|
|
dir := makeDir(t, "a.txt", "b.txt")
|
|
renamed, err := files.Rename(context.Background(), dir, []files.Entry{
|
|
{Original: "a.txt", Proposed: "1.txt"},
|
|
{Original: "b.txt", Proposed: "b.txt"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Rename() error: %v", err)
|
|
}
|
|
if renamed != 1 {
|
|
t.Errorf("Rename() = %d, want 1", renamed)
|
|
}
|
|
if got, want := namesOn(t, dir), []string{"1.txt", "b.txt"}; !slices.Equal(got, want) {
|
|
t.Errorf("directory holds %v, want %v", got, want)
|
|
}
|
|
})
|
|
|
|
t.Run("swaps names", func(t *testing.T) {
|
|
dir := makeDir(t, "a.txt", "b.txt")
|
|
if _, err := files.Rename(context.Background(), dir, []files.Entry{
|
|
{Original: "a.txt", Proposed: "b.txt"},
|
|
{Original: "b.txt", Proposed: "a.txt"},
|
|
}); err != nil {
|
|
t.Fatalf("Rename() error: %v", err)
|
|
}
|
|
content, err := os.ReadFile(filepath.Join(dir, "a.txt"))
|
|
if err != nil {
|
|
t.Fatalf("reading a.txt: %v", err)
|
|
}
|
|
if string(content) != "b.txt" {
|
|
t.Errorf("a.txt holds %q, want the contents of the old b.txt", content)
|
|
}
|
|
})
|
|
|
|
t.Run("rotates names", func(t *testing.T) {
|
|
dir := makeDir(t, "a.txt", "b.txt", "c.txt")
|
|
if _, err := files.Rename(context.Background(), dir, []files.Entry{
|
|
{Original: "a.txt", Proposed: "b.txt"},
|
|
{Original: "b.txt", Proposed: "c.txt"},
|
|
{Original: "c.txt", Proposed: "a.txt"},
|
|
}); err != nil {
|
|
t.Fatalf("Rename() error: %v", err)
|
|
}
|
|
for name, want := range map[string]string{"b.txt": "a.txt", "c.txt": "b.txt", "a.txt": "c.txt"} {
|
|
content, err := os.ReadFile(filepath.Join(dir, name))
|
|
if err != nil {
|
|
t.Fatalf("reading %s: %v", name, err)
|
|
}
|
|
if string(content) != want {
|
|
t.Errorf("%s holds %q, want %q", name, content, want)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("refuses a conflicting plan", func(t *testing.T) {
|
|
dir := makeDir(t, "a.txt", "b.txt")
|
|
_, err := files.Rename(context.Background(), dir, []files.Entry{
|
|
{Original: "a.txt", Proposed: "same.txt"},
|
|
{Original: "b.txt", Proposed: "same.txt"},
|
|
})
|
|
if err == nil {
|
|
t.Fatal("Rename() accepted a conflicting plan")
|
|
}
|
|
if !strings.Contains(err.Error(), "a.txt") {
|
|
t.Errorf("error %v does not name the offending file", err)
|
|
}
|
|
if got, want := namesOn(t, dir), []string{"a.txt", "b.txt"}; !slices.Equal(got, want) {
|
|
t.Errorf("directory holds %v, want the untouched %v", got, want)
|
|
}
|
|
})
|
|
|
|
t.Run("rolls back when a file disappears", func(t *testing.T) {
|
|
dir := makeDir(t, "a.txt", "b.txt")
|
|
if err := os.Remove(filepath.Join(dir, "b.txt")); err != nil {
|
|
t.Fatalf("removing b.txt: %v", err)
|
|
}
|
|
_, err := files.Rename(context.Background(), dir, []files.Entry{
|
|
{Original: "a.txt", Proposed: "1.txt"},
|
|
{Original: "b.txt", Proposed: "2.txt"},
|
|
})
|
|
if err == nil {
|
|
t.Fatal("Rename() succeeded although a file was missing")
|
|
}
|
|
if got, want := namesOn(t, dir), []string{"a.txt"}; !slices.Equal(got, want) {
|
|
t.Errorf("directory holds %v, want the rolled back %v", got, want)
|
|
}
|
|
})
|
|
|
|
t.Run("rolls back when the final pass fails", func(t *testing.T) {
|
|
dir := makeDir(t, "a.txt", "b.txt")
|
|
tooLong := strings.Repeat("x", 256)
|
|
|
|
renamed, err := files.Rename(context.Background(), dir, []files.Entry{
|
|
{Original: "a.txt", Proposed: "1.txt"},
|
|
{Original: "b.txt", Proposed: tooLong},
|
|
})
|
|
if err == nil {
|
|
t.Fatal("Rename() succeeded with an overlong destination name")
|
|
}
|
|
if renamed != 1 {
|
|
t.Errorf("Rename() = %d completed renames before rollback, want 1", renamed)
|
|
}
|
|
if got, want := namesOn(t, dir), []string{"a.txt", "b.txt"}; !slices.Equal(got, want) {
|
|
t.Errorf("directory holds files %v, want the rolled back %v", got, want)
|
|
}
|
|
})
|
|
|
|
t.Run("cancelled context stops before touching anything", func(t *testing.T) {
|
|
dir := makeDir(t, "a.txt")
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
if _, err := files.Rename(ctx, dir, []files.Entry{{Original: "a.txt", Proposed: "1.txt"}}); !errors.Is(err, context.Canceled) {
|
|
t.Errorf("Rename() = %v, want context.Canceled", err)
|
|
}
|
|
if got, want := namesOn(t, dir), []string{"a.txt"}; !slices.Equal(got, want) {
|
|
t.Errorf("directory holds %v, want %v", got, want)
|
|
}
|
|
})
|
|
}
|