Files
go-mana-prism/request_test.go
T
2026-05-15 15:30:21 -04:00

855 lines
18 KiB
Go

package prism
import (
"fmt"
"sync"
"testing"
"time"
"git.wisehodl.dev/jay/go-honeybee"
"git.wisehodl.dev/jay/go-roots-ws"
"github.com/stretchr/testify/assert"
)
// ----------------------------------------------------------------------------
// Session
// ----------------------------------------------------------------------------
func TestSession_SendFailure_Terminates(t *testing.T) {
sendFn := func(data []byte) error {
return fmt.Errorf("write error")
}
terminateCalled := make(chan struct{})
terminate := func() { close(terminateCalled) }
done := make(chan struct{})
s := newSession("TESTID01", []byte(`["REQ"]`), sendFn, done, terminate)
go s.run()
Eventually(t, func() bool {
select {
case <-terminateCalled:
return true
default:
return false
}
}, "terminate should be called on send failure")
}
// ----------------------------------------------------------------------------
// Query
// ----------------------------------------------------------------------------
func TestQuery_ReturnsEventsBeforeEOSE(t *testing.T) {
h := newManagerHarness(t)
h.connect()
Eventually(t, h.envoy.IsConnected, "envoy should be connected")
filters := [][]byte{[]byte(`{"kinds":[1]}`)}
var events []ReqEvent
var queryDone = make(chan struct{})
go func() {
events, _ = h.manager.Query(filters, TestTimeout)
close(queryDone)
}()
// Intercept the outbound REQ to learn the sub-ID.
var req []byte
Eventually(t, func() bool {
select {
case req = <-h.sent:
return true
default:
return false
}
}, "manager should send REQ")
subID, _, err := envelope.FindReq(req)
assert.NoError(t, err)
h.sendEvent(subID, []byte(`{"id":"aaa"}`))
h.sendEvent(subID, []byte(`{"id":"bbb"}`))
h.sendEOSE(subID)
Eventually(t, func() bool {
select {
case <-queryDone:
return true
default:
return false
}
}, "query should return after EOSE")
assert.Len(t, events, 2)
}
func TestQuery_EOSE_SendsCLOSEAndReturns(t *testing.T) {
h := newManagerHarness(t)
h.connect()
Eventually(t, h.envoy.IsConnected, "envoy should be connected")
filters := [][]byte{[]byte(`{"kinds":[1]}`)}
var events []ReqEvent
queryDone := make(chan struct{})
go func() {
events, _ = h.manager.Query(filters, TestTimeout)
close(queryDone)
}()
var req []byte
Eventually(t, func() bool {
select {
case req = <-h.sent:
return true
default:
return false
}
}, "manager should send REQ")
subID, _, err := envelope.FindReq(req)
assert.NoError(t, err)
h.sendEOSE(subID)
Eventually(t, func() bool {
select {
case <-queryDone:
return true
default:
return false
}
}, "query should return after EOSE")
assert.Empty(t, events)
// Verify a CLOSE was sent after EOSE.
Eventually(t, func() bool {
select {
case msg := <-h.sent:
closedID, err := envelope.FindClose(msg)
return err == nil && closedID == subID
default:
return false
}
}, "manager should send CLOSE after EOSE")
}
func TestQuery_CLOSED_ReturnsMessageAndEmptyEvents(t *testing.T) {
h := newManagerHarness(t)
h.connect()
Eventually(t, h.envoy.IsConnected, "envoy should be connected")
filters := [][]byte{[]byte(`{"kinds":[1]}`)}
var retEvents []ReqEvent
var retClosed *ReqClosed
queryDone := make(chan struct{})
go func() {
retEvents, retClosed = h.manager.Query(filters, TestTimeout)
close(queryDone)
}()
var req []byte
Eventually(t, func() bool {
select {
case req = <-h.sent:
return true
default:
return false
}
}, "manager should send REQ")
subID, _, err := envelope.FindReq(req)
assert.NoError(t, err)
h.sendClosed(subID, "rate limited: too many subscriptions")
Eventually(t, func() bool {
select {
case <-queryDone:
return true
default:
return false
}
}, "query should return after CLOSED")
assert.Empty(t, retEvents)
assert.NotNil(t, retClosed)
assert.Equal(t, "rate limited: too many subscriptions", retClosed.Data)
}
func TestQuery_Timeout_ReturnsPartialEvents(t *testing.T) {
h := newManagerHarness(t)
h.connect()
Eventually(t, h.envoy.IsConnected, "envoy should be connected")
filters := [][]byte{[]byte(`{"kinds":[1]}`)}
var retEvents []ReqEvent
queryDone := make(chan struct{})
go func() {
retEvents, _ = h.manager.Query(filters, 200*time.Millisecond)
close(queryDone)
}()
var req []byte
Eventually(t, func() bool {
select {
case req = <-h.sent:
return true
default:
return false
}
}, "manager should send REQ")
subID, _, err := envelope.FindReq(req)
assert.NoError(t, err)
h.sendEvent(subID, []byte(`{"id":"aaa"}`))
// No EOSE — let the timeout fire.
Eventually(t, func() bool {
select {
case <-queryDone:
return true
default:
return false
}
}, "query should return after timeout")
assert.Len(t, retEvents, 1)
}
// ----------------------------------------------------------------------------
// Disconnect / Reconnect
// ----------------------------------------------------------------------------
func TestStream_Disconnect_TerminatesSession(t *testing.T) {
h := newManagerHarness(t)
h.connect()
Eventually(t, h.envoy.IsConnected, "envoy should be connected")
filters := [][]byte{[]byte(`{"kinds":[1]}`)}
_, _, _ = h.manager.Stream(filters)
// Wait for the REQ to be sent so we know the session is running.
Eventually(t, func() bool {
select {
case <-h.sent:
return true
default:
return false
}
}, "manager should send REQ")
h.disconnect()
// After disconnect, reqWg should reach zero, meaning the session exited.
wgDone := make(chan struct{})
go func() {
h.manager.reqWg.Wait()
close(wgDone)
}()
Eventually(t, func() bool {
select {
case <-wgDone:
return true
default:
return false
}
}, "reqWg should reach zero after disconnect")
}
func TestStream_Disconnect_PreservesRegistration(t *testing.T) {
h := newManagerHarness(t)
h.connect()
Eventually(t, h.envoy.IsConnected, "envoy should be connected")
filters := [][]byte{[]byte(`{"kinds":[1]}`)}
id, _, _ := h.manager.Stream(filters)
Eventually(t, func() bool {
select {
case <-h.sent:
return true
default:
return false
}
}, "manager should send REQ")
h.disconnect()
wgDone := make(chan struct{})
go func() {
h.manager.reqWg.Wait()
close(wgDone)
}()
Eventually(t, func() bool {
select {
case <-wgDone:
return true
default:
return false
}
}, "reqWg should clear")
h.manager.mu.RLock()
_, regExists := h.manager.regs[id]
h.manager.mu.RUnlock()
assert.True(t, regExists, "registration should survive disconnect")
}
func TestStream_Reconnect_ResendsREQ(t *testing.T) {
h := newManagerHarness(t)
h.connect()
Eventually(t, h.envoy.IsConnected, "envoy should be connected")
filters := [][]byte{[]byte(`{"kinds":[1]}`)}
_, _, _ = h.manager.Stream(filters)
Eventually(t, func() bool {
select {
case <-h.sent:
return true
default:
return false
}
}, "first REQ should be sent")
h.disconnect()
wgDone := make(chan struct{})
go func() { h.manager.reqWg.Wait(); close(wgDone) }()
Eventually(t, func() bool {
select {
case <-wgDone:
return true
default:
return false
}
}, "reqWg should clear after disconnect")
h.connect()
Eventually(t, func() bool {
select {
case <-h.sent:
return true
default:
return false
}
}, "REQ should be resent after reconnect")
}
func TestStream_Reconnect_ResumesForwardingEvents(t *testing.T) {
h := newManagerHarness(t)
h.connect()
Eventually(t, h.envoy.IsConnected, "envoy should be connected")
filters := [][]byte{[]byte(`{"kinds":[1]}`)}
_, eventsCh, _ := h.manager.Stream(filters)
var firstREQ []byte
Eventually(t, func() bool {
select {
case firstREQ = <-h.sent:
return true
default:
return false
}
}, "first REQ should be sent")
firstSubID, _, err := envelope.FindReq(firstREQ)
assert.NoError(t, err)
h.sendEvent(firstSubID, []byte(`{"id":"before"}`))
Eventually(t, func() bool {
select {
case <-eventsCh:
return true
default:
return false
}
}, "event before disconnect should arrive")
h.disconnect()
wgDone := make(chan struct{})
go func() { h.manager.reqWg.Wait(); close(wgDone) }()
Eventually(t, func() bool {
select {
case <-wgDone:
return true
default:
return false
}
}, "reqWg should clear after disconnect")
h.connect()
var secondREQ []byte
Eventually(t, func() bool {
select {
case secondREQ = <-h.sent:
return true
default:
return false
}
}, "second REQ should be sent after reconnect")
secondSubID, _, err := envelope.FindReq(secondREQ)
assert.NoError(t, err)
h.sendEvent(secondSubID, []byte(`{"id":"after"}`))
var gotAfter ReqEvent
Eventually(t, func() bool {
select {
case gotAfter = <-eventsCh:
return true
default:
return false
}
}, "event after reconnect should arrive on same channel")
assert.Equal(t, []byte(`{"id":"after"}`), gotAfter.Data)
}
func TestQuery_Disconnect_ReturnsEmpty(t *testing.T) {
h := newManagerHarness(t)
h.connect()
Eventually(t, h.envoy.IsConnected, "envoy should be connected")
filters := [][]byte{[]byte(`{"kinds":[1]}`)}
var retEvents []ReqEvent
queryDone := make(chan struct{})
go func() {
retEvents, _ = h.manager.Query(filters, TestTimeout)
close(queryDone)
}()
Eventually(t, func() bool {
select {
case <-h.sent:
return true
default:
return false
}
}, "manager should send REQ")
h.disconnect()
Eventually(t, func() bool {
select {
case <-queryDone:
return true
default:
return false
}
}, "query should return after disconnect")
assert.Empty(t, retEvents)
}
// ----------------------------------------------------------------------------
// Inbox routing
// ----------------------------------------------------------------------------
func TestRouteInbox_RoutesEventToCorrectSession(t *testing.T) {
h := newManagerHarness(t)
h.connect()
Eventually(t, h.envoy.IsConnected, "envoy should be connected")
idA, evA, _ := h.manager.Stream([][]byte{[]byte(`{"kinds":[1]}`)})
idB, evB, _ := h.manager.Stream([][]byte{[]byte(`{"kinds":[2]}`)})
// Collect both outbound REQs (order is non-deterministic).
reqs := make(map[string]bool)
Eventually(t, func() bool {
for {
select {
case msg := <-h.sent:
subID, _, err := envelope.FindReq(msg)
if err == nil {
reqs[subID] = true
}
default:
return len(reqs) >= 2
}
}
}, "both REQs should be sent")
assert.Contains(t, reqs, idA)
assert.Contains(t, reqs, idB)
// Send an event targeted at idA's subscription.
h.sendEvent(idA, []byte(`{"id":"for-a"}`))
var gotA ReqEvent
Eventually(t, func() bool {
select {
case gotA = <-evA:
return true
default:
return false
}
}, "event for idA should arrive on evA")
assert.Equal(t, []byte(`{"id":"for-a"}`), gotA.Data)
Never(t, func() bool {
select {
case <-evB:
return true
default:
return false
}
}, "event for idA should not appear on evB")
}
func TestRouteInbox_DropsUnknownSubID(t *testing.T) {
h := newManagerHarness(t)
h.connect()
Eventually(t, h.envoy.IsConnected, "envoy should be connected")
// Send an EVENT for an ID no session knows about.
h.sendEvent("UNKNOWN1", []byte(`{"id":"ghost"}`))
// No panic, no block, manager continues operating.
_, evCh, _ := h.manager.Stream([][]byte{[]byte(`{"kinds":[1]}`)})
var req []byte
Eventually(t, func() bool {
select {
case req = <-h.sent:
return true
default:
return false
}
}, "REQ should be sent")
subID, _, err := envelope.FindReq(req)
assert.NoError(t, err)
h.sendEvent(subID, []byte(`{"id":"real"}`))
Eventually(t, func() bool {
select {
case <-evCh:
return true
default:
return false
}
}, "real event should still arrive after unknown drop")
}
func TestRouteInbox_DropsUnparseable(t *testing.T) {
h := newManagerHarness(t)
h.connect()
Eventually(t, h.envoy.IsConnected, "envoy should be connected")
// Inject a malformed message directly into the inbox.
h.inbox <- honeybee.InboxMessage{
ID: "wss://test",
Data: []byte(`not valid json at all`),
ReceivedAt: time.Now(),
}
// Manager should still be alive and routing correctly.
_, evCh, _ := h.manager.Stream([][]byte{[]byte(`{"kinds":[1]}`)})
var req []byte
Eventually(t, func() bool {
select {
case req = <-h.sent:
return true
default:
return false
}
}, "REQ should be sent after malformed message")
subID, _, err := envelope.FindReq(req)
assert.NoError(t, err)
h.sendEvent(subID, []byte(`{"id":"ok"}`))
Eventually(t, func() bool {
select {
case <-evCh:
return true
default:
return false
}
}, "event should arrive after malformed message was dropped")
}
// ----------------------------------------------------------------------------
// Manager lifecycle
// ----------------------------------------------------------------------------
func TestManager_StreamWhileDisconnected_SessionSpawnedOnConnect(t *testing.T) {
h := newManagerHarness(t)
// Do NOT connect yet.
filters := [][]byte{[]byte(`{"kinds":[1]}`)}
_, _, _ = h.manager.Stream(filters)
// No REQ should be sent while disconnected.
Never(t, func() bool {
select {
case <-h.sent:
return true
default:
return false
}
}, "no REQ should be sent before connect")
// Now connect — session should spawn and REQ should be sent.
h.connect()
Eventually(t, func() bool {
select {
case <-h.sent:
return true
default:
return false
}
}, "REQ should be sent after connect")
}
func TestManager_QueryWhileDisconnected_ReturnsEmpty(t *testing.T) {
h := newManagerHarness(t)
// Do NOT connect.
filters := [][]byte{[]byte(`{"kinds":[1]}`)}
events, closed := h.manager.Query(filters, TestTimeout)
assert.Empty(t, events)
assert.Nil(t, closed)
}
func TestManager_Close_TerminatesAllSessions(t *testing.T) {
h := newManagerHarness(t)
h.connect()
Eventually(t, h.envoy.IsConnected, "envoy should be connected")
h.manager.Stream([][]byte{[]byte(`{"kinds":[1]}`)})
h.manager.Stream([][]byte{[]byte(`{"kinds":[2]}`)})
// Wait for both REQs.
Eventually(t, func() bool {
for {
select {
case <-h.sent:
default:
return len(h.manager.sessions) >= 2
}
}
}, "both sessions should be active")
closed := make(chan struct{})
go func() {
h.manager.Close()
close(closed)
}()
Eventually(t, func() bool {
select {
case <-closed:
return true
default:
return false
}
}, "manager.Close() should return")
}
// ----------------------------------------------------------------------------
// Stream (unit-level session tests)
// ----------------------------------------------------------------------------
func TestStream_ForwardsEvents(t *testing.T) {
si := newSessionInbox()
done := make(chan struct{})
terminate := func() {}
eventsCh := make(chan ReqEvent, 4)
s := newSession(
"TESTID01",
[]byte(`["REQ"]`),
func([]byte) error { return nil },
done,
terminate,
withSessionInbox(si),
withForwardEvents(eventsCh),
)
go s.run()
eventData := []byte(`{"id":"abc"}`)
si.events <- inboxEvent{
subID: "TESTID01",
data: eventData,
receivedAt: time.Now(),
}
Eventually(t, func() bool { return len(eventsCh) > 0 }, "event should be forwarded")
got := <-eventsCh
assert.Equal(t, eventData, got.Data)
close(done)
}
func TestStream_IgnoresEOSE(t *testing.T) {
si := newSessionInbox()
done := make(chan struct{})
terminateCalled := make(chan struct{})
terminate := func() { close(terminateCalled) }
s := newSession(
"TESTID01",
[]byte(`["REQ"]`),
func([]byte) error { return nil },
done,
terminate,
withSessionInbox(si),
)
go s.run()
si.eose <- inboxEOSE{subID: "TESTID01", receivedAt: time.Now()}
Never(t, func() bool {
select {
case <-terminateCalled:
return true
default:
return false
}
}, "stream session should not terminate on EOSE")
close(done)
}
func TestStream_CLOSED_ForwardsMessageAndTerminates(t *testing.T) {
si := newSessionInbox()
done := make(chan struct{})
terminateCalled := make(chan struct{})
deregisterCalled := make(chan struct{})
terminate := func() { close(terminateCalled) }
deregister := func() { close(deregisterCalled) }
forwardedClosed := make(chan ReqClosed, 1)
s := newSession(
"TESTID01",
[]byte(`["REQ"]`),
func([]byte) error { return nil },
done,
terminate,
withSessionInbox(si),
withDeregister(deregister),
withForwardClosed(forwardedClosed),
)
go s.run()
si.closed <- inboxClosed{
subID: "TESTID01",
message: "rate limited: too many subscriptions",
receivedAt: time.Now(),
}
Eventually(t, func() bool {
select {
case <-terminateCalled:
return true
default:
return false
}
}, "terminate should be called on CLOSED")
Eventually(t, func() bool {
select {
case <-deregisterCalled:
return true
default:
return false
}
}, "deregister should be called on CLOSED")
assert.Len(t, forwardedClosed, 1)
got := <-forwardedClosed
assert.Equal(t, "rate limited: too many subscriptions", got.Data)
}
func TestStream_Cancel_SendsCLOSEAndTerminates(t *testing.T) {
si := newSessionInbox()
done := make(chan struct{})
terminateCalled := make(chan struct{})
terminate := func() { close(terminateCalled) }
var sentMessages [][]byte
var sentMu sync.Mutex
sendFn := func(data []byte) error {
sentMu.Lock()
sentMessages = append(sentMessages, data)
sentMu.Unlock()
return nil
}
id := "TESTID01"
s := newSession(
id,
[]byte(`["REQ"]`),
sendFn,
done,
terminate,
withSessionInbox(si),
)
go s.run()
// Wait for REQ to be sent before cancelling.
Eventually(t, func() bool {
sentMu.Lock()
defer sentMu.Unlock()
return len(sentMessages) > 0
}, "REQ should be sent before cancel")
s.Close()
Eventually(t, func() bool {
select {
case <-terminateCalled:
return true
default:
return false
}
}, "terminate should be called on cancel")
sentMu.Lock()
defer sentMu.Unlock()
expectedCLOSE := []byte(envelope.EncloseClose(id))
assert.Contains(t, sentMessages, expectedCLOSE)
}
func TestSession_SendsREQOnStart(t *testing.T) {
sent := make(chan []byte, 1)
sendFn := func(data []byte) error {
sent <- data
return nil
}
id := "TESTID01"
filters := [][]byte{[]byte(`{"kinds":[1]}`)}
req := mustEncloseReq(id, filters)
done := make(chan struct{})
terminate := func() {}
s := newSession(id, req, sendFn, done, terminate)
go s.run()
Eventually(t, func() bool { return len(sent) > 0 }, "session should send REQ")
got := <-sent
assert.Equal(t, req, got)
close(done)
}