diff --git a/cache_more_test.go b/cache_more_test.go new file mode 100644 index 0000000..88ea104 --- /dev/null +++ b/cache_more_test.go @@ -0,0 +1,396 @@ +package wtcache + +import ( + "os" + "testing" + "time" + + "github.com/spf13/afero" +) + +func (f *failingFs) Remove(name string) error { + if f.failRemove { + return errRemoteUnavailable + } + return f.Fs.Remove(name) +} + +func (f *failingFs) RemoveAll(path string) error { + if f.failRemoveAll { + return errRemoteUnavailable + } + return f.Fs.RemoveAll(path) +} + +func (f *failingFs) Rename(oldname, newname string) error { + if f.failRename { + return errRemoteUnavailable + } + return f.Fs.Rename(oldname, newname) +} + +func (f *failingFs) Mkdir(name string, perm os.FileMode) error { + if f.failMkdir { + return errRemoteUnavailable + } + return f.Fs.Mkdir(name, perm) +} + +func (f *failingFs) MkdirAll(path string, perm os.FileMode) error { + if f.failMkdir { + return errRemoteUnavailable + } + return f.Fs.MkdirAll(path, perm) +} + +func (f *failingFs) Chmod(name string, mode os.FileMode) error { + if f.failChmod { + return errRemoteUnavailable + } + return f.Fs.Chmod(name, mode) +} + +func (f *failingFs) Chown(name string, uid, gid int) error { + if f.failChown { + return errRemoteUnavailable + } + return f.Fs.Chown(name, uid, gid) +} + +func (f *failingFs) Chtimes(name string, atime, mtime time.Time) error { + if f.failChtimes { + return errRemoteUnavailable + } + return f.Fs.Chtimes(name, atime, mtime) +} + +func TestFsName(t *testing.T) { + f, _, _ := newTestFs(t, time.Minute) + if got := f.Name(); got != "WriteThroughCacheFs" { + t.Fatalf("expected fixed Name(), got %q", got) + } +} + +func TestMkdirAndMkdirAllApplyToRemoteAndMirrorToMem(t *testing.T) { + f, remote, _ := newTestFs(t, time.Minute) + + if err := f.Mkdir("onedir", 0o755); err != nil { + t.Fatalf("Mkdir: %v", err) + } + if err := f.MkdirAll("nested/deep/dir", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + + for _, path := range []string{"onedir", "nested/deep/dir"} { + if _, err := remote.Stat(path); err != nil { + t.Fatalf("expected %q to exist on remote: %v", path, err) + } + if _, err := f.mem.Stat(path); err != nil { + t.Fatalf("expected %q mirrored into mem: %v", path, err) + } + } +} + +func TestMkdirPropagatesRemoteError(t *testing.T) { + remote := &failingFs{Fs: afero.NewMemMapFs(), failMkdir: true} + f := New(remote, time.Minute, WithSweepInterval(time.Hour)) + t.Cleanup(func() { _ = f.Close() }) + + if err := f.Mkdir("dir", 0o755); err == nil { + t.Fatalf("expected Mkdir to propagate remote error") + } + if err := f.MkdirAll("a/b", 0o755); err == nil { + t.Fatalf("expected MkdirAll to propagate remote error") + } +} + +func TestOpenFileReadOnlyDelegatesToOpen(t *testing.T) { + f, _, _ := newTestFs(t, time.Minute) + mustWrite(t, f, "hello.txt", "content") + + file, err := f.OpenFile("hello.txt", os.O_RDONLY, 0) + if err != nil { + t.Fatalf("OpenFile(O_RDONLY): %v", err) + } + data, err := afero.ReadAll(file) + file.Close() + if err != nil { + t.Fatalf("ReadAll: %v", err) + } + if string(data) != "content" { + t.Fatalf("expected %q, got %q", "content", data) + } +} + +func TestOpenFileWritePreloadsRemoteContentWhenNotFreshAndNotTruncating(t *testing.T) { + f, remote, _ := newTestFs(t, time.Minute) + + if err := afero.WriteFile(remote, "hello.txt", []byte("hello"), 0o644); err != nil { + t.Fatalf("seed remote: %v", err) + } + // Not cached yet (never opened/written through f), so this OpenFile + // must preload the existing remote content before appending. + file, err := f.OpenFile("hello.txt", os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + t.Fatalf("OpenFile(O_APPEND): %v", err) + } + if _, err := file.Write([]byte(" world")); err != nil { + t.Fatalf("Write: %v", err) + } + if err := file.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + if got := mustRead(t, f, "hello.txt"); got != "hello world" { + t.Fatalf("expected preloaded+appended content %q, got %q", "hello world", got) + } +} + +func TestOpenFileWriteWithTruncateSkipsPreload(t *testing.T) { + f, remote, _ := newTestFs(t, time.Minute) + + if err := afero.WriteFile(remote, "hello.txt", []byte("hello"), 0o644); err != nil { + t.Fatalf("seed remote: %v", err) + } + file, err := f.OpenFile("hello.txt", os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644) + if err != nil { + t.Fatalf("OpenFile(O_TRUNC): %v", err) + } + if _, err := file.Write([]byte("new")); err != nil { + t.Fatalf("Write: %v", err) + } + if err := file.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + if got := mustRead(t, f, "hello.txt"); got != "new" { + t.Fatalf("expected truncated content %q, got %q", "new", got) + } +} + +func TestOpenNonexistentClearsCacheAndReturnsError(t *testing.T) { + f, _, _ := newTestFs(t, time.Minute) + + if _, err := f.Open("missing.txt"); err == nil { + t.Fatalf("expected error opening nonexistent file") + } + if f.isFresh("missing.txt") { + t.Fatalf("expected no cache entry for a failed open") + } + if _, err := f.mem.Stat("missing.txt"); err == nil { + t.Fatalf("expected no mem entry for a failed open") + } +} + +func TestOpenDirectoryDelegatesToRemote(t *testing.T) { + f, _, _ := newTestFs(t, time.Minute) + + if err := f.MkdirAll("adir", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + + file, err := f.Open("adir") + if err != nil { + t.Fatalf("Open(dir): %v", err) + } + defer file.Close() + + info, err := file.Stat() + if err != nil { + t.Fatalf("Stat: %v", err) + } + if !info.IsDir() { + t.Fatalf("expected directory FileInfo") + } +} + +func TestRemoveAllRemovesNestedCacheEntriesUnderPrefix(t *testing.T) { + f, remote, _ := newTestFs(t, time.Minute) + + mustWrite(t, f, "dir/a.txt", "a") + mustWrite(t, f, "dir/b.txt", "b") + + if err := f.RemoveAll("dir"); err != nil { + t.Fatalf("RemoveAll: %v", err) + } + + for _, path := range []string{"dir/a.txt", "dir/b.txt"} { + if f.isFresh(path) { + t.Fatalf("expected %q cache entry cleared by RemoveAll", path) + } + if _, err := f.mem.Stat(path); err == nil { + t.Fatalf("expected %q removed from mem", path) + } + } + if _, err := remote.Stat("dir"); err == nil { + t.Fatalf("expected dir removed from remote") + } +} + +func TestStatServesFromCacheWhenFreshElseRemote(t *testing.T) { + f, remote, clock := newTestFs(t, time.Minute) + + mustWrite(t, f, "hello.txt", "content") + + info, err := f.Stat("hello.txt") + if err != nil { + t.Fatalf("Stat (fresh): %v", err) + } + if info.Size() != int64(len("content")) { + t.Fatalf("expected cached size %d, got %d", len("content"), info.Size()) + } + + *clock = clock.Add(2 * time.Minute) + if err := afero.WriteFile(remote, "hello.txt", []byte("longer content"), 0o644); err != nil { + t.Fatalf("out-of-band remote write: %v", err) + } + + info, err = f.Stat("hello.txt") + if err != nil { + t.Fatalf("Stat (expired): %v", err) + } + if info.Size() != int64(len("longer content")) { + t.Fatalf("expected remote size %d, got %d", len("longer content"), info.Size()) + } +} + +func TestChmodChownChtimesMirrorToFreshCacheEntry(t *testing.T) { + f, remote, _ := newTestFs(t, time.Minute) + mustWrite(t, f, "hello.txt", "content") + + if err := f.Chmod("hello.txt", 0o600); err != nil { + t.Fatalf("Chmod: %v", err) + } + remoteInfo, err := remote.Stat("hello.txt") + if err != nil { + t.Fatalf("remote Stat: %v", err) + } + memInfo, err := f.mem.Stat("hello.txt") + if err != nil { + t.Fatalf("mem Stat: %v", err) + } + if remoteInfo.Mode().Perm() != 0o600 || memInfo.Mode().Perm() != 0o600 { + t.Fatalf("expected mode 0600 on both layers, got remote=%v mem=%v", remoteInfo.Mode(), memInfo.Mode()) + } + + mtime := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC) + if err := f.Chtimes("hello.txt", mtime, mtime); err != nil { + t.Fatalf("Chtimes: %v", err) + } + remoteInfo, _ = remote.Stat("hello.txt") + memInfo, _ = f.mem.Stat("hello.txt") + if !remoteInfo.ModTime().Equal(mtime) || !memInfo.ModTime().Equal(mtime) { + t.Fatalf("expected mtime %v on both layers, got remote=%v mem=%v", mtime, remoteInfo.ModTime(), memInfo.ModTime()) + } + + if err := f.Chown("hello.txt", 1000, 1000); err != nil { + t.Fatalf("Chown: %v", err) + } +} + +func TestChmodChownChtimesPropagateRemoteError(t *testing.T) { + remote := &failingFs{ + Fs: afero.NewMemMapFs(), + failChmod: true, + failChown: true, + failChtimes: true, + } + f := New(remote, time.Minute, WithSweepInterval(time.Hour)) + t.Cleanup(func() { _ = f.Close() }) + + if err := f.Chmod("hello.txt", 0o600); err == nil { + t.Fatalf("expected Chmod to propagate remote error") + } + if err := f.Chown("hello.txt", 1, 1); err == nil { + t.Fatalf("expected Chown to propagate remote error") + } + if err := f.Chtimes("hello.txt", time.Now(), time.Now()); err == nil { + t.Fatalf("expected Chtimes to propagate remote error") + } +} + +func TestRemovePropagatesRemoteError(t *testing.T) { + remote := &failingFs{Fs: afero.NewMemMapFs(), failRemove: true} + f := New(remote, time.Minute, WithSweepInterval(time.Hour)) + t.Cleanup(func() { _ = f.Close() }) + + mustWrite(t, f, "hello.txt", "content") + if err := f.Remove("hello.txt"); err == nil { + t.Fatalf("expected Remove to propagate remote error") + } +} + +func TestRemoveAllPropagatesRemoteError(t *testing.T) { + remote := &failingFs{Fs: afero.NewMemMapFs(), failRemoveAll: true} + f := New(remote, time.Minute, WithSweepInterval(time.Hour)) + t.Cleanup(func() { _ = f.Close() }) + + mustWrite(t, f, "dir/a.txt", "a") + if err := f.RemoveAll("dir"); err == nil { + t.Fatalf("expected RemoveAll to propagate remote error") + } +} + +func TestRenamePropagatesRemoteError(t *testing.T) { + remote := &failingFs{Fs: afero.NewMemMapFs(), failRename: true} + f := New(remote, time.Minute, WithSweepInterval(time.Hour)) + t.Cleanup(func() { _ = f.Close() }) + + mustWrite(t, f, "old.txt", "content") + if err := f.Rename("old.txt", "new.txt"); err == nil { + t.Fatalf("expected Rename to propagate remote error") + } +} + +func TestRenamePrefixMovesNestedDirectoryEntries(t *testing.T) { + f, remote, _ := newTestFs(t, time.Minute) + + mustWrite(t, f, "olddir/a.txt", "a") + mustWrite(t, f, "olddir/b.txt", "b") + + if err := f.Rename("olddir", "newdir"); err != nil { + t.Fatalf("Rename: %v", err) + } + + for _, old := range []string{"olddir/a.txt", "olddir/b.txt"} { + if f.isFresh(old) { + t.Fatalf("expected old path %q no longer cached", old) + } + } + + // Mutate remote out-of-band under the new name to prove the renamed + // entries are still served from cache. + if err := afero.WriteFile(remote, "newdir/a.txt", []byte("changed"), 0o644); err != nil { + t.Fatalf("out-of-band remote write: %v", err) + } + if got := mustRead(t, f, "newdir/a.txt"); got != "a" { + t.Fatalf("expected renamed cache entry to serve %q, got %q", "a", got) + } +} + +func TestCloseIsIdempotent(t *testing.T) { + f, _, _ := newTestFs(t, time.Minute) + + if err := f.Close(); err != nil { + t.Fatalf("first Close: %v", err) + } + if err := f.Close(); err != nil { + t.Fatalf("second Close: %v", err) + } +} + +func TestNewDefaultSweepIntervalFloorsAtOneSecond(t *testing.T) { + f := New(afero.NewMemMapFs(), 100*time.Millisecond) + defer f.Close() + if f.interval != time.Second { + t.Fatalf("expected default interval floored to 1s, got %v", f.interval) + } +} + +func TestNewDefaultSweepIntervalIsQuarterOfTTL(t *testing.T) { + f := New(afero.NewMemMapFs(), 8*time.Second) + defer f.Close() + if f.interval != 2*time.Second { + t.Fatalf("expected default interval ttl/4=2s, got %v", f.interval) + } +} diff --git a/cache_test.go b/cache_test.go index 8341daa..840efa0 100644 --- a/cache_test.go +++ b/cache_test.go @@ -198,11 +198,18 @@ func TestRenameAppliesToBothLayersAndCache(t *testing.T) { } } -// failingFs wraps an afero.Fs and forces OpenFile/Create writes to fail, -// simulating a remote write failure for async error-callback testing. +// failingFs wraps an afero.Fs and selectively forces operations to fail, +// simulating remote fs failures for error-propagation testing. type failingFs struct { afero.Fs - failWrite bool + failWrite bool + failRemove bool + failRemoveAll bool + failRename bool + failMkdir bool + failChmod bool + failChown bool + failChtimes bool } func (f *failingFs) OpenFile(name string, flag int, perm os.FileMode) (afero.File, error) { diff --git a/file_test.go b/file_test.go new file mode 100644 index 0000000..ddb6613 --- /dev/null +++ b/file_test.go @@ -0,0 +1,63 @@ +package wtcache + +import ( + "errors" + "testing" + "time" + + "github.com/spf13/afero" +) + +// errCloseFile wraps an afero.File and forces Close to fail, without +// touching any other behavior. +type errCloseFile struct { + afero.File + err error +} + +func (e errCloseFile) Close() error { + return e.err +} + +func TestCacheFileCloseNonWritableSkipsSync(t *testing.T) { + f, remote, _ := newTestFs(t, time.Minute) + + mfile, err := f.mem.Create("direct.txt") + if err != nil { + t.Fatalf("mem.Create: %v", err) + } + if _, err := mfile.Write([]byte("data")); err != nil { + t.Fatalf("Write: %v", err) + } + + cf := &cacheFile{File: mfile, fs: f, path: "direct.txt", writable: false} + if err := cf.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + if _, err := remote.Stat("direct.txt"); err == nil { + t.Fatalf("expected non-writable Close to skip remote sync") + } +} + +func TestCacheFileCloseReturnsUnderlyingErrorWithoutSync(t *testing.T) { + f, remote, _ := newTestFs(t, time.Minute) + + mfile, err := f.mem.Create("broken.txt") + if err != nil { + t.Fatalf("mem.Create: %v", err) + } + if _, err := mfile.Write([]byte("data")); err != nil { + t.Fatalf("Write: %v", err) + } + + closeErr := errors.New("boom") + cf := &cacheFile{File: errCloseFile{File: mfile, err: closeErr}, fs: f, path: "broken.txt", writable: true} + + if err := cf.Close(); !errors.Is(err, closeErr) { + t.Fatalf("expected underlying close error %v, got %v", closeErr, err) + } + if _, err := remote.Stat("broken.txt"); err == nil { + t.Fatalf("expected sync to be skipped when the underlying Close fails") + } +}