From b7ad47ab2a60dd7bdc3938162bd3162e6f8288b1 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 13:45:30 +0100 Subject: [PATCH] =?UTF-8?q?Seat=20list=20reader:=20an=20integration=20case?= =?UTF-8?q?=20over=20a=20real=20bus,=20from=20the=20bootstrap=20alone=20to?= =?UTF-8?q?=20the=20seat's=20answer=20and=20its=20events=20(ADR=200636=20?= =?UTF-8?q?=C2=A78;=20issue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/seatlist/seatlist_integration_test.go | 107 +++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 go/seatlist/seatlist_integration_test.go diff --git a/go/seatlist/seatlist_integration_test.go b/go/seatlist/seatlist_integration_test.go new file mode 100644 index 0000000..ed2841a --- /dev/null +++ b/go/seatlist/seatlist_integration_test.go @@ -0,0 +1,107 @@ +//go:build integration + +package seatlist_test + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "github.com/nats-io/nats.go" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/seatdef" + "git.novox.be/novox/mesh-sdk/go/seatlist" + "git.novox.be/novox/mesh-sdk/go/testbus" +) + +// natsBus is a reader's bus over a real NATS connection: a seat verb is a request on the seat's tool subject, a seat's +// event a subscription on its event subject. +type natsBus struct{ c *nats.Conn } + +func (b natsBus) Ask(key string, body any) (json.RawMessage, error) { + seat, verb, _ := strings.Cut(strings.TrimPrefix(key, "seat:"), ".") + raw, _ := json.Marshal(body) + m, err := b.c.Request("mesh.seat."+seat+".tool."+verb, raw, time.Second) + if err != nil { + return nil, err + } + return m.Data, nil +} + +func (b natsBus) Subscribe(pattern string, handle func(string, json.RawMessage) error) error { + seat, event, _ := strings.Cut(pattern, ".") + _, err := b.c.Subscribe(seatdef.EventSubject(seat, event), func(m *nats.Msg) { + _ = handle(seat+"."+event, m.Data) + }) + return err +} + +// ADR 0636 ยง8: a reader started with only the bootstrapped definitions, and the seat not yet serving, acts on the bootstrapped +// modules alone and holds nothing new; when the seat answers it reads the whole list, and it follows the seat's changed and +// removed events, over a real bus. +func TestAReaderStartsFromTheBootstrapAndReadsTheSeatOnceItAnswers(t *testing.T) { + c, err := nats.Connect(testbus.URL(t)) + if err != nil { + t.Fatal(err) + } + defer c.Close() + bus := natsBus{c} + changed := make(chan string, 8) + r, err := seatlist.Start(seatlist.Config{Bus: bus, Bootstrap: seatlist.Bootstrapped(), + OnChange: func([]catalogue.Seat) { changed <- "changed" }}) + if err != nil { + t.Fatal(err) + } + if st := r.Status(); st.Heard || st.LastError == "" || !r.MayAct("node-engine") || r.MayAct("node-files") { + t.Fatalf("with the seat silent: %+v, may act on node-files: %v", st, r.MayAct("node-files")) + } + + table := map[string]string{"node-files": `{"format":1,"seat":"node-files","scope":"node","grain":"seat-list","decision":"ADR 1",` + + `"verbs":[{"name":"read","input":{"type":"object"}}]}`} + serve := func(m *nats.Msg) { + var q struct { + Seat string `json:"seat"` + } + _ = json.Unmarshal(m.Data, &q) + if q.Seat == "" { + out, _ := json.Marshal(seatlist.Listing{Revision: 3, Seats: []seatlist.Entry{{Seat: "node-files", Source: seatlist.SourceTrunk, Digest: "d1"}}}) + _ = m.Respond(out) + return + } + out, _ := json.Marshal(seatlist.Answer{Seat: q.Seat, Found: true, Revision: 3, + Entry: &seatlist.Entry{Seat: q.Seat, Source: seatlist.SourceTrunk, Digest: "d1", Definition: json.RawMessage(table[q.Seat])}}) + _ = m.Respond(out) + } + if _, err := c.Subscribe("mesh.seat."+seatlist.Seat+".tool."+seatlist.Verb, serve); err != nil { + t.Fatal(err) + } + // The seat says a definition changed: the silent reader reads the list at the event, not on a timer. + if err := c.Publish(seatdef.EventSubject(seatlist.Seat, seatlist.ChangedEvent), []byte(`{"seat":"node-files","source":"trunk","digest":"d1"}`)); err != nil { + t.Fatal(err) + } + until(t, changed, func() bool { return r.Has("node-files") }) + if err := r.Reload(); err != nil { + t.Fatal(err) + } + if st := r.Status(); !st.Heard || st.LastError != "" || !r.MayAct("node-files") || st.Revision != 3 { + t.Fatalf("after the seat answered: %+v", st) + } + if err := c.Publish(seatdef.EventSubject(seatlist.Seat, seatlist.RemovedEvent), []byte(`{"seat":"node-files"}`)); err != nil { + t.Fatal(err) + } + until(t, changed, func() bool { return !r.Has("node-files") }) +} + +// until waits for the reader's own notice of a change (OnChange), never a sleep, until ok holds. +func until(t *testing.T, changed <-chan string, ok func() bool) { + t.Helper() + for !ok() { + select { + case <-changed: + case <-time.After(5 * time.Second): + t.Fatal("the reader did not follow the seat's event") + } + } +}