package servicedef import ( "os" "path/filepath" "strings" "testing" ) func boolPtr(b bool) *bool { return &b } func sampleDef() *ServiceDef { return &ServiceDef{ Name: "metacrypt", Node: "rift", Active: boolPtr(true), Components: []ComponentDef{ { Name: "api", Image: "mcr.svc.mcp.metacircular.net:8443/metacrypt:latest", Network: "docker_default", User: "0:0", Restart: "unless-stopped", Ports: []string{"127.0.0.1:18443:8443", "127.0.0.1:19443:9443"}, Volumes: []string{"/srv/metacrypt:/srv/metacrypt"}, }, { Name: "web", Image: "mcr.svc.mcp.metacircular.net:8443/metacrypt-web:latest", Network: "docker_default", User: "0:0", Restart: "unless-stopped", Ports: []string{"127.0.0.1:18080:8080"}, Volumes: []string{"/srv/metacrypt:/srv/metacrypt"}, Cmd: []string{"server", "--config", "/srv/metacrypt/metacrypt.toml"}, }, }, } } // compareComponents asserts that two component slices are equal field by field. func compareComponents(t *testing.T, prefix string, got, want []ComponentDef) { t.Helper() if len(got) != len(want) { t.Fatalf("%s components: got %d, want %d", prefix, len(got), len(want)) } for i, wantC := range want { gotC := got[i] if gotC.Name != wantC.Name { t.Fatalf("%s component[%d] name: got %q, want %q", prefix, i, gotC.Name, wantC.Name) } if gotC.Image != wantC.Image { t.Fatalf("%s component[%d] image: got %q, want %q", prefix, i, gotC.Image, wantC.Image) } if gotC.Network != wantC.Network { t.Fatalf("%s component[%d] network: got %q, want %q", prefix, i, gotC.Network, wantC.Network) } if gotC.User != wantC.User { t.Fatalf("%s component[%d] user: got %q, want %q", prefix, i, gotC.User, wantC.User) } if gotC.Restart != wantC.Restart { t.Fatalf("%s component[%d] restart: got %q, want %q", prefix, i, gotC.Restart, wantC.Restart) } compareStrSlice(t, prefix, i, "ports", gotC.Ports, wantC.Ports) compareStrSlice(t, prefix, i, "volumes", gotC.Volumes, wantC.Volumes) compareStrSlice(t, prefix, i, "cmd", gotC.Cmd, wantC.Cmd) } } func compareStrSlice(t *testing.T, prefix string, idx int, field string, got, want []string) { t.Helper() if len(got) != len(want) { t.Fatalf("%s component[%d] %s: got %d, want %d", prefix, idx, field, len(got), len(want)) } for j := range want { if got[j] != want[j] { t.Fatalf("%s component[%d] %s[%d]: got %q, want %q", prefix, idx, field, j, got[j], want[j]) } } } func TestLoadWrite(t *testing.T) { def := sampleDef() dir := t.TempDir() path := filepath.Join(dir, "metacrypt.toml") if err := Write(path, def); err != nil { t.Fatalf("write: %v", err) } got, err := Load(path) if err != nil { t.Fatalf("load: %v", err) } if got.Name != def.Name { t.Fatalf("name: got %q, want %q", got.Name, def.Name) } if got.Node != def.Node { t.Fatalf("node: got %q, want %q", got.Node, def.Node) } if *got.Active != *def.Active { t.Fatalf("active: got %v, want %v", *got.Active, *def.Active) } compareComponents(t, "load-write", got.Components, def.Components) } func TestValidation(t *testing.T) { tests := []struct { name string def *ServiceDef wantErr string }{ { name: "missing name", def: &ServiceDef{ Node: "rift", Components: []ComponentDef{{Name: "api", Image: "img:v1"}}, }, wantErr: "service name is required", }, { name: "missing node", def: &ServiceDef{ Name: "svc", Components: []ComponentDef{{Name: "api", Image: "img:v1"}}, }, wantErr: "service node is required", }, { name: "empty components", def: &ServiceDef{ Name: "svc", Node: "rift", }, wantErr: "must have at least one component", }, { name: "duplicate component names", def: &ServiceDef{ Name: "svc", Node: "rift", Components: []ComponentDef{ {Name: "api", Image: "img:v1"}, {Name: "api", Image: "img:v2"}, }, }, wantErr: "duplicate component name", }, { name: "component missing name", def: &ServiceDef{ Name: "svc", Node: "rift", Components: []ComponentDef{{Image: "img:v1"}}, }, wantErr: "component name is required", }, { name: "component missing image", def: &ServiceDef{ Name: "svc", Node: "rift", Components: []ComponentDef{{Name: "api"}}, }, wantErr: "image is required", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := validate(tt.def) if err == nil { t.Fatal("expected validation error") } if got := err.Error(); !strings.Contains(got, tt.wantErr) { t.Fatalf("error %q does not contain %q", got, tt.wantErr) } }) } } func TestLoadAll(t *testing.T) { dir := t.TempDir() // Write services in non-alphabetical order. defs := []*ServiceDef{ { Name: "mcr", Node: "rift", Active: boolPtr(true), Components: []ComponentDef{{Name: "api", Image: "mcr:latest"}}, }, { Name: "metacrypt", Node: "rift", Active: boolPtr(true), Components: []ComponentDef{{Name: "api", Image: "metacrypt:latest"}}, }, { Name: "mcias", Node: "rift", Active: boolPtr(false), Components: []ComponentDef{{Name: "api", Image: "mcias:latest"}}, }, } for _, d := range defs { if err := Write(filepath.Join(dir, d.Name+".toml"), d); err != nil { t.Fatalf("write %s: %v", d.Name, err) } } // Write a non-TOML file that should be ignored. if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("# ignore"), 0o600); err != nil { t.Fatalf("write readme: %v", err) } got, err := LoadAll(dir) if err != nil { t.Fatalf("load all: %v", err) } if len(got) != 3 { t.Fatalf("count: got %d, want 3", len(got)) } wantOrder := []string{"mcias", "mcr", "metacrypt"} for i, name := range wantOrder { if got[i].Name != name { t.Fatalf("order[%d]: got %q, want %q", i, got[i].Name, name) } } } func TestActiveDefault(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "svc.toml") content := `name = "svc" node = "rift" [[components]] name = "api" image = "img:latest" ` if err := os.WriteFile(path, []byte(content), 0o600); err != nil { t.Fatalf("write: %v", err) } def, err := Load(path) if err != nil { t.Fatalf("load: %v", err) } if def.Active == nil { t.Fatal("active should not be nil") } if !*def.Active { t.Fatal("active should default to true") } } func TestProtoConversion(t *testing.T) { def := sampleDef() spec := ToProto(def) if spec.Name != def.Name { t.Fatalf("proto name: got %q, want %q", spec.Name, def.Name) } if !spec.Active { t.Fatal("proto active should be true") } if len(spec.Components) != len(def.Components) { t.Fatalf("proto components: got %d, want %d", len(spec.Components), len(def.Components)) } got := FromProto(spec, def.Node) if got.Name != def.Name { t.Fatalf("round-trip name: got %q, want %q", got.Name, def.Name) } if got.Node != def.Node { t.Fatalf("round-trip node: got %q, want %q", got.Node, def.Node) } if *got.Active != *def.Active { t.Fatalf("round-trip active: got %v, want %v", *got.Active, *def.Active) } compareComponents(t, "round-trip", got.Components, def.Components) }