Files
logos-messaging-go-bindings/pkg/messaging/event_test.go
Igor SirotinandClaude Opus 5 7b138bf71f feat: Messaging API (MessagingClient) (#120)
* feat(messaging): object-oriented Go mirror of the Nim MessagingClient

Adds pkg/messaging: a high-level, idiomatic Go binding for the Messaging
API, mirroring logos-delivery's Nim MessagingClient. A MessagingClient owns
a node and carries the messaging surface as methods on it — New / Start /
Stop / Close, Subscribe / Unsubscribe, Send(ctx, Envelope) (RequestID,
error) — over internal/ffi rather than exposing the raw FFI.

Events arrive on a single Events() <-chan Event with a sealed Event
interface: MessageReceivedEvent, MessageSentEvent, MessagePropagatedEvent,
MessageErrorEvent and ConnectionStatusEvent. Delivery never blocks the
library's event thread; an event is dropped when a consumer falls behind.
Config marshals to the layered configuration JSON (mode / preset /
messagingOverrides / channelsOverrides), with every field omitempty so it
can never be mistaken for the legacy flat blob.

Migrating internal/ffi to the current C ABI comes with it, because the
generated surface has moved on since the bridge was written and no longer
compiles: nim-ffi now generates the header from the {.ffi.} annotations,
argument-taking calls pass a per-call <Name>Req struct and a typed
<Name>ReplyFn, no-argument calls take a raw scalar callback, destroy is
synchronous, and the single set_event_callback has been replaced by a
per-event listener registry. The bridge now also copies every callback
string while it is still borrowed, and ignores the non-terminal
STALE_WARN progress code instead of settling the call on it.

pkg/kernel follows the same listener change, registering the three kernel
events it already consumed.

Verified against a liblogosdelivery built from logos-delivery master:
build / vet / golangci-lint / go mod tidy clean, unit tests green, and the
tagged integration test does a full create-start-subscribe-send round trip
on logos.dev, observing the message back and its propagation confirmation.

Closes #119.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CnzdnSMtHM5aLHLtDGBGR9

* review: address feedback on the MessagingClient API

- Send takes contentTopic / payload / ephemeral directly instead of an
  Envelope struct, which drops the Envelope type; ContentTopic and RequestID
  move to types.go.
- messagingEvents becomes a function returning the slice, so the set cannot
  be mutated by accident.
- Document what seals the Event interface and what that buys callers.
- Document why the received payload is decoded from a JSON integer array:
  base64 is only used on the send path and by the channel events, not by the
  messaging events.

* ci: stop golangci-lint's config verify from failing on a network timeout

golangci-lint-action runs `golangci-lint config verify` before linting, which
fetches the v2.4 JSON schema from golangci-lint.run on every run. That request
timed out on the runner and failed the gate with no lint finding behind it. An
invalid config still fails the lint run itself, so the pre-check only costs a
network dependency.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 11:53:36 +01:00

153 lines
5.5 KiB
Go

package messaging
import (
"bytes"
"encoding/base64"
"encoding/json"
"testing"
)
// The JSON documents below are verbatim captures from a running
// liblogosdelivery node, so these tests pin the decoder to the real wire
// format rather than to an assumption about it.
const (
connectionStatusJSON = `{"eventType":"connection_status_change","connectionStatus":"Connected"}`
messageReceivedJSON = `{"eventType":"message_received","messageHash":"0x270090e9d88219b9e8f8a51820664ff2a972e9e101cdd87b584d547d40582118","message":{"payload":[104,101,108,108,111],"contentTopic":"/logos-delivery-go-bindings/1/raw/proto","meta":[],"version":0,"timestamp":1787098057353072384,"ephemeral":false,"proof":[]}}`
messagePropagatedJSON = `{"eventType":"message_propagated","requestId":"f9620781ac7c85234b41","messageHash":"0x270090e9d88219b9e8f8a51820664ff2a972e9e101cdd87b584d547d40582118"}`
messageSentJSON = `{"eventType":"message_sent","requestId":"f9620781ac7c85234b41","messageHash":"0x270090e9d88219b9e8f8a51820664ff2a972e9e101cdd87b584d547d40582118"}`
messageErrorJSON = `{"eventType":"message_error","requestId":"f9620781ac7c85234b41","messageHash":"0x2700","error":"Unable to send within retry time window"}`
)
func TestDecodeMessageReceived(t *testing.T) {
ev, err := decodeEvent(messageReceivedJSON)
if err != nil {
t.Fatalf("decodeEvent: %v", err)
}
e, ok := ev.(MessageReceivedEvent)
if !ok {
t.Fatalf("got %T, want MessageReceivedEvent", ev)
}
if want := "0x270090e9d88219b9e8f8a51820664ff2a972e9e101cdd87b584d547d40582118"; e.MessageHash != want {
t.Errorf("MessageHash = %q, want %q", e.MessageHash, want)
}
// The payload crosses as a JSON array of byte integers, not base64.
if !bytes.Equal(e.Message.Payload, []byte("hello")) {
t.Errorf("Payload = %q, want %q", e.Message.Payload, "hello")
}
if want := "/logos-delivery-go-bindings/1/raw/proto"; e.Message.ContentTopic != want {
t.Errorf("ContentTopic = %q, want %q", e.Message.ContentTopic, want)
}
if len(e.Message.Meta) != 0 {
t.Errorf("Meta = %v, want empty", e.Message.Meta)
}
if e.Message.Timestamp != 1787098057353072384 {
t.Errorf("Timestamp = %d, want 1787098057353072384", e.Message.Timestamp)
}
if e.Message.Version != 0 || e.Message.Ephemeral {
t.Errorf("Version/Ephemeral = %d/%v, want 0/false", e.Message.Version, e.Message.Ephemeral)
}
}
func TestDecodeSendLifecycleEvents(t *testing.T) {
const requestID = RequestID("f9620781ac7c85234b41")
sent, err := decodeEvent(messageSentJSON)
if err != nil {
t.Fatalf("decodeEvent(sent): %v", err)
}
if e, ok := sent.(MessageSentEvent); !ok || e.RequestID != requestID {
t.Errorf("got %#v, want MessageSentEvent with request id %s", sent, requestID)
}
propagated, err := decodeEvent(messagePropagatedJSON)
if err != nil {
t.Fatalf("decodeEvent(propagated): %v", err)
}
if e, ok := propagated.(MessagePropagatedEvent); !ok || e.RequestID != requestID {
t.Errorf("got %#v, want MessagePropagatedEvent with request id %s", propagated, requestID)
}
failed, err := decodeEvent(messageErrorJSON)
if err != nil {
t.Fatalf("decodeEvent(error): %v", err)
}
e, ok := failed.(MessageErrorEvent)
if !ok {
t.Fatalf("got %T, want MessageErrorEvent", failed)
}
if e.RequestID != requestID {
t.Errorf("RequestID = %s, want %s", e.RequestID, requestID)
}
if want := "Unable to send within retry time window"; e.Err != want {
t.Errorf("Err = %q, want %q", e.Err, want)
}
}
func TestDecodeConnectionStatus(t *testing.T) {
for _, tc := range []struct {
name string
want ConnectionStatus
}{
{"Connected", Connected},
{"PartiallyConnected", PartiallyConnected},
{"Disconnected", Disconnected},
} {
raw := `{"eventType":"connection_status_change","connectionStatus":"` + tc.name + `"}`
ev, err := decodeEvent(raw)
if err != nil {
t.Fatalf("decodeEvent(%s): %v", tc.name, err)
}
e, ok := ev.(ConnectionStatusEvent)
if !ok {
t.Fatalf("got %T, want ConnectionStatusEvent", ev)
}
if e.Status != tc.want {
t.Errorf("Status = %v, want %v", e.Status, tc.want)
}
if e.Status.String() != tc.name {
t.Errorf("Status.String() = %q, want %q", e.Status.String(), tc.name)
}
}
}
// An unknown eventType is not an error: a listener registered for a wider set
// of events must be able to ignore what it does not model.
func TestDecodeUnknownEventIsIgnored(t *testing.T) {
ev, err := decodeEvent(`{"eventType":"relay_topic_health_change","pubsubTopic":"/waku/2/rs/3/0","topicHealth":"SufficientlyHealthy"}`)
if err != nil {
t.Fatalf("decodeEvent: %v", err)
}
if ev != nil {
t.Errorf("got %#v, want nil", ev)
}
}
func TestDecodeMalformedEventIsAnError(t *testing.T) {
if _, err := decodeEvent(`not json`); err == nil {
t.Error("decodeEvent(garbage) succeeded, want an error")
}
if _, err := decodeEvent(`{"eventType":"message_received","message":{"payload":"!!!not base64!!!"}}`); err == nil {
t.Error("decodeEvent(bad payload) succeeded, want an error")
}
}
// wireBytes also accepts the base64 and null encodings, so a field that moves
// to the encoding the send path uses keeps decoding.
func TestWireBytesAlternativeEncodings(t *testing.T) {
var b wireBytes
if err := json.Unmarshal([]byte(`"`+base64.StdEncoding.EncodeToString([]byte("hello"))+`"`), &b); err != nil {
t.Fatalf("base64: %v", err)
}
if !bytes.Equal(b, []byte("hello")) {
t.Errorf("base64 decoded to %q, want %q", b, "hello")
}
b = wireBytes("stale")
if err := json.Unmarshal([]byte(`null`), &b); err != nil {
t.Fatalf("null: %v", err)
}
if b != nil {
t.Errorf("null decoded to %v, want nil", b)
}
}