Files
mesh-delivery/modules/mesh-delivery-view/cmd/mesh-delivery-cli/main_test.go
T
jschoubben 0de534c692 Names N3 (view): the status view names its own buckets from its credential's module
Its states answers, reach and waits (viewstate WaitsBucket, ReachBucket, ViewAnswersBucket), its
first views and its marks were named from the constant ViewModule; they are now
`<cred.Module>_<state>` (viewstate.BucketsOf), and the model is made for its module.

Builds: design 47 G A-296

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 19:14:55 +01:00

303 lines
13 KiB
Go

package main
import (
"bytes"
"context"
"encoding/json"
"maps"
"mesh-delivery-view/internal/view"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient"
"git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-assigner/asks"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshdeliveryclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshwantsclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-view/viewstate"
)
func serve(t *testing.T) string {
t.Helper()
m := view.New(viewstate.ViewModule)
placed, err := meshmoduleassignerclient.AssignmentsValues(meshmoduleassigner.Assignments{Node: "a", Modules: []meshmoduleassigner.PlacedModule{{Module: "x"}}})
if err != nil {
t.Fatal(err)
}
for _, k := range slices.Sorted(maps.Keys(placed)) { // a key per module, then the node key
m.Apply(view.Entry{Bucket: view.AssignmentsBucket, Key: k, Value: placed[k]})
}
dir, err := os.MkdirTemp(os.Getenv("TMPDIR"), "c")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.RemoveAll(dir) })
l, err := view.ListenSocket(filepath.Join(dir, "cli.sock"))
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
go view.ServeSocket(ctx, l, m)
t.Cleanup(cancel)
return filepath.Join(dir, "cli.sock")
}
func TestPrintsATable(t *testing.T) { // 1134/AC6
var out, errOut bytes.Buffer
if code := run(nil, serve(t), &out, &errOut); code != 0 {
t.Fatalf("exit %d: %s", code, errOut.String())
}
if !strings.Contains(out.String(), "MODULE") || !strings.Contains(out.String(), "no have yet") {
t.Fatalf("table = %q", out.String())
}
}
func TestJSONFlag(t *testing.T) { // 1134/AC6
var out, errOut bytes.Buffer
if code := run([]string{"--json"}, serve(t), &out, &errOut); code != 0 {
t.Fatalf("exit %d: %s", code, errOut.String())
}
var s view.Snapshot
if err := json.Unmarshal(out.Bytes(), &s); err != nil || len(s.Rows) != 1 || s.Rows[0].Module != "x" {
t.Fatalf("json = %q, %v", out.String(), err)
}
}
func TestFailsLoudWhenTheViewIsNotThere(t *testing.T) { // 1134/AC6
var out, errOut bytes.Buffer
if code := run(nil, filepath.Join(os.Getenv("TMPDIR"), "none.sock"), &out, &errOut); code == 0 || errOut.Len() == 0 {
t.Fatalf("exit %d, stderr %q", code, errOut.String())
}
if code := run([]string{"--start"}, "", &out, &errOut); code != 2 {
t.Fatalf("an unknown flag must exit 2, got %d", code)
}
}
func TestPrintsTheCandidateGroupsAndTheFingerprintComparison(t *testing.T) { // 1241/AC6
m := view.New(viewstate.ViewModule)
const head = "0123456789abcdef0123456789abcdef01234567"
cand, err := json.Marshal(meshcatalog.CandidatesByGroupValue{Group: "feat/x", Pulls: []meshcatalog.CandidatesByGroupValuePullsItem{{Repository: "novox/mesh-delivery", Number: 12, Head: head, Modules: []string{"a"}}},
Heads: mustHash(t, meshdeliveryclient.Head{Repository: "novox/mesh-delivery", Number: 12, Head: head})})
if err != nil {
t.Fatal(err)
}
mod, err := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{None: "no tree", Repository: "novox/mesh-delivery", Commit: head})
if err != nil {
t.Fatal(err)
}
m.Apply(view.Entry{Bucket: view.CandidatesBucket, Key: "feat/x", Value: cand})
m.Apply(view.Entry{Bucket: view.ModulesBucket, Key: "a", Value: mod})
dir, err := os.MkdirTemp(os.Getenv("TMPDIR"), "c")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.RemoveAll(dir) })
l, err := view.ListenSocket(filepath.Join(dir, "cli.sock"))
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
go view.ServeSocket(ctx, l, m)
t.Cleanup(cancel)
var out, errOut bytes.Buffer
if code := run(nil, filepath.Join(dir, "cli.sock"), &out, &errOut); code != 0 {
t.Fatalf("exit %d: %s", code, errOut.String())
}
for _, want := range []string{"CANDIDATES", "feat/x", "novox/mesh-delivery#12", head[:12], "none", "no tree"} {
if !strings.Contains(out.String(), want) {
t.Errorf("the command's table lacks %q:\n%s", want, out.String())
}
}
}
func mustHash(t *testing.T, h meshdeliveryclient.Head) string {
t.Helper()
s, err := meshdeliveryclient.HeadsHash([]meshdeliveryclient.Head{h})
if err != nil {
t.Fatal(err)
}
return s
}
// TestPrintsTheOpenAsksInTheTableAndTheJSON: the command shows what waits for the operator's Approve (1345/AC2). The ask is
// the gate's, as mesh-sdk go/delivery EncodeAsk writes it under the key MergeAskKey gives (as internal/view asks_test.go).
func TestPrintsTheOpenAsksInTheTableAndTheJSON(t *testing.T) { // 1345/AC2
m := view.New(viewstate.ViewModule)
key, err := asks.HoldAskKey("plex", "src2:2222222222222222222222222222222222222222222222222222222222222222")
if err != nil {
t.Fatal(err)
}
ask := oc.Ask{ID: meshdeliveryclient.AskID(key), Headline: "plex is held after a failed health check", Explanation: "ace failed the check on the new build.", Who: occ.Operator,
Expires: occ.FormatTime(time.Now().Add(time.Hour)), OnExpiry: "the fingerprint stays held",
Options: []oc.Option{{ID: "release", Label: "Release", Does: "goes on", Level: occ.Approve}, {ID: "keep", Label: "Keep", Does: "stays", Level: occ.Approve}}}
value, err := asks.EncodeAsk(key, asks.Ask{Ask: rawAsk(t, ask)})
if err != nil {
t.Fatal(err)
}
m.Apply(view.Entry{Bucket: view.AssignerAsksBucket, Key: key, Value: value})
dir, err := os.MkdirTemp(os.Getenv("TMPDIR"), "c")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.RemoveAll(dir) })
l, err := view.ListenSocket(filepath.Join(dir, "cli.sock"))
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
go view.ServeSocket(ctx, l, m)
t.Cleanup(cancel)
socket := filepath.Join(dir, "cli.sock")
var out, errOut bytes.Buffer
if code := run(nil, socket, &out, &errOut); code != 0 {
t.Fatalf("exit %d: %s", code, errOut.String())
}
for _, want := range []string{"ASKS", "plex is held after a failed health check", "ace failed the check on the new build.", view.AnswerInTheView} {
if !strings.Contains(out.String(), want) {
t.Errorf("the table lacks %q:\n%s", want, out.String())
}
}
out.Reset()
if code := run([]string{"--json"}, socket, &out, &errOut); code != 0 {
t.Fatalf("exit %d: %s", code, errOut.String())
}
var got struct {
Asks []struct {
Key string `json:"key"`
Headline string `json:"headline"`
How string `json:"how"`
} `json:"asks"`
}
if err := json.Unmarshal(out.Bytes(), &got); err != nil || len(got.Asks) != 1 || got.Asks[0].Key != key ||
got.Asks[0].Headline != "plex is held after a failed health check" || got.Asks[0].How != view.AnswerInTheView {
t.Errorf("json = %s (%v)", out.String(), err)
}
}
type putter struct{ puts map[string][]byte }
func (p *putter) Put(key string, value []byte) error { p.puts[key] = value; return nil }
func serveWith(t *testing.T, m *view.Model) string {
t.Helper()
dir, err := os.MkdirTemp(os.Getenv("TMPDIR"), "c")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.RemoveAll(dir) })
l, err := view.ListenSocket(filepath.Join(dir, "cli.sock"))
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
go view.ServeSocket(ctx, l, m)
t.Cleanup(cancel)
return filepath.Join(dir, "cli.sock")
}
// TestTheAnswerCommandAnswersAnAskNeedingNoProof: `answer <ask key> <option> [words]` writes the view's answer; the merge's
// ask is refused with the phone as the reason (1297/B9b).
func TestTheAnswerCommandAnswersAnAskNeedingNoProof(t *testing.T) { // 1297/B9b AC
const fp = "src2:2222222222222222222222222222222222222222222222222222222222222222"
key, err := asks.HoldAskKey("plex", fp)
if err != nil {
t.Fatal(err)
}
hold := oc.Ask{ID: meshdeliveryclient.AskID(key), Headline: "plex is held", Explanation: "ace failed the check.", Who: occ.Operator, Expires: occ.FormatTime(time.Now().Add(time.Hour)), OnExpiry: "stays held",
Options: []oc.Option{{ID: "release", Label: "Release", Does: "goes on", Level: occ.Approve}, {ID: "keep", Label: "Keep", Does: "stays", Level: occ.Approve}}}
value, _ := asks.EncodeAsk(key, asks.Ask{Ask: rawAsk(t, hold)})
heads := []meshdeliveryclient.Head{{Repository: "novox/mesh-delivery", Number: 12, Head: "0123456789abcdef0123456789abcdef01234567"}}
mkey, err := meshdeliveryclient.MergeAskKey("feat/x", heads)
if err != nil {
t.Fatal(err)
}
m, w := view.New(viewstate.ViewModule), &putter{puts: map[string][]byte{}}
m.SetWriter(w)
m.Apply(view.Entry{Bucket: view.AssignerAsksBucket, Key: key, Value: value})
socket := serveWith(t, m)
var out, errOut bytes.Buffer
if code := run([]string{"answer", key, "release", "the", "check", "was", "a", "flake"}, socket, &out, &errOut); code != 0 {
t.Fatalf("exit %d: %s", code, errOut.String())
}
a, err := viewstate.DecodeViewAnswer(w.puts[key+".view"])
if err != nil || a.Option != "release" || a.Words != "the check was a flake" || !strings.Contains(out.String(), key) {
t.Errorf("answer = %+v, %v; out %q", a, err, out.String())
}
out.Reset()
errOut.Reset()
if code := run([]string{"answer", mkey, "approve"}, socket, &out, &errOut); code != 1 || !strings.Contains(errOut.String(), "answer it on the phone (issue 953)") {
t.Errorf("merge: exit %d, stderr %q", code, errOut.String())
}
if len(w.puts) != 1 {
t.Errorf("puts = %v, want only the hold's", w.puts)
}
for _, args := range [][]string{{"answer"}, {"answer", key}, {"settle", key, "release"}} {
errOut.Reset()
if code := run(args, socket, &out, &errOut); code != 2 || errOut.Len() == 0 {
t.Errorf("%v: exit %d, stderr %q", args, code, errOut.String())
}
}
}
// 1906/N23-AC3: `holds` lists the holds with node, cause and answer; `hold-release <module> <fingerprint>` and `hold-keep` answer
// the hold ask through the view, also one answered already.
func TestTheHoldCommandsListAndAnswerHolds(t *testing.T) {
fp := "src2:04efd1a1aa" + strings.Repeat("2", 54)
key, err := asks.HoldAskKey("systemd-resolved", fp)
if err != nil {
t.Fatal(err)
}
hold := oc.Ask{ID: meshdeliveryclient.AskID(key), Headline: "systemd-resolved is held", Explanation: "ace failed the check.", Who: occ.Operator, Expires: occ.FormatTime(time.Now().Add(time.Hour)), OnExpiry: "stays held",
Options: []oc.Option{{ID: "keep", Label: "Keep", Does: "stays", Level: occ.Acknowledge}, {ID: "release", Label: "Release", Does: "goes on", Level: occ.Acknowledge}}}
value, _ := asks.EncodeAsk(key, asks.Ask{Ask: rawAsk(t, hold)})
hkey, _ := meshwantsclient.HeldKey("systemd-resolved", fp)
held, _ := meshwantsclient.EncodeHeld(meshwantsclient.Held{Node: "ace", Words: "health failed, phase running: dns timeout"})
ans, _ := meshdeliveryclient.EncodeAnswer(meshdeliveryclient.Answer{Option: "keep", Warrant: hold.ID + "@2026-10-25T04:00:00Z", Words: "the operator chose keep"})
m, w := view.New(viewstate.ViewModule), &putter{puts: map[string][]byte{}}
m.SetWriter(w)
m.Apply(view.Entry{Bucket: view.AssignerAsksBucket, Key: key, Value: value})
m.Apply(view.Entry{Bucket: view.AssignerWantsBucket, Key: hkey, Value: held})
m.Apply(view.Entry{Bucket: view.AnswersBucket, Key: key, Value: ans})
socket := serveWith(t, m)
var out, errOut bytes.Buffer
if code := run([]string{"holds"}, socket, &out, &errOut); code != 0 {
t.Fatalf("exit %d: %s", code, errOut.String())
}
for _, want := range []string{"systemd-resolved", "04efd1a1aa22", "ace", "keep", "dns timeout"} {
if !strings.Contains(out.String(), want) {
t.Errorf("holds printed %q, missing %q", out.String(), want)
}
}
out.Reset()
if code := run([]string{"hold-release", "systemd-resolved", "04efd1a1", "a", "flake"}, socket, &out, &errOut); code != 0 {
t.Fatalf("exit %d: %s", code, errOut.String())
}
if a, err := viewstate.DecodeViewAnswer(w.puts[key+".view"]); err != nil || a.Option != "release" || a.Asking != hold.ID || a.Words != "a flake" {
t.Errorf("the view's answer = %+v, %v", a, err)
}
errOut.Reset()
if code := run([]string{"hold-keep", "systemd-resolved"}, socket, &out, &errOut); code != 2 {
t.Errorf("hold-keep without a fingerprint: exit %d", code)
}
}
// rawAsk is the router's ask as the JSON a gate's or an assigner's value carries (mesh-sdk keeps it raw, ADR 0627 section 4).
func rawAsk(t *testing.T, a oc.Ask) json.RawMessage {
t.Helper()
b, err := json.Marshal(a)
if err != nil {
t.Fatal(err)
}
return b
}