khatru: add tests and fix dispatcher.

This commit is contained in:
fiatjaf
2026-04-21 21:20:40 -03:00
parent 94ea432818
commit 5b28d08e47
6 changed files with 299 additions and 23 deletions
+261
View File
@@ -0,0 +1,261 @@
package khatru
import (
"slices"
"strconv"
"testing"
"fiatjaf.com/nostr"
"github.com/stretchr/testify/require"
)
func TestDispatcherCandidates(t *testing.T) {
d := newDispatcher()
d.addSubscription(subscription{
id: "...",
filter: nostr.Filter{
Kinds: []nostr.Kind{9},
Tags: nostr.TagMap{"h": []string{"aaa"}},
},
})
d.addSubscription(subscription{
id: "...",
filter: nostr.Filter{
Kinds: []nostr.Kind{11},
Tags: nostr.TagMap{"h": []string{"aaa"}},
},
})
d.addSubscription(subscription{
id: "...",
filter: nostr.Filter{
Kinds: []nostr.Kind{9, 11, 1111},
Tags: nostr.TagMap{"h": []string{"aaa"}},
},
})
d.addSubscription(subscription{
id: "...",
filter: nostr.Filter{
Kinds: []nostr.Kind{9, 11, 1111},
Tags: nostr.TagMap{"h": []string{"bbb"}},
},
})
d.addSubscription(subscription{
id: "...",
filter: nostr.Filter{
Kinds: []nostr.Kind{9, 11, 1111},
Authors: []nostr.PubKey{
nostr.MustPubKeyFromHex("87f5650744bed197fcb170ae05fd8d1948a24b2aac34cedf7bdb1c47d6d93273"),
},
},
})
matched := 0
for range d.candidates(nostr.Event{
PubKey: nostr.MustPubKeyFromHex("87f5650744bed197fcb170ae05fd8d1948a24b2aac34cedf7bdb1c47d6d93273"),
ID: nostr.MustIDFromHex("87f5650744bed197fcb170ae05fd8d1948a24b2aac34cedf7bdb1c47d6d93273"),
Kind: 9,
CreatedAt: nostr.Now(),
Content: "hello",
Tags: nostr.Tags{
{"h", "aaa"},
},
}) {
matched++
}
require.Equal(t, 3, matched)
}
func FuzzDispatcherCandidates(f *testing.F) {
f.Add(1, 1, uint8(8), uint8(16))
f.Add(2, 3, uint8(32), uint8(32))
f.Fuzz(func(t *testing.T, seed int, advance int, ops uint8, checks uint8) {
d := newDispatcher()
state := fuzzState{value: seed, advance: advance}
active := make(map[int]subscription)
activeSSIDs := make([]int, 0, int(ops))
nextSubID := 0
steps := int(ops) + 1
for range steps {
if len(activeSSIDs) == 0 || state.next(10) != 0 {
nextSubID++
sub := subscription{
id: strconv.Itoa(nextSubID),
filter: fuzzDispatcherFilter(&state),
}
ssid := d.addSubscription(sub)
active[ssid] = sub
activeSSIDs = append(activeSSIDs, ssid)
} else {
idx := state.next(len(activeSSIDs))
ssid := activeSSIDs[idx]
d.removeSubscription(ssid)
delete(active, ssid)
activeSSIDs = append(activeSSIDs[:idx], activeSSIDs[idx+1:]...)
}
for range int(checks%7) + 1 {
event := fuzzDispatcherEvent(&state)
expected := expectedDispatcherCandidates(active, event)
actual := collectedDispatcherCandidates(&d, event)
require.Equalf(t, expected, actual, "seed=%d advance=%d event=%s active=%v", seed, advance, event.String(), active)
}
}
for _, ssid := range activeSSIDs {
d.removeSubscription(ssid)
delete(active, ssid)
}
require.Empty(t, collectedDispatcherCandidates(&d, fuzzDispatcherEvent(&state)))
})
}
type fuzzState struct {
value int
advance int
}
func (state *fuzzState) next(n int) int {
if n <= 0 {
return 0
}
value := state.value % n
if value < 0 {
value += n
}
state.value += state.advance
return value
}
func fuzzDispatcherFilter(seed *fuzzState) nostr.Filter {
filter := nostr.Filter{
Authors: fuzzDispatcherAuthors(seed),
Kinds: fuzzDispatcherKinds(seed),
Tags: fuzzDispatcherTagMap(seed),
}
if seed.next(3) == 0 {
since := nostr.Timestamp(seed.next(6))
until := since + nostr.Timestamp(seed.next(6))
filter.Since = since
filter.Until = until
} else if seed.next(4) == 0 {
filter.Since = nostr.Timestamp(seed.next(8))
} else if seed.next(4) == 0 {
filter.Until = nostr.Timestamp(seed.next(8))
}
return filter
}
func fuzzDispatcherAuthors(seed *fuzzState) []nostr.PubKey {
switch seed.next(4) {
case 0:
return nil
case 1:
return []nostr.PubKey{}
}
count := seed.next(3) + 1
authors := make([]nostr.PubKey, 0, count)
for range count {
pk := nostr.PubKey{byte(seed.next(4) + 1)}
if !slices.Contains(authors, pk) {
authors = append(authors, pk)
}
}
return authors
}
func fuzzDispatcherKinds(seed *fuzzState) []nostr.Kind {
switch seed.next(4) {
case 0:
return nil
case 1:
return []nostr.Kind{}
}
count := seed.next(3) + 1
kinds := make([]nostr.Kind, 0, count)
for range count {
kind := nostr.Kind(seed.next(5) + 1)
if !slices.Contains(kinds, kind) {
kinds = append(kinds, kind)
}
}
return kinds
}
func fuzzDispatcherTagMap(seed *fuzzState) nostr.TagMap {
if seed.next(3) == 0 {
return nil
}
keys := []string{"e", "p", "t"}
values := []string{"a", "b", "c", "d"}
count := seed.next(3)
if count == 0 {
return nostr.TagMap{}
}
tags := make(nostr.TagMap, count)
start := seed.next(len(keys))
for i := range count {
idx := (start + i) % len(keys)
valueCount := seed.next(3) + 1
entries := make([]string, 0, valueCount)
for range valueCount {
value := values[seed.next(len(values))]
if !slices.Contains(entries, value) {
entries = append(entries, value)
}
}
tags[keys[idx]] = entries
}
return tags
}
func fuzzDispatcherEvent(seed *fuzzState) nostr.Event {
tags := make(nostr.Tags, 0, seed.next(4))
keys := []string{"e", "p", "t"}
values := []string{"a", "b", "c", "d"}
for range cap(tags) {
tags = append(tags, nostr.Tag{keys[seed.next(len(keys))], values[seed.next(len(values))]})
}
return nostr.Event{
PubKey: nostr.PubKey{byte(seed.next(4) + 1)},
Kind: nostr.Kind(seed.next(5) + 1),
CreatedAt: nostr.Timestamp(seed.next(8)),
Tags: tags,
}
}
func expectedDispatcherCandidates(active map[int]subscription, event nostr.Event) []string {
ids := make([]string, 0, len(active))
for _, sub := range active {
if sub.filter.Matches(event) {
ids = append(ids, sub.id)
}
}
slices.Sort(ids)
return ids
}
func collectedDispatcherCandidates(d *dispatcher, event nostr.Event) []string {
ids := make([]string, 0, d.subscriptions.Size())
for sub := range d.candidates(event) {
ids = append(ids, sub.id)
}
slices.Sort(ids)
return ids
}
+14 -3
View File
@@ -134,16 +134,27 @@ func (d *dispatcher) candidates(event nostr.Event) iter.Seq[subscription] {
for _, ssid := range authorSubs.Slice() {
sub, _ := d.subscriptions.Load(ssid)
if kindSubs.Has(ssid) {
if kindSubs.Has(ssid) || sub.filter.Kinds == nil {
if filterMatchesTimestampConstraintsAndTags(sub.filter, event) {
if !yield(sub) {
return
}
}
} else {
// matched author but not tags, so this event doesn't qualify for any filter
}
}
for _, ssid := range kindSubs.Slice() {
sub, _ := d.subscriptions.Load(ssid)
if sub.filter.Authors != nil {
continue
}
if filterMatchesTimestampConstraintsAndTags(sub.filter, event) {
if !yield(sub) {
return
}
}
}
} else if hasAuthorSubs {
for _, ssid := range authorSubs.Slice() {
+1 -5
View File
@@ -1,7 +1,6 @@
package khatru
import (
"math/rand"
"testing"
"fiatjaf.com/nostr"
@@ -125,10 +124,7 @@ func FuzzRandomListenerIdRemoving(f *testing.F) {
}
require.Equal(t, len(subs)+extra, ssidCount)
rand.Shuffle(len(subs), func(i, j int) {
subs[i], subs[j] = subs[j], subs[i]
})
for _, wsidToRemove := range subs {
for _, wsidToRemove := range moduloOrder(subs, int(utw+ubs+ualf+ualef)) {
rl.removeListenerId(wsidToRemove.ws, wsidToRemove.id)
}
+16 -8
View File
@@ -1,7 +1,6 @@
package khatru
import (
"math/rand"
"strings"
"testing"
@@ -23,6 +22,18 @@ func idFromSeq(seq int, min, max int) string {
return result.String()
}
func moduloOrder[T any](items []T, seed int) []T {
remaining := append([]T(nil), items...)
ordered := make([]T, 0, len(items))
for len(remaining) > 0 {
idx := seed % len(remaining)
ordered = append(ordered, remaining[idx])
remaining = append(remaining[:idx], remaining[idx+1:]...)
seed++
}
return ordered
}
func TestListenerSetupAndRemoveOnce(t *testing.T) {
rl := NewRelay()
@@ -321,7 +332,7 @@ func TestRandomListenerClientRemoving(t *testing.T) {
ws := websockets[i]
w := idFromSeqUpper(i)
if rand.Intn(2) < 1 {
if (i+j)%2 == 0 {
l++
rl.addListener(ws, w+":"+idFromSeqLower(j), f, cancel)
}
@@ -374,12 +385,12 @@ func TestRandomListenerIdRemoving(t *testing.T) {
ws := websockets[i]
w := idFromSeqUpper(i)
if rand.Intn(2) < 1 {
if (i+j)%2 == 0 {
id := w + ":" + idFromSeqLower(j)
rl.addListener(ws, id, f, cancel)
subs = append(subs, wsid{ws, id})
if rand.Intn(5) < 1 {
if (i+j)%5 == 0 {
rl.addListener(ws, id, f, cancel)
extra++
}
@@ -394,10 +405,7 @@ func TestRandomListenerIdRemoving(t *testing.T) {
}
require.Equal(t, len(subs)+extra, ssidCount)
rand.Shuffle(len(subs), func(i, j int) {
subs[i], subs[j] = subs[j], subs[i]
})
for _, wsidToRemove := range subs {
for _, wsidToRemove := range moduloOrder(subs, 20) {
rl.removeListenerId(wsidToRemove.ws, wsidToRemove.id)
}
+5 -6
View File
@@ -3,7 +3,6 @@ package khatru
import (
"context"
"math"
"math/rand/v2"
"net/http/httptest"
"testing"
"time"
@@ -14,13 +13,15 @@ import (
)
func FuzzReplaceableEvents(f *testing.F) {
f.Add(uint(1), uint(2))
f.Add(1, 1, uint(2))
f.Fuzz(func(t *testing.T, seed uint, nevents uint) {
f.Fuzz(func(t *testing.T, seed int, advance int, nevents uint) {
if nevents == 0 {
return
}
state := fuzzState{value: seed, advance: advance}
relay := NewRelay()
store := &lmdb.LMDBBackend{Path: "/tmp/fuzz"}
store.Init()
@@ -67,12 +68,10 @@ func FuzzReplaceableEvents(f *testing.F) {
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel()
rnd := rand.New(rand.NewPCG(uint64(seed), 0))
newest := nostr.Timestamp(0)
for range nevents {
evt := createEvent(sk1, 0, `{"name":"blblbl"}`, nil)
evt.CreatedAt = nostr.Timestamp(rnd.Int64() % math.MaxUint32)
evt.CreatedAt = nostr.Timestamp(state.next(math.MaxUint32))
evt.Sign(sk1)
err = client1.Publish(ctx, evt)
if err != nil {
@@ -1,3 +1,4 @@
go test fuzz v1
uint(25)
int(25)
int(1)
uint(223)