Files
mesh-delivery/modules/mesh-delivery-view/internal/view/view_test.go
T
jschoubben 0dbe8f9a32 Derive the delivery view's rows from the have, assignments and builds buckets (issue 1134)
The operator sees per module and node what is assigned, what the node reports and what it waits on; a have with no open assignment is left behind, never running.
2026-10-18 19:31:50 +02:00

212 lines
7.7 KiB
Go

package view
import (
"errors"
"reflect"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
var t0 = time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC)
func have(t *testing.T, build string, phase delivery.Phase, health delivery.Health, detail string) []byte {
t.Helper()
b, err := delivery.EncodeHave(delivery.Have{Build: build, Phase: phase, Health: health, Detail: detail})
if err != nil {
t.Fatal(err)
}
return b
}
func haveE(t *testing.T, node, module string, v []byte, at time.Time) Entry {
return Entry{Bucket: delivery.HaveBucket, Key: node + "." + module, Value: v, At: at}
}
func assign(node, module, build, extra string) Entry {
v := `{"node":"` + node + `","module":"` + module + `","build":"` + build + `","commit":"c","since":"2026-10-18T11:00:00Z"` + extra + `}`
return Entry{Bucket: AssignmentsBucket, Key: node + "." + module, Value: []byte(v), At: t0}
}
func buildRec(module, id, extra string) []Entry {
rec := `{"id":"` + id + `","module":"` + module + `","commit":"c"` + extra + `}`
return []Entry{
{Bucket: BuildsBucket, Key: module + ".build." + id, Value: []byte(rec), At: t0},
{Bucket: BuildsBucket, Key: module + ".newest", Value: []byte(id), At: t0},
}
}
func find(t *testing.T, s Snapshot, node, module string) Row {
t.Helper()
for _, r := range s.Rows {
if r.Node == node && r.Module == module {
return r
}
}
t.Fatalf("no row for %s.%s in %+v", node, module, s.Rows)
return Row{}
}
func fill(m *Model, es ...Entry) {
for _, e := range es {
m.Apply(e)
}
}
// base: two nodes (a, b), three modules (x, y, z).
func base(t *testing.T) []Entry {
es := []Entry{
assign("a", "x", "b1", ""), assign("a", "y", "b2", ""), assign("b", "x", "b1", ""), assign("b", "z", "b3", ""),
haveE(t, "a", "x", have(t, "b1", delivery.PhaseRunning, delivery.HealthOK, ""), t0.Add(1*time.Minute)),
haveE(t, "b", "x", have(t, "b1", delivery.PhaseChecking, delivery.HealthWaits, "starting"), t0.Add(2*time.Minute)),
haveE(t, "b", "z", have(t, "b3", delivery.PhaseRunning, delivery.HealthOK, ""), t0.Add(3*time.Minute)),
}
es = append(es, buildRec("x", "b1", "")...)
es = append(es, buildRec("y", "b2", "")...)
es = append(es, buildRec("z", "b3", "")...)
return es
}
func TestRowsPerAssignedModuleAndNode(t *testing.T) { // 1134/AC1
m := New()
fill(m, base(t)...)
s := m.Snapshot()
if len(s.Rows) != 4 || s.Counted() != 4 {
t.Fatalf("want 4 rows, got %d counted %d: %+v", len(s.Rows), s.Counted(), s.Rows)
}
r := find(t, s, "b", "x")
want := Row{Node: "b", Module: "x", Status: StatusAssigned, Assigned: "b1", Have: "b1", Phase: "checking", Health: "waits",
Since: t0.Add(2 * time.Minute), Detail: "starting"}
if !reflect.DeepEqual(r, want) {
t.Fatalf("row = %+v, want %+v", r, want)
}
y := find(t, s, "a", "y")
if y.Waits != "no have yet" || y.Assigned != "b2" || y.Have != "" {
t.Fatalf("a.y = %+v, want 'no have yet'", y)
}
}
func TestWaitsForTheNodeAndForANewerBuild(t *testing.T) { // 1134/AC2
m := New()
fill(m, base(t)...)
fill(m, haveE(t, "a", "x", have(t, "b0", delivery.PhaseRunning, delivery.HealthOK, ""), t0))
if w := find(t, m.Snapshot(), "a", "x").Waits; w != "the node has not applied b1" {
t.Fatalf("waits = %q", w)
}
fill(m, buildRec("z", "b4", "")...)
if w := find(t, m.Snapshot(), "b", "z").Waits; w != "z has a newer build b4 not yet assigned" {
t.Fatalf("waits = %q", w)
}
// a newest build that is not done is not a newer build to assign
fill(m, buildRec("z", "b5", `,"failed":"boom"`)...)
if w := find(t, m.Snapshot(), "b", "z").Waits; w != "" {
t.Fatalf("a failed newest build must not be waited on, waits = %q", w)
}
fill(m, buildRec("z", "b6", `,"state":"asked"`)...)
if w := find(t, m.Snapshot(), "b", "z").Waits; w != "" {
t.Fatalf("an asked newest build must not be waited on, waits = %q", w)
}
}
func TestLeftBehindIsNotRunning(t *testing.T) { // 1134/AC3
for name, extra := range map[string]string{"ended": `,"ended":"2026-10-18T11:30:00Z"`, "state": `,"state":"pending"`, "pulled-in": `,"pulled-in":true`} {
m := New()
fill(m, base(t)...)
fill(m, assign("b", "z", "b3", extra))
s := m.Snapshot()
r := find(t, s, "b", "z")
if r.Status != StatusLeftBehind {
t.Errorf("%s: status = %q, want %q", name, r.Status, StatusLeftBehind)
}
if s.Counted() != 3 {
t.Errorf("%s: counted = %d, want 3", name, s.Counted())
}
}
m := New()
fill(m, base(t)...)
fill(m, haveE(t, "a", "w", have(t, "b9", delivery.PhaseRunning, delivery.HealthOK, ""), t0)) // no assignment at all
s := m.Snapshot()
if find(t, s, "a", "w").Status != StatusLeftBehind || s.Counted() != 4 {
t.Fatalf("a have with no assignment: %+v counted %d", find(t, s, "a", "w"), s.Counted())
}
}
func TestUnknownVersionSaysSo(t *testing.T) { // 1134/AC4
m := New()
fill(m, base(t)...)
before := m.Snapshot()
fill(m, haveE(t, "a", "x", []byte(`{"v":9,"phase":"running","health":"ok"}`), t0))
s := m.Snapshot()
r := find(t, s, "a", "x")
_, sdkErr := delivery.DecodeHave([]byte(`{"v":9,"phase":"running","health":"ok"}`))
if !errors.Is(sdkErr, delivery.ErrUnknownVersion) {
t.Fatal("test premise: the SDK must refuse version 9")
}
if r.Error != sdkErr.Error() || !strings.Contains(r.Error, "version 9") {
t.Fatalf("error = %q, want the SDK's %q", r.Error, sdkErr)
}
if r.Have != "" || r.Phase != "" || r.Health != "" {
t.Fatalf("an unreadable have must not be guessed: %+v", r)
}
for _, n := range [][2]string{{"a", "y"}, {"b", "x"}, {"b", "z"}} {
if !reflect.DeepEqual(find(t, s, n[0], n[1]), find(t, before, n[0], n[1])) {
t.Errorf("row %s.%s changed", n[0], n[1])
}
}
}
func TestOnlyTheAffectedRowChanges(t *testing.T) { // 1134/AC5
m := New()
fill(m, base(t)...)
before := m.Snapshot()
fill(m, haveE(t, "b", "x", have(t, "b1", delivery.PhaseRunning, delivery.HealthOK, ""), t0.Add(9*time.Minute)))
after := m.Snapshot()
for i := range before.Rows {
changed := !reflect.DeepEqual(before.Rows[i], after.Rows[i])
if changed != (before.Rows[i].Node == "b" && before.Rows[i].Module == "x") {
t.Errorf("row %s.%s changed = %v", before.Rows[i].Node, before.Rows[i].Module, changed)
}
}
if r := find(t, after, "b", "x"); r.Phase != "running" || r.Health != "ok" || !r.Since.Equal(t0.Add(9*time.Minute)) {
t.Fatalf("changed row = %+v", r)
}
// an assignment change and a build change touch only their rows
fill(m, assign("a", "y", "b7", ""))
if r := find(t, m.Snapshot(), "a", "y"); r.Assigned != "b7" {
t.Fatalf("assignment change not seen: %+v", r)
}
if !reflect.DeepEqual(find(t, m.Snapshot(), "b", "z"), find(t, after, "b", "z")) {
t.Fatal("an assignment of another row changed b.z")
}
fill(m, buildRec("x", "b8", "")...)
s := m.Snapshot()
if find(t, s, "a", "x").Waits == "" || find(t, s, "b", "x").Waits == "" || find(t, s, "b", "z").Waits != "" {
t.Fatalf("a build of x must touch x's rows only: %+v", s.Rows)
}
// a deleted key (a bucket's expiry) removes its value
m.Apply(Entry{Bucket: delivery.HaveBucket, Key: "a.x", Deleted: true})
if !strings.HasPrefix(find(t, m.Snapshot(), "a", "x").Waits, "no have yet") {
t.Fatal("a deleted have must read as no have")
}
// a restart rebuilds the same rows from last values
a, b := New(), New()
es := base(t)
fill(a, es...)
fill(b, es...)
if !reflect.DeepEqual(a.Snapshot(), b.Snapshot()) {
t.Fatal("the same last values must give the same rows")
}
}
func TestNodeRow(t *testing.T) { // 1134/AC1 (the node's own key gives a node row)
m := New()
v, _ := delivery.EncodeHaveNode(delivery.HaveNode{Windows: []byte(`[1,2]`), Busy: []byte(`{"a":1}`), Strays: []byte(`[]`)})
fill(m, Entry{Bucket: delivery.HaveBucket, Key: "a.node", Value: v, At: t0})
n := m.Snapshot().Nodes
if len(n) != 1 || n[0].Node != "a" || n[0].Windows != 2 || n[0].Busy != 1 || n[0].Strays != 0 {
t.Fatalf("nodes = %+v", n)
}
}