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"} }), "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"}, // 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) } 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) } } }