The send record of issue 234 is now readable only through the controller's verbs (issue 506), and only plan said it. status says per machine when it was last sent a declaration, by whom, from which generation and what it answered; node show says it in full with the refusal's words. A machine's answer is read from its report only when that report is about the send and came after it.
161 lines
6.9 KiB
Go
161 lines
6.9 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/novox/mesh-controller/internal/inventory"
|
|
)
|
|
|
|
// novox/hq issue 485: the mesh records every send — sender, lease epoch, generation, modules — and only `plan`
|
|
// said it. `status` says per machine when it was last sent a declaration and by whom, in one line; `node show`
|
|
// says the last send in full and what the machine answered; and `status --json` carries it as fields, since
|
|
// the store's reader may not read the controller's tables and these verbs are the only window onto them.
|
|
|
|
func aLastSend(answer inventory.SendAnswer) inventory.Send {
|
|
sent := time.Date(2026, 10, 11, 18, 40, 5, 0, time.Local)
|
|
return inventory.Send{NodeName: "laptop", Sequence: 12, Epoch: 57,
|
|
Sender: "a one-shot push by jochen at a shell on anchor", Generation: 40, Digest: "d12",
|
|
Modules: []string{"docker", "sudo"}, SentAt: sent, Recorded: true, Answer: answer}
|
|
}
|
|
|
|
func TestStatusSaysEachMachinesLastSendInOneLine(t *testing.T) {
|
|
at := time.Date(2026, 10, 11, 18, 41, 0, 0, time.Local)
|
|
asked := answers{
|
|
nodes: []inventory.Node{{Name: "anchor", LastSeen: time.Now()}, {Name: "laptop", LastSeen: time.Now()}},
|
|
lastSent: map[string]inventory.Send{"laptop": aLastSend(inventory.SendAnswer{
|
|
Outcome: inventory.OutcomeApplied, At: &at})},
|
|
}
|
|
shown := printed(t, func() error { return printStatus(asked) })
|
|
var laptop, anchor []string
|
|
for _, line := range strings.Split(shown, "\n") {
|
|
fields := strings.Fields(line)
|
|
if len(fields) > 0 && fields[0] == "laptop" {
|
|
laptop = append(laptop, line)
|
|
}
|
|
if len(fields) > 0 && fields[0] == "anchor" {
|
|
anchor = append(anchor, line)
|
|
}
|
|
}
|
|
if len(laptop) != 1 || !strings.Contains(laptop[0], "2026-10-11 18:40") ||
|
|
!strings.Contains(laptop[0], "by a one-shot push by jochen at a shell on anchor") ||
|
|
!strings.Contains(laptop[0], "generation 40") || !strings.Contains(laptop[0], "applied") {
|
|
t.Fatalf("status does not say laptop's last send in one line:\n%s", shown)
|
|
}
|
|
if len(anchor) != 1 || !strings.Contains(anchor[0], "no send recorded") {
|
|
t.Fatalf("status does not say anchor has no send recorded, in one line:\n%s", shown)
|
|
}
|
|
}
|
|
|
|
func TestStatusSaysTheLastSendsCouldNotBeRead(t *testing.T) {
|
|
asked := answers{nodes: []inventory.Node{{Name: "laptop", LastSeen: time.Now()}},
|
|
lastSentUnread: "the store went away"}
|
|
if shown := printed(t, func() error { return printStatus(asked) }); !strings.Contains(shown, "the store went away") {
|
|
t.Fatalf("a record of sends that could not be read is not said:\n%s", shown)
|
|
}
|
|
if asked.well() {
|
|
t.Error("a mesh whose sends could not be read is called well")
|
|
}
|
|
}
|
|
|
|
func TestNodeShowSaysTheLastSendInFull(t *testing.T) {
|
|
at := time.Date(2026, 10, 11, 18, 41, 0, 0, time.Local)
|
|
refused := strings.Join(lastSendLines(aLastSend(inventory.SendAnswer{Outcome: inventory.OutcomeRefused,
|
|
Refused: "unknown field \"generation\"\nsecond line", At: &at})), "\n")
|
|
for _, want := range []string{"sequence 12", "2026-10-11 18:40:05", "a one-shot push by jochen at a shell on anchor",
|
|
"lease epoch 57", "generation 40", "docker, sudo", "refused", "unknown field \"generation\""} {
|
|
if !strings.Contains(refused, want) {
|
|
t.Errorf("node show's last send does not say %q:\n%s", want, refused)
|
|
}
|
|
}
|
|
if applied := strings.Join(lastSendLines(aLastSend(inventory.SendAnswer{Outcome: inventory.OutcomeApplied,
|
|
At: &at})), "\n"); !strings.Contains(applied, "applied it at 2026-10-11 18:41:00") {
|
|
t.Errorf("an applied send is not said applied:\n%s", applied)
|
|
}
|
|
if waiting := strings.Join(lastSendLines(aLastSend(inventory.SendAnswer{})), "\n"); !strings.Contains(waiting,
|
|
"not reported on it yet") {
|
|
t.Errorf("a send not reported on is not said so:\n%s", waiting)
|
|
}
|
|
stale := aLastSend(inventory.SendAnswer{})
|
|
when := at
|
|
stale.RefusedAt, stale.RefusedApplied = &when, 44
|
|
if s := strings.Join(lastSendLines(stale), "\n"); !strings.Contains(s, "refused it at 2026-10-11 18:41:00") ||
|
|
!strings.Contains(s, "generation 44") {
|
|
t.Errorf("a send refused for its generation is not said so:\n%s", s)
|
|
}
|
|
}
|
|
|
|
func TestStatusJSONCarriesEachMachinesLastSend(t *testing.T) {
|
|
at := time.Date(2026, 10, 11, 18, 41, 0, 0, time.UTC)
|
|
asked := answers{nodes: []inventory.Node{{Name: "anchor"}, {Name: "laptop"}},
|
|
lastSent: map[string]inventory.Send{"laptop": aLastSend(inventory.SendAnswer{Outcome: inventory.OutcomeRefused,
|
|
Refused: "unknown field", At: &at})}}
|
|
body, err := statusAsJSON(asked)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var got struct {
|
|
LastSent []map[string]any `json:"lastSent"`
|
|
}
|
|
if err := json.Unmarshal(body, &got); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(got.LastSent) != 1 {
|
|
t.Fatalf("status --json carries %d last sends, want laptop's only:\n%s", len(got.LastSent), body)
|
|
}
|
|
s := got.LastSent[0]
|
|
if s["node"] != "laptop" || s["sequence"] != float64(12) || s["epoch"] != float64(57) ||
|
|
s["generation"] != float64(40) || s["sender"] != "a one-shot push by jochen at a shell on anchor" ||
|
|
s["digest"] != "d12" || s["sentAt"] == nil || s["answer"] != "refused" || s["refused"] != "unknown field" ||
|
|
s["answeredAt"] == nil {
|
|
t.Fatalf("laptop's last send reads %v", s)
|
|
}
|
|
if modules, _ := s["modules"].([]any); len(modules) != 2 || modules[0] != "docker" {
|
|
t.Fatalf("the modules it named read %v", s["modules"])
|
|
}
|
|
}
|
|
|
|
// And through the store: a send recorded and refused by the machine is what `node show` and `status --json`
|
|
// read back.
|
|
func TestNodeShowAndStatusReadTheLastSendFromTheStore(t *testing.T) {
|
|
open := aMesh(t)
|
|
ctx := t.Context()
|
|
record, err := open.inventory.NodeByName(ctx, "laptop")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := open.inventory.RecordSentWith(ctx, record.ID, "d12", nil, 57, 40, inventory.Send{Sequence: 12,
|
|
Epoch: 57, Sender: "a one-shot push by jochen at a shell on anchor", Generation: 40, Digest: "d12",
|
|
Modules: []string{"docker", "sudo"}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := open.inventory.RecordDoing(ctx, record.ID, inventory.Doing{Outcome: inventory.OutcomeRefused,
|
|
Declared: "d12", Refused: "unknown field \"generation\""}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
shown := printed(t, func() error { return showNode(ctx, open, "laptop") })
|
|
for _, want := range []string{"a one-shot push by jochen at a shell on anchor", "lease epoch 57", "generation 40",
|
|
"docker, sudo", "unknown field \"generation\""} {
|
|
if !strings.Contains(shown, want) {
|
|
t.Errorf("node show laptop does not say %q:\n%s", want, shown)
|
|
}
|
|
}
|
|
body := printed(t, func() error { return statusFor(ctx, open, true) })
|
|
var got struct {
|
|
LastSent []struct {
|
|
Node string `json:"node"`
|
|
Sender string `json:"sender"`
|
|
Answer string `json:"answer"`
|
|
} `json:"lastSent"`
|
|
}
|
|
if err := json.Unmarshal([]byte(body), &got); err != nil {
|
|
t.Fatalf("%v:\n%s", err, body)
|
|
}
|
|
if len(got.LastSent) != 1 || got.LastSent[0].Node != "laptop" || got.LastSent[0].Answer != "refused" ||
|
|
got.LastSent[0].Sender != "a one-shot push by jochen at a shell on anchor" {
|
|
t.Fatalf("status --json's last sends read %+v", got.LastSent)
|
|
}
|
|
}
|