1363 lines
35 KiB
Go
1363 lines
35 KiB
Go
package fsedsl
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"testing"
|
|
)
|
|
|
|
// TestCtx is a simple test context.
|
|
type TestCtx struct {
|
|
Data any
|
|
UserID string
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════
|
|
// NodeOption tests
|
|
// ══════════════════════════════════════════════════════════════════
|
|
|
|
func TestNodeOptions(t *testing.T) {
|
|
// Test Stream option
|
|
t.Run("Stream", func(t *testing.T) {
|
|
streamFn := func(ctx TestCtx, cctx context.Context, base string) ([]byte, string, error) {
|
|
return nil, "", nil
|
|
}
|
|
spec := FileNode[TestCtx]("test", 0444, Stream(streamFn))
|
|
if spec.Stream == nil {
|
|
t.Error("Stream not set")
|
|
}
|
|
})
|
|
|
|
// Test BlockOnce option
|
|
t.Run("BlockOnce", func(t *testing.T) {
|
|
blockFn := func(ctx TestCtx, cctx context.Context, base string) ([]byte, string, error) {
|
|
return nil, "", nil
|
|
}
|
|
spec := FileNode[TestCtx]("test", 0444, BlockOnce(blockFn))
|
|
if spec.BlockOnce == nil {
|
|
t.Error("BlockOnce not set")
|
|
}
|
|
})
|
|
|
|
// Test Request option
|
|
t.Run("Request", func(t *testing.T) {
|
|
reqFn := func(ctx TestCtx, data []byte) ([]byte, error) {
|
|
return nil, nil
|
|
}
|
|
spec := FileNode[TestCtx]("test", 0666, Request(reqFn))
|
|
if spec.Request == nil {
|
|
t.Error("Request not set")
|
|
}
|
|
})
|
|
|
|
// Test RemoveNode option
|
|
t.Run("RemoveNode", func(t *testing.T) {
|
|
removeFn := func(ctx TestCtx) error {
|
|
return nil
|
|
}
|
|
spec := DirNode[TestCtx]("test",
|
|
FileNode[TestCtx]("child", 0444),
|
|
RemoveNode[TestCtx](removeFn),
|
|
)
|
|
if spec.Remove == nil {
|
|
t.Error("Remove not set")
|
|
}
|
|
})
|
|
|
|
// Test StatOverride option
|
|
t.Run("StatOverride", func(t *testing.T) {
|
|
statFn := func() os.FileInfo {
|
|
return &SyntheticFileInfo{Name_: "test"}
|
|
}
|
|
spec := FileNode[TestCtx]("test", 0444, StatOverride[TestCtx](statFn))
|
|
if spec.Stat == nil {
|
|
t.Error("Stat not set")
|
|
}
|
|
})
|
|
|
|
// Test UID option
|
|
t.Run("UID", func(t *testing.T) {
|
|
spec := FileNode[TestCtx]("test", 0444, UID[TestCtx]("owner"))
|
|
if spec.UID != "owner" {
|
|
t.Errorf("UID = %q, want 'owner'", spec.UID)
|
|
}
|
|
})
|
|
|
|
// Test GID option
|
|
t.Run("GID", func(t *testing.T) {
|
|
spec := FileNode[TestCtx]("test", 0444, GID[TestCtx]("group"))
|
|
if spec.GID != "group" {
|
|
t.Errorf("GID = %q, want 'group'", spec.GID)
|
|
}
|
|
})
|
|
|
|
// Test Alias option
|
|
// Test Bind option
|
|
t.Run("Bind", func(t *testing.T) {
|
|
spec := DirNode[TestCtx]("test",
|
|
FileNode[TestCtx]("child", 0444),
|
|
Bind[TestCtx](BindData),
|
|
)
|
|
if len(spec.BindTargets) != 1 {
|
|
t.Errorf("BindTargets = %v, want 1 element", spec.BindTargets)
|
|
}
|
|
})
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════
|
|
// Tree tests
|
|
// ══════════════════════════════════════════════════════════════════
|
|
|
|
func TestTreeMount(t *testing.T) {
|
|
parent := newTree(nil, 0755)
|
|
child := newTree(nil, 0755,
|
|
withLister(func(_ []string) ([]os.DirEntry, error) {
|
|
return []os.DirEntry{fileEntry("childfile", 0444)}, nil
|
|
}),
|
|
)
|
|
|
|
parent.Mount("subdir", child)
|
|
|
|
// Test Child retrieval
|
|
got := parent.Child("subdir")
|
|
if got != child {
|
|
t.Error("Child did not return mounted child")
|
|
}
|
|
|
|
// Test Children map
|
|
children := parent.Children()
|
|
if len(children) != 1 {
|
|
t.Errorf("Children = %d, want 1", len(children))
|
|
}
|
|
if children["subdir"] != child {
|
|
t.Error("Children map missing 'subdir'")
|
|
}
|
|
|
|
// Test nil child
|
|
nilChild := parent.Child("nonexistent")
|
|
if nilChild != nil {
|
|
t.Error("Child should return nil for nonexistent")
|
|
}
|
|
}
|
|
|
|
func TestTreeSplit(t *testing.T) {
|
|
parent := newTree(nil, 0755)
|
|
child := newTree(nil, 0755)
|
|
parent.Mount("sub", child)
|
|
|
|
// Test exact match
|
|
c, rest, ok := parent.split("sub")
|
|
if !ok {
|
|
t.Error("split should find 'sub'")
|
|
}
|
|
if c != child {
|
|
t.Error("split returned wrong child")
|
|
}
|
|
if rest != "" {
|
|
t.Errorf("rest = %q, want ''", rest)
|
|
}
|
|
|
|
// Test prefix match
|
|
c2, rest2, ok2 := parent.split("sub/file")
|
|
if !ok2 {
|
|
t.Error("split should find 'sub/file'")
|
|
}
|
|
if c2 != child {
|
|
t.Error("split returned wrong child")
|
|
}
|
|
if rest2 != "file" {
|
|
t.Errorf("rest = %q, want 'file'", rest2)
|
|
}
|
|
|
|
// Test no match
|
|
_, _, ok3 := parent.split("other")
|
|
if ok3 {
|
|
t.Error("split should not find 'other'")
|
|
}
|
|
|
|
// Test tree without children
|
|
empty := newTree(nil, 0755)
|
|
_, _, ok4 := empty.split("anything")
|
|
if ok4 {
|
|
t.Error("split should not find anything in empty tree")
|
|
}
|
|
}
|
|
|
|
func TestTreeIndexOf(t *testing.T) {
|
|
// Test indexOf helper
|
|
if indexOf("hello/world", '/') != 5 {
|
|
t.Error("indexOf failed for slash")
|
|
}
|
|
if indexOf("noslash", '/') != -1 {
|
|
t.Error("indexOf should return -1")
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════
|
|
// fileConfig tests
|
|
// ══════════════════════════════════════════════════════════════════
|
|
|
|
func TestFileConfigMethods(t *testing.T) {
|
|
fc := &fileConfig{
|
|
StatFn: func() (os.FileInfo, error) { return &SyntheticFileInfo{Name_: "test"}, nil },
|
|
ReadFn: func() ([]byte, error) { return []byte("content"), nil },
|
|
WriteFn: func(data []byte) error { return nil },
|
|
BlockingReadFn: func(ctx context.Context, base string) ([]byte, string, error) { return nil, "", nil },
|
|
}
|
|
|
|
// Stat
|
|
fi, err := fc.Stat()
|
|
if err != nil || fi.Name() != "test" {
|
|
t.Error("Stat failed")
|
|
}
|
|
|
|
// Read
|
|
data, err := fc.Read()
|
|
if err != nil || string(data) != "content" {
|
|
t.Error("Read failed")
|
|
}
|
|
|
|
// Write
|
|
err = fc.Write([]byte("x"))
|
|
if err != nil {
|
|
t.Error("Write failed")
|
|
}
|
|
|
|
// BlockingRead
|
|
_, _, err = fc.BlockingRead(context.Background(), "")
|
|
if err != nil {
|
|
t.Error("BlockingRead failed")
|
|
}
|
|
}
|
|
|
|
func TestFileConfigModes(t *testing.T) {
|
|
// Default: all false
|
|
fc := &fileConfig{}
|
|
if fc.BlockingReadMode() || fc.StreamMode() || fc.RequestRespMode() {
|
|
t.Error("default modes should be false")
|
|
}
|
|
|
|
// BlockingReadMode
|
|
fc2 := &fileConfig{BlockingRead_: true}
|
|
if !fc2.BlockingReadMode() {
|
|
t.Error("BlockingReadMode should be true")
|
|
}
|
|
|
|
// StreamMode
|
|
fc3 := &fileConfig{StreamMode_: true}
|
|
if !fc3.StreamMode() {
|
|
t.Error("StreamMode should be true")
|
|
}
|
|
|
|
// RequestRespMode
|
|
fc4 := &fileConfig{RequestResp_: true}
|
|
if !fc4.RequestRespMode() {
|
|
t.Error("RequestRespMode should be true")
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════
|
|
// SyntheticFileInfo tests
|
|
// ══════════════════════════════════════════════════════════════════
|
|
|
|
func TestSyntheticFileInfo(t *testing.T) {
|
|
fi := &SyntheticFileInfo{
|
|
Name_: "testfile",
|
|
Mode_: 0644,
|
|
Size_: 100,
|
|
IsDir_: false,
|
|
UID_: "user",
|
|
GID_: "group",
|
|
Vers_: 1,
|
|
}
|
|
|
|
if fi.Name() != "testfile" {
|
|
t.Errorf("Name = %q", fi.Name())
|
|
}
|
|
if fi.Size() != 100 {
|
|
t.Errorf("Size = %d", fi.Size())
|
|
}
|
|
if fi.Mode() != 0644 {
|
|
t.Errorf("Mode = %o", fi.Mode())
|
|
}
|
|
if !fi.ModTime().IsZero() {
|
|
t.Error("ModTime should be zero")
|
|
}
|
|
if fi.IsDir() {
|
|
t.Error("IsDir should be false")
|
|
}
|
|
if fi.Sys() != nil {
|
|
t.Error("Sys should be nil")
|
|
}
|
|
|
|
// Test directory
|
|
fiDir := &SyntheticFileInfo{IsDir_: true}
|
|
if !fiDir.IsDir() {
|
|
t.Error("IsDir should be true")
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════
|
|
// syntheticEntry tests
|
|
// ══════════════════════════════════════════════════════════════════
|
|
|
|
func TestSyntheticEntry(t *testing.T) {
|
|
// File entry
|
|
fe := &syntheticEntry{name: "file", mode: 0644, isDir: false}
|
|
if fe.Name() != "file" {
|
|
t.Errorf("Name = %q", fe.Name())
|
|
}
|
|
if fe.IsDir() {
|
|
t.Error("IsDir should be false")
|
|
}
|
|
if fe.Type() != 0 {
|
|
t.Errorf("Type = %v, want 0", fe.Type())
|
|
}
|
|
fi, err := fe.Info()
|
|
if err != nil {
|
|
t.Fatalf("Info: %v", err)
|
|
}
|
|
if fi.Name() != "file" || fi.Mode() != 0644 {
|
|
t.Error("Info returned wrong values")
|
|
}
|
|
|
|
// Dir entry
|
|
de := &syntheticEntry{name: "dir", mode: 0755, isDir: true}
|
|
if !de.IsDir() {
|
|
t.Error("IsDir should be true")
|
|
}
|
|
if de.Type() != os.ModeDir {
|
|
t.Errorf("Type = %v, want ModeDir", de.Type())
|
|
}
|
|
}
|
|
|
|
func TestFileEntryAndDirEntry(t *testing.T) {
|
|
fe := fileEntry("myfile", 0644)
|
|
if fe.Name() != "myfile" || fe.IsDir() {
|
|
t.Error("fileEntry wrong")
|
|
}
|
|
|
|
de := dirEntry("mydir", 0755)
|
|
if de.Name() != "mydir" || !de.IsDir() {
|
|
t.Error("DirEntry wrong")
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════
|
|
// Builder tests - delete/rename operations
|
|
// ══════════════════════════════════════════════════════════════════
|
|
|
|
func TestDeleteDir(t *testing.T) {
|
|
var deleted bool
|
|
spec := DirNode[TestCtx]("/",
|
|
Each("{id}", func(ctx TestCtx) ([]Binding[TestCtx], error) {
|
|
return []Binding[TestCtx]{{Name: "item"}}, nil
|
|
},
|
|
FileNode[TestCtx]("data", 0444),
|
|
RemoveNode[TestCtx](func(ctx TestCtx) error {
|
|
deleted = true
|
|
return nil
|
|
}),
|
|
),
|
|
)
|
|
|
|
tree := BuildTree(spec, TestCtx{})
|
|
err := tree.Delete("item")
|
|
if err != nil {
|
|
t.Fatalf("Delete: %v", err)
|
|
}
|
|
if !deleted {
|
|
t.Error("remove handler not called")
|
|
}
|
|
}
|
|
|
|
func TestDeleteDirNotFound(t *testing.T) {
|
|
spec := DirNode[TestCtx]("/",
|
|
FileNode[TestCtx]("file", 0444),
|
|
)
|
|
tree := BuildTree(spec, TestCtx{})
|
|
err := tree.Delete("nonexistent")
|
|
if err == nil {
|
|
t.Error("Delete should fail for nonexistent")
|
|
}
|
|
}
|
|
|
|
func TestDeleteDirNotRemovable(t *testing.T) {
|
|
spec := DirNode[TestCtx]("/",
|
|
Each("{id}", func(ctx TestCtx) ([]Binding[TestCtx], error) {
|
|
return []Binding[TestCtx]{{Name: "item"}}, nil
|
|
},
|
|
FileNode[TestCtx]("data", 0444),
|
|
// No RemoveNode set
|
|
),
|
|
)
|
|
tree := BuildTree(spec, TestCtx{})
|
|
err := tree.Delete("item")
|
|
if err == nil {
|
|
t.Error("Delete should fail when not removable")
|
|
}
|
|
}
|
|
|
|
func TestRenameDir(t *testing.T) {
|
|
var newName string
|
|
spec := DirNode[TestCtx]("/",
|
|
Each("{id}", func(ctx TestCtx) ([]Binding[TestCtx], error) {
|
|
return []Binding[TestCtx]{{
|
|
Name: "item",
|
|
Rename: func(n string) error {
|
|
newName = n
|
|
return nil
|
|
},
|
|
}}, nil
|
|
},
|
|
FileNode[TestCtx]("data", 0444),
|
|
),
|
|
)
|
|
|
|
tree := BuildTree(spec, TestCtx{})
|
|
err := tree.Rename("item", "newitem")
|
|
if err != nil {
|
|
t.Fatalf("Rename: %v", err)
|
|
}
|
|
if newName != "newitem" {
|
|
t.Errorf("newName = %q, want 'newitem'", newName)
|
|
}
|
|
}
|
|
|
|
func TestRenameDirNotFound(t *testing.T) {
|
|
spec := DirNode[TestCtx]("/",
|
|
FileNode[TestCtx]("file", 0444),
|
|
)
|
|
tree := BuildTree(spec, TestCtx{})
|
|
err := tree.Rename("nonexistent", "new")
|
|
if err == nil {
|
|
t.Error("Rename should fail for nonexistent")
|
|
}
|
|
}
|
|
|
|
func TestRenameDirNotSupported(t *testing.T) {
|
|
spec := DirNode[TestCtx]("/",
|
|
Each("{id}", func(ctx TestCtx) ([]Binding[TestCtx], error) {
|
|
return []Binding[TestCtx]{{Name: "item"}}, nil // No Rename handler
|
|
},
|
|
FileNode[TestCtx]("data", 0444),
|
|
),
|
|
)
|
|
tree := BuildTree(spec, TestCtx{})
|
|
err := tree.Rename("item", "new")
|
|
if err == nil {
|
|
t.Error("Rename should fail when not supported")
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════
|
|
// Builder tests - blocking modes in openLeaf
|
|
// ══════════════════════════════════════════════════════════════════
|
|
|
|
func TestOpenLeafBlockOnce(t *testing.T) {
|
|
spec := DirNode[TestCtx]("/",
|
|
FileNode[TestCtx]("blocking", 0444,
|
|
BlockOnce(func(ctx TestCtx, cctx context.Context, base string) ([]byte, string, error) {
|
|
return []byte("data"), "next", nil
|
|
}),
|
|
),
|
|
)
|
|
|
|
tree := BuildTree(spec, TestCtx{})
|
|
f, err := tree.Open("blocking")
|
|
if err != nil {
|
|
t.Fatalf("Open: %v", err)
|
|
}
|
|
if !f.BlockingReadMode() {
|
|
t.Error("expected BlockingReadMode")
|
|
}
|
|
if f.StreamMode() || f.RequestRespMode() {
|
|
t.Error("other modes should be false")
|
|
}
|
|
}
|
|
|
|
func TestOpenLeafStream(t *testing.T) {
|
|
spec := DirNode[TestCtx]("/",
|
|
FileNode[TestCtx]("streaming", 0444,
|
|
Stream(func(ctx TestCtx, cctx context.Context, base string) ([]byte, string, error) {
|
|
return []byte("stream"), "cursor", nil
|
|
}),
|
|
),
|
|
)
|
|
|
|
tree := BuildTree(spec, TestCtx{})
|
|
f, err := tree.Open("streaming")
|
|
if err != nil {
|
|
t.Fatalf("Open: %v", err)
|
|
}
|
|
if !f.StreamMode() {
|
|
t.Error("expected StreamMode")
|
|
}
|
|
if f.BlockingReadMode() || f.RequestRespMode() {
|
|
t.Error("other modes should be false")
|
|
}
|
|
}
|
|
|
|
func TestOpenLeafRequest(t *testing.T) {
|
|
spec := DirNode[TestCtx]("/",
|
|
FileNode[TestCtx]("request", 0666,
|
|
Request(func(ctx TestCtx, data []byte) ([]byte, error) {
|
|
return append([]byte("echo:"), data...), nil
|
|
}),
|
|
),
|
|
)
|
|
|
|
tree := BuildTree(spec, TestCtx{})
|
|
f, err := tree.Open("request")
|
|
if err != nil {
|
|
t.Fatalf("Open: %v", err)
|
|
}
|
|
if !f.RequestRespMode() {
|
|
t.Error("expected RequestRespMode")
|
|
}
|
|
|
|
// Write then read
|
|
err = f.Write([]byte("test"))
|
|
if err != nil {
|
|
t.Fatalf("Write: %v", err)
|
|
}
|
|
data, err := f.Read()
|
|
if err != nil {
|
|
t.Fatalf("Read: %v", err)
|
|
}
|
|
if string(data) != "echo:test" {
|
|
t.Errorf("data = %q, want 'echo:test'", data)
|
|
}
|
|
}
|
|
|
|
func TestOpenLeafRequestError(t *testing.T) {
|
|
spec := DirNode[TestCtx]("/",
|
|
FileNode[TestCtx]("request", 0666,
|
|
Request(func(ctx TestCtx, data []byte) ([]byte, error) {
|
|
return nil, errors.New("request error")
|
|
}),
|
|
),
|
|
)
|
|
|
|
tree := BuildTree(spec, TestCtx{})
|
|
f, err := tree.Open("request")
|
|
if err != nil {
|
|
t.Fatalf("Open: %v", err)
|
|
}
|
|
err = f.Write([]byte("test"))
|
|
if err == nil {
|
|
t.Error("Write should fail when request returns error")
|
|
}
|
|
}
|
|
|
|
func TestOpenLeafRequestWithFallbackRead(t *testing.T) {
|
|
spec := DirNode[TestCtx]("/",
|
|
FileNode[TestCtx]("request", 0666,
|
|
Read(func(ctx TestCtx) ([]byte, error) {
|
|
return []byte("fallback"), nil
|
|
}),
|
|
Request(func(ctx TestCtx, data []byte) ([]byte, error) {
|
|
return []byte("response"), nil
|
|
}),
|
|
),
|
|
)
|
|
|
|
tree := BuildTree(spec, TestCtx{})
|
|
f, err := tree.Open("request")
|
|
if err != nil {
|
|
t.Fatalf("Open: %v", err)
|
|
}
|
|
|
|
// Read without write should use fallback
|
|
data, err := f.Read()
|
|
if err != nil {
|
|
t.Fatalf("Read: %v", err)
|
|
}
|
|
if string(data) != "fallback" {
|
|
t.Errorf("data = %q, want 'fallback'", data)
|
|
}
|
|
}
|
|
|
|
func TestOpenLeafNoWriteHandler(t *testing.T) {
|
|
spec := DirNode[TestCtx]("/",
|
|
FileNode[TestCtx]("readonly", 0444,
|
|
Read(func(ctx TestCtx) ([]byte, error) {
|
|
return []byte("data"), nil
|
|
}),
|
|
// No Write handler
|
|
),
|
|
)
|
|
|
|
tree := BuildTree(spec, TestCtx{})
|
|
f, err := tree.Open("readonly")
|
|
if err != nil {
|
|
t.Fatalf("Open: %v", err)
|
|
}
|
|
err = f.Write([]byte("test"))
|
|
if err == nil {
|
|
t.Error("Write should fail on read-only file")
|
|
}
|
|
}
|
|
|
|
func TestOpenLeafNoReadHandler(t *testing.T) {
|
|
spec := DirNode[TestCtx]("/",
|
|
FileNode[TestCtx]("noread", 0222,
|
|
Write(func(ctx TestCtx, data []byte) error {
|
|
return nil
|
|
}),
|
|
// No Read handler
|
|
),
|
|
)
|
|
|
|
tree := BuildTree(spec, TestCtx{})
|
|
f, err := tree.Open("noread")
|
|
if err != nil {
|
|
t.Fatalf("Open: %v", err)
|
|
}
|
|
// Read should return empty
|
|
data, err := f.Read()
|
|
if err != nil {
|
|
t.Fatalf("Read: %v", err)
|
|
}
|
|
if len(data) != 0 {
|
|
t.Errorf("data = %q, want empty", data)
|
|
}
|
|
}
|
|
|
|
func TestOpenLeafStatWithCustomStat(t *testing.T) {
|
|
spec := DirNode[TestCtx]("/",
|
|
FileNode[TestCtx]("custom", 0444,
|
|
StatOverride[TestCtx](func() os.FileInfo {
|
|
return &SyntheticFileInfo{Name_: "customname", Size_: 999, Mode_: 0777}
|
|
}),
|
|
),
|
|
)
|
|
|
|
tree := BuildTree(spec, TestCtx{})
|
|
f, err := tree.Open("custom")
|
|
if err != nil {
|
|
t.Fatalf("Open: %v", err)
|
|
}
|
|
fi, err := f.Stat()
|
|
if err != nil {
|
|
t.Fatalf("Stat: %v", err)
|
|
}
|
|
if fi.Name() != "customname" {
|
|
t.Errorf("Name = %q", fi.Name())
|
|
}
|
|
if fi.Size() != 999 {
|
|
t.Errorf("Size = %d", fi.Size())
|
|
}
|
|
}
|
|
|
|
func TestOpenLeafDefaultBlockingRead(t *testing.T) {
|
|
spec := DirNode[TestCtx]("/",
|
|
FileNode[TestCtx]("normal", 0444,
|
|
Read(func(ctx TestCtx) ([]byte, error) {
|
|
return []byte("data"), nil
|
|
}),
|
|
),
|
|
)
|
|
|
|
tree := BuildTree(spec, TestCtx{})
|
|
f, err := tree.Open("normal")
|
|
if err != nil {
|
|
t.Fatalf("Open: %v", err)
|
|
}
|
|
// BlockingRead should return error for non-blocking file
|
|
_, _, err = f.BlockingRead(context.Background(), "")
|
|
if err == nil {
|
|
t.Error("BlockingRead should fail for non-blocking file")
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════
|
|
// Builder tests - validation
|
|
// ══════════════════════════════════════════════════════════════════
|
|
|
|
func TestValidateDirWithFileHandlers(t *testing.T) {
|
|
defer func() {
|
|
if r := recover(); r == nil {
|
|
t.Error("should panic for dir with file handlers")
|
|
}
|
|
}()
|
|
|
|
spec := DirNode[TestCtx]("/",
|
|
FileNode[TestCtx]("child", 0444),
|
|
Read(func(ctx TestCtx) ([]byte, error) { return nil, nil }),
|
|
)
|
|
BuildTree(spec, TestCtx{})
|
|
}
|
|
|
|
func TestValidateMultipleBlockingModes(t *testing.T) {
|
|
defer func() {
|
|
if r := recover(); r == nil {
|
|
t.Error("should panic for multiple blocking modes")
|
|
}
|
|
}()
|
|
|
|
spec := DirNode[TestCtx]("/",
|
|
FileNode[TestCtx]("file", 0444,
|
|
BlockOnce(func(ctx TestCtx, cctx context.Context, base string) ([]byte, string, error) { return nil, "", nil }),
|
|
Stream(func(ctx TestCtx, cctx context.Context, base string) ([]byte, string, error) { return nil, "", nil }),
|
|
),
|
|
)
|
|
BuildTree(spec, TestCtx{})
|
|
}
|
|
|
|
func TestValidateTemplateWithoutBindings(t *testing.T) {
|
|
defer func() {
|
|
if r := recover(); r == nil {
|
|
t.Error("should panic for template without bindings")
|
|
}
|
|
}()
|
|
|
|
spec := DirNode[TestCtx]("/",
|
|
DirNode[TestCtx]("{id}",
|
|
FileNode[TestCtx]("data", 0444),
|
|
),
|
|
)
|
|
BuildTree(spec, TestCtx{})
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════
|
|
// Builder tests - findChild with nested paths
|
|
// ══════════════════════════════════════════════════════════════════
|
|
|
|
func TestFindChildNestedPath(t *testing.T) {
|
|
spec := DirNode[TestCtx]("/",
|
|
DirNode[TestCtx]("level1",
|
|
DirNode[TestCtx]("level2",
|
|
FileNode[TestCtx]("file", 0444,
|
|
Read(func(ctx TestCtx) ([]byte, error) {
|
|
return []byte("deep"), nil
|
|
}),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
|
|
tree := BuildTree(spec, TestCtx{})
|
|
|
|
// Open nested file
|
|
f, err := tree.Open("level1/level2/file")
|
|
if err != nil {
|
|
t.Fatalf("Open: %v", err)
|
|
}
|
|
data, err := f.Read()
|
|
if err != nil {
|
|
t.Fatalf("Read: %v", err)
|
|
}
|
|
if string(data) != "deep" {
|
|
t.Errorf("data = %q, want 'deep'", data)
|
|
}
|
|
}
|
|
|
|
func TestFindChildNestedNotFound(t *testing.T) {
|
|
spec := DirNode[TestCtx]("/",
|
|
DirNode[TestCtx]("level1",
|
|
FileNode[TestCtx]("file", 0444),
|
|
),
|
|
)
|
|
|
|
tree := BuildTree(spec, TestCtx{})
|
|
_, err := tree.Open("level1/nonexistent/file")
|
|
if err == nil {
|
|
t.Error("Open should fail for nonexistent nested path")
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════
|
|
// Builder tests - Each with file handlers (file template)
|
|
// ══════════════════════════════════════════════════════════════════
|
|
|
|
func TestEachFileTemplate(t *testing.T) {
|
|
items := map[string]string{
|
|
"item1": "content1",
|
|
"item2": "content2",
|
|
}
|
|
|
|
spec := DirNode[TestCtx]("/",
|
|
Each("{id}", func(ctx TestCtx) ([]Binding[TestCtx], error) {
|
|
var bindings []Binding[TestCtx]
|
|
for name, content := range items {
|
|
c := content // capture
|
|
bindings = append(bindings, Binding[TestCtx]{
|
|
Name: name,
|
|
Applier: func(ctx TestCtx) TestCtx {
|
|
ctx.Data = c
|
|
return ctx
|
|
},
|
|
})
|
|
}
|
|
return bindings, nil
|
|
},
|
|
Read(func(ctx TestCtx) ([]byte, error) {
|
|
return []byte(ctx.Data.(string)), nil
|
|
}),
|
|
),
|
|
)
|
|
|
|
tree := BuildTree(spec, TestCtx{})
|
|
|
|
// List should show files
|
|
entries, err := tree.List()
|
|
if err != nil {
|
|
t.Fatalf("List: %v", err)
|
|
}
|
|
if len(entries) != 2 {
|
|
t.Errorf("entries = %d, want 2", len(entries))
|
|
}
|
|
for _, e := range entries {
|
|
if e.IsDir() {
|
|
t.Errorf("%s should be a file", e.Name())
|
|
}
|
|
}
|
|
|
|
// Open and read
|
|
f, err := tree.Open("item1")
|
|
if err != nil {
|
|
t.Fatalf("Open: %v", err)
|
|
}
|
|
data, err := f.Read()
|
|
if err != nil {
|
|
t.Fatalf("Read: %v", err)
|
|
}
|
|
if string(data) != "content1" {
|
|
t.Errorf("data = %q, want 'content1'", data)
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════
|
|
// Builder tests - Each bindings error
|
|
// ══════════════════════════════════════════════════════════════════
|
|
|
|
func TestEachBindingsError(t *testing.T) {
|
|
spec := DirNode[TestCtx]("/",
|
|
Each("{id}", func(ctx TestCtx) ([]Binding[TestCtx], error) {
|
|
return nil, errors.New("bindings error")
|
|
},
|
|
FileNode[TestCtx]("data", 0444),
|
|
),
|
|
)
|
|
|
|
tree := BuildTree(spec, TestCtx{})
|
|
|
|
// List should return empty (error is swallowed)
|
|
entries, err := tree.List()
|
|
if err != nil {
|
|
t.Fatalf("List: %v", err)
|
|
}
|
|
if len(entries) != 0 {
|
|
t.Errorf("entries = %d, want 0", len(entries))
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════
|
|
// Builder tests - statDir
|
|
// ══════════════════════════════════════════════════════════════════
|
|
|
|
func TestStatDirSelf(t *testing.T) {
|
|
spec := DirNode[TestCtx]("/",
|
|
DirNode[TestCtx]("subdir",
|
|
FileNode[TestCtx]("file", 0444),
|
|
),
|
|
)
|
|
|
|
tree := BuildTree(spec, TestCtx{})
|
|
|
|
// Stat directory itself
|
|
fi, err := tree.Stat("")
|
|
if err != nil {
|
|
t.Fatalf("Stat: %v", err)
|
|
}
|
|
if !fi.IsDir() {
|
|
t.Error("should be dir")
|
|
}
|
|
|
|
// Stat with "."
|
|
fi2, err := tree.Stat(".")
|
|
if err != nil {
|
|
// This might not be supported, which is OK
|
|
return
|
|
}
|
|
if !fi2.IsDir() {
|
|
t.Error("should be dir")
|
|
}
|
|
}
|
|
|
|
func TestStatDirChild(t *testing.T) {
|
|
spec := DirNode[TestCtx]("/",
|
|
DirNode[TestCtx]("subdir",
|
|
FileNode[TestCtx]("file", 0444),
|
|
),
|
|
)
|
|
|
|
tree := BuildTree(spec, TestCtx{})
|
|
|
|
// Stat child directory
|
|
fi, err := tree.Stat("subdir")
|
|
if err != nil {
|
|
t.Fatalf("Stat: %v", err)
|
|
}
|
|
if !fi.IsDir() {
|
|
t.Error("should be dir")
|
|
}
|
|
if fi.Name() != "subdir" {
|
|
t.Errorf("Name = %q", fi.Name())
|
|
}
|
|
}
|
|
|
|
func TestStatDirFile(t *testing.T) {
|
|
spec := DirNode[TestCtx]("/",
|
|
FileNode[TestCtx]("myfile", 0644),
|
|
)
|
|
|
|
tree := BuildTree(spec, TestCtx{})
|
|
|
|
fi, err := tree.Stat("myfile")
|
|
if err != nil {
|
|
t.Fatalf("Stat: %v", err)
|
|
}
|
|
if fi.IsDir() {
|
|
t.Error("should not be dir")
|
|
}
|
|
if fi.Name() != "myfile" {
|
|
t.Errorf("Name = %q", fi.Name())
|
|
}
|
|
}
|
|
|
|
func TestStatDirWithStatOverride(t *testing.T) {
|
|
spec := DirNode[TestCtx]("/",
|
|
FileNode[TestCtx]("custom", 0444,
|
|
StatOverride[TestCtx](func() os.FileInfo {
|
|
return &SyntheticFileInfo{Name_: "override", Size_: 123}
|
|
}),
|
|
),
|
|
)
|
|
|
|
tree := BuildTree(spec, TestCtx{})
|
|
|
|
fi, err := tree.Stat("custom")
|
|
if err != nil {
|
|
t.Fatalf("Stat: %v", err)
|
|
}
|
|
if fi.Name() != "override" {
|
|
t.Errorf("Name = %q", fi.Name())
|
|
}
|
|
if fi.Size() != 123 {
|
|
t.Errorf("Size = %d", fi.Size())
|
|
}
|
|
}
|
|
|
|
func TestStatDirNotFound(t *testing.T) {
|
|
spec := DirNode[TestCtx]("/",
|
|
FileNode[TestCtx]("file", 0444),
|
|
)
|
|
|
|
tree := BuildTree(spec, TestCtx{})
|
|
|
|
_, err := tree.Stat("nonexistent")
|
|
if err == nil {
|
|
t.Error("Stat should fail for nonexistent")
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════
|
|
// Builder tests - create not supported
|
|
// ══════════════════════════════════════════════════════════════════
|
|
|
|
func TestCreateNotSupported(t *testing.T) {
|
|
spec := DirNode[TestCtx]("/",
|
|
FileNode[TestCtx]("file", 0444),
|
|
// No create handler — always returns error
|
|
)
|
|
|
|
tree := BuildTree(spec, TestCtx{})
|
|
err := tree.Create("newfile")
|
|
if err == nil {
|
|
t.Error("Create should fail when not supported")
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════
|
|
// Builder tests - UID/GID inheritance
|
|
// ══════════════════════════════════════════════════════════════════
|
|
|
|
func TestUIDGIDInheritance(t *testing.T) {
|
|
spec := DirNode[TestCtx]("/",
|
|
UID[TestCtx]("rootuser"),
|
|
GID[TestCtx]("rootgroup"),
|
|
DirNode[TestCtx]("sub",
|
|
// Should inherit from parent
|
|
FileNode[TestCtx]("file", 0444),
|
|
),
|
|
DirNode[TestCtx]("sub2",
|
|
UID[TestCtx]("subuser"),
|
|
FileNode[TestCtx]("file2", 0444,
|
|
UID[TestCtx]("fileuser"), // Explicitly set UID on file
|
|
),
|
|
),
|
|
)
|
|
|
|
tree := BuildTree(spec, TestCtx{})
|
|
|
|
// Check inherited values - sub/file inherits from root
|
|
f, err := tree.Open("sub/file")
|
|
if err != nil {
|
|
t.Fatalf("Open: %v", err)
|
|
}
|
|
fi, err := f.Stat()
|
|
if err != nil {
|
|
t.Fatalf("Stat: %v", err)
|
|
}
|
|
sfi := fi.(*SyntheticFileInfo)
|
|
if sfi.UID_ != "rootuser" {
|
|
t.Errorf("UID = %q, want 'rootuser'", sfi.UID_)
|
|
}
|
|
if sfi.GID_ != "rootgroup" {
|
|
t.Errorf("GID = %q, want 'rootgroup'", sfi.GID_)
|
|
}
|
|
|
|
// Check overridden UID on file
|
|
f2, err := tree.Open("sub2/file2")
|
|
if err != nil {
|
|
t.Fatalf("Open: %v", err)
|
|
}
|
|
fi2, err := f2.Stat()
|
|
if err != nil {
|
|
t.Fatalf("Stat: %v", err)
|
|
}
|
|
sfi2 := fi2.(*SyntheticFileInfo)
|
|
if sfi2.UID_ != "fileuser" {
|
|
t.Errorf("UID = %q, want 'fileuser'", sfi2.UID_)
|
|
}
|
|
// GID should still be inherited from root
|
|
if sfi2.GID_ != "rootgroup" {
|
|
t.Errorf("GID = %q, want 'rootgroup'", sfi2.GID_)
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════
|
|
// Builder tests - mode defaults
|
|
// ══════════════════════════════════════════════════════════════════
|
|
|
|
func TestModeDefaults(t *testing.T) {
|
|
spec := DirNode[TestCtx]("/",
|
|
DirNode[TestCtx]("subdir",
|
|
FileNode[TestCtx]("file", 0644), // Explicit mode
|
|
),
|
|
)
|
|
|
|
tree := BuildTree(spec, TestCtx{})
|
|
|
|
// Check dir mode - 0 defaults to 0755
|
|
fi, err := tree.Stat("subdir")
|
|
if err != nil {
|
|
t.Fatalf("Stat: %v", err)
|
|
}
|
|
if fi.Mode()&os.ModePerm != 0755 {
|
|
t.Errorf("dir mode = %o, want 0755", fi.Mode()&os.ModePerm)
|
|
}
|
|
|
|
// Check file mode - explicit 0644
|
|
fi2, err := tree.Stat("subdir/file")
|
|
if err != nil {
|
|
t.Fatalf("Stat: %v", err)
|
|
}
|
|
if fi2.Mode() != 0644 {
|
|
t.Errorf("file mode = %o, want 0644", fi2.Mode())
|
|
}
|
|
|
|
// Also test that root dir defaults to 0755
|
|
fi3, err := tree.Stat("")
|
|
if err != nil {
|
|
t.Fatalf("Stat root: %v", err)
|
|
}
|
|
if fi3.Mode()&os.ModePerm != 0755 {
|
|
t.Errorf("root mode = %o, want 0755", fi3.Mode()&os.ModePerm)
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════
|
|
// Original tests (preserved)
|
|
// ══════════════════════════════════════════════════════════════════
|
|
|
|
func TestEachBasic(t *testing.T) {
|
|
type testData struct {
|
|
ID string
|
|
Name string
|
|
}
|
|
|
|
instances := []testData{
|
|
{ID: "aaa-111", Name: "first"},
|
|
{ID: "bbb-222", Name: "second"},
|
|
}
|
|
|
|
spec := DirNode[TestCtx]("/",
|
|
Each("{id}", func(ctx TestCtx) ([]Binding[TestCtx], error) {
|
|
var out []Binding[TestCtx]
|
|
for _, d := range instances {
|
|
data := d // capture
|
|
out = append(out, Binding[TestCtx]{
|
|
Name: data.Name,
|
|
Aliases: []string{data.ID},
|
|
UID: data.ID,
|
|
GID: "testgroup",
|
|
})
|
|
}
|
|
return out, nil
|
|
},
|
|
FileNode[TestCtx]("id", 0444, Read(func(ctx TestCtx) ([]byte, error) {
|
|
return []byte("test-id\n"), nil
|
|
})),
|
|
FileNode[TestCtx]("name", 0666,
|
|
Read(func(ctx TestCtx) ([]byte, error) {
|
|
return []byte("test-name\n"), nil
|
|
}),
|
|
Write(func(ctx TestCtx, data []byte) error {
|
|
return nil // no-op for test
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
|
|
tree := BuildTree(spec, TestCtx{})
|
|
|
|
// Test: List root should show the two instances by name
|
|
entries, err := tree.List()
|
|
if err != nil {
|
|
t.Fatalf("List root: %v", err)
|
|
}
|
|
if len(entries) != 2 {
|
|
t.Errorf("List root: got %d entries, want 2", len(entries))
|
|
}
|
|
|
|
names := make(map[string]bool)
|
|
for _, e := range entries {
|
|
names[e.Name()] = true
|
|
}
|
|
if !names["first"] {
|
|
t.Error("List root: missing 'first'")
|
|
}
|
|
if !names["second"] {
|
|
t.Error("List root: missing 'second'")
|
|
}
|
|
|
|
// Test: Access by name
|
|
sub, err := tree.Readdir("first")
|
|
if err != nil {
|
|
t.Fatalf("Readdir(first): %v", err)
|
|
}
|
|
foundID := false
|
|
foundName := false
|
|
for _, e := range sub {
|
|
switch e.Name() {
|
|
case "id":
|
|
foundID = true
|
|
case "name":
|
|
foundName = true
|
|
}
|
|
}
|
|
if !foundID {
|
|
t.Error("Readdir(first): missing 'id' leaf")
|
|
}
|
|
if !foundName {
|
|
t.Error("Readdir(first): missing 'name' leaf")
|
|
}
|
|
|
|
// Test: Access by alias
|
|
subByAlias, err := tree.Readdir("aaa-111")
|
|
if err != nil {
|
|
t.Fatalf("Readdir(aaa-111): %v", err)
|
|
}
|
|
if len(subByAlias) != len(sub) {
|
|
t.Errorf("Readdir by alias: got %d entries, want %d", len(subByAlias), len(sub))
|
|
}
|
|
|
|
// Test: Read leaf file
|
|
f, err := tree.Open("first/id")
|
|
if err != nil {
|
|
t.Fatalf("Open(first/id): %v", err)
|
|
}
|
|
data, err := f.Read()
|
|
if err != nil {
|
|
t.Fatalf("Read(first/id): %v", err)
|
|
}
|
|
if string(data) != "test-id\n" {
|
|
t.Errorf("Read(first/id) = %q, want %q", data, "test-id\n")
|
|
}
|
|
|
|
// Test: Stat instance directory
|
|
fi, err := tree.Stat("first")
|
|
if err != nil {
|
|
t.Fatalf("Stat(first): %v", err)
|
|
}
|
|
if !fi.IsDir() {
|
|
t.Error("Stat(first): expected directory")
|
|
}
|
|
if fi.Name() != "first" {
|
|
t.Errorf("Stat(first).Name() = %q, want 'first'", fi.Name())
|
|
}
|
|
|
|
// Test: Stat via alias
|
|
fiAlias, err := tree.Stat("aaa-111")
|
|
if err != nil {
|
|
t.Fatalf("Stat(aaa-111): %v", err)
|
|
}
|
|
if fiAlias.Name() != "aaa-111" {
|
|
t.Errorf("Stat(aaa-111).Name() = %q, want 'aaa-111'", fiAlias.Name())
|
|
}
|
|
}
|
|
|
|
func TestContextApplier(t *testing.T) {
|
|
type Ctx struct {
|
|
UserID string
|
|
}
|
|
|
|
spec := DirNode[Ctx]("/",
|
|
Each("{user}", func(ctx Ctx) ([]Binding[Ctx], error) {
|
|
return []Binding[Ctx]{
|
|
{
|
|
Name: "alice",
|
|
Applier: func(c Ctx) Ctx {
|
|
c.UserID = "alice-123"
|
|
return c
|
|
},
|
|
},
|
|
{
|
|
Name: "bob",
|
|
Applier: func(c Ctx) Ctx {
|
|
c.UserID = "bob-456"
|
|
return c
|
|
},
|
|
},
|
|
}, nil
|
|
},
|
|
FileNode[Ctx]("whoami", 0444, Read(func(ctx Ctx) ([]byte, error) {
|
|
return []byte(ctx.UserID + "\n"), nil
|
|
})),
|
|
),
|
|
)
|
|
|
|
tree := BuildTree(spec, Ctx{})
|
|
|
|
// Test: Read alice's whoami
|
|
f, err := tree.Open("alice/whoami")
|
|
if err != nil {
|
|
t.Fatalf("Open(alice/whoami): %v", err)
|
|
}
|
|
data, err := f.Read()
|
|
if err != nil {
|
|
t.Fatalf("Read: %v", err)
|
|
}
|
|
if string(data) != "alice-123\n" {
|
|
t.Errorf("alice/whoami = %q, want 'alice-123\\n'", data)
|
|
}
|
|
|
|
// Test: Read bob's whoami
|
|
f2, err := tree.Open("bob/whoami")
|
|
if err != nil {
|
|
t.Fatalf("Open(bob/whoami): %v", err)
|
|
}
|
|
data2, err := f2.Read()
|
|
if err != nil {
|
|
t.Fatalf("Read: %v", err)
|
|
}
|
|
if string(data2) != "bob-456\n" {
|
|
t.Errorf("bob/whoami = %q, want 'bob-456\\n'", data2)
|
|
}
|
|
}
|
|
|
|
func TestGenerateHelp(t *testing.T) {
|
|
spec := DirNode[TestCtx]("/",
|
|
FileNode[TestCtx]("version", 0444,
|
|
Doc[TestCtx]("API version"),
|
|
Read(func(ctx TestCtx) ([]byte, error) {
|
|
return []byte("1.0\n"), nil
|
|
}),
|
|
),
|
|
DirNode[TestCtx]("data",
|
|
Doc[TestCtx]("Data directory"),
|
|
FileNode[TestCtx]("config", 0666,
|
|
Doc[TestCtx]("Configuration file"),
|
|
),
|
|
),
|
|
)
|
|
|
|
help := GenerateHelp(spec)
|
|
|
|
if help == "" {
|
|
t.Error("GenerateHelp returned empty string")
|
|
}
|
|
if !contains(help, "version") {
|
|
t.Error("help missing 'version'")
|
|
}
|
|
if !contains(help, "API version") {
|
|
t.Error("help missing 'API version' description")
|
|
}
|
|
if !contains(help, "data") {
|
|
t.Error("help missing 'data'")
|
|
}
|
|
}
|
|
|
|
func contains(s, substr string) bool {
|
|
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsAt(s, substr))
|
|
}
|
|
|
|
func containsAt(s, substr string) bool {
|
|
for i := 0; i <= len(s)-len(substr); i++ {
|
|
if s[i:i+len(substr)] == substr {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func TestStatMode(t *testing.T) {
|
|
spec := DirNode[TestCtx]("/",
|
|
Each("{id}", func(ctx TestCtx) ([]Binding[TestCtx], error) {
|
|
return []Binding[TestCtx]{{Name: "instance"}}, nil
|
|
},
|
|
FileNode[TestCtx]("readable", 0444, Read(func(ctx TestCtx) ([]byte, error) {
|
|
return []byte("r\n"), nil
|
|
})),
|
|
FileNode[TestCtx]("writable", 0666,
|
|
Read(func(ctx TestCtx) ([]byte, error) {
|
|
return []byte("rw\n"), nil
|
|
}),
|
|
Write(func(ctx TestCtx, data []byte) error {
|
|
return nil
|
|
}),
|
|
),
|
|
FileNode[TestCtx]("executable", 0555, Read(func(ctx TestCtx) ([]byte, error) {
|
|
return []byte("#!/bin/sh\n"), nil
|
|
})),
|
|
),
|
|
)
|
|
|
|
tree := BuildTree(spec, TestCtx{})
|
|
|
|
tests := []struct {
|
|
path string
|
|
mode os.FileMode
|
|
}{
|
|
{"instance", os.ModeDir | 0755},
|
|
{"instance/readable", 0444},
|
|
{"instance/writable", 0666},
|
|
{"instance/executable", 0555},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
fi, err := tree.Stat(tc.path)
|
|
if err != nil {
|
|
t.Errorf("Stat(%q): %v", tc.path, err)
|
|
continue
|
|
}
|
|
if fi.Mode() != tc.mode {
|
|
t.Errorf("Stat(%q).Mode() = %o, want %o", tc.path, fi.Mode(), tc.mode)
|
|
}
|
|
}
|
|
}
|