package index import ( "context" "encoding/json" "strings" "testing" "git.novox.be/novox/mesh-sdk/go/delivery" ) const ( h1 = "0123456789abcdef0123456789abcdef01234567" h2 = "89abcdef0123456789abcdef0123456789abcdef" h3 = "fedcba9876543210fedcba9876543210fedcba98" ) // memStore is the candidates bucket's compare-and-set behaviour in memory: a revision per write, a conflict when the // revision read is no longer the key's. Writes counts the writes that went through; beforeWrite lets a test change the // key under the indexer. type memStore struct { vals map[string][]byte revs map[string]uint64 next uint64 writes int beforeWrite func() } func newMem() *memStore { return &memStore{vals: map[string][]byte{}, revs: map[string]uint64{}} } func (m *memStore) Get(_ context.Context, key string) ([]byte, uint64, bool, error) { v, ok := m.vals[key] return v, m.revs[key], ok, nil } func (m *memStore) Keys(context.Context) ([]string, error) { var keys []string for k := range m.vals { keys = append(keys, k) } return keys, nil } func (m *memStore) hook() { if h := m.beforeWrite; h != nil { m.beforeWrite = nil h() } } func (m *memStore) put(key string, value []byte) { m.next++ m.vals[key], m.revs[key] = value, m.next } func (m *memStore) Create(_ context.Context, key string, value []byte) error { m.hook() if _, ok := m.vals[key]; ok { return ErrConflict } m.writes++ m.put(key, value) return nil } func (m *memStore) Update(_ context.Context, key string, value []byte, rev uint64) error { m.hook() if _, ok := m.vals[key]; !ok || m.revs[key] != rev { return ErrConflict } m.writes++ m.put(key, value) return nil } func (m *memStore) Delete(_ context.Context, key string, rev uint64) error { m.hook() if _, ok := m.vals[key]; !ok || m.revs[key] != rev { return ErrConflict } m.writes++ delete(m.vals, key) return nil } // buildRecord is the controller's record as mesh-sdk go/delivery/testdata/build-record-build-agent.json (captured from // the controller's encoder; copied here, the vendored SDK does not carry testdata) has it, with the repository, module and path changed. func buildRecord(t *testing.T, repo, module, path, at string) []byte { t.Helper() raw := []byte(`{"id":"build-1792395447088936520","state":"built","repository":"novox/mesh-controller","ref":"main","module":"build-agent","commit":"7a74cee83f88e6585a5511c3cc53bdf0f5303d20","path":"modules/build-agent","source-fingerprint":"src1:e846efe73716b78e80860baaac96153de2cb76551325753838ca245d2160a3d7","at":"2026-10-19T09:38:00Z"}`) var m map[string]any if err := json.Unmarshal(raw, &m); err != nil { t.Fatal(err) } m["repository"], m["module"], m["path"], m["at"] = repo, module, path, at b, _ := json.Marshal(m) return b } func pull(repo string, number int, branch, head string, dirs ...string) delivery.Forge { return delivery.Forge{V: delivery.Version, Kind: delivery.ForgePull, Repository: repo, Branch: branch, Number: number, Head: head, Action: delivery.PullUpdated, Dirs: dirs, DirsKnown: dirs != nil} } func newIndexer(t *testing.T) (*Indexer, *memStore, *strings.Builder) { t.Helper() st, logs := newMem(), &strings.Builder{} b := NewBuilds() b.Put(delivery.BuildKey("a", "build-1"), buildRecord(t, "novox/mesh-delivery", "a", "modules/a", "2026-10-19T09:00:00Z")) b.Put(delivery.BuildKey("c", "build-2"), buildRecord(t, "novox/mesh-catalog", "c", "modules/c", "2026-10-19T09:00:00Z")) b.Put(delivery.BuildKey("odd", "build-3"), buildRecord(t, "novox/mesh-delivery", "odd-name", "modules/odd", "2026-10-19T09:00:00Z")) return &Indexer{Store: st, Builds: b, Logf: func(f string, a ...any) { logs.WriteString(strings.TrimSpace(strings.ReplaceAll(f, "\n", " ")) + "\n") _ = a }}, st, logs } func read(t *testing.T, st *memStore, key string) (delivery.Candidate, bool) { t.Helper() v, ok := st.vals[key] if !ok { return delivery.Candidate{}, false } c, err := delivery.DecodeCandidate(v) if err != nil { t.Fatal(err) } return c, true } func TestTwoRepositoriesFormOneGroupByBranch(t *testing.T) { // 1238/AC2 (the grouping; the bus is the integration test) ix, st, _ := newIndexer(t) ctx := context.Background() if err := ix.Apply(ctx, pull("novox/mesh-delivery", 12, "feat/x", h1, "modules/a")); err != nil { t.Fatal(err) } if err := ix.Apply(ctx, pull("novox/mesh-catalog", 300, "feat/x", h2, "modules/c")); err != nil { t.Fatal(err) } c, ok := read(t, st, "feat/x") if !ok { t.Fatal("no candidate under feat/x") } hash, _ := delivery.HeadsHash([]delivery.Head{{Repository: "novox/mesh-delivery", Number: 12, Head: h1}, {Repository: "novox/mesh-catalog", Number: 300, Head: h2}}) if c.Group != "feat/x" || c.Base != 0 || c.HeadsHash != hash || len(c.Pulls) != 2 { t.Fatalf("candidate = %+v", c) } got := map[string][]string{} for _, p := range c.Pulls { got[p.Repository] = p.Modules } if strings.Join(got["novox/mesh-delivery"], ",") != "a" || strings.Join(got["novox/mesh-catalog"], ",") != "c" { t.Errorf("modules = %v", got) } } func TestModuleOfADirectory(t *testing.T) { // 1238/AC3 ix, st, _ := newIndexer(t) // modules/odd is named by a build record of the repository (module odd-name); modules/plain is named by none. if err := ix.Apply(context.Background(), pull("novox/mesh-delivery", 12, "feat/x", h1, "modules/odd", "modules/plain", "modules/odd")); err != nil { t.Fatal(err) } c, _ := read(t, st, "feat/x") if strings.Join(c.Pulls[0].Modules, ",") != "odd-name,plain" { t.Errorf("modules = %v, want the record's module for modules/odd, the last element for modules/plain, no repeats", c.Pulls[0].Modules) } // A record of another repository does not name this repository's directory. if m, ok := ix.Builds.Module("novox/other", "modules/a"); ok { t.Errorf("modules/a of novox/other is %q", m) } // The newest record of a directory wins. ix.Builds.Put(delivery.BuildKey("renamed", "build-9"), buildRecord(t, "novox/mesh-delivery", "renamed", "modules/a", "2026-10-19T10:00:00Z")) if m := ix.Builds.ModuleOf("novox/mesh-delivery", "modules/a"); m != "renamed" { t.Errorf("modules/a = %q, want the newest record's module", m) } } func TestDirsNotKnownKeepTheModulesOfTheSameHead(t *testing.T) { // 1238/AC4 ix, st, _ := newIndexer(t) ctx := context.Background() _ = ix.Apply(ctx, pull("novox/mesh-delivery", 12, "feat/x", h1, "modules/a")) unknown := pull("novox/mesh-delivery", 12, "feat/x", h1) if err := ix.Apply(ctx, unknown); err != nil { t.Fatal(err) } c, _ := read(t, st, "feat/x") if strings.Join(c.Pulls[0].Modules, ",") != "a" { t.Errorf("modules = %v, want a kept", c.Pulls[0].Modules) } // A new head with directories not known is listed with no modules. unknown.Head = h3 if err := ix.Apply(ctx, unknown); err != nil { t.Fatal(err) } c, _ = read(t, st, "feat/x") if c.Pulls[0].Head.Head != h3 || len(c.Pulls[0].Modules) != 0 { t.Errorf("pull = %+v, want the new head with no modules", c.Pulls[0]) } // Known and empty is no modules, not unknown. known := pull("novox/mesh-delivery", 12, "feat/x", h3) known.Dirs, known.DirsKnown = nil, true if err := ix.Apply(ctx, known); err != nil { t.Fatal(err) } } func TestClosedAndMergedTakeAPullOutAndTheLastDeletesTheKey(t *testing.T) { // 1238/AC5 for _, how := range []string{"closed", "merged"} { ix, st, _ := newIndexer(t) ctx := context.Background() _ = ix.Apply(ctx, pull("novox/mesh-delivery", 12, "feat/x", h1, "modules/a")) _ = ix.Apply(ctx, pull("novox/mesh-catalog", 300, "feat/x", h2, "modules/c")) gone := func(repo string, n int, head string) delivery.Forge { if how == "closed" { f := pull(repo, n, "feat/x", head) f.Action = delivery.PullClosed return f } return delivery.Forge{V: delivery.Version, Kind: delivery.ForgeMerged, Repository: repo, Branch: "feat/x", Number: n, Head: head, Merge: h3, Root: "r", Trees: map[string]string{"a": "t"}} } if err := ix.Apply(ctx, gone("novox/mesh-delivery", 12, h1)); err != nil { t.Fatal(err) } c, ok := read(t, st, "feat/x") if !ok || len(c.Pulls) != 1 || c.Pulls[0].Repository != "novox/mesh-catalog" { t.Fatalf("%s: candidate = %+v (kept %v)", how, c, ok) } hash, _ := delivery.HeadsHash([]delivery.Head{c.Pulls[0].Head}) if c.HeadsHash != hash { t.Errorf("%s: heads hash %q is not the remaining pull request's %q", how, c.HeadsHash, hash) } if err := ix.Apply(ctx, gone("novox/mesh-catalog", 300, h2)); err != nil { t.Fatal(err) } if _, ok := read(t, st, "feat/x"); ok { t.Errorf("%s: the last pull request left and the key is still there", how) } // A pull request the group does not hold changes nothing and writes nothing. writes := st.writes if err := ix.Apply(ctx, gone("novox/mesh-catalog", 300, h2)); err != nil || st.writes != writes { t.Errorf("%s: closing an unheld pull request wrote (%d writes, err %v)", how, st.writes-writes, err) } } } func TestTrunkLikeBranchesMakeNoCandidate(t *testing.T) { // 1238/AC6 for _, branch := range []string{"main", "master", "trunk", ""} { ix, st, logs := newIndexer(t) if err := ix.Apply(context.Background(), pull("novox/mesh-delivery", 12, branch, h1, "modules/a")); err != nil { t.Fatal(err) } if len(st.vals) != 0 || st.writes != 0 { t.Errorf("branch %q: %d keys written", branch, len(st.vals)) } if !strings.Contains(logs.String(), "no delivery group") { t.Errorf("branch %q: log %q does not say why", branch, logs.String()) } } } func TestSameMessageWritesNoNewRevision(t *testing.T) { // 1238/AC7 ix, st, _ := newIndexer(t) ctx := context.Background() m := pull("novox/mesh-delivery", 12, "feat/x", h1, "modules/a") _ = ix.Apply(ctx, m) rev, writes := st.revs["feat/x"], st.writes for i := 0; i < 3; i++ { if err := ix.Apply(ctx, m); err != nil { t.Fatal(err) } } if st.revs["feat/x"] != rev || st.writes != writes { t.Errorf("the same message wrote again: revision %d to %d", rev, st.revs["feat/x"]) } } func TestALonePullRequestIsAGroupOfOne(t *testing.T) { // 1238/AC8 ix, st, _ := newIndexer(t) if err := ix.Apply(context.Background(), pull("novox/mesh-delivery", 12, "fix/lone", h1, "modules/a")); err != nil { t.Fatal(err) } c, ok := read(t, st, "fix/lone") if !ok || c.Group != "fix/lone" || len(c.Pulls) != 1 { t.Errorf("candidate = %+v (%v)", c, ok) } } func TestAConflictReadsAgain(t *testing.T) { // 1238 (compare-and-set: Update with the revision read, read again on a conflict) ix, st, _ := newIndexer(t) ctx := context.Background() _ = ix.Apply(ctx, pull("novox/mesh-delivery", 12, "feat/x", h1, "modules/a")) // Between the indexer's read and its write another writer adds #300 to the group. st.beforeWrite = func() { other := delivery.Candidate{V: delivery.Version, Group: "feat/x", Pulls: []delivery.CandidatePull{ {Head: delivery.Head{Repository: "novox/mesh-delivery", Number: 12, Head: h1}, Modules: []string{"a"}}, {Head: delivery.Head{Repository: "novox/mesh-catalog", Number: 300, Head: h2}, Modules: []string{"c"}}}} other.HeadsHash, _ = delivery.HeadsHash([]delivery.Head{other.Pulls[0].Head, other.Pulls[1].Head}) data, _ := delivery.EncodeCandidate(other) st.put("feat/x", data) } if err := ix.Apply(ctx, pull("novox/mesh-delivery", 12, "feat/x", h3, "modules/a")); err != nil { t.Fatal(err) } c, _ := read(t, st, "feat/x") if len(c.Pulls) != 2 { t.Fatalf("pulls = %+v, want #12 moved and #300 kept (the other writer's)", c.Pulls) } for _, p := range c.Pulls { if p.Number == 12 && p.Head.Head != h3 { t.Errorf("#12 head = %s", p.Head.Head) } } } func TestAnUnusableBranchNameIsUnreadable(t *testing.T) { ix, _, _ := newIndexer(t) err := ix.Apply(context.Background(), pull("novox/mesh-delivery", 12, "feat x?", h1, "modules/a")) if err == nil || !strings.Contains(err.Error(), "unreadable") { t.Errorf("err = %v", err) } } func open(pulls ...delivery.OpenPull) delivery.Forge { return delivery.Forge{V: delivery.Version, Kind: delivery.ForgeOpen, Open: pulls} } // 1279/AC7 (ADR 0443 ยง5): on an open message, candidates lose every pull request it does not name, a group left empty is // deleted, and a pull request added by a later message stays. func TestAnOpenMessageDropsEveryCandidateItDoesNotName(t *testing.T) { // 1279/AC7 ix, st, _ := newIndexer(t) ctx := context.Background() for _, f := range []delivery.Forge{ pull("novox/mesh-delivery", 12, "feat/x", h1, "modules/a"), pull("novox/mesh-catalog", 300, "feat/x", h2, "modules/c"), pull("novox/mesh-delivery", 13, "feat/y", h2, "modules/a"), pull("novox/mesh-catalog", 280, "feat/old", h3, "modules/c"), } { if err := ix.Apply(ctx, f); err != nil { t.Fatal(err) } } before, _ := read(t, st, "feat/x") // Open: mesh-delivery#12 only (and a pull request no group holds, which changes nothing). if err := ix.Apply(ctx, open(delivery.OpenPull{Repository: "novox/mesh-delivery", Number: 12}, delivery.OpenPull{Repository: "novox/mesh-catalog", Number: 999})); err != nil { t.Fatal(err) } c, ok := read(t, st, "feat/x") hash, _ := delivery.HeadsHash([]delivery.Head{{Repository: "novox/mesh-delivery", Number: 12, Head: h1}}) if !ok || len(c.Pulls) != 1 || c.Pulls[0].Number != 12 || c.Pulls[0].Head.Head != h1 || strings.Join(c.Pulls[0].Modules, ",") != "a" || c.HeadsHash != hash || c.HeadsHash == before.HeadsHash { t.Fatalf("feat/x after the open message = %+v, want only mesh-delivery#12 with its modules and the heads hash of it alone", c) } for _, gone := range []string{"feat/y", "feat/old"} { if _, ok := st.vals[gone]; ok { t.Errorf("the group %s is left with no open pull request and was not deleted", gone) } } // A pull request opened after the read is heard as a message after the open message and stays. if err := ix.Apply(ctx, pull("novox/mesh-catalog", 300, "feat/x", h2, "modules/c")); err != nil { t.Fatal(err) } if c, _ := read(t, st, "feat/x"); len(c.Pulls) != 2 { t.Errorf("a pull request added after the open message is not kept: %+v", c) } // The same open message again changes nothing (no write), and an empty one drops every group. writes := st.writes if err := ix.Apply(ctx, open(delivery.OpenPull{Repository: "novox/mesh-delivery", Number: 12}, delivery.OpenPull{Repository: "novox/mesh-catalog", Number: 300})); err != nil { t.Fatal(err) } if st.writes != writes { t.Errorf("an open message that names every candidate wrote %d times", st.writes-writes) } if err := ix.Apply(ctx, open()); err != nil { t.Fatal(err) } if len(st.vals) != 0 { t.Errorf("after an empty open message the candidates hold %v", st.vals) } }