diff --git a/go/announcetest/announcetest.go b/go/announcetest/announcetest.go new file mode 100644 index 0000000..ff874b3 --- /dev/null +++ b/go/announcetest/announcetest.go @@ -0,0 +1,91 @@ +// Package announcetest is the test each module runs with its own checks to hold that it announces on the bus every +// state it keeps (novox/hq ADR 0495 clause 5; issue 1790): it starts the module against a bus of the test's own and +// compares what the module announced with what its code declares. With the bind guard of process.Conn.Keeps, +// stdio.Keeps and the TypeScript keeps, which refuses a state the code did not announce, announcement and code agree. +// +// It replaces the merge gate's check of one module's reads against another's manifest (clause 5); a test using it +// carries `//go:build integration`, since it starts a bus. +package announcetest + +import ( + "context" + "fmt" + "reflect" + "sort" + "testing" + "time" + + "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" + + "git.novox.be/novox/mesh-sdk/go/announce" + "git.novox.be/novox/mesh-sdk/go/testbus" +) + +// Wait is how long Check waits for the module's announcement once it started it. +var Wait = 10 * time.Second + +// Check starts a bus of the test's own, with a stream taking module's events, calls start with its address (start +// runs the module, or its part that connects, against it, and answers how to stop it), waits for the module's +// announcement and fails the test unless it is module's and names exactly want (in any order), readable as want says. +// It answers the announcement. +func Check(t testing.TB, module string, want []announce.State, start func(url string) (stop func())) announce.States { + t.Helper() + url := testbus.URL(t) + nc, err := nats.Connect(url) + if err != nil { + t.Fatalf("announcetest: connecting to the test bus: %v", err) + } + defer nc.Close() + js, err := jetstream.New(nc) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), Wait) + defer cancel() + stream, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "ANNOUNCETEST", Subjects: []string{"mesh.mod." + module + ".event.>"}}) + if err != nil { + t.Fatalf("announcetest: making the events stream: %v", err) + } + stop := start(url) + if stop != nil { + defer stop() + } + got, err := waitFor(ctx, stream, module) + if err != nil { + t.Fatalf("announcetest: %v", err) + } + if problem := differs(got, module, want); problem != "" { + t.Fatalf("announcetest: %s", problem) + } + return got +} + +// waitFor reads the stream until module's announcement is in it. +func waitFor(ctx context.Context, stream jetstream.Stream, module string) (announce.States, error) { + for { + msg, err := stream.GetLastMsgForSubject(ctx, announce.Subject(module)) + if err == nil { + return announce.Decode(msg.Data) + } + select { + case <-ctx.Done(): + return announce.States{}, fmt.Errorf("%s announced nothing on %s within %s", module, announce.Subject(module), Wait) + case <-time.After(50 * time.Millisecond): + } + } +} + +// differs says how got is not module's announcement of want, or "". +func differs(got announce.States, module string, want []announce.State) string { + if got.Module != module { + return fmt.Sprintf("the announcement names the module %q, not %q", got.Module, module) + } + w := append([]announce.State{}, want...) + sort.Slice(w, func(i, j int) bool { return w[i].Name < w[j].Name }) + g := append([]announce.State{}, got.States...) + if !reflect.DeepEqual(g, w) && !(len(g) == 0 && len(w) == 0) { + return fmt.Sprintf("%s announced %v, and its code declares %v", module, g, w) + } + return "" +} diff --git a/go/announcetest/announcetest_integration_test.go b/go/announcetest/announcetest_integration_test.go new file mode 100644 index 0000000..eebbe96 --- /dev/null +++ b/go/announcetest/announcetest_integration_test.go @@ -0,0 +1,29 @@ +//go:build integration + +package announcetest + +import ( + "testing" + + "git.novox.be/novox/mesh-sdk/go/announce" + "git.novox.be/novox/mesh-sdk/go/process" +) + +// 1790/S11: a module started against the test bus announces through process.Conn.Keeps, and Check reads that +// announcement back against what the code declares. +func TestCheckReadsTheModulesAnnouncementOffARealBus(t *testing.T) { + declared := []announce.State{{Name: "accounts", Readable: true}, {Name: "drafts"}} + got := Check(t, "mesh-mail", declared, func(url string) func() { + conn, err := process.Connect(process.Account{URL: url, Module: "mesh-mail", Node: "novox", User: "u", Password: "p"}) + if err != nil { + t.Fatal(err) + } + if err := conn.Keeps(declared...); err != nil { + t.Fatal(err) + } + return conn.Close + }) + if got.Node != "novox" { + t.Fatalf("announced from %q", got.Node) + } +} diff --git a/go/announcetest/announcetest_test.go b/go/announcetest/announcetest_test.go new file mode 100644 index 0000000..1e43165 --- /dev/null +++ b/go/announcetest/announcetest_test.go @@ -0,0 +1,32 @@ +package announcetest + +import ( + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/announce" +) + +// The comparison is exact up to order: a state missing, one extra, a readability that differs or another module's +// announcement each fail, naming what differs. +func TestAnAnnouncementDiffersFromTheCodeByNameAndReadability(t *testing.T) { + got := announce.States{Module: "m", States: []announce.State{{Name: "a", Readable: true}, {Name: "b"}}} + if p := differs(got, "m", []announce.State{{Name: "b"}, {Name: "a", Readable: true}}); p != "" { + t.Fatalf("an equal announcement differs: %s", p) + } + for _, want := range [][]announce.State{ + {{Name: "a", Readable: true}}, + {{Name: "a", Readable: true}, {Name: "b"}, {Name: "c"}}, + {{Name: "a"}, {Name: "b"}}, + } { + if p := differs(got, "m", want); p == "" || !strings.Contains(p, "declares") { + t.Errorf("want %v: %q", want, p) + } + } + if p := differs(got, "other", nil); !strings.Contains(p, `"other"`) { + t.Errorf("another module's announcement: %q", p) + } + if p := differs(announce.States{Module: "m"}, "m", nil); p != "" { + t.Errorf("a module keeping nothing: %q", p) + } +} diff --git a/go/merge-check.sh b/go/merge-check.sh index 92e9d9c..3b6bb8e 100644 --- a/go/merge-check.sh +++ b/go/merge-check.sh @@ -10,7 +10,7 @@ # 1. the unit level: `go test ./...` untagged, no bus or other process, timed and held to a bound # (unit_bound below); over it the check fails and names the slowest tests (issue 895); # 2. the integration level: the whole suite with `-tags integration`, each test against a bus of its -# own (internal/testbus, which issue 1171 brings with the first test that needs a bus) so nothing is +# own (testbus, which issue 1171 brings with the first test that needs a bus) so nothing is # serialised. With no tagged test yet, the level runs the unit tests again and says their count. # # **A test that starts a bus or another process carries `//go:build integration`** as the first line of @@ -117,7 +117,7 @@ if [ "$status" -ne 0 ]; then fi judge_unit_run "$work/unit.out" "$unit_bound" -# The integration level: the whole suite with the tag, each test against its own bus (internal/testbus). +# The integration level: the whole suite with the tag, each test against its own bus (testbus). integration_start=$(date +%s) status=0 go test -tags integration -count=1 -v ./... >"$work/integration.out" 2>&1 || status=$? diff --git a/go/process/conn_integration_test.go b/go/process/conn_integration_test.go index 2633219..6e425f1 100644 --- a/go/process/conn_integration_test.go +++ b/go/process/conn_integration_test.go @@ -22,7 +22,7 @@ import ( ) // selfSignedServer is a nats-server with JetStream and TLS, self-signed, for this test alone (mirroring -// go/internal/testbus.Start, with a certificate added since testbus has none): a mesh's bus presents its own +// go/testbus.Start, with a certificate added since testbus has none): a mesh's bus presents its own // certificate in no public trust store, which is exactly what a process pins to (ADR 0436 clause 4). func selfSignedServer(t *testing.T) (url, fingerprint string) { t.Helper() diff --git a/go/internal/testbus/testbus.go b/go/testbus/testbus.go similarity index 93% rename from go/internal/testbus/testbus.go rename to go/testbus/testbus.go index d290a7f..d061ef8 100644 --- a/go/internal/testbus/testbus.go +++ b/go/testbus/testbus.go @@ -1,5 +1,7 @@ // Package testbus is the bus an integration test of the SDK's Go binding runs against: a server of -// its own, of the release go.mod pins, started for the one test and gone after it. +// its own, of the release go.mod pins, started for the one test and gone after it. It is exported so a +// module's own integration tests start against the same (announcetest, novox/hq ADR 0495 clause 5; +// issue 1182 names the controller's copy). // // **Why one per test.** A test that reaches a shared bus asserts, reads and removes objects by fixed // name — a stream, a consumer — and two tests at once would step on each other's: one deleting what diff --git a/go/internal/testbus/testbus_integration_test.go b/go/testbus/testbus_integration_test.go similarity index 100% rename from go/internal/testbus/testbus_integration_test.go rename to go/testbus/testbus_integration_test.go