Files
mesh-delivery/modules/mesh-delivery-view/internal/view/asks_test.go
T
jschoubben 5788fbb5d6 VW5: the view reads neither the gate's asks nor the teller's answers
The Approve per merge goes with G, so the gate's merge asks are no asks of
the view's; the teller's answers are followed by their views family
(teller_read_answers, then views.answers), never read from its state.

Builds: design 47 G §512, §431; lead answers A-172, A-175

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 05:46:41 +01:00

156 lines
5.3 KiB
Go

package view
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshdeliveryclient"
)
// answerEntry is the teller's answer to the first asking of the key; answerToAsking to the asking with this router id. The
// answer's warrant is `<router id>@<time>` (teller.go warrantID).
func answerEntry(t *testing.T, key string) Entry {
return answerToAsking(t, key, meshdeliveryclient.AskID(key))
}
func answerToAsking(t *testing.T, key, id string) Entry {
t.Helper()
v, err := meshdeliveryclient.EncodeAnswer(meshdeliveryclient.Answer{Option: "release", Warrant: id + "@2026-10-18T12:00:00Z", Words: "the operator, via telegram, chose Release"})
if err != nil {
t.Fatal(err)
}
return Entry{Bucket: AnswersBucket, Key: key, Value: v, At: t0}
}
func TestAnAskWithNoAnswerIsListedAndAnAnsweredOneIsNot(t *testing.T) { // 1345/AC1
m := New()
e, a := holdAsk(t)
fill(m, e)
s := m.Snapshot()
if len(s.Asks) != 1 || s.Asks[0].Key != e.Key || s.Asks[0].Headline != a.Headline || s.Asks[0].How != AnswerInTheView {
t.Fatalf("asks = %+v, want the one open ask", s.Asks)
}
fill(m, answerEntry(t, e.Key))
if got := m.Snapshot().Asks; len(got) != 0 {
t.Errorf("an answered ask is listed: %+v", got)
}
// An answer taken away leaves the ask open again; an ask taken away leaves the list.
fill(m, Entry{Bucket: AnswersBucket, Key: e.Key, Deleted: true})
if got := m.Snapshot().Asks; len(got) != 1 {
t.Errorf("asks after the answer was taken away = %+v", got)
}
fill(m, Entry{Bucket: AssignerAsksBucket, Key: e.Key, Deleted: true})
if got := m.Snapshot().Asks; len(got) != 0 {
t.Errorf("a withdrawn ask is listed: %+v", got)
}
}
// The gate's asks go with the Approve per merge (design 47 G §431, §512): a value under the gate's former state is no ask
// of the view's, and nothing names it unreadable.
func TestTheGatesMergeAsksAreNoAsksOfTheView(t *testing.T) {
m := New()
fill(m, Entry{Bucket: "mesh-delivery-gate_asks", Key: "merge.g.0123456789ab", Value: []byte(`{"v":1,"group":"g"}`), At: t0},
Entry{Bucket: "mesh-delivery-gate_asks", Key: "merge.h.0123456789ab", Value: []byte("{"), At: t0})
if s := m.Snapshot(); len(s.Asks) != 0 || len(s.Unreadable) != 0 {
t.Errorf("snapshot = %+v", s)
}
}
func TestAnAnswerHeardBeforeItsAskStillHidesTheAsk(t *testing.T) {
m := New()
e, _ := holdAsk(t)
fill(m, answerEntry(t, e.Key), e)
if got := m.Snapshot().Asks; len(got) != 0 {
t.Errorf("asks = %+v", got)
}
}
func TestAnUnreadableAskIsNamedAndNotListed(t *testing.T) { // never dropped silently
m := New()
e, _ := holdAsk(t)
fill(m, Entry{Bucket: AssignerAsksBucket, Key: e.Key, Value: []byte("{"), At: t0})
s := m.Snapshot()
if len(s.Asks) != 0 || len(s.Unreadable) != 1 || s.Unreadable[0].Bucket != AssignerAsksBucket {
t.Errorf("snapshot = %+v", s)
}
}
func TestTableAndPageShowTheAsks(t *testing.T) { // 1345/AC2
m := New()
e, a := holdAsk(t)
fill(m, e)
var out bytes.Buffer
Table(&out, m.Snapshot())
for _, want := range []string{"ASKS", a.Headline, a.Explanation, AnswerInTheView} {
if !strings.Contains(out.String(), want) {
t.Errorf("the table lacks %q:\n%s", want, out.String())
}
}
rec := httptest.NewRecorder()
Web(m).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
for _, want := range []string{"ASKS", a.Headline, AnswerInTheView} {
if !strings.Contains(rec.Body.String(), want) {
t.Errorf("the page lacks %q:\n%s", want, rec.Body.String())
}
}
// No ask, no section.
out.Reset()
Table(&out, New().Snapshot())
if strings.Contains(out.String(), "ASKS") {
t.Errorf("the table shows an ASKS section with no ask:\n%s", out.String())
}
raw, err := json.Marshal(m.Snapshot())
if err != nil {
t.Fatal(err)
}
var back struct {
Asks []struct {
Headline string `json:"headline"`
How string `json:"how"`
} `json:"asks"`
}
if err := json.Unmarshal(raw, &back); err != nil || len(back.Asks) != 1 || back.Asks[0].How != AnswerInTheView {
t.Errorf("json %s: %+v %v", raw, back, err)
}
}
func TestAHaveOfARemovedModuleIsShownRemovedNotLeftBehind(t *testing.T) { // 1370: the node-engine writes phase removed
m := New()
fill(m, haveE(t, "novox", "mesh-delivery", have(t, "b1", nodeengineclient.PhaseRemoved, nodeengineclient.HealthOK, ""), t0),
haveE(t, "novox", "stray", have(t, "b2", nodeengineclient.PhaseRunning, nodeengineclient.HealthOK, ""), t0))
s := m.Snapshot()
if r := find(t, s, "novox", "mesh-delivery"); r.Status != StatusRemoved {
t.Errorf("row = %+v, want status %q", r, StatusRemoved)
}
if r := find(t, s, "novox", "stray"); r.Status != StatusLeftBehind {
t.Errorf("row = %+v, want left behind", r)
}
var out bytes.Buffer
Table(&out, s)
line := ""
for _, l := range strings.Split(out.String(), "\n") {
if strings.Contains(l, "mesh-delivery") {
line = l
}
}
if !strings.Contains(line, "removed") || strings.Contains(line, "left behind") {
t.Errorf("table line %q", line)
}
}
// rawAsk is the router's ask as the JSON 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
}