465 lines
12 KiB
Go
465 lines
12 KiB
Go
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())
|
|
}
|
|
}
|
|
|