Files
mesh-delivery/modules/mesh-delivery-indexer/internal/index/index_test.go
T
jschoubben 9f7e082688 List a repository's modules from the merged message's manifests and the indexer's own bucket, not the controller's build records, so a new module gets a value and a key with no build asked (issue 1851, I1 I2 I4, ADR 0501 §6, ADR 0453 §6)
I1: a merge writes each module its manifests name, at its directory. I2: a pull request's changed directory, a
preview's touched module and a source revision's modules come from the modules bucket (a module a pull request adds is
found in the preview by its manifest). I4: the value of today's form (fp, none) is no longer written, since it was
computed from the build records; mesh-sdk keeps it readable until R4g. One commit: the three share applyMerged and its
tests. Pins mesh-sdk feat/r4e-sdk 9cb4d59.
2026-10-22 18:08:48 +02:00

390 lines
15 KiB
Go

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","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, as R4e has it: the module of a directory is the indexer's own (I2, ADR 0501 clause 6)
ix, st, _ := newIndexer(t)
mods := newMem()
ix.Modules = mods
ctx := context.Background()
// The trunk holds modules/odd as the module odd-name (its manifest names it so); modules/plain is held by none.
merged := mergedMsg("novox/mesh-delivery", map[string]string{"modules/odd": "t"})
merged.Manifests["modules/odd"] = `{"module":"odd-name","version":"1"}`
if err := ix.Apply(ctx, merged); err != nil {
t.Fatal(err)
}
if err := ix.Apply(ctx, 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 held module for modules/odd, the last element for modules/plain, no repeats", c.Pulls[0].Modules)
}
held := map[string]delivery.Module{"x": {Repository: "novox/other", Path: "modules/a"}}
if name, ok := moduleAt(held, "novox/mesh-delivery", "modules/a"); ok {
t.Errorf("modules/a of novox/mesh-delivery is %q, held only of novox/other", name)
}
// A manifest the pull message carries names its module, whatever directory it is in.
f := pull("novox/mesh-delivery", 13, "feat/y", h2, "modules/odd")
f.Manifests = map[string]string{"modules/odd": `{"module":"renamed","version":"1"}`}
if name := moduleOfDir(held, f, "modules/odd"); name != "renamed" {
t.Errorf("modules/odd = %q, want the module its manifest names", name)
}
}
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)
}
}
}
// The SDK escapes a branch name's odd characters into a key (mesh-sdk a6dd698), so a name such as "feat x?" keys and is
// indexed; no branch name is unreadable any more.
func TestAnOddBranchNameKeysEscaped(t *testing.T) {
ix, st, _ := newIndexer(t)
if err := ix.Apply(context.Background(), pull("novox/mesh-delivery", 12, "feat x?", h1, "modules/a")); err != nil {
t.Fatal(err)
}
key, err := delivery.CandidatesKey("feat x?")
if err != nil {
t.Fatal(err)
}
if _, ok := st.vals[key]; !ok {
t.Errorf("no candidate under %q", key)
}
}
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)
}
}