mirror of
https://github.com/MeshCore-Beacon/beacon-server.git
synced 2026-10-17 22:20:12 +00:00
Duplicate observations are dropped before the hub today, so a live map only sees each packet's first hearing per observer. Clients that configure includeRepeats also get later hearings whose path is new; nothing is stored, repeats skip the count query, and they are dropped first when the broadcast channel is half full.
546 lines
16 KiB
Go
546 lines
16 KiB
Go
// Copyright 2026 Beacon Contributors
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
package hub
|
|
|
|
import (
|
|
"encoding/json"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestScopeMatches_EmptyScope(t *testing.T) {
|
|
// empty scope matches everything — no filters means no restrictions
|
|
s := Scope{}
|
|
e := Event{Type: EventPacketObservation, IATA: "YVR", PayloadType: 4}
|
|
if !scopeMatches(s, e) {
|
|
t.Error("empty scope should match all events")
|
|
}
|
|
}
|
|
|
|
func TestScopeMatches_EventFilter(t *testing.T) {
|
|
s := Scope{Events: []EventType{EventNodeUpdate}}
|
|
if !scopeMatches(s, Event{Type: EventNodeUpdate, IATA: "YVR"}) {
|
|
t.Error("expected nodeUpdate to match")
|
|
}
|
|
if scopeMatches(s, Event{Type: EventPacketObservation, IATA: "YVR"}) {
|
|
t.Error("expected packetObservation not to match")
|
|
}
|
|
}
|
|
|
|
func TestScopeMatches_IATAFilter(t *testing.T) {
|
|
s := Scope{Events: []EventType{EventPacketObservation}, IATAs: []string{"YVR", "YYJ"}}
|
|
if !scopeMatches(s, Event{Type: EventPacketObservation, IATA: "YVR"}) {
|
|
t.Error("expected YVR to match")
|
|
}
|
|
if !scopeMatches(s, Event{Type: EventPacketObservation, IATA: "YYJ"}) {
|
|
t.Error("expected YYJ to match")
|
|
}
|
|
if scopeMatches(s, Event{Type: EventPacketObservation, IATA: "YYC"}) {
|
|
t.Error("expected YYC not to match")
|
|
}
|
|
}
|
|
|
|
func TestScopeMatches_PayloadTypeFilter(t *testing.T) {
|
|
s := Scope{Events: []EventType{EventPacketObservation}, PayloadTypes: []uint8{4}}
|
|
if !scopeMatches(s, Event{Type: EventPacketObservation, PayloadType: 4}) {
|
|
t.Error("expected payload type 4 to match")
|
|
}
|
|
if scopeMatches(s, Event{Type: EventPacketObservation, PayloadType: 5}) {
|
|
t.Error("expected payload type 5 not to match")
|
|
}
|
|
}
|
|
|
|
func TestScopeMatches_ChannelHashFilter(t *testing.T) {
|
|
s := Scope{Events: []EventType{EventChannelMessage}, ChannelHashes: []string{"ab"}}
|
|
if !scopeMatches(s, Event{Type: EventChannelMessage, ChannelHash: "ab"}) {
|
|
t.Error("expected channel hash ab to match")
|
|
}
|
|
if scopeMatches(s, Event{Type: EventChannelMessage, ChannelHash: "cd"}) {
|
|
t.Error("expected channel hash cd not to match")
|
|
}
|
|
}
|
|
|
|
func TestScopeMatches_ChannelHashesOnlyAppliesToChannelMessage(t *testing.T) {
|
|
s := Scope{
|
|
Events: []EventType{EventPacketObservation, EventChannelMessage},
|
|
ChannelHashes: []string{"11"},
|
|
}
|
|
// packetObservation has no channel hash — must still match despite the
|
|
// channelHashes filter, since that filter only applies to channelMessage.
|
|
if !scopeMatches(s, Event{Type: EventPacketObservation, ChannelHash: ""}) {
|
|
t.Error("expected packetObservation to match even with channelHashes set")
|
|
}
|
|
if !scopeMatches(s, Event{Type: EventChannelMessage, ChannelHash: "11"}) {
|
|
t.Error("expected channelMessage with matching hash to match")
|
|
}
|
|
if scopeMatches(s, Event{Type: EventChannelMessage, ChannelHash: "ff"}) {
|
|
t.Error("expected channelMessage with non-matching hash to be filtered")
|
|
}
|
|
}
|
|
|
|
func TestScopeMatches_AllFiltersPass(t *testing.T) {
|
|
s := Scope{
|
|
Events: []EventType{EventPacketObservation},
|
|
IATAs: []string{"YVR"},
|
|
PayloadTypes: []uint8{4},
|
|
}
|
|
if !scopeMatches(s, Event{Type: EventPacketObservation, IATA: "YVR", PayloadType: 4}) {
|
|
t.Error("expected all-matching event to pass")
|
|
}
|
|
}
|
|
|
|
func TestScopeMatches_OneFilterFails(t *testing.T) {
|
|
s := Scope{
|
|
Events: []EventType{EventPacketObservation},
|
|
IATAs: []string{"YVR"},
|
|
PayloadTypes: []uint8{4},
|
|
}
|
|
if scopeMatches(s, Event{Type: EventPacketObservation, IATA: "YYC", PayloadType: 4}) {
|
|
t.Error("expected wrong IATA to fail")
|
|
}
|
|
if scopeMatches(s, Event{Type: EventPacketObservation, IATA: "YVR", PayloadType: 5}) {
|
|
t.Error("expected wrong payload type to fail")
|
|
}
|
|
}
|
|
|
|
func runHub(t *testing.T) *Hub {
|
|
t.Helper()
|
|
h := New()
|
|
go h.Run()
|
|
return h
|
|
}
|
|
|
|
func TestHub_NewClient_RegistersClient(t *testing.T) {
|
|
h := runHub(t)
|
|
c := h.NewClient()
|
|
if c == nil {
|
|
t.Fatal("expected non-nil client")
|
|
}
|
|
if c.Send == nil {
|
|
t.Error("expected Send channel to be initialized")
|
|
}
|
|
}
|
|
|
|
func TestHub_Broadcast_DeliveredToSubscriber(t *testing.T) {
|
|
h := runHub(t)
|
|
c := h.NewClient()
|
|
h.AddScope(c, "sub1", Scope{Events: []EventType{EventPacketObservation}})
|
|
|
|
// give hub time to process
|
|
time.Sleep(10 * time.Millisecond)
|
|
|
|
h.Broadcast(Event{Type: EventPacketObservation, IATA: "YVR"})
|
|
|
|
select {
|
|
case evt := <-c.Send:
|
|
if evt.Type != EventPacketObservation {
|
|
t.Errorf("expected packetObservation, got %s", evt.Type)
|
|
}
|
|
case <-time.After(100 * time.Millisecond):
|
|
t.Fatal("expected event, timed out")
|
|
}
|
|
}
|
|
|
|
func TestHub_Broadcast_NotDeliveredWithoutMatchingScope(t *testing.T) {
|
|
h := runHub(t)
|
|
c := h.NewClient()
|
|
h.AddScope(c, "sub1", Scope{Events: []EventType{EventNodeUpdate}})
|
|
|
|
time.Sleep(10 * time.Millisecond)
|
|
|
|
h.Broadcast(Event{Type: EventPacketObservation, IATA: "YVR"})
|
|
|
|
select {
|
|
case <-c.Send:
|
|
t.Error("expected no event for non-matching scope")
|
|
case <-time.After(50 * time.Millisecond):
|
|
// expected
|
|
}
|
|
}
|
|
|
|
func TestHub_Broadcast_NoSubscriptions_NotDelivered(t *testing.T) {
|
|
h := runHub(t)
|
|
c := h.NewClient()
|
|
|
|
time.Sleep(10 * time.Millisecond)
|
|
|
|
h.Broadcast(Event{Type: EventPacketObservation, IATA: "YVR"})
|
|
|
|
select {
|
|
case <-c.Send:
|
|
t.Error("expected no event for client with no subscriptions")
|
|
case <-time.After(50 * time.Millisecond):
|
|
// expected
|
|
}
|
|
}
|
|
|
|
func TestHub_RemoveScope_StopsDelivery(t *testing.T) {
|
|
h := runHub(t)
|
|
c := h.NewClient()
|
|
h.AddScope(c, "sub1", Scope{Events: []EventType{EventPacketObservation}})
|
|
|
|
time.Sleep(10 * time.Millisecond)
|
|
|
|
h.RemoveScope(c, "sub1")
|
|
time.Sleep(20 * time.Millisecond) // wait for hub to process RemoveScope
|
|
|
|
// drain anything that snuck in before removal was processed
|
|
for len(c.Send) > 0 {
|
|
<-c.Send
|
|
}
|
|
|
|
h.Broadcast(Event{Type: EventPacketObservation, IATA: "YVR"})
|
|
|
|
select {
|
|
case <-c.Send:
|
|
t.Error("expected no event after scope removed")
|
|
case <-time.After(50 * time.Millisecond):
|
|
// expected
|
|
}
|
|
}
|
|
|
|
func TestHub_Remove_ClosesChannels(t *testing.T) {
|
|
h := runHub(t)
|
|
c := h.NewClient()
|
|
|
|
time.Sleep(10 * time.Millisecond)
|
|
|
|
h.Remove(c)
|
|
|
|
select {
|
|
case _, ok := <-c.Send:
|
|
if ok {
|
|
t.Error("expected Send channel to be closed")
|
|
}
|
|
case <-time.After(100 * time.Millisecond):
|
|
t.Fatal("expected Send channel to be closed, timed out")
|
|
}
|
|
}
|
|
|
|
func TestHub_Broadcast_FullBuffer_SendsLaggedNotification(t *testing.T) {
|
|
h := runHub(t)
|
|
c := h.NewClient()
|
|
h.AddScope(c, "sub1", Scope{Events: []EventType{EventPacketObservation}})
|
|
|
|
time.Sleep(10 * time.Millisecond)
|
|
|
|
// fill the send buffer
|
|
for i := 0; i < cap(c.Send)+10; i++ {
|
|
h.Broadcast(Event{Type: EventPacketObservation, IATA: "YVR"})
|
|
}
|
|
|
|
select {
|
|
case notif := <-c.LaggedCH():
|
|
if notif.DroppedCount < 1 {
|
|
t.Errorf("expected DroppedCount >= 1, got %d", notif.DroppedCount)
|
|
}
|
|
case <-time.After(200 * time.Millisecond):
|
|
t.Fatal("expected lagged notification, timed out")
|
|
}
|
|
}
|
|
|
|
func TestClientMatches_NoSubscriptions(t *testing.T) {
|
|
c := &Client{subscriptions: make(map[string]Scope)}
|
|
if c.matches(Event{Type: EventPacketObservation}) {
|
|
t.Error("expected no match with empty subscriptions")
|
|
}
|
|
}
|
|
|
|
func TestClientMatches_ORSemantics(t *testing.T) {
|
|
c := &Client{
|
|
subscriptions: map[string]Scope{
|
|
"s1": {Events: []EventType{EventNodeUpdate}},
|
|
"s2": {Events: []EventType{EventPacketObservation}},
|
|
},
|
|
}
|
|
if !c.matches(Event{Type: EventPacketObservation}) {
|
|
t.Error("expected match on second scope")
|
|
}
|
|
if !c.matches(Event{Type: EventNodeUpdate}) {
|
|
t.Error("expected match on first scope")
|
|
}
|
|
if c.matches(Event{Type: EventChannelMessage}) {
|
|
t.Error("expected no match for unsubscribed event type")
|
|
}
|
|
}
|
|
|
|
func TestHub_ResolvePath_OptedIn_GetsResolvedPayload(t *testing.T) {
|
|
h := runHub(t)
|
|
c := h.NewClient()
|
|
h.AddScope(c, "sub1", Scope{Events: []EventType{EventPacketObservation}})
|
|
h.Configure(c, ClientOptions{ResolvePath: true})
|
|
|
|
time.Sleep(10 * time.Millisecond)
|
|
|
|
h.Broadcast(Event{
|
|
Type: EventPacketObservation,
|
|
IATA: "YVR",
|
|
Payload: json.RawMessage(`{"resolvedPath":null}`),
|
|
PayloadResolved: json.RawMessage(`{"resolvedPath":[{"confidence":"high"}]}`),
|
|
})
|
|
|
|
select {
|
|
case evt := <-c.Send:
|
|
if string(evt.Payload) != `{"resolvedPath":[{"confidence":"high"}]}` {
|
|
t.Errorf("expected resolved payload, got %s", evt.Payload)
|
|
}
|
|
case <-time.After(100 * time.Millisecond):
|
|
t.Fatal("expected event, timed out")
|
|
}
|
|
}
|
|
|
|
func TestHub_ResolvePath_DefaultOff_GetsBasePayload(t *testing.T) {
|
|
h := runHub(t)
|
|
c := h.NewClient()
|
|
h.AddScope(c, "sub1", Scope{Events: []EventType{EventPacketObservation}})
|
|
// no Configure call — default is off
|
|
|
|
time.Sleep(10 * time.Millisecond)
|
|
|
|
h.Broadcast(Event{
|
|
Type: EventPacketObservation,
|
|
IATA: "YVR",
|
|
Payload: json.RawMessage(`{"resolvedPath":null}`),
|
|
PayloadResolved: json.RawMessage(`{"resolvedPath":[{"confidence":"high"}]}`),
|
|
})
|
|
|
|
select {
|
|
case evt := <-c.Send:
|
|
if string(evt.Payload) != `{"resolvedPath":null}` {
|
|
t.Errorf("expected base payload (not opted in), got %s", evt.Payload)
|
|
}
|
|
case <-time.After(100 * time.Millisecond):
|
|
t.Fatal("expected event, timed out")
|
|
}
|
|
}
|
|
|
|
func TestHub_ResolvePath_OptedIn_NoResolvedVariant_FallsBackToBase(t *testing.T) {
|
|
h := runHub(t)
|
|
c := h.NewClient()
|
|
// e.g. nodeUpdate events never carry a PayloadResolved variant
|
|
h.AddScope(c, "sub1", Scope{Events: []EventType{EventNodeUpdate}})
|
|
h.Configure(c, ClientOptions{ResolvePath: true})
|
|
|
|
time.Sleep(10 * time.Millisecond)
|
|
|
|
h.Broadcast(Event{
|
|
Type: EventNodeUpdate,
|
|
IATA: "YVR",
|
|
Payload: json.RawMessage(`{"nodeId":"abc"}`),
|
|
// PayloadResolved intentionally left nil
|
|
})
|
|
|
|
select {
|
|
case evt := <-c.Send:
|
|
if string(evt.Payload) != `{"nodeId":"abc"}` {
|
|
t.Errorf("expected base payload as fallback, got %s", evt.Payload)
|
|
}
|
|
case <-time.After(100 * time.Millisecond):
|
|
t.Fatal("expected event, timed out")
|
|
}
|
|
}
|
|
|
|
func TestHub_ResolvePath_ToggleableLive(t *testing.T) {
|
|
h := runHub(t)
|
|
c := h.NewClient()
|
|
h.AddScope(c, "sub1", Scope{Events: []EventType{EventPacketObservation}})
|
|
|
|
broadcastAndRead := func() string {
|
|
h.Broadcast(Event{
|
|
Type: EventPacketObservation,
|
|
IATA: "YVR",
|
|
Payload: json.RawMessage(`{"resolvedPath":null}`),
|
|
PayloadResolved: json.RawMessage(`{"resolvedPath":[{"confidence":"high"}]}`),
|
|
})
|
|
select {
|
|
case evt := <-c.Send:
|
|
return string(evt.Payload)
|
|
case <-time.After(100 * time.Millisecond):
|
|
t.Fatal("expected event, timed out")
|
|
return ""
|
|
}
|
|
}
|
|
|
|
time.Sleep(10 * time.Millisecond)
|
|
if got := broadcastAndRead(); got != `{"resolvedPath":null}` {
|
|
t.Errorf("expected base payload before opting in, got %s", got)
|
|
}
|
|
|
|
h.Configure(c, ClientOptions{ResolvePath: true})
|
|
time.Sleep(10 * time.Millisecond)
|
|
if got := broadcastAndRead(); got != `{"resolvedPath":[{"confidence":"high"}]}` {
|
|
t.Errorf("expected resolved payload after opting in, got %s", got)
|
|
}
|
|
|
|
h.Configure(c, ClientOptions{})
|
|
time.Sleep(10 * time.Millisecond)
|
|
if got := broadcastAndRead(); got != `{"resolvedPath":null}` {
|
|
t.Errorf("expected base payload after opting back out, got %s", got)
|
|
}
|
|
}
|
|
|
|
func TestEvent_PayloadFor(t *testing.T) {
|
|
full := Event{
|
|
Payload: json.RawMessage(`base`),
|
|
PayloadResolved: json.RawMessage(`resolved`),
|
|
PayloadWithKey: json.RawMessage(`key`),
|
|
PayloadResolvedWithKey: json.RawMessage(`resolved+key`),
|
|
}
|
|
baseOnly := Event{Payload: json.RawMessage(`base`), PayloadResolved: json.RawMessage(`resolved`)}
|
|
for _, tc := range []struct {
|
|
name string
|
|
evt Event
|
|
opts ClientOptions
|
|
want string
|
|
}{
|
|
{"default", full, ClientOptions{}, "base"},
|
|
{"resolve", full, ClientOptions{ResolvePath: true}, "resolved"},
|
|
{"key", full, ClientOptions{IncludeObserverKey: true}, "key"},
|
|
{"both", full, ClientOptions{ResolvePath: true, IncludeObserverKey: true}, "resolved+key"},
|
|
{"key missing", baseOnly, ClientOptions{IncludeObserverKey: true}, "base"},
|
|
{"both, key missing", baseOnly, ClientOptions{ResolvePath: true, IncludeObserverKey: true}, "resolved"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
c := &Client{ResolvePath: tc.opts.ResolvePath, IncludeObserverKey: tc.opts.IncludeObserverKey}
|
|
if got := string(tc.evt.payloadFor(c)); got != tc.want {
|
|
t.Fatalf("payloadFor = %q, want %q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHub_ObserverKeyWanted_TracksOptIns(t *testing.T) {
|
|
h := runHub(t)
|
|
waitFor := func(want bool) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(time.Second)
|
|
for h.ObserverKeyWanted() != want {
|
|
if time.Now().After(deadline) {
|
|
t.Fatalf("ObserverKeyWanted = %t, want %t", !want, want)
|
|
}
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
}
|
|
a, b := h.NewClient(), h.NewClient()
|
|
if h.ObserverKeyWanted() {
|
|
t.Fatal("no client opted in yet")
|
|
}
|
|
h.Configure(a, ClientOptions{IncludeObserverKey: true})
|
|
h.Configure(a, ClientOptions{IncludeObserverKey: true}) // repeat must not double count
|
|
h.Configure(b, ClientOptions{IncludeObserverKey: true})
|
|
waitFor(true)
|
|
h.Configure(a, ClientOptions{ResolvePath: true})
|
|
h.Remove(b)
|
|
waitFor(false)
|
|
}
|
|
|
|
func TestHub_Repeat_OnlyReachesOptedInClients(t *testing.T) {
|
|
h := runHub(t)
|
|
plain, opted := h.NewClient(), h.NewClient()
|
|
h.AddScope(plain, "all", Scope{})
|
|
h.AddScope(opted, "all", Scope{})
|
|
h.Configure(opted, ClientOptions{IncludeRepeats: true})
|
|
time.Sleep(10 * time.Millisecond)
|
|
|
|
read := func(c *Client) (Event, bool) {
|
|
select {
|
|
case evt := <-c.Send:
|
|
return evt, true
|
|
case <-time.After(50 * time.Millisecond):
|
|
return Event{}, false
|
|
}
|
|
}
|
|
h.BroadcastRepeat(Event{Type: EventPacketObservation})
|
|
if evt, ok := read(opted); !ok || !evt.Repeat {
|
|
t.Fatalf("opted-in client: got %+v, %t", evt, ok)
|
|
}
|
|
if evt, ok := read(plain); ok {
|
|
t.Fatalf("plain client got repeat %+v", evt)
|
|
}
|
|
h.Broadcast(Event{Type: EventPacketObservation})
|
|
for _, c := range []*Client{plain, opted} {
|
|
if evt, ok := read(c); !ok || evt.Repeat {
|
|
t.Fatalf("normal event: got %+v, %t", evt, ok)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHub_RepeatsWanted_TracksOptIns(t *testing.T) {
|
|
h := runHub(t)
|
|
waitFor := func(want bool) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(time.Second)
|
|
for h.RepeatsWanted() != want {
|
|
if time.Now().After(deadline) {
|
|
t.Fatalf("RepeatsWanted = %t, want %t", !want, want)
|
|
}
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
}
|
|
a, b := h.NewClient(), h.NewClient()
|
|
if h.RepeatsWanted() {
|
|
t.Fatal("no client opted in yet")
|
|
}
|
|
h.Configure(a, ClientOptions{IncludeRepeats: true})
|
|
h.Configure(a, ClientOptions{IncludeRepeats: true}) // repeat must not double count
|
|
h.Configure(b, ClientOptions{IncludeRepeats: true})
|
|
waitFor(true)
|
|
h.Configure(a, ClientOptions{ResolvePath: true})
|
|
h.Remove(b)
|
|
waitFor(false)
|
|
}
|
|
|
|
func TestHub_BroadcastRepeat_DropsFirstWhenBusy(t *testing.T) {
|
|
h := New() // not running, so the broadcast channel only fills
|
|
h.BroadcastRepeat(Event{Type: EventPacketObservation})
|
|
if len(h.broadcast) != 1 || h.repeatDrops.Load() != 0 {
|
|
t.Fatalf("repeat on an idle hub: queued %d, dropped %d", len(h.broadcast), h.repeatDrops.Load())
|
|
}
|
|
for len(h.broadcast) < cap(h.broadcast)/2 {
|
|
h.Broadcast(Event{Type: EventPacketObservation})
|
|
}
|
|
queued := len(h.broadcast)
|
|
h.BroadcastRepeat(Event{Type: EventPacketObservation})
|
|
h.BroadcastRepeat(Event{Type: EventPacketObservation})
|
|
if len(h.broadcast) != queued || h.repeatDrops.Load() != 2 {
|
|
t.Fatalf("repeats past half full: queued %d (want %d), dropped %d", len(h.broadcast), queued, h.repeatDrops.Load())
|
|
}
|
|
h.Broadcast(Event{Type: EventPacketObservation})
|
|
if len(h.broadcast) != queued+1 {
|
|
t.Fatal("normal event not enqueued past half full")
|
|
}
|
|
}
|
|
|
|
func TestSentPaths(t *testing.T) {
|
|
start := time.Now()
|
|
s := newSentPaths(time.Minute, 3)
|
|
hash, observer := []byte{1, 2}, []byte{3}
|
|
if !s.mark(hash, observer, []byte{0xaa}, start) {
|
|
t.Fatal("first hearing refused")
|
|
}
|
|
if s.mark(hash, observer, []byte{0xaa}, start.Add(time.Second)) {
|
|
t.Fatal("exact copy within the window allowed")
|
|
}
|
|
if !s.mark(hash, observer, []byte{0xaa, 0xbb}, start.Add(time.Second)) {
|
|
t.Fatal("new path refused")
|
|
}
|
|
if !s.mark(hash, []byte{4}, []byte{0xaa}, start.Add(time.Second)) {
|
|
t.Fatal("other observer refused")
|
|
}
|
|
if !s.mark(hash, observer, []byte{0xaa}, start.Add(time.Minute)) {
|
|
t.Fatal("copy after the window refused")
|
|
}
|
|
|
|
s = newSentPaths(time.Hour, 3)
|
|
for i := range 4 {
|
|
s.mark(hash, observer, []byte{byte(i)}, start)
|
|
}
|
|
if len(s.at) != 3 || len(s.order) != 3 {
|
|
t.Fatalf("size bound: %d keys, %d queued", len(s.at), len(s.order))
|
|
}
|
|
if !s.mark(hash, observer, []byte{0}, start) {
|
|
t.Fatal("oldest key not evicted at the size bound")
|
|
}
|
|
}
|