604 lines
14 KiB
Go
604 lines
14 KiB
Go
package fsedsl
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
// ══════════════════════════════════════════════════════════════════
|
|
// Filesystem-backed Tree tests
|
|
// ══════════════════════════════════════════════════════════════════
|
|
|
|
func TestTreeFilesystemOperations(t *testing.T) {
|
|
// Create temp directory
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create a test file
|
|
testFile := filepath.Join(tmpDir, "testfile.txt")
|
|
if err := os.WriteFile(testFile, []byte("hello"), 0644); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
|
|
tree := NewTree([]string{tmpDir}, 0644)
|
|
|
|
// Test resolve (via Stat)
|
|
fi, err := tree.Stat("testfile.txt")
|
|
if err != nil {
|
|
t.Fatalf("Stat: %v", err)
|
|
}
|
|
if fi.Name() != "testfile.txt" {
|
|
t.Errorf("Name = %q", fi.Name())
|
|
}
|
|
|
|
// Test List (without listFn, uses os.ReadDir)
|
|
entries, err := tree.List()
|
|
if err != nil {
|
|
t.Fatalf("List: %v", err)
|
|
}
|
|
if len(entries) != 1 {
|
|
t.Errorf("List: got %d entries, want 1", len(entries))
|
|
}
|
|
|
|
// Test Open (without openFn, uses file system)
|
|
f, err := tree.Open("testfile.txt")
|
|
if err != nil {
|
|
t.Fatalf("Open: %v", err)
|
|
}
|
|
data, err := f.Read()
|
|
if err != nil {
|
|
t.Fatalf("Read: %v", err)
|
|
}
|
|
if string(data) != "hello" {
|
|
t.Errorf("data = %q, want 'hello'", data)
|
|
}
|
|
|
|
// Test Write
|
|
err = f.Write([]byte("world"))
|
|
if err != nil {
|
|
t.Fatalf("Write: %v", err)
|
|
}
|
|
// Verify write
|
|
content, _ := os.ReadFile(testFile)
|
|
if string(content) != "world" {
|
|
t.Errorf("content = %q, want 'world'", content)
|
|
}
|
|
|
|
// Test Readdir (without readdirFn)
|
|
subDir := filepath.Join(tmpDir, "subdir")
|
|
if err := os.Mkdir(subDir, 0755); err != nil {
|
|
t.Fatalf("Mkdir: %v", err)
|
|
}
|
|
os.WriteFile(filepath.Join(subDir, "nested.txt"), []byte("nested"), 0644)
|
|
|
|
subEntries, err := tree.Readdir("subdir")
|
|
if err != nil {
|
|
t.Fatalf("Readdir: %v", err)
|
|
}
|
|
if len(subEntries) != 1 {
|
|
t.Errorf("Readdir: got %d entries, want 1", len(subEntries))
|
|
}
|
|
}
|
|
|
|
func TestTreeCreate(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
tree := NewTree([]string{tmpDir}, 0644)
|
|
|
|
// Test Create (without createFn)
|
|
err := tree.Create("newfile.txt")
|
|
if err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
|
|
// Verify file exists
|
|
_, err = os.Stat(filepath.Join(tmpDir, "newfile.txt"))
|
|
if err != nil {
|
|
t.Error("Created file should exist")
|
|
}
|
|
|
|
// Test Create with nested path
|
|
err = tree.Create("nested/deep/file.txt")
|
|
if err != nil {
|
|
t.Fatalf("Create nested: %v", err)
|
|
}
|
|
_, err = os.Stat(filepath.Join(tmpDir, "nested/deep/file.txt"))
|
|
if err != nil {
|
|
t.Error("Nested created file should exist")
|
|
}
|
|
}
|
|
|
|
func TestTreeDelete(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create test file
|
|
testFile := filepath.Join(tmpDir, "todelete.txt")
|
|
os.WriteFile(testFile, []byte("delete me"), 0644)
|
|
|
|
tree := NewTree([]string{tmpDir}, 0644)
|
|
|
|
// Test Delete (without deleteFn)
|
|
err := tree.Delete("todelete.txt")
|
|
if err != nil {
|
|
t.Fatalf("Delete: %v", err)
|
|
}
|
|
|
|
// Verify file is gone
|
|
_, err = os.Stat(testFile)
|
|
if !os.IsNotExist(err) {
|
|
t.Error("Deleted file should not exist")
|
|
}
|
|
}
|
|
|
|
func TestTreeRename(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create test file
|
|
oldFile := filepath.Join(tmpDir, "old.txt")
|
|
os.WriteFile(oldFile, []byte("rename me"), 0644)
|
|
|
|
tree := NewTree([]string{tmpDir}, 0644)
|
|
|
|
// Test Rename (without renameFn)
|
|
err := tree.Rename("old.txt", "new.txt")
|
|
if err != nil {
|
|
t.Fatalf("Rename: %v", err)
|
|
}
|
|
|
|
// Verify old file is gone
|
|
_, err = os.Stat(oldFile)
|
|
if !os.IsNotExist(err) {
|
|
t.Error("Old file should not exist")
|
|
}
|
|
|
|
// Verify new file exists
|
|
newFile := filepath.Join(tmpDir, "new.txt")
|
|
_, err = os.Stat(newFile)
|
|
if err != nil {
|
|
t.Error("New file should exist")
|
|
}
|
|
}
|
|
|
|
func TestTreeMkdirAll(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
tree := NewTree([]string{tmpDir}, 0755)
|
|
|
|
// Test MkdirAll
|
|
err := tree.MkdirAll("a/b/c")
|
|
if err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
|
|
// Verify directory exists
|
|
fi, err := os.Stat(filepath.Join(tmpDir, "a/b/c"))
|
|
if err != nil {
|
|
t.Fatal("Directory should exist")
|
|
}
|
|
if !fi.IsDir() {
|
|
t.Error("Should be a directory")
|
|
}
|
|
}
|
|
|
|
func TestTreeReadOnly(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
tree := NewTree([]string{tmpDir}, 0644, WithReadOnly())
|
|
|
|
// Create should fail
|
|
err := tree.Create("file.txt")
|
|
if err == nil {
|
|
t.Error("Create should fail on read-only tree")
|
|
}
|
|
|
|
// Delete should fail
|
|
os.WriteFile(filepath.Join(tmpDir, "existing.txt"), []byte("x"), 0644)
|
|
err = tree.Delete("existing.txt")
|
|
if err == nil {
|
|
t.Error("Delete should fail on read-only tree")
|
|
}
|
|
|
|
// Rename should fail
|
|
err = tree.Rename("existing.txt", "new.txt")
|
|
if err == nil {
|
|
t.Error("Rename should fail on read-only tree")
|
|
}
|
|
|
|
// MkdirAll should fail
|
|
err = tree.MkdirAll("newdir")
|
|
if err == nil {
|
|
t.Error("MkdirAll should fail on read-only tree")
|
|
}
|
|
|
|
// Write via Open should fail
|
|
f, err := tree.Open("existing.txt")
|
|
if err != nil {
|
|
t.Fatalf("Open: %v", err)
|
|
}
|
|
err = f.Write([]byte("new content"))
|
|
if err == nil {
|
|
t.Error("Write should fail on read-only tree")
|
|
}
|
|
}
|
|
|
|
func TestTreeMkdirAllNoBackingDir(t *testing.T) {
|
|
tree := NewTree(nil, 0755)
|
|
|
|
err := tree.MkdirAll("somedir")
|
|
if err == nil {
|
|
t.Error("MkdirAll should fail with no backing directory")
|
|
}
|
|
}
|
|
|
|
func TestTreeReaddirNoBackingDir(t *testing.T) {
|
|
tree := NewTree(nil, 0755)
|
|
|
|
_, err := tree.Readdir("somedir")
|
|
if err == nil {
|
|
t.Error("Readdir should fail with no backing directory")
|
|
}
|
|
}
|
|
|
|
func TestTreeOpenNotFound(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
tree := NewTree([]string{tmpDir}, 0644, WithReadOnly())
|
|
|
|
// Open non-existent file on read-only tree should fail
|
|
_, err := tree.Open("nonexistent.txt")
|
|
if err == nil {
|
|
t.Error("Open should fail for nonexistent file on read-only tree")
|
|
}
|
|
}
|
|
|
|
func TestTreeOpenNewFile(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
tree := NewTree([]string{tmpDir}, 0644)
|
|
|
|
// Open non-existent file on writable tree should work (creates on write)
|
|
f, err := tree.Open("newfile.txt")
|
|
if err != nil {
|
|
t.Fatalf("Open: %v", err)
|
|
}
|
|
|
|
// Write creates the file
|
|
err = f.Write([]byte("created"))
|
|
if err != nil {
|
|
t.Fatalf("Write: %v", err)
|
|
}
|
|
|
|
// Verify file exists
|
|
content, err := os.ReadFile(filepath.Join(tmpDir, "newfile.txt"))
|
|
if err != nil {
|
|
t.Fatal("File should exist after write")
|
|
}
|
|
if string(content) != "created" {
|
|
t.Errorf("content = %q, want 'created'", content)
|
|
}
|
|
}
|
|
|
|
func TestTreeUnionDirs(t *testing.T) {
|
|
// Create two directories
|
|
dir1 := t.TempDir()
|
|
dir2 := t.TempDir()
|
|
|
|
// Put different files in each
|
|
os.WriteFile(filepath.Join(dir1, "file1.txt"), []byte("from dir1"), 0644)
|
|
os.WriteFile(filepath.Join(dir2, "file2.txt"), []byte("from dir2"), 0644)
|
|
// Same file in both - dir1 should win
|
|
os.WriteFile(filepath.Join(dir1, "common.txt"), []byte("dir1 version"), 0644)
|
|
os.WriteFile(filepath.Join(dir2, "common.txt"), []byte("dir2 version"), 0644)
|
|
|
|
tree := NewTree([]string{dir1, dir2}, 0644)
|
|
|
|
// List should show all unique files
|
|
entries, err := tree.List()
|
|
if err != nil {
|
|
t.Fatalf("List: %v", err)
|
|
}
|
|
names := make(map[string]bool)
|
|
for _, e := range entries {
|
|
names[e.Name()] = true
|
|
}
|
|
if !names["file1.txt"] || !names["file2.txt"] || !names["common.txt"] {
|
|
t.Errorf("Missing expected files, got: %v", names)
|
|
}
|
|
|
|
// Resolve common.txt should return dir1 version
|
|
f, err := tree.Open("common.txt")
|
|
if err != nil {
|
|
t.Fatalf("Open: %v", err)
|
|
}
|
|
data, err := f.Read()
|
|
if err != nil {
|
|
t.Fatalf("Read: %v", err)
|
|
}
|
|
if string(data) != "dir1 version" {
|
|
t.Errorf("data = %q, want 'dir1 version'", data)
|
|
}
|
|
|
|
// Resolve file2.txt from dir2
|
|
f2, err := tree.Open("file2.txt")
|
|
if err != nil {
|
|
t.Fatalf("Open file2: %v", err)
|
|
}
|
|
data2, err := f2.Read()
|
|
if err != nil {
|
|
t.Fatalf("Read: %v", err)
|
|
}
|
|
if string(data2) != "from dir2" {
|
|
t.Errorf("data2 = %q, want 'from dir2'", data2)
|
|
}
|
|
}
|
|
|
|
func TestTreeChildDelegation(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
parentDir := t.TempDir()
|
|
|
|
// Create file in child
|
|
os.WriteFile(filepath.Join(tmpDir, "childfile.txt"), []byte("child content"), 0644)
|
|
|
|
parent := NewTree([]string{parentDir}, 0755)
|
|
child := NewTree([]string{tmpDir}, 0644)
|
|
parent.Mount("sub", child)
|
|
|
|
// Operations should delegate to child
|
|
|
|
// Create in child
|
|
err := parent.Create("sub/newfile.txt")
|
|
if err != nil {
|
|
t.Fatalf("Create in child: %v", err)
|
|
}
|
|
_, err = os.Stat(filepath.Join(tmpDir, "newfile.txt"))
|
|
if err != nil {
|
|
t.Error("File should exist in child dir")
|
|
}
|
|
|
|
// Delete in child
|
|
err = parent.Delete("sub/newfile.txt")
|
|
if err != nil {
|
|
t.Fatalf("Delete in child: %v", err)
|
|
}
|
|
|
|
// Rename in child
|
|
err = parent.Rename("sub/childfile.txt", "renamed.txt")
|
|
if err != nil {
|
|
t.Fatalf("Rename in child: %v", err)
|
|
}
|
|
|
|
// Readdir via child (rest != "")
|
|
subDir := filepath.Join(tmpDir, "nested")
|
|
os.Mkdir(subDir, 0755)
|
|
os.WriteFile(filepath.Join(subDir, "deep.txt"), []byte("deep"), 0644)
|
|
|
|
entries, err := parent.Readdir("sub/nested")
|
|
if err != nil {
|
|
t.Fatalf("Readdir nested: %v", err)
|
|
}
|
|
if len(entries) != 1 {
|
|
t.Errorf("Readdir: got %d, want 1", len(entries))
|
|
}
|
|
|
|
// MkdirAll in child
|
|
err = parent.MkdirAll("sub/new/deep/path")
|
|
if err != nil {
|
|
t.Fatalf("MkdirAll in child: %v", err)
|
|
}
|
|
_, err = os.Stat(filepath.Join(tmpDir, "new/deep/path"))
|
|
if err != nil {
|
|
t.Error("Directory should exist in child")
|
|
}
|
|
|
|
// Stat child directory itself
|
|
fi, err := parent.Stat("sub")
|
|
if err != nil {
|
|
t.Fatalf("Stat child: %v", err)
|
|
}
|
|
if !fi.IsDir() {
|
|
t.Error("Child should be dir")
|
|
}
|
|
|
|
// Stat nested path in child
|
|
fi2, err := parent.Stat("sub/renamed.txt")
|
|
if err != nil {
|
|
t.Fatalf("Stat in child: %v", err)
|
|
}
|
|
if fi2.IsDir() {
|
|
t.Error("File should not be dir")
|
|
}
|
|
}
|
|
|
|
func TestTreeOpenChildDir(t *testing.T) {
|
|
parent := NewTree(nil, 0755)
|
|
child := NewTree(nil, 0755)
|
|
parent.Mount("sub", child)
|
|
|
|
// Opening a directory should fail
|
|
_, err := parent.Open("sub")
|
|
if err == nil {
|
|
t.Error("Open directory should fail")
|
|
}
|
|
}
|
|
|
|
func TestTreeStatWithOpenerFallback(t *testing.T) {
|
|
tree := NewTree(nil, 0755,
|
|
WithOpener(func(dirs []string, name string) (File, error) {
|
|
return &FileConfig{
|
|
StatFn: func() (os.FileInfo, error) {
|
|
return &SyntheticFileInfo{Name_: name, Mode_: 0600, Size_: 42}, nil
|
|
},
|
|
ReadFn: func() ([]byte, error) { return []byte("content"), nil },
|
|
WriteFn: func([]byte) error { return nil },
|
|
BlockingReadFn: func(context.Context, string) ([]byte, string, error) { return nil, "", nil },
|
|
}, nil
|
|
}),
|
|
)
|
|
|
|
// Stat should use opener to get file info
|
|
fi, err := tree.Stat("anyfile")
|
|
if err != nil {
|
|
t.Fatalf("Stat: %v", err)
|
|
}
|
|
if fi.Name() != "anyfile" {
|
|
t.Errorf("Name = %q", fi.Name())
|
|
}
|
|
if fi.Size() != 42 {
|
|
t.Errorf("Size = %d, want 42", fi.Size())
|
|
}
|
|
}
|
|
|
|
func TestTreeStatWithResolverNameOverride(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
os.WriteFile(filepath.Join(tmpDir, "realfile.txt"), []byte("content"), 0644)
|
|
|
|
tree := NewTree([]string{tmpDir}, 0755,
|
|
WithResolver(func(dirs []string, name string) (string, error) {
|
|
// Map virtual name to real file
|
|
if name == "virtual.txt" {
|
|
return filepath.Join(dirs[0], "realfile.txt"), nil
|
|
}
|
|
return "", os.ErrNotExist
|
|
}),
|
|
)
|
|
|
|
// Stat via resolver should use synthetic info with original name
|
|
fi, err := tree.Stat("virtual.txt")
|
|
if err != nil {
|
|
t.Fatalf("Stat: %v", err)
|
|
}
|
|
// Name should be the virtual name, not the real filename
|
|
if fi.Name() != "virtual.txt" {
|
|
t.Errorf("Name = %q, want 'virtual.txt'", fi.Name())
|
|
}
|
|
}
|
|
|
|
func TestTreeListWithIndexAndChildren(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
os.WriteFile(filepath.Join(tmpDir, "file.txt"), []byte("x"), 0644)
|
|
|
|
tree := NewTree([]string{tmpDir}, 0755,
|
|
WithIndex(func(ft FileTree) ([]byte, error) {
|
|
return []byte("index"), nil
|
|
}),
|
|
)
|
|
|
|
child := NewTree(nil, 0755)
|
|
tree.Mount("child", child)
|
|
|
|
entries, err := tree.List()
|
|
if err != nil {
|
|
t.Fatalf("List: %v", err)
|
|
}
|
|
|
|
names := make(map[string]bool)
|
|
for _, e := range entries {
|
|
names[e.Name()] = true
|
|
}
|
|
|
|
if !names["idx"] {
|
|
t.Error("Missing idx")
|
|
}
|
|
if !names["file.txt"] {
|
|
t.Error("Missing file.txt")
|
|
}
|
|
if !names["child"] {
|
|
t.Error("Missing child")
|
|
}
|
|
}
|
|
|
|
func TestTreeListWithListerAndIndex(t *testing.T) {
|
|
tree := NewTree(nil, 0755,
|
|
WithLister(func(dirs []string) ([]os.DirEntry, error) {
|
|
return []os.DirEntry{FileEntry("synthetic", 0644)}, nil
|
|
}),
|
|
WithIndex(func(ft FileTree) ([]byte, error) {
|
|
return []byte("index"), nil
|
|
}),
|
|
)
|
|
|
|
child := NewTree(nil, 0755)
|
|
tree.Mount("mounted", child)
|
|
|
|
entries, err := tree.List()
|
|
if err != nil {
|
|
t.Fatalf("List: %v", err)
|
|
}
|
|
|
|
names := make(map[string]bool)
|
|
for _, e := range entries {
|
|
names[e.Name()] = true
|
|
}
|
|
|
|
if !names["idx"] {
|
|
t.Error("Missing idx (should be prepended)")
|
|
}
|
|
if !names["synthetic"] {
|
|
t.Error("Missing synthetic")
|
|
}
|
|
if !names["mounted"] {
|
|
t.Error("Missing mounted child")
|
|
}
|
|
}
|
|
|
|
func TestValidateReadModeAllThree(t *testing.T) {
|
|
defer func() {
|
|
if r := recover(); r == nil {
|
|
t.Error("should panic with all three modes set")
|
|
}
|
|
}()
|
|
|
|
fc := &FileConfig{
|
|
BlockingRead_: true,
|
|
StreamMode_: true,
|
|
RequestResp_: true,
|
|
}
|
|
fc.ValidateReadMode()
|
|
}
|
|
|
|
// Test readdirDir error path
|
|
func TestReaddirDirNotFound(t *testing.T) {
|
|
spec := DirNode[TestCtx]("/",
|
|
FileNode[TestCtx]("file", 0444),
|
|
)
|
|
|
|
tree := BuildTree(spec, TestCtx{})
|
|
_, err := tree.Readdir("nonexistent")
|
|
if err == nil {
|
|
t.Error("Readdir should fail for nonexistent")
|
|
}
|
|
}
|
|
|
|
// Test help.go edge cases
|
|
func TestGenerateHelpModes(t *testing.T) {
|
|
spec := DirNode[TestCtx]("/",
|
|
FileNode[TestCtx]("readonly", 0444,
|
|
Read(func(ctx TestCtx) ([]byte, error) { return nil, nil }),
|
|
Doc[TestCtx]("Read-only file"),
|
|
),
|
|
FileNode[TestCtx]("writeonly", 0222,
|
|
Write(func(ctx TestCtx, data []byte) error { return nil }),
|
|
Doc[TestCtx]("Write-only file"),
|
|
),
|
|
FileNode[TestCtx]("executable", 0755,
|
|
Doc[TestCtx]("Executable file"),
|
|
),
|
|
DirNode[TestCtx]("subdir",
|
|
Doc[TestCtx]("A subdirectory"),
|
|
FileNode[TestCtx]("nested", 0444),
|
|
),
|
|
)
|
|
|
|
help := GenerateHelp(spec)
|
|
|
|
if !contains(help, "readonly") {
|
|
t.Error("Missing readonly in help")
|
|
}
|
|
if !contains(help, "writeonly") {
|
|
t.Error("Missing writeonly in help")
|
|
}
|
|
if !contains(help, "subdir") {
|
|
t.Error("Missing subdir in help")
|
|
}
|
|
// fmtMode produces 4-char strings like "-rr-", "-w--", "drwx"
|
|
if !contains(help, "-rr-") && !contains(help, "-w--") {
|
|
t.Errorf("Missing mode indicators in help:\n%s", help)
|
|
}
|
|
}
|