package filesystem import ( "context" "errors" "os" "testing" "time" "github.com/alicebob/miniredis/v2" "github.com/valkey-io/valkey-go" ) // MockFS implements filesystem.FS interface for testing. type MockFS struct { files map[string]*MockFile } // MockFile implements FileInfo and holds content. type MockFile struct { name string content []byte mode os.FileMode modTime time.Time isDir bool contentType string path string } func (f *MockFile) Name() string { return f.name } func (f *MockFile) Size() int64 { return int64(len(f.content)) } func (f *MockFile) Mode() os.FileMode { return f.mode } func (f *MockFile) ModTime() time.Time { return f.modTime } func (f *MockFile) IsDir() bool { return f.isDir } func (f *MockFile) Sys() any { return nil } func (f *MockFile) ContentType() string { return f.contentType } func (f *MockFile) Path() string { return f.path } func NewMockFS() *MockFS { return &MockFS{ files: make(map[string]*MockFile), } } func (m *MockFS) AddFile(path string, content []byte) { m.files[path] = &MockFile{ name: path, content: content, mode: 0o644, modTime: time.Now().Truncate(time.Second), isDir: false, contentType: "text/plain", path: path, } } func (m *MockFS) AddDir(path string) { m.files[path] = &MockFile{ name: path, mode: 0o755, modTime: time.Now().Truncate(time.Second), isDir: true, path: path, } } func (m *MockFS) Read(path string) ([]byte, error) { f, ok := m.files[path] if !ok { return nil, os.ErrNotExist } if f.isDir { return nil, errors.New("is a directory") } return f.content, nil } func (m *MockFS) ReadDir(path string) ([]os.FileInfo, error) { // Simple implementation: return files that start with path + "/" // For deeper nesting, simulate direct children only. // Assume flat structure for simplicity or specific test setup. var infos []os.FileInfo for p, f := range m.files { // Just check if it's logically "inside" the directory // e.g. path="/foo", file="/foo/bar" -> yes // file="/foo/bar/baz" -> no (if strict direct children) // file="/other" -> no if p == path { continue // self } // Check prefix // if path is root "/", check if p has no other slashes? // if path is "/foo", check if p starts with "/foo/" and has no further slashes // This logic is simple but sufficient for tests. // Let's assume absolute paths. // If path does not end with /, append it for prefix check prefix := path if len(prefix) > 0 && prefix[len(prefix)-1] != '/' { prefix += "/" } if len(p) > len(prefix) && p[:len(prefix)] == prefix { // Ensure direct child (no more slashes) rel := p[len(prefix):] // Count slashes in rel. If 0, it's a direct child. slashCount := 0 for _, c := range rel { if c == '/' { slashCount++ } } if slashCount == 0 { infos = append(infos, f) } } } if len(infos) == 0 { // Check if directory exists at all if _, ok := m.files[path]; !ok { return nil, os.ErrNotExist } } return infos, nil } func (m *MockFS) Stat(path string) (os.FileInfo, error) { f, ok := m.files[path] if !ok { return nil, os.ErrNotExist } return f, nil } func TestCachedClient_Stat(t *testing.T) { mr, err := miniredis.Run() if err != nil { t.Fatalf("failed to start miniredis: %v", err) } defer mr.Close() client, err := valkey.NewClient(valkey.ClientOption{ InitAddress: []string{mr.Addr()}, DisableCache: true, }) if err != nil { t.Fatalf("failed to create valkey client: %v", err) } defer client.Close() mockFS := NewMockFS() mockFS.AddFile("/test.txt", []byte("hello")) c := NewCachedClient(mockFS, client, time.Hour) // First call: Cache miss info, err := c.Stat("/test.txt") if err != nil { t.Fatalf("Stat failed: %v", err) } if info.Name() != "/test.txt" { t.Errorf("expected name /test.txt, got %s", info.Name()) } if c.misses.Load() != 1 { t.Errorf("expected 1 miss, got %d", c.misses.Load()) } if c.hits.Load() != 0 { t.Errorf("expected 0 hits, got %d", c.hits.Load()) } // Second call: Cache hit info2, err := c.Stat("/test.txt") if err != nil { t.Fatalf("Stat failed: %v", err) } if info2.Size() != info.Size() { t.Errorf("size mismatch") } if c.misses.Load() != 1 { t.Errorf("expected 1 miss, got %d", c.misses.Load()) } if c.hits.Load() != 1 { t.Errorf("expected 1 hit, got %d", c.hits.Load()) } // Verify valkey content keys := mr.Keys() if len(keys) != 1 { t.Errorf("expected 1 key in redis, got %d", len(keys)) } } func TestCachedClient_Read(t *testing.T) { mr, err := miniredis.Run() if err != nil { t.Fatalf("failed to start miniredis: %v", err) } defer mr.Close() client, err := valkey.NewClient(valkey.ClientOption{ InitAddress: []string{mr.Addr()}, DisableCache: true, }) if err != nil { t.Fatalf("failed to create valkey client: %v", err) } defer client.Close() mockFS := NewMockFS() content := []byte("hello world") mockFS.AddFile("/read.txt", content) c := NewCachedClient(mockFS, client, time.Hour) // First call: Miss data, err := c.Read("/read.txt") if err != nil { t.Fatalf("Read failed: %v", err) } if string(data) != string(content) { t.Errorf("content mismatch") } if c.misses.Load() != 1 { t.Errorf("expected 1 miss, got %d", c.misses.Load()) } // Second call: Hit data2, err := c.Read("/read.txt") if err != nil { t.Fatalf("Read failed: %v", err) } if string(data2) != string(content) { t.Errorf("content mismatch") } if c.hits.Load() != 1 { t.Errorf("expected 1 hit, got %d", c.hits.Load()) } } func TestCachedClient_ReadDir(t *testing.T) { mr, err := miniredis.Run() if err != nil { t.Fatalf("failed to start miniredis: %v", err) } defer mr.Close() client, err := valkey.NewClient(valkey.ClientOption{ InitAddress: []string{mr.Addr()}, DisableCache: true, }) if err != nil { t.Fatalf("failed to create valkey client: %v", err) } defer client.Close() mockFS := NewMockFS() mockFS.AddDir("/dir") mockFS.AddFile("/dir/f1.txt", []byte("1")) mockFS.AddFile("/dir/f2.txt", []byte("2")) c := NewCachedClient(mockFS, client, time.Hour) // First call: Miss infos, err := c.ReadDir("/dir") if err != nil { t.Fatalf("ReadDir failed: %v", err) } if len(infos) != 2 { t.Errorf("expected 2 files, got %d", len(infos)) } if c.misses.Load() != 1 { t.Errorf("expected 1 miss, got %d", c.misses.Load()) } // Second call: Hit infos2, err := c.ReadDir("/dir") if err != nil { t.Fatalf("ReadDir failed: %v", err) } if len(infos2) != 2 { t.Errorf("expected 2 files, got %d", len(infos2)) } if c.hits.Load() != 1 { t.Errorf("expected 1 hit, got %d", c.hits.Load()) } } func TestCachedClient_RevalidateStat(t *testing.T) { mr, err := miniredis.Run() if err != nil { t.Fatalf("failed to start miniredis: %v", err) } defer mr.Close() client, err := valkey.NewClient(valkey.ClientOption{ InitAddress: []string{mr.Addr()}, DisableCache: true, }) if err != nil { t.Fatalf("failed to create valkey client: %v", err) } defer client.Close() mockFS := NewMockFS() mockFS.AddFile("/reval.txt", []byte("initial")) c := NewCachedClient(mockFS, client, time.Hour) // Populate cache _, _ = c.Stat("/reval.txt") // Update mock file (simulate external change) // For simplicity, just update size f := mockFS.files["/reval.txt"] f.content = []byte("updated content") // Important: update modTime to be newer than cached f.modTime = time.Now().Add(time.Minute) // Run revalidation manually // Note: revalidateStat is a private method, so we can test it directly here // because we are in package filesystem (if using package filesystem_test we couldn't) // But `revalidateStat` only iterates over keys in redis. So we need to ensure the key is there. // `Stat` put it there. // Wait, Stat puts it there. `revalidateStat` iterates keys matching `keyStat*`. // For each key, it checks `Stat` again. // Wait, looking at `revalidateStat` implementation: // It calls `c.impl.Stat(path)`. If successful, it updates cache. // It doesn't check ModTime explicitly for `Stat` revalidation, it just blindly updates? // Let's check `revalidateStat` implementation in `cache.go`. // func (c *CachedClient) revalidateStat(ctx context.Context) { ... // info, err := c.impl.Stat(path) // ... // bytes, err := json.Marshal(f) // c.client.Do(...Set...) // } // Yes, it blindly updates. So modification time doesn't matter for `Stat`, it just fetches fresh info. c.revalidateStat(context.Background()) // Verify cache is updated by fetching from cache again // We can inspect redis directly or trust `Stat` returns cached value (which should be updated). // Let's inspect redis to be sure. // Or just call Stat again. It should be a cache HIT, but return NEW values. // Reset stats to check hit c.hits.Store(0) c.misses.Store(0) info, err := c.Stat("/reval.txt") if err != nil { t.Fatalf("Stat failed: %v", err) } if info.Size() != int64(len("updated content")) { t.Errorf("expected size %d, got %d", len("updated content"), info.Size()) } // Should be a hit because revalidate updated the cache entry, so it exists and is valid. if c.hits.Load() != 1 { t.Errorf("expected 1 hit, got %d", c.hits.Load()) } } func TestCachedClient_RevalidateRead(t *testing.T) { mr, err := miniredis.Run() if err != nil { t.Fatalf("failed to start miniredis: %v", err) } defer mr.Close() client, err := valkey.NewClient(valkey.ClientOption{ InitAddress: []string{mr.Addr()}, DisableCache: true, }) if err != nil { t.Fatalf("failed to create valkey client: %v", err) } defer client.Close() mockFS := NewMockFS() mockFS.AddFile("/read_reval.txt", []byte("initial")) c := NewCachedClient(mockFS, client, time.Hour) // Populate cache _, _ = c.Read("/read_reval.txt") // Update mock file (simulate external change) f := mockFS.files["/read_reval.txt"] f.content = []byte("updated content") f.modTime = time.Now().Add(time.Minute) // Newer ModTime triggers update // Run revalidation manually c.revalidateRead(context.Background()) // Verify cache is updated // Reset stats c.hits.Store(0) c.misses.Store(0) data, err := c.Read("/read_reval.txt") if err != nil { t.Fatalf("Read failed: %v", err) } if string(data) != "updated content" { t.Errorf("expected updated content, got %s", string(data)) } if c.hits.Load() != 1 { t.Errorf("expected 1 hit, got %d", c.hits.Load()) } } func TestCachedClient_RevalidateReadDir(t *testing.T) { mr, err := miniredis.Run() if err != nil { t.Fatalf("failed to start miniredis: %v", err) } defer mr.Close() client, err := valkey.NewClient(valkey.ClientOption{ InitAddress: []string{mr.Addr()}, DisableCache: true, }) if err != nil { t.Fatalf("failed to create valkey client: %v", err) } defer client.Close() mockFS := NewMockFS() mockFS.AddDir("/dir_reval") mockFS.AddFile("/dir_reval/f1.txt", []byte("1")) c := NewCachedClient(mockFS, client, time.Hour) // Populate cache _, err = c.ReadDir("/dir_reval") if err != nil { t.Fatalf("ReadDir failed: %v", err) } // Update mock file (simulate external change) mockFS.AddFile("/dir_reval/f2.txt", []byte("2")) // Run revalidation manually c.revalidateReadDir(context.Background()) // Verify cache is updated // Reset stats c.hits.Store(0) c.misses.Store(0) infos2, err := c.ReadDir("/dir_reval") if err != nil { t.Fatalf("ReadDir failed: %v", err) } if len(infos2) != 2 { t.Errorf("expected 2 files after revalidation, got %d", len(infos2)) } if c.hits.Load() != 1 { t.Errorf("expected 1 hit, got %d", c.hits.Load()) } }