Files
mesh-delivery/sdk/graph/graph_test.go
T
jschoubben 1387b10e78 Take a missing graph state as no graph yet and a Publisher's directory as the nearest module above it
The indexer's recompute and merge failed on listing a graph bucket not yet on the bus, and a package published from inside a module's directory had no publisher, so the dependents of a build module missed what imports its packages (review findings 3 and 4 of L3).

Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
2026-11-03 17:21:01 +01:00

189 lines
8.4 KiB
Go

package graph
import (
"errors"
"reflect"
"slices"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// TestGraphEdges: the module graph's cycle, dependents and providers, each from the edges alone (ADR 0603 §2, ADR 0604 §1).
func TestGraphEdges(t *testing.T) {
t.Run("a node's value", nodeValues)
fp := "src2:" + strings.Repeat("b", 64)
node := func(repository, path string, edit func(*Node)) Node {
n := Node{FP: fp, Repository: repository, Path: path}
if edit != nil {
edit(&n)
}
return n
}
gotool := map[string]Toolchain{"go": {Module: "build-go", Version: "go1", Recipe: "1"}}
// A base chain, a package and a build module: app stands on base, imports lib's package, is built by build-go.
g := Graph{
"build-go": node("novox/mesh-catalog", "modules/build-go", func(n *Node) { n.Toolchains = gotool }),
"lib": node("novox/lib", delivery.RootPath, nil),
"base": node("novox/mesh-catalog", "modules/base", nil),
"app": node("novox/mesh-catalog", "modules/app", func(n *Node) {
n.Bases, n.Packages, n.Toolchains = []string{"base"}, []string{"git.novox.be/novox/lib"}, gotool
}),
"top": node("novox/mesh-catalog", "modules/top", func(n *Node) { n.Bases = []string{"app"} }),
// A package published from inside a module's directory belongs to that module (ADR 0604 §1): users imports build-go's.
"users": node("novox/other", "modules/users", func(n *Node) {
n.Packages = []string{"git.novox.be/novox/mesh-catalog/modules/build-go/buildrun"}
}),
"db": node("novox/mesh-catalog", "modules/db", func(n *Node) { n.Provides = []string{"database"} }),
"web": node("novox/mesh-catalog", "modules/web", func(n *Node) {
n.Requires, n.Awaits = []string{"database", "db"}, []string{"database"}
}),
}
for module, want := range map[string][]string{
"base": {"app", "top"},
"lib": {"app", "top"},
"build-go": {"app", "top", "users"}, // build-go's own toolchain edge reaches itself, which is left out
"top": nil,
"db": nil, // a provision edge is no build edge
} {
if got := g.Dependents(module); !reflect.DeepEqual(got, want) {
t.Errorf("dependents of %s = %v, want %v", module, got, want)
}
}
if got := g.Providers("database"); !reflect.DeepEqual(got, []string{"db"}) {
t.Errorf("providers of database = %v", got)
}
if got := g.Providers("db"); !reflect.DeepEqual(got, []string{"db"}) { // a module provides its own name
t.Errorf("providers of db = %v", got)
}
if got := g.Before("web"); !reflect.DeepEqual(got, []string{"db"}) { // web awaits database, which db provides
t.Errorf("web waits for %v", got)
}
if got := g.Before("top"); !reflect.DeepEqual(got, []string{"app"}) {
t.Errorf("top waits for %v", got)
}
if got := g.Requirers("db"); !reflect.DeepEqual(got, []string{"web"}) {
t.Errorf("requirers of db = %v", got)
}
for pkg, want := range map[string]string{
"git.novox.be/novox/mesh-catalog/modules/build-go/buildrun": "build-go", // inside its directory
"git.novox.be/novox/mesh-catalog/modules/build-go": "build-go", // its directory
"git.novox.be/novox/mesh-catalog/modules/build-gone": "", // beside it, not inside
"git.novox.be/novox/lib/sub": "lib", // a root module publishes what is below it
} {
if got := g.Publisher(pkg); got != want {
t.Errorf("publisher of %s = %q, want %q", pkg, got, want)
}
}
if g.Publisher("git.novox.be/novox/lib") != "lib" || g.Publisher("git.novox.be/novox/mesh-sdk/go") != "" {
t.Error("a package's publisher is not the module at its repository and directory")
}
// From manifests alone (the check's graph): a requirement answered by a helper at mesh scope is awaited, an optional
// one is not, and two modules awaiting each other's provision are a cycle.
offer := func(name string) []catalogue.Offer {
return []catalogue.Offer{{Name: name, Scope: catalogue.ScopeMesh, AnsweredBy: catalogue.AnsweredByHelper}}
}
ms := map[string]catalogue.Manifest{
"db": {Module: "db", Provides: offer("database"), Requires: []string{"site", "logs"}, OptionalRequires: map[string]bool{"logs": true}},
"web": {Module: "web", Provides: offer("site"), Requires: []string{"database"}, Build: &catalogue.Build{On: []catalogue.BuildsOn{{Module: "base"}}}},
}
mg := OfManifests(ms)
if db := mg["db"]; !reflect.DeepEqual(db.Requires, []string{"site"}) || !reflect.DeepEqual(db.Awaits, []string{"site"}) ||
!reflect.DeepEqual(mg["web"].Bases, []string{"base"}) || !reflect.DeepEqual(db.Provides, []string{"database"}) {
t.Errorf("the graph of manifests = %+v", mg)
}
if got := mg.Cycle(); !reflect.DeepEqual(got, []string{"db", "web", "db"}) {
t.Errorf("the cycle of manifests = %v", got)
}
for name, c := range map[string]struct {
edit func(Graph)
want []string
}{
"none: a toolchain on itself and a requirement not awaited order nothing": {func(Graph) {}, nil},
"bases": {func(g Graph) {
b := g["base"]
b.Bases = []string{"top"}
g["base"] = b
}, []string{"app", "base", "top", "app"}},
"provisions a -> b -> a": {func(g Graph) {
d := g["db"]
d.Requires, d.Awaits = []string{"site"}, []string{"site"}
g["db"] = d
w := g["web"]
w.Provides = []string{"site"}
g["web"] = w
}, []string{"db", "web", "db"}},
"a requirement not awaited is no edge": {func(g Graph) {
d := g["db"]
d.Requires = []string{"site"}
g["db"] = d
w := g["web"]
w.Provides = []string{"site"}
g["web"] = w
}, nil},
} {
h := Graph{}
for k, v := range g {
h[k] = v
}
c.edit(h)
if got := h.Cycle(); !reflect.DeepEqual(got, c.want) {
t.Errorf("%s: cycle = %v, want %v", name, got, c.want)
}
}
}
// sampleNode is a graph node with every kind of edge, its lists unsorted.
func sampleNode() Node {
return Node{FP: "src2:" + strings.Repeat("a", 64), Repository: "novox/mesh-catalog", Path: "modules/gitea",
Bases: []string{"z-base", "a-base"}, Packages: []string{"git.novox.be/novox/mesh-sdk/go"},
Toolchains: map[string]Toolchain{"go": {Module: "build-go", Version: "go1.27.1", Recipe: "3"}},
Provides: []string{"forge"}, Requires: []string{"postgres-database", "dns"}, Awaits: []string{"postgres-database"}}
}
// nodeValues: a node round-trips with its lists sorted, an unknown version is refused, a node that does not hold is
// refused naming why, and the state is the indexer's graph (ADR 0603 §2).
func nodeValues(t *testing.T) {
if Bucket != "mesh-delivery-indexer_graph" || Read != "mesh-delivery-indexer.graph" || History != 5 || State != "graph" {
t.Error("the graph's state differs from the decided layout")
}
g := sampleNode()
data, err := Encode(g)
if err != nil || !strings.Contains(string(data), `"v":1`) {
t.Fatalf("encode: %s %v", data, err)
}
if got, err := Decode(data); err != nil || !Same(got, g) || !slices.IsSorted(got.Bases) {
t.Errorf("round trip: %+v %v", got, err)
}
if Same(g, Node{None: "x", Repository: "a/b"}) {
t.Error("different graph nodes are the same")
}
for _, in := range []string{`{"none":"x","repository":"a/b"}`, `{"v":2,"none":"x","repository":"a/b"}`} {
if _, err := Decode([]byte(in)); !errors.Is(err, ErrUnknownVersion) {
t.Errorf("decode of %s = %v, want ErrUnknownVersion", in, err)
}
}
for name, c := range map[string]struct {
change func(*Node)
word string
}{
"both": {func(g *Node) { g.None = "why" }, "both"},
"neither": {func(g *Node) { g.FP = "" }, "neither"},
"an old form": {func(g *Node) { g.FP = "src1:x" }, "src2:"},
"a dotted base": {func(g *Node) { g.Bases = []string{"a.b"} }, "a.b"},
"a foreign package": {func(g *Node) { g.Packages = []string{"example.com/x"} }, "example.com/x"},
"a toolchain of no module": {func(g *Node) { g.Toolchains = map[string]Toolchain{"go": {Version: "go1", Recipe: "1"}} }, "build module"},
"a toolchain of no recipe": {func(g *Node) { g.Toolchains = map[string]Toolchain{"go": {Module: "b", Version: "go1"}} }, "recipe"},
"awaiting what it does not require": {func(g *Node) { g.Awaits = []string{"other"} }, "other"},
"no repository": {func(g *Node) { g.Repository = "" }, "repositor"},
} {
g := sampleNode()
c.change(&g)
if _, err := Encode(g); err == nil || !strings.Contains(err.Error(), c.word) {
t.Errorf("a node with %s: %v, want it refused naming %q", name, err, c.word)
}
}
}