package prism import ( "context" "fmt" "git.wisehodl.dev/jay/go-mana-component" "git.wisehodl.dev/jay/go-roots-ws" "github.com/stretchr/testify/assert" "reflect" "slices" "sync" "sync/atomic" "testing" "time" ) // TODO: remove var ( _ context.Context _ assert.Assertions _ testing.T _ time.Time _ fmt.Formatter ) // Helpers type reqTestHarness struct { ctx context.Context pm *Postmaster events chan PoolEvent sent map[string][]string sentMu *sync.RWMutex isConnected func(string) bool collector *JournalCollector journals <-chan JournalEntry closed atomic.Bool } func setupReqHarness(t *testing.T, peers []string) reqTestHarness { ctx := component.MustNew(context.Background(), "prism", "test") pm, poolEvents, sent, sentMu, isConnected := mockReqPostmaster(t, ctx, peers) collector := NewJournalCollector() journals := collector.Out() return reqTestHarness{ ctx: ctx, pm: pm, events: poolEvents, sent: sent, sentMu: sentMu, isConnected: isConnected, collector: collector, journals: journals, } } func mockReqPostmaster( t *testing.T, ctx context.Context, peers []string, ) ( pm *Postmaster, poolEvents chan PoolEvent, sent map[string][]string, sentMu *sync.RWMutex, isConnected func(id string) bool, ) { t.Helper() poolHasPeer := func(id string) (string, bool) { if ok := slices.Contains(peers, id); ok { return id, true } return "", false } poolEvents = make(chan PoolEvent, 4) pmEvents := make(chan PoolEvent, 4) connected := make(map[string]bool) connMu := sync.RWMutex{} isConnected = func(id string) bool { connMu.RLock() defer connMu.RUnlock() return connected[id] } go func() { for ev := range poolEvents { connMu.Lock() switch ev.Kind { case EventConnected: connected[ev.ID] = true case EventDisconnected: connected[ev.ID] = false } connMu.Unlock() pmEvents <- ev } }() sent = make(map[string][]string) sentMu = &sync.RWMutex{} poolSendFunc := func(id string, data Envelope) error { sentMu.Lock() defer sentMu.Unlock() sent[id] = append(sent[id], string(data)) return nil } pm = NewPostmaster(ctx, poolHasPeer, pmEvents, poolSendFunc, nil) for _, id := range peers { poolEvents <- PoolEvent{ID: id, Kind: EventAdded, At: time.Now()} connected[id] = false } Eventually(t, func() bool { return len(pm.Peers()) == len(peers) }, "should add peers") return } func expectSentMessage(t *testing.T, sent map[string][]string, mu *sync.RWMutex, peerID string, msg []byte, index int, ) { t.Helper() Eventually(t, func() bool { mu.RLock() defer mu.RUnlock() if len(sent[peerID]) <= index { return false } return sent[peerID][index] == string(msg) }, fmt.Sprintf("expected message to be sent to %q: %s", peerID, string(msg))) } func neverSentMessage(t *testing.T, sent map[string][]string, mu *sync.RWMutex, peerID string, msg []byte, index int, ) { t.Helper() Never(t, func() bool { mu.RLock() defer mu.RUnlock() if len(sent[peerID]) <= index { return false } return sent[peerID][index] == string(msg) }, fmt.Sprintf("unexpected message sent to %q: %s", peerID, string(msg))) } func expectJournalEntry(t *testing.T, journals <-chan JournalEntry, expected reflect.Type, ) { t.Helper() Eventually(t, func() bool { select { default: return false case entry := <-journals: got := reflect.TypeOf(entry) return expected == got } }, fmt.Sprintf("expected journal entry: %s", expected)) } // Tests func TestStreamReq_InitialReq(t *testing.T) { t.Run("sends req to one peer", func(t *testing.T) { peers := []string{"peer"} h := setupReqHarness(t, peers) // connect to peer h.events <- PoolEvent{ID: "peer", Kind: EventConnected, At: time.Now()} Eventually(t, func() bool { return h.isConnected("peer") }, "expected peer to connect") // open req filters := [][]byte{[]byte("{}")} req := NewStreamReq( h.ctx, "REQ", filters, peers, h.pm, h.isConnected, h.collector, func() { h.closed.Store(true) }, nil) expectedReq := envelope.EncloseReq("REQ", filters) expectedClose := envelope.EncloseClose("REQ") expectJournalEntry(t, h.journals, reflect.TypeOf(ReqQueuedJournal{})) expectJournalEntry(t, h.journals, reflect.TypeOf(ReqSendOutcomeJournal{})) expectSentMessage(t, h.sent, h.sentMu, "peer", expectedReq, 0) // close req req.Close() expectJournalEntry(t, h.journals, reflect.TypeOf(CloseQueuedJournal{})) expectJournalEntry(t, h.journals, reflect.TypeOf(CloseSendOutcomeJournal{})) expectSentMessage(t, h.sent, h.sentMu, "peer", expectedClose, 1) Eventually(t, func() bool { return h.closed.Load() }, "expected close callback to be called") }) t.Run("doesn't send to disconnected peer", func(t *testing.T) { peers := []string{"peer"} h := setupReqHarness(t, peers) // open req filters := [][]byte{[]byte("{}")} req := NewStreamReq( h.ctx, "REQ", filters, peers, h.pm, h.isConnected, h.collector, func() { h.closed.Store(true) }, nil) expectedReq := envelope.EncloseReq("REQ", filters) expectedClose := envelope.EncloseClose("REQ") neverSentMessage(t, h.sent, h.sentMu, "peer", expectedReq, 0) // close req req.Close() neverSentMessage(t, h.sent, h.sentMu, "peer", expectedClose, 0) Eventually(t, func() bool { return h.closed.Load() }, "expected close callback to be called") }) t.Run("sends req to multiple peers", func(t *testing.T) { peers := []string{"peer1", "peer2"} h := setupReqHarness(t, peers) // connect to peers h.events <- PoolEvent{ID: "peer1", Kind: EventConnected, At: time.Now()} h.events <- PoolEvent{ID: "peer2", Kind: EventConnected, At: time.Now()} Eventually(t, func() bool { return h.isConnected("peer1") }, "expected peer 1 to connect") Eventually(t, func() bool { return h.isConnected("peer2") }, "expected peer 2 to connect") // open req filters := [][]byte{[]byte("{}")} req := NewStreamReq( h.ctx, "REQ", filters, peers, h.pm, h.isConnected, h.collector, func() { h.closed.Store(true) }, nil) expectedReq := envelope.EncloseReq("REQ", filters) expectedClose := envelope.EncloseClose("REQ") expectSentMessage(t, h.sent, h.sentMu, "peer1", expectedReq, 0) expectSentMessage(t, h.sent, h.sentMu, "peer2", expectedReq, 0) // expect two req outcomes expectJournalEntry(t, h.journals, reflect.TypeOf(ReqSendOutcomeJournal{})) expectJournalEntry(t, h.journals, reflect.TypeOf(ReqSendOutcomeJournal{})) // close req req.Close() expectSentMessage(t, h.sent, h.sentMu, "peer1", expectedClose, 1) expectSentMessage(t, h.sent, h.sentMu, "peer2", expectedClose, 1) Eventually(t, func() bool { return h.closed.Load() }, "expected close callback to be called") }) } func TestStreamReq_EventForwarding(t *testing.T) { t.Run("events are forwarded", func(t *testing.T) { peers := []string{"peer"} h := setupReqHarness(t, peers) filters := [][]byte{[]byte("{}")} req := NewStreamReq( h.ctx, "REQ", filters, peers, h.pm, h.isConnected, h.collector, func() { h.closed.Store(true) }, nil) // simulate receive event req.order(newEventTask("peer", time.Now(), []byte("event"))) // receive event var event ReqEvent Eventually(t, func() bool { select { default: return false case event = <-req.events: return true } }, "expected event") assert.Equal(t, "peer", event.PeerID) assert.False(t, event.ReceivedAt.IsZero()) assert.Equal(t, []byte("event"), event.Data) }) t.Run("events channel closes on close", func(t *testing.T) { peers := []string{"peer"} h := setupReqHarness(t, peers) // open req filters := [][]byte{[]byte("{}")} req := NewStreamReq( h.ctx, "REQ", filters, peers, h.pm, h.isConnected, h.collector, func() { h.closed.Store(true) }, nil) // close req req.Close() Eventually(t, func() bool { select { default: return false case _, ok := <-req.events: // expect channel close return !ok } }, "expected event channel to close") }) } func TestStreamReq_EOSEHandling(t *testing.T) { t.Run("eose emits journal", func(t *testing.T) { peers := []string{"peer"} h := setupReqHarness(t, peers) // open req filters := [][]byte{[]byte("{}")} req := NewStreamReq( h.ctx, "REQ", filters, peers, h.pm, h.isConnected, h.collector, func() { h.closed.Store(true) }, nil) // simulate EOSE req.order(newEOSETask("peer", time.Now())) expectJournalEntry(t, h.journals, reflect.TypeOf(ReceivedEOSEJournal{})) }) } func TestStreamReq_ClosedHandling(t *testing.T) { t.Run("closed forwards message once", func(t *testing.T) { peers := []string{"peer"} h := setupReqHarness(t, peers) // open req filters := [][]byte{[]byte("{}")} req := NewStreamReq( h.ctx, "REQ", filters, peers, h.pm, h.isConnected, h.collector, func() { h.closed.Store(true) }, nil) // simulate closed req.order(newClosedTask("peer", time.Now(), "closed")) expectJournalEntry(t, h.journals, reflect.TypeOf(ReceivedClosedJournal{})) // receive message var message ReqMessage Eventually(t, func() bool { select { default: return false case message = <-req.messages: return true } }, "expected closed message") assert.Equal(t, "peer", message.PeerID) assert.False(t, message.ReceivedAt.IsZero()) assert.Equal(t, "closed", message.Data) // multiple closed emit journals req.order(newClosedTask("peer", time.Now(), "closed")) expectJournalEntry(t, h.journals, reflect.TypeOf(ReceivedClosedJournal{})) // but do not emit more than one message to the caller Never(t, func() bool { select { default: return false case <-req.messages: return true } }, "second closed message should not arrive") // close req req.Close() // expect messages channel to close Eventually(t, func() bool { select { default: return false case _, ok := <-req.messages: // expect channel close return !ok } }, "expected messages channel to close") }) } func TestStreamReq_Reconnect(t *testing.T) { t.Run("req replays after reconnect", func(t *testing.T) { peers := []string{"peer"} h := setupReqHarness(t, peers) h.events <- PoolEvent{ID: "peer", Kind: EventConnected, At: time.Now()} Eventually(t, func() bool { return h.isConnected("peer") }, "expected peer to connect") // open req filters := [][]byte{[]byte("{}")} req := NewStreamReq( h.ctx, "REQ", filters, peers, h.pm, h.isConnected, h.collector, func() { h.closed.Store(true) }, nil) expectedReq := envelope.EncloseReq("REQ", filters) expectedClose := envelope.EncloseClose("REQ") // initial req is sent expectSentMessage(t, h.sent, h.sentMu, "peer", expectedReq, 0) expectJournalEntry(t, h.journals, reflect.TypeOf(ReqSendOutcomeJournal{})) // cycle disconnect-reconnect h.events <- PoolEvent{ID: "peer", Kind: EventDisconnected, At: time.Now()} Eventually(t, func() bool { return !h.isConnected("peer") }, "expected peer to disconnect") h.events <- PoolEvent{ID: "peer", Kind: EventConnected, At: time.Now()} Eventually(t, func() bool { return h.isConnected("peer") }, "expected peer to connect") // simulate req manager handling connect event req.order(newHandleReconnect("peer")) // expect replayed req expectSentMessage(t, h.sent, h.sentMu, "peer", expectedReq, 1) expectJournalEntry(t, h.journals, reflect.TypeOf(ReqSendOutcomeJournal{})) // close req req.Close() expectSentMessage(t, h.sent, h.sentMu, "peer", expectedClose, 2) Eventually(t, func() bool { return h.closed.Load() }, "expected close callback to be called") }) t.Run("delayed connection sends req", func(t *testing.T) { peers := []string{"peer"} h := setupReqHarness(t, peers) // open req filters := [][]byte{[]byte("{}")} req := NewStreamReq( h.ctx, "REQ", filters, peers, h.pm, h.isConnected, h.collector, func() { h.closed.Store(true) }, nil) expectedReq := envelope.EncloseReq("REQ", filters) expectedClose := envelope.EncloseClose("REQ") neverSentMessage(t, h.sent, h.sentMu, "peer", expectedReq, 0) // postmaster-side connect h.events <- PoolEvent{ID: "peer", Kind: EventConnected, At: time.Now()} Eventually(t, func() bool { return h.isConnected("peer") }, "expected peer to connect") // simulate req manager handling connect event req.order(newHandleReconnect("peer")) expectSentMessage(t, h.sent, h.sentMu, "peer", expectedReq, 0) expectJournalEntry(t, h.journals, reflect.TypeOf(ReqSendOutcomeJournal{})) // close req req.Close() expectSentMessage(t, h.sent, h.sentMu, "peer", expectedClose, 1) Eventually(t, func() bool { return h.closed.Load() }, "expected close callback to be called") }) t.Run("no replay when closing", func(t *testing.T) { peers := []string{"peer"} h := setupReqHarness(t, peers) // open req filters := [][]byte{[]byte("{}")} req := NewStreamReq( h.ctx, "REQ", filters, peers, h.pm, h.isConnected, h.collector, func() { h.closed.Store(true) }, nil) expectedReq := envelope.EncloseReq("REQ", filters) neverSentMessage(t, h.sent, h.sentMu, "peer", expectedReq, 0) // postmaster-side connect h.events <- PoolEvent{ID: "peer", Kind: EventConnected, At: time.Now()} Eventually(t, func() bool { return h.isConnected("peer") }, "expected peer to connect") // close req req.Close() // reconnect during or after close req.order(newHandleReconnect("peer")) neverSentMessage(t, h.sent, h.sentMu, "peer", expectedReq, 0) Eventually(t, func() bool { return h.closed.Load() }, "expected close callback to be called") }) } func TestStreamReq_Terminal(t *testing.T) { } func TestStreamReq_Close(t *testing.T) { t.Run("close is idempotent", func(t *testing.T) { peers := []string{"peer"} h := setupReqHarness(t, peers) // connect to peer h.events <- PoolEvent{ID: "peer", Kind: EventConnected, At: time.Now()} Eventually(t, func() bool { return h.isConnected("peer") }, "expected peer to connect") // open req filters := [][]byte{[]byte("{}")} req := NewStreamReq( h.ctx, "REQ", filters, peers, h.pm, h.isConnected, h.collector, func() { h.closed.Store(true) }, nil) expectedReq := envelope.EncloseReq("REQ", filters) expectedClose := envelope.EncloseClose("REQ") expectSentMessage(t, h.sent, h.sentMu, "peer", expectedReq, 0) expectJournalEntry(t, h.journals, reflect.TypeOf(ReqSendOutcomeJournal{})) // close req twice req.Close() req.Close() // only expect one close expectSentMessage(t, h.sent, h.sentMu, "peer", expectedClose, 1) // second close never arrives neverSentMessage(t, h.sent, h.sentMu, "peer", expectedClose, 2) Eventually(t, func() bool { return h.closed.Load() }, "expected close callback to be called") }) t.Run("close not sent if req was never sent", func(t *testing.T) { peers := []string{"peer"} h := setupReqHarness(t, peers) // open req filters := [][]byte{[]byte("{}")} req := NewStreamReq( h.ctx, "REQ", filters, peers, h.pm, h.isConnected, h.collector, func() { h.closed.Store(true) }, nil) expectedReq := envelope.EncloseReq("REQ", filters) expectedClose := envelope.EncloseClose("REQ") neverSentMessage(t, h.sent, h.sentMu, "peer", expectedReq, 0) // close req req.Close() // req was never sent, so a close should not be sent neverSentMessage(t, h.sent, h.sentMu, "peer", expectedClose, 0) Eventually(t, func() bool { return h.closed.Load() }, "expected close callback to be called") }) t.Run("close not sent if req was cancelled", func(t *testing.T) { peers := []string{"peer"} h := setupReqHarness(t, peers) // open req filters := [][]byte{[]byte("{}")} req := NewStreamReq( h.ctx, "REQ", filters, peers, h.pm, h.isConnected, h.collector, func() { h.closed.Store(true) }, nil) expectedClose := envelope.EncloseClose("REQ") // simulate cancelled req outcome req.order(newReqOutcomeTask("peer", LetterOutcome{Kind: OutcomeCancelled})) // close req req.Close() // req was never sent, so a close should not be sent neverSentMessage(t, h.sent, h.sentMu, "peer", expectedClose, 0) Eventually(t, func() bool { return h.closed.Load() }, "expected close callback to be called") }) } func TestStreamReq_Journals(t *testing.T) { }