Files
mesh-delivery/modules/mesh-delivery-indexer/internal/index/index_test.go
T
jschoubben 677f94e901
mesh/delivery-group group fix/1279-consumers-from-now rejected: a member's own check failed
mesh/merge-gate waits for its group's composed check
mesh/repo-check head 677f94e9012b merged into trunk 1325c8b452c8 beside mesh-controller#400@a0c971ce — fail: its merge-check.sh failed: --- FAIL: TestBridg…
mesh/delivery superseded: a newer head of the same pull request
Say which pull requests are open and drop every other candidate, so the candidates hold only open pull requests (issue 1279, ADR 0443)
2026-10-19 21:56:42 +02:00

374 lines
14 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","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)
}
}