package index import ( "context" "errors" "fmt" "path" "strings" "testing" "time" "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/process" ) const merge = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" // logged is one entry of the builder's log (mesh-sdk go/delivery BuildLogEntry, as the builder appends it): a build of // module from repo's directory path at ref ("" for the trunk), its outcome ("" done, "failed", or "asked" for a build only // started), at the time at; id makes its fingerprint. func logged(t *testing.T, id, module, repo, path, ref, outcome, at string) []byte { t.Helper() when, err := time.Parse(time.RFC3339, at) if err != nil { t.Fatal(err) } if path == "" { path = delivery.RootPath } e := delivery.BuildLogEntry{Event: delivery.BuildEndedEvent, Module: module, FP: "src2:" + id, Repository: repo, Directory: path, Commit: "7a74cee83f88e6585a5511c3cc53bdf0f5303d20", Ref: ref, Node: "n1", At: when, Outcome: delivery.BuildDone} switch outcome { case "": case "failed": e.Outcome = delivery.BuildFailed default: e.Event, e.Outcome = delivery.BuildStartedEvent, "" } raw, err := delivery.EncodeBuildLogEntry(e) if err != nil { t.Fatal(err) } return raw } // record is logged under the module's log key, as the tests' tables carry it. func record(t *testing.T, id, module, repo, path, ref, state, at, _ string) (string, []byte) { t.Helper() return module, logged(t, id, module, repo, path, ref, state, at) } func modulesIndexer(t *testing.T, recs ...[2]any) (*Indexer, *memStore) { t.Helper() mods := newMem() ix := &Indexer{Store: newMem(), Modules: mods, Builds: NewBuilds()} for _, r := range recs { if err := ix.Builds.Put(r[1].([]byte)); err != nil { t.Fatal(err) } } return ix, mods } func rec(t *testing.T, id, module, repo, path, ref, state, at, inputs string) [2]any { k, v := record(t, id, module, repo, path, ref, state, at, inputs) return [2]any{k, v} } // mergedMsg is a merged message of the repository with these module directories' trees, each with a manifest naming its // module, the directory's last element ("r" for the root), as the forge bridge sends it (one manifest per directory that // holds a module.json). func mergedMsg(repo string, trees map[string]string) delivery.Forge { manifests := map[string]string{} for dir := range trees { manifests[dir] = `{"module":"` + nameOfDir(dir) + `","version":"1"}` } return delivery.Forge{V: delivery.Version, Kind: delivery.ForgeMerged, Repository: repo, Branch: "feat/x", Number: 12, Head: h1, Merge: merge, Root: "r0", Trees: trees, Manifests: manifests} } func nameOfDir(dir string) string { if dir == "" { return "r" } return path.Base(dir) } func moduleOf(t *testing.T, st *memStore, key string) (delivery.Module, bool) { t.Helper() v, ok := st.vals[key] if !ok { return delivery.Module{}, false } m, err := delivery.DecodeModule(v) if err != nil { t.Fatal(err) } return m, true } const aInputs = `{"Module":"a","Tree":"oldtree","Bases":["registry.example/base@sha256:aaa"],"Contexts":null,"Toolchains":{"code":"go sha256:ccc 0123456789abcdef"},"Unpinned":""}` func TestAnUnchangedModuleWritesNothing(t *testing.T) { // 1239 (unchanged value writes nothing); the operator 2026-10-25 (a module's value moves only with the module) const repo, next = "novox/mesh-delivery", "cccccccccccccccccccccccccccccccccccccccc" three := map[string]string{"modules/a": "treeA", "modules/b": "treeB", "modules/c": "treeC"} for _, tc := range []struct { name string then func(ix *Indexer) error // after the first merge of a, b and c at merge, stamped by source revision 7 // writes is the values written by then; commit is each module's commit after. writes int commit map[string]string // taken is the merge the repository's taken key names after then (issue 1280, ADR 0556 clause 1). taken string // err says then fails (the message is left for redelivery); said is a line the indexer's log (or that error) must hold. err bool said string // contains, when set, is the forge's order, and asks how often then asks it (one question per merged message). contains func(context.Context, string, string, string) (bool, error) asks int }{ {name: "the same merge again", writes: 0, commit: map[string]string{"a": merge, "b": merge, "c": merge}, taken: merge, then: func(ix *Indexer) error { return ix.Apply(context.Background(), mergedMsg(repo, three)) }}, {name: "a merge touching one of three modules", writes: 1, commit: map[string]string{"a": next, "b": merge, "c": merge}, taken: next, said: "novox/mesh-delivery at cccccccccccc (#12): merged message applied; modules written: a", then: func(ix *Indexer) error { m := mergedMsg(repo, map[string]string{"modules/a": "treeA2", "modules/b": "treeB", "modules/c": "treeC"}) m.Merge = next return ix.Apply(context.Background(), m) }}, {name: "a merge touching none of them is still taken in", writes: 0, commit: map[string]string{"a": merge, "b": merge, "c": merge}, taken: next, then: func(ix *Indexer) error { m := mergedMsg(repo, three) m.Merge = next return ix.Apply(context.Background(), m) }}, {name: "a merge while the taken state is not on the bus yet is still taken in", writes: 1, commit: map[string]string{"a": next, "b": merge, "c": merge}, taken: merge, then: func(ix *Indexer) error { // issue 1790: the state is bound when it appears, so the indexer runs before it does ix.Taken = &memStore{unbound: process.ErrNotYet} m := mergedMsg(repo, map[string]string{"modules/a": "treeA2", "modules/b": "treeB", "modules/c": "treeC"}) m.Merge = next return ix.Apply(context.Background(), m) }}, {name: "a merge heard while the taken state was missing is filled when the state appears", writes: 2, commit: map[string]string{"a": next, "b": merge, "c": merge}, taken: next, then: func(ix *Indexer) error { // issue 1280: the key was missing, the merge was taken in without it on := ix.Taken.(*memStore) key, _ := delivery.RepositoryKey(repo) delete(on.vals, key) ix.Taken = &memStore{unbound: process.ErrNotYet} m := mergedMsg(repo, map[string]string{"modules/a": "treeA2", "modules/b": "treeB", "modules/c": "treeC"}) m.Merge = next if err := ix.Apply(context.Background(), m); err != nil { return err } if err := ix.ApplySource(context.Background(), 8, sourceOf(repo, next)); err != nil { return err } ix.Taken = on return ix.FillTaken(context.Background()) // no Drained: only the merge applied is written, not the source's word }}, {name: "a source head whose merge was not applied is not a taken key", writes: 0, commit: map[string]string{"a": merge, "b": merge, "c": merge}, taken: "", then: func(ix *Indexer) error { // the forge stream still holds the merge: the consumer is not drained delete(ix.Taken.(*memStore).vals, takenKey(repo)) if err := ix.ApplySource(context.Background(), 8, sourceOf(repo, next)); err != nil { return err } return ix.FillTaken(context.Background()) }}, // Issue 1126 (mesh-catalog#535): the forge bridge never wrote the merged message of 9fd9569, so the forge stream was // drained with the merge not applied; the source head moved the key all the same, and the view said the merge was // on the machines. A key moves only by a merge applied (ADR 0556 clause 1), never by the source's word. {name: "a source head of a drained forge stream whose merge was never heard leaves the key", writes: 0, commit: map[string]string{"a": merge, "b": merge, "c": merge}, taken: merge, then: func(ix *Indexer) error { ix.Contains = func(_ context.Context, _, commit, in string) (bool, error) { return commit == merge && in == next, nil } if err := ix.ApplySource(context.Background(), 8, sourceOf(repo, next)); err != nil { return err } return ix.FillTaken(context.Background()) }}, {name: "a source head of a drained forge stream fills no missing key", writes: 0, commit: map[string]string{"a": merge, "b": merge, "c": merge}, taken: "", then: func(ix *Indexer) error { delete(ix.Taken.(*memStore).vals, takenKey(repo)) if err := ix.ApplySource(context.Background(), 8, sourceOf(repo, next)); err != nil { return err } return ix.FillTaken(context.Background()) }}, {name: "an earlier merge heard again after a later one leaves taken", writes: 0, commit: map[string]string{"a": merge, "b": merge, "c": merge}, taken: merge, then: func(ix *Indexer) error { ix.Contains = func(_ context.Context, _, commit, in string) (bool, error) { return commit == next && in == merge, nil } m := mergedMsg(repo, three) m.Merge = next return ix.Apply(context.Background(), m) }}, // Issue 1126 note 76: mesh-catalog's merge 9cdac09a was written back over the later 69056948, rolling kernel and // mesh-build-image back on the nodes. A merge never moves a module backwards: the held commit must be in the incoming one. {name: "an earlier merge heard again after a later one moves no module back", writes: 1, commit: map[string]string{"a": next, "b": merge, "c": merge}, taken: next, said: "the merged message is older than taken.novox/mesh-delivery at cccccccccccc", then: func(ix *Indexer) error { ix.Contains = func(_ context.Context, _, commit, in string) (bool, error) { return commit == in || commit == merge && in == next, nil } later := mergedMsg(repo, map[string]string{"modules/a": "treeA2", "modules/b": "treeB", "modules/c": "treeC"}) later.Merge = next if err := ix.Apply(context.Background(), later); err != nil { return err } return ix.Apply(context.Background(), mergedMsg(repo, three)) }}, {name: "an earlier merge that lacks a later merge's module deletes nothing", writes: 1, commit: map[string]string{"a": merge, "d": next}, taken: next, said: "the merged message is older than taken.novox/mesh-delivery at cccccccccccc", then: func(ix *Indexer) error { ix.Contains = func(_ context.Context, _, commit, in string) (bool, error) { return commit == in || commit == merge && in == next, nil } later := mergedMsg(repo, map[string]string{"modules/a": "treeA", "modules/b": "treeB", "modules/c": "treeC", "modules/d": "treeD"}) later.Merge = next if err := ix.Apply(context.Background(), later); err != nil { return err } return ix.Apply(context.Background(), mergedMsg(repo, three)) }}, // Review round 2: a 502 whose words carry "404" inside a sha is no not-found; the late message is left for redelivery. {name: "a late message whose order the forge fails with a 502 writes nothing and is left for redelivery", writes: 1, commit: map[string]string{"a": next, "b": merge, "c": merge}, taken: next, err: true, said: "502 Bad Gateway", then: func(ix *Indexer) error { ix.Contains = func(context.Context, string, string, string) (bool, error) { return false, nil } later := mergedMsg(repo, map[string]string{"modules/a": "treeA2", "modules/b": "treeB", "modules/c": "treeC"}) later.Merge = next if err := ix.Apply(context.Background(), later); err != nil { return err } ix.Contains = func(context.Context, string, string, string) (bool, error) { return false, errors.New("Gitea API /repos/x/y/compare/aaa404aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb: 502 Bad Gateway") } return ix.Apply(context.Background(), mergedMsg(repo, three)) }}, {name: "a forge that cannot say the order writes nothing and leaves the message for redelivery", writes: 0, commit: map[string]string{"a": merge}, taken: merge, err: true, said: "cannot be read", then: func(ix *Indexer) error { ix.Contains = func(context.Context, string, string, string) (bool, error) { return false, errors.New("the forge did not answer") } later := mergedMsg(repo, map[string]string{"modules/a": "treeA2", "modules/b": "treeB", "modules/c": "treeC"}) later.Merge = next return ix.Apply(context.Background(), later) }}, // Review of 77d738d: only a merge older than the one taken in is kept back. A diverged or rewritten history, or a // taken merge the forge no longer has, is not older, and one question per message is asked. {name: "a merge of a diverged history is written, asking the forge once", writes: 1, commit: map[string]string{"a": next, "b": merge, "c": merge}, taken: next, contains: func(context.Context, string, string, string) (bool, error) { return false, nil }, asks: 1, then: func(ix *Indexer) error { later := mergedMsg(repo, map[string]string{"modules/a": "treeA2", "modules/b": "treeB", "modules/c": "treeC"}) later.Merge = next return ix.Apply(context.Background(), later) }}, {name: "a taken merge the forge no longer has stops no later merge", writes: 1, commit: map[string]string{"a": next, "b": merge, "c": merge}, taken: next, said: "is not known to be older", then: func(ix *Indexer) error { ix.Contains = func(context.Context, string, string, string) (bool, error) { return false, errors.New("gitea_contains: the forge answered: 404 Not Found") } later := mergedMsg(repo, map[string]string{"modules/a": "treeA2", "modules/b": "treeB", "modules/c": "treeC"}) later.Merge = next return ix.Apply(context.Background(), later) }}, {name: "a later source revision of another repository's merge", writes: 0, commit: map[string]string{"a": merge, "b": merge, "c": merge}, taken: merge, then: func(ix *Indexer) error { return ix.ApplySource(context.Background(), 9, delivery.Source{Group: "g", Heads: map[string]string{repo: merge, "novox/mesh-catalog": otherMerge}}) }}, } { t.Run(tc.name, func(t *testing.T) { ix, mods := modulesIndexer(t) taken := newMem() ix.Taken = taken ctx := context.Background() if err := ix.Apply(ctx, mergedMsg(repo, three)); err != nil { t.Fatal(err) } if err := ix.ApplySource(ctx, 7, sourceOf(repo, merge)); err != nil { t.Fatal(err) } writes := mods.writes asks := 0 if tc.contains != nil { ix.Contains = func(ctx context.Context, repo, commit, in string) (bool, error) { asks++ return tc.contains(ctx, repo, commit, in) } } var said strings.Builder ix.Logf = func(format string, args ...any) { fmt.Fprintf(&said, format+"\n", args...) } err := tc.then(ix) if tc.contains != nil && asks != tc.asks { t.Errorf("the forge was asked %d times, want %d", asks, tc.asks) } if (err != nil) != tc.err { t.Fatalf("then = %v, want an error %v", err, tc.err) } if err != nil { said.WriteString(err.Error()) // what handle logs as left for redelivery } if !strings.Contains(said.String(), tc.said) { t.Errorf("the log %q does not say %q", said.String(), tc.said) } if got := mods.writes - writes; got != tc.writes { t.Errorf("%d values written, want %d", got, tc.writes) } for module, want := range tc.commit { if m, ok := moduleOf(t, mods, module); !ok || m.Commit != want { t.Errorf("%s = %+v (%v), want commit %.8s", module, m, ok, want) } } key, _ := delivery.RepositoryKey(repo) if _, held := taken.vals[key]; !held && tc.taken == "" { return } if got, err := delivery.DecodeTaken(taken.vals[key]); err != nil || got.Merge != tc.taken || got.Repository != repo { t.Errorf("taken.%s = %+v (%v), want merge %.8s", key, got, err, tc.taken) } }) } } func TestMergedMessageRemovesThePullFromItsGroupToo(t *testing.T) { // 1238/AC5 with 1239 (both effects of one message) ix, _ := modulesIndexer(t, rec(t, "build-1", "a", "novox/mesh-delivery", "modules/a", "main", "", "2026-10-19T09:00:00Z", aInputs)) cands := ix.Store.(*memStore) _ = ix.Apply(context.Background(), pull("novox/mesh-delivery", 12, "feat/x", h1, "modules/a")) _ = ix.Apply(context.Background(), mergedMsg("novox/mesh-delivery", map[string]string{"modules/a": "newtree"})) if _, ok := cands.vals["feat/x"]; ok { t.Error("the merged pull request is still a candidate") } } func TestAMergeWritesTheModulesDirectoryAndKeepsItThroughSourceAndRecompute(t *testing.T) { // 1606/AC1, AC2 ix, mods := modulesIndexer(t, rec(t, "build-1", "a", "novox/mesh-delivery", "modules/a", "main", "", "2026-10-19T09:00:00Z", aInputs), rec(t, "build-2", "r", "novox/mesh-delivery", "", "main", "", "2026-10-19T09:00:00Z", strings.Replace(aInputs, `"Module":"a"`, `"Module":"r"`, 1))) ctx := context.Background() if err := ix.Apply(ctx, mergedMsg("novox/mesh-delivery", map[string]string{"modules/a": "newtree", "": "roottree"})); err != nil { t.Fatal(err) } for module, want := range map[string]string{"a": "modules/a", "r": delivery.RootPath} { if m, ok := moduleOf(t, mods, module); !ok || m.Path != want { t.Errorf("after the merge %s = %+v (%v), want path %q", module, m, ok, want) } } if err := ix.ApplySource(ctx, 7, sourceOf("novox/mesh-delivery", merge)); err != nil { t.Fatal(err) } if err := ix.Recompute(ctx); err != nil { t.Fatal(err) } if m, _ := moduleOf(t, mods, "a"); m.Path != "modules/a" || m.Src != 7 { t.Errorf("after the source and a recompute a = %+v, want path modules/a kept and Src 7", m) } raw := string(mods.vals["a"]) if !strings.Contains(raw, `"path":"modules/a"`) { t.Errorf("the stored value %s has no path", raw) } } func TestARootModuleHasTheDirectoryDot(t *testing.T) { // 1615/AC3: empty means not known, a root module is "." ix, mods := modulesIndexer(t, rec(t, "build-1", "r", "novox/mesh-sdk", "", "main", "", "2026-10-19T09:00:00Z", strings.Replace(aInputs, `"Module":"a"`, `"Module":"r"`, 1))) if err := ix.Apply(context.Background(), mergedMsg("novox/mesh-sdk", map[string]string{"": "roottree"})); err != nil { t.Fatal(err) } if m, ok := moduleOf(t, mods, "r"); !ok || m.Path != delivery.RootPath { t.Errorf("module = %+v (%v), want path %q", m, ok, delivery.RootPath) } } func TestAMergeAddingAModuleDirectoryWritesItsValueWithNoBuildRecordAnywhere(t *testing.T) { // I1: issue 1851, ADR 0501 clause 6 ix, mods, _ := manifestsIndexer(t) // no build record of any module avahi := catalogueManifest(t, "avahi") f := withManifests(mergedMsg(catalogueRepo, map[string]string{"modules/avahi": "newtree"}), map[string]string{"modules/avahi": avahi}, map[string]map[string]string{"modules/avahi": {"go.sum": avahiGoSum}}) if err := ix.Apply(context.Background(), f); err != nil { t.Fatal(err) } m, ok := moduleOf(t, mods, "avahi") if !ok || m.Path != "modules/avahi" || m.Repository != catalogueRepo || m.Commit != merge || m.Manifest != delivery.ManifestDigest([]byte(avahi)) || m.Inputs2 == nil || m.Inputs2.Tree != "newtree" { t.Fatalf("module = %+v (%v), want its directory, merge, manifest and inputs from the message alone", m, ok) } if m.FP != "" || m.None != "" { t.Errorf("module = %+v, want no value of today's form (I4: it came from the build records)", m) } if m.FP2 == "" && m.None2 == "" { t.Errorf("module = %+v, want a new-form fingerprint or its reason", m) } } func TestAMergeWritesOnlyTheModulesItsManifestsName(t *testing.T) { // I1 ix, mods := modulesIndexer(t, rec(t, "build-2", "c", "novox/mesh-catalog", "modules/c", "main", "", "2026-10-19T09:00:00Z", aInputs)) f := mergedMsg("novox/mesh-delivery", map[string]string{"modules/a": "t", "modules/b": "t"}) f.Trees["modules/c"] = "t" // a directory whose manifest the message does not carry if err := ix.Apply(context.Background(), f); err != nil { t.Fatal(err) } for name, want := range map[string]bool{"a": true, "b": true, "c": false} { if _, ok := mods.vals[name]; ok != want { t.Errorf("modules.%s written %v, want %v", name, ok, want) } } } func TestAModuleHeldOfAnotherRepositoryIsLeftAndSaid(t *testing.T) { // I1: a move between repositories is the operator's (ADR 0420 clause 7) ix, mods := modulesIndexer(t) var logs []string ix.Logf = func(format string, args ...any) { logs = append(logs, format) } ctx := context.Background() if err := ix.Apply(ctx, mergedMsg("novox/mesh-catalog", map[string]string{"modules/a": "t"})); err != nil { t.Fatal(err) } before := string(mods.vals["a"]) if err := ix.Apply(ctx, mergedMsg("novox/mesh-delivery", map[string]string{"modules/a": "t2"})); err != nil { t.Fatal(err) } if string(mods.vals["a"]) != before || len(logs) == 0 { t.Errorf("modules.a = %s (was %s), logs %v: want it left, and said", mods.vals["a"], before, logs) } } func TestTwoDirectoriesNamingOneModuleWriteOneAndSayTheOther(t *testing.T) { // I1 ix, mods := modulesIndexer(t) var logs []string ix.Logf = func(format string, args ...any) { logs = append(logs, format) } f := mergedMsg("novox/mesh-delivery", map[string]string{"modules/a": "t1", "other/a": "t2"}) if err := ix.Apply(context.Background(), f); err != nil { t.Fatal(err) } if m, ok := moduleOf(t, mods, "a"); !ok || m.Path != "modules/a" || len(logs) == 0 { t.Errorf("module = %+v (%v), logs %v: want the first directory by name, and the other said", m, ok, logs) } // Once held, the directory the bucket holds stays the module's. f = mergedMsg("novox/mesh-delivery", map[string]string{"modules/a": "t1", "aaa/a": "t3"}) if err := ix.Apply(context.Background(), f); err != nil { t.Fatal(err) } if m, _ := moduleOf(t, mods, "a"); m.Path != "modules/a" { t.Errorf("module = %+v, want the held directory kept", m) } } func takenKey(repo string) string { key, _ := delivery.RepositoryKey(repo) return key }