Files
mesh-delivery/modules/mesh-delivery-view/internal/view/view_test.go
T
jschoubben 67964cae3f
mesh/delivery delivered
mesh/merge-gate out of date: judged against trunk 6c218876e14d, the trunk is now 0177ea7a7914; judged again on a recheck or when it is merged
mesh/repo-check out of date: judged against trunk 6c218876e14d, the trunk is now 0177ea7a7914; judged again on a recheck or when it is merged
Show a pulled-in module as running with what pulled it in (issue 1134, AC3 revised)
2026-10-18 19:36:03 +02:00

223 lines
8.0 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"`} {
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 TestPulledInIsRunning(t *testing.T) { // 1134/AC3
m := New()
fill(m, base(t)...)
fill(m, assign("b", "z", "b3", `,"pulled-in":true,"required-by":"x"`))
s := m.Snapshot()
r := find(t, s, "b", "z")
if r.Status != StatusAssigned || r.RequiredBy != "x" || r.Have != "b3" || s.Counted() != 4 {
t.Fatalf("pulled-in row = %+v counted %d, want shown and counted with required-by x", r, 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)
}
}