296 lines
6.5 KiB
Go
296 lines
6.5 KiB
Go
package fsedsl
|
|
|
|
import (
|
|
"os"
|
|
"testing"
|
|
)
|
|
|
|
// TestCtx is a simple test context.
|
|
type TestCtx struct {
|
|
Data any
|
|
}
|
|
|
|
// TestEachBasic tests the basic Blueprint pattern:
|
|
// - Declares structure statically
|
|
// - Bindings function provides identity (name, aliases, UID/GID)
|
|
// - Handlers receive context with bound data
|
|
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())
|
|
}
|
|
}
|
|
|
|
// TestContextApplier tests that Binding.Applier correctly propagates context
|
|
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)
|
|
}
|
|
}
|
|
|
|
// TestGenerateHelp tests help generation
|
|
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
|
|
}
|
|
|
|
// TestStatMode tests file modes are preserved
|
|
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)
|
|
}
|
|
}
|
|
}
|