Files
mesh-sdk/go/process/call_test.go
T
jschoubben ba6c2a5207 process: AnswerSeat answers a seat's verbs over the bus with the proved caller
A process answers its seat's verbs through one call: caller-named and
unproven subjects, one queue group per seat, reply {result, error, node}.
The rule that a process answers no call (issue 1171 AC3) is replaced by one
that AnswerSeat is the only way to serve. A compare-and-set write on a
process's own state already exists (State.Create, Update).

Builds: design 47 G A-107 (G-20-lead-answers)

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-11 23:50:15 +01:00

157 lines
6.3 KiB
Go

package process
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
"github.com/nats-io/nats.go"
"git.novox.be/novox/mesh-sdk/go/busretry"
)
// 1191/AC3: given a module tool key <module>.<tool>, when called, then the subject is
// mesh.mod.<module>.call.<tool>.<token> with the same fallback (mesh.mod.<module>.tool.<tool>).
func TestCallSubjectsForAModuleTool(t *testing.T) {
called, fallback, err := callSubjects("mesh-mail.send", "novox~mesh-mail~send")
if err != nil {
t.Fatal(err)
}
if called != "mesh.mod.mesh-mail.call.send.novox~mesh-mail~send" {
t.Errorf("called: %q", called)
}
if fallback != "mesh.mod.mesh-mail.tool.send" {
t.Errorf("fallback: %q", fallback)
}
}
// 1191/AC1's subject (the seat form): seat:<seat>.<verb> calls mesh.seat.<seat>.call.<verb>.<token>,
// falling back to mesh.seat.<seat>.tool.<verb>.
func TestCallSubjectsForASeatVerb(t *testing.T) {
called, fallback, err := callSubjects("seat:operator-channel.tell", "novox~mesh-mail~send")
if err != nil {
t.Fatal(err)
}
if called != "mesh.seat.operator-channel.call.tell.novox~mesh-mail~send" {
t.Errorf("called: %q", called)
}
if fallback != "mesh.seat.operator-channel.tool.tell" {
t.Errorf("fallback: %q", fallback)
}
}
func TestCallSubjectsRefusesAKeyNamingNeither(t *testing.T) {
for _, key := range []string{"", "nodot", "seat:", "seat:noverb", "."} {
if _, _, err := callSubjects(key, "token"); err == nil {
t.Errorf("callSubjects(%q) was accepted", key)
}
}
}
// 1470/AC1: key mesh-build-go.fetch, node ace, caller ace.mesh-delivery-builder.
func TestCallSubjectsOnANodeForAModuleTool(t *testing.T) {
c := &Conn{account: Account{User: "ace.mesh-delivery-builder"}}
called, fallback, err := callSubjectsOn("mesh-build-go.fetch", "ace", c.callerToken())
if err != nil {
t.Fatal(err)
}
if called != "mesh.mod.mesh-build-go.call.fetch.ace.ace~mesh-delivery-builder" {
t.Errorf("called: %q", called)
}
if fallback != "mesh.mod.mesh-build-go.tool.fetch.ace" {
t.Errorf("fallback: %q", fallback)
}
}
// 1470/AC2: seat:node-files.read on g14.
func TestCallSubjectsOnANodeForASeatVerb(t *testing.T) {
called, fallback, err := callSubjectsOn("seat:node-files.read", "g14", "tok")
if err != nil {
t.Fatal(err)
}
if called != "mesh.seat.node-files.call.read.g14.tok" || fallback != "mesh.seat.node-files.tool.read.g14" {
t.Errorf("called %q, fallback %q", called, fallback)
}
// Issue 774 notes 84-87: the machine named in the key after '@' is the same subject, never a token holding '@'.
called, fallback, err = callSubjects("seat:node-files.read@g14", "tok")
if err != nil || called != "mesh.seat.node-files.call.read.g14.tok" || fallback != "mesh.seat.node-files.tool.read.g14" {
t.Errorf("@g14: called %q, fallback %q, %v", called, fallback, err)
}
if _, _, err := callSubjectsOn("seat:node-files.read@ace", "g14", "tok"); err == nil {
t.Error("a key naming ace was asked of g14")
}
}
// 1470/AC3: a node that is not one token is refused before anything is published (the Conn has no
// connection, so only the name check can answer with this error).
func TestCallOnRefusesANodeThatIsNotOneToken(t *testing.T) {
c := &Conn{}
for _, node := range []string{"", "a.b", "*", ">", "a b", "a\tb", "a@b"} {
if _, err := c.CallOn(node, "mesh-build-go.fetch", nil); err == nil || !strings.Contains(err.Error(), "not a node name") {
t.Errorf("CallOn(%q) = %v, want the refusal of the name", node, err)
}
}
// Issue 774 notes 84-87: Call refuses a malformed key the same way, before anything is published.
for _, key := range []string{"seat:node-service-manager.stop@novox-be.lan", "seat:a.b@x@y", "seat:a.*", "a.b@"} {
if _, err := c.Call(key, nil); err == nil {
t.Errorf("Call(%q) was published", key)
}
}
}
// The review of 2026-10-30 (issue 774): a callee's own answer is delivered, whatever its words, and is never asked
// again during a blackout; only the transport's no responders is.
func TestACallIsAskedAgainOnlyWhenItReachedNoOne(t *testing.T) {
t.Cleanup(busretry.Waits(10*time.Second, time.Millisecond, 2*time.Millisecond))
for _, row := range []struct {
name string
answer []error // per ask; the last repeats
asks int
kept bool // CallKept (novox/hq ADR 0668; issue 1966)
}{
{"a callee answers jetstream not enabled: asked once", []error{answered("jetstream not enabled")}, 1, false},
{"a callee answers no responders in its words: asked once", []error{answered("nats: no responders available for request")}, 1, false},
{"no responders on either subject, then an answer", []error{nats.ErrNoResponders, nats.ErrNoResponders, nil}, 3, false},
{"a timeout may have been delivered: Call asks once", []error{context.DeadlineExceeded, nil}, 1, false},
{"kept: no responders on a bus that answers (a callee restarting), then an answer", []error{nats.ErrNoResponders, nats.ErrNoResponders, nil}, 3, true},
{"kept: a timeout, then an answer", []error{context.DeadlineExceeded, nil}, 2, true},
{"kept: a callee's answer is asked once", []error{answered("nats: timeout")}, 1, true},
} {
asks := 0
request := func(string, []byte) (json.RawMessage, error) {
e := row.answer[min(asks, len(row.answer)-1)]
asks++
return json.RawMessage(`{}`), e
}
var err error
if row.kept {
_, err = callKeptWith(request, "called", "fallback", nil)
} else {
_, err = callWith(request, func() bool { return true }, "called", "fallback", nil)
}
if asks != row.asks {
t.Errorf("%s: asked %d times (%v), want %d", row.name, asks, err, row.asks)
}
}
}
// A-107: AnswerSeat reads the caller from a call's last token (dots written `~`), "" for anything that is not a
// bus user's name; and it refuses what it cannot serve before subscribing.
func TestUserOfTokenAndAnswerSeatRefusals(t *testing.T) {
for token, want := range map[string]string{"novox~mesh-delivery-builder": "novox.mesh-delivery-builder", "person~jo": "person.jo",
"plain": "plain", "": "", "a b": "", "a*": "", "~": ""} {
if got := userOfToken(token); got != want {
t.Errorf("userOfToken(%q) = %q, want %q", token, got, want)
}
}
c := &Conn{}
ok := func(string, json.RawMessage) (any, error) { return nil, nil }
for name, verbs := range map[string]map[string]SeatHandler{"none": nil, "nil handler": {"v": nil}, "bad verb": {"a b": ok}, "wildcard": {"*": ok}} {
if _, err := c.AnswerSeat("seat", verbs); err == nil {
t.Errorf("AnswerSeat accepted %s", name)
}
}
}