547 lines
18 KiB
Go
547 lines
18 KiB
Go
package buzz
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/nbd-wtf/go-nostr"
|
|
"github.com/nbd-wtf/go-nostr/nip19"
|
|
|
|
"github.com/sipeed/picoclaw/pkg/bus"
|
|
"github.com/sipeed/picoclaw/pkg/config"
|
|
)
|
|
|
|
func TestNormalizeSecretKeyAcceptsHex(t *testing.T) {
|
|
sk := nostr.GeneratePrivateKey()
|
|
|
|
got, err := normalizeSecretKey(sk)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got != sk {
|
|
t.Fatalf("expected %s, got %s", sk, got)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeSecretKeyAcceptsNsec(t *testing.T) {
|
|
sk := nostr.GeneratePrivateKey()
|
|
nsec, err := nip19.EncodePrivateKey(sk)
|
|
if err != nil {
|
|
t.Fatalf("failed to encode nsec: %v", err)
|
|
}
|
|
|
|
got, err := normalizeSecretKey(nsec)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got != sk {
|
|
t.Fatalf("expected nsec to decode to %s, got %s", sk, got)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeSecretKeyTrimsWhitespace(t *testing.T) {
|
|
sk := nostr.GeneratePrivateKey()
|
|
|
|
got, err := normalizeSecretKey(" " + sk + "\n")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got != sk {
|
|
t.Fatalf("expected %s, got %s", sk, got)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeSecretKeyRejectsBadInput(t *testing.T) {
|
|
cases := map[string]string{
|
|
"empty": "",
|
|
"whitespace": " ",
|
|
"short hex": "abcdef",
|
|
"bad nsec": "nsec1notarealkey",
|
|
"npub prefix": "npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq",
|
|
}
|
|
|
|
for name, input := range cases {
|
|
t.Run(name, func(t *testing.T) {
|
|
if _, err := normalizeSecretKey(input); err == nil {
|
|
t.Fatalf("expected an error for %q", input)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFirstTagValue(t *testing.T) {
|
|
tags := nostr.Tags{
|
|
nostr.Tag{"p", "pubkey-one"},
|
|
nostr.Tag{"h", "channel-abc"},
|
|
nostr.Tag{"h", "channel-second"},
|
|
nostr.Tag{"malformed"},
|
|
}
|
|
|
|
if got := firstTagValue(tags, "h"); got != "channel-abc" {
|
|
t.Fatalf("expected first h tag, got %q", got)
|
|
}
|
|
if got := firstTagValue(tags, "e"); got != "" {
|
|
t.Fatalf("expected empty string for missing tag, got %q", got)
|
|
}
|
|
// A tag with no value must not panic or be treated as present.
|
|
if got := firstTagValue(tags, "malformed"); got != "" {
|
|
t.Fatalf("expected empty string for valueless tag, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestHasPTag(t *testing.T) {
|
|
const self = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
|
|
|
tags := nostr.Tags{
|
|
nostr.Tag{"h", "channel-abc"},
|
|
nostr.Tag{"p", strings.ToUpper(self)},
|
|
}
|
|
if !hasPTag(tags, self) {
|
|
t.Fatal("expected p tag match to be case-insensitive")
|
|
}
|
|
|
|
other := nostr.Tags{
|
|
nostr.Tag{"h", "channel-abc"},
|
|
nostr.Tag{"p", "ffff"},
|
|
}
|
|
if hasPTag(other, self) {
|
|
t.Fatal("expected no match for a different pubkey")
|
|
}
|
|
|
|
// An "h" tag carrying our pubkey must not count as a mention.
|
|
spoof := nostr.Tags{nostr.Tag{"h", self}}
|
|
if hasPTag(spoof, self) {
|
|
t.Fatal("expected only p tags to count as mentions")
|
|
}
|
|
}
|
|
|
|
func TestShortPubkey(t *testing.T) {
|
|
const full = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
|
|
|
got := shortPubkey(full)
|
|
if !strings.HasPrefix(got, "01234567") || !strings.HasSuffix(got, "cdef") {
|
|
t.Fatalf("unexpected short form: %s", got)
|
|
}
|
|
|
|
// Short inputs are returned unchanged rather than sliced out of range.
|
|
if got := shortPubkey("abc"); got != "abc" {
|
|
t.Fatalf("expected short input unchanged, got %s", got)
|
|
}
|
|
}
|
|
|
|
// newTestBuzzChannel builds a real BuzzChannel wired to a temp-disk seen store
|
|
// and a live message bus, without touching the network. The caller's pubkey
|
|
// (c.publicKey) is the bot identity; events are signed with a different key so
|
|
// they are not treated as self-messages.
|
|
func newTestBuzzChannel(t *testing.T, seenPath string) (*BuzzChannel, *bus.MessageBus, context.Context) {
|
|
t.Helper()
|
|
|
|
botSK := nostr.GeneratePrivateKey()
|
|
if _, err := nostr.GetPublicKey(botSK); err != nil {
|
|
t.Fatalf("bot pubkey: %v", err)
|
|
}
|
|
|
|
bc := &config.Channel{}
|
|
bc.SetName("buzz-test")
|
|
cfg := &config.BuzzSettings{
|
|
RelayURL: "wss://relay.invalid",
|
|
PrivateKey: *config.NewSecureString(botSK),
|
|
Channels: config.FlexibleStringSlice{"test-channel"},
|
|
SeenStatePath: seenPath,
|
|
}
|
|
|
|
msgBus := bus.NewMessageBus()
|
|
t.Cleanup(msgBus.Close)
|
|
|
|
ch, err := NewBuzzChannel(bc, cfg, msgBus)
|
|
if err != nil {
|
|
t.Fatalf("NewBuzzChannel: %v", err)
|
|
}
|
|
// onEvent reads c.ctx; Start would set it but we don't want a relay.
|
|
ch.ctx, ch.cancel = context.WithCancel(context.Background())
|
|
t.Cleanup(func() {
|
|
if ch.cancel != nil {
|
|
ch.cancel()
|
|
}
|
|
})
|
|
return ch, msgBus, ch.ctx
|
|
}
|
|
|
|
// makeEvent builds and signs a kind:9 event from senderSK scoped to chatID,
|
|
// returning a real nostr.Event with a valid ID.
|
|
func makeEvent(t *testing.T, senderSK, chatID, content string, createdAt nostr.Timestamp) *nostr.Event {
|
|
t.Helper()
|
|
senderPK, err := nostr.GetPublicKey(senderSK)
|
|
if err != nil {
|
|
t.Fatalf("sender pubkey: %v", err)
|
|
}
|
|
evt := &nostr.Event{
|
|
PubKey: senderPK,
|
|
CreatedAt: createdAt,
|
|
Kind: kindStreamMessage,
|
|
Tags: nostr.Tags{nostr.Tag{"h", chatID}},
|
|
Content: content,
|
|
}
|
|
if err := evt.Sign(senderSK); err != nil {
|
|
t.Fatalf("sign: %v", err)
|
|
}
|
|
return evt
|
|
}
|
|
|
|
// expectInbound drains one inbound message from the bus, failing if none
|
|
// arrives within the timeout or if the bus is closed empty.
|
|
func expectInbound(t *testing.T, msgBus *bus.MessageBus, wantContent string) {
|
|
t.Helper()
|
|
select {
|
|
case msg, ok := <-msgBus.InboundChan():
|
|
if !ok {
|
|
t.Fatal("expected an inbound message but bus channel was closed")
|
|
}
|
|
if msg.Content != wantContent {
|
|
t.Fatalf("inbound content = %q, want %q", msg.Content, wantContent)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("expected an inbound message but none arrived within 1s")
|
|
}
|
|
}
|
|
|
|
// expectNoInbound asserts that no inbound message arrives within the timeout,
|
|
// i.e. the event was suppressed by the seen store.
|
|
func expectNoInbound(t *testing.T, msgBus *bus.MessageBus) {
|
|
t.Helper()
|
|
select {
|
|
case msg, ok := <-msgBus.InboundChan():
|
|
if ok {
|
|
t.Fatalf("expected no inbound message, but got one: %q (id=%q)", msg.Content, msg.Context.MessageID)
|
|
}
|
|
case <-time.After(200 * time.Millisecond):
|
|
// No message within the window — replay suppression held. This is the
|
|
// success path.
|
|
}
|
|
}
|
|
|
|
// TestReplaySuppression_PersistedEventNotRedelivered covers the core restart
|
|
// scenario: an event handled in run 1 is recorded to the persistent store; a
|
|
// fresh channel loaded from the same store path must not redeliver it.
|
|
func TestReplaySuppression_PersistedEventNotRedelivered(t *testing.T) {
|
|
seenPath := filepath.Join(t.TempDir(), "seen.json")
|
|
|
|
// Run 1: a fresh channel handles one event.
|
|
ch1, msgBus1, _ := newTestBuzzChannel(t, seenPath)
|
|
senderSK := nostr.GeneratePrivateKey()
|
|
evt := makeEvent(t, senderSK, "test-channel", "hello from the past", nostr.Now())
|
|
|
|
ch1.onEvent(evt)
|
|
expectInbound(t, msgBus1, "hello from the past")
|
|
|
|
// The store file must exist on disk after handling.
|
|
if _, err := os.Stat(seenPath); err != nil {
|
|
t.Fatalf("stat seen file: %v", err)
|
|
}
|
|
|
|
// The persisted JSON must concretely record the handled event's ID and
|
|
// high-water timestamp, so a restart can dedup it without re-running the
|
|
// handler.
|
|
raw, err := os.ReadFile(seenPath)
|
|
if err != nil {
|
|
t.Fatalf("read seen file: %v", err)
|
|
}
|
|
var persisted map[string]*channelHighWater
|
|
if err := json.Unmarshal(raw, &persisted); err != nil {
|
|
t.Fatalf("unmarshal seen file: %v", err)
|
|
}
|
|
entry, ok := persisted["test-channel"]
|
|
if !ok {
|
|
t.Fatalf("persisted state missing test-channel entry; got %v", persisted)
|
|
}
|
|
if entry.MaxCreatedAt != int64(evt.CreatedAt) {
|
|
t.Fatalf("persisted max_created_at = %d, want %d", entry.MaxCreatedAt, int64(evt.CreatedAt))
|
|
}
|
|
foundID := false
|
|
for _, id := range entry.RecentIDs {
|
|
if id == evt.ID {
|
|
foundID = true
|
|
break
|
|
}
|
|
}
|
|
if !foundID {
|
|
t.Fatalf("persisted recent_ids %v do not contain event ID %q", entry.RecentIDs, evt.ID)
|
|
}
|
|
|
|
// Run 2: a brand-new channel instance reading the same store must not
|
|
// redeliver the same event ID, simulating a service restart.
|
|
ch2, msgBus2, _ := newTestBuzzChannel(t, seenPath)
|
|
ch2.onEvent(evt)
|
|
expectNoInbound(t, msgBus2)
|
|
|
|
// High-water mark must reflect the handled event's CreatedAt so the
|
|
// subscription Since filter would skip older history on restart.
|
|
if hw := ch2.seen.highWaterMark(); hw != evt.CreatedAt {
|
|
t.Fatalf("high-water mark = %d, want %d", hw, evt.CreatedAt)
|
|
}
|
|
}
|
|
|
|
// TestReplaySuppression_FreshEventStillHandled ensures the seen store does not
|
|
// suppress events that were never handled before — the live delivery path is
|
|
// preserved.
|
|
func TestReplaySuppression_FreshEventStillHandled(t *testing.T) {
|
|
seenPath := filepath.Join(t.TempDir(), "seen.json")
|
|
ch, msgBus, _ := newTestBuzzChannel(t, seenPath)
|
|
senderSK := nostr.GeneratePrivateKey()
|
|
|
|
first := makeEvent(t, senderSK, "test-channel", "first message", nostr.Now())
|
|
ch.onEvent(first)
|
|
expectInbound(t, msgBus, "first message")
|
|
|
|
// A different event ID must still be delivered.
|
|
second := makeEvent(t, senderSK, "test-channel", "second message", nostr.Now()+1)
|
|
ch.onEvent(second)
|
|
expectInbound(t, msgBus, "second message")
|
|
|
|
// Replaying the first event again is still suppressed within the same run
|
|
// (idempotency, not just restart protection).
|
|
ch.onEvent(first)
|
|
expectNoInbound(t, msgBus)
|
|
}
|
|
|
|
// TestReplaySuppression_SelfMessageNotRecorded confirms self-message filtering
|
|
// still works and that such messages do not pollute the seen store (so a future
|
|
// legitimate event with the same hypothetical ID — impossible in practice but
|
|
// the invariant matters — would not be wrongly suppressed).
|
|
func TestReplaySuppression_SelfMessageNotRecorded(t *testing.T) {
|
|
seenPath := filepath.Join(t.TempDir(), "seen.json")
|
|
ch, msgBus, _ := newTestBuzzChannel(t, seenPath)
|
|
|
|
// Build an event signed with the bot's own key so PubKey == c.publicKey.
|
|
selfEvt := makeEvent(t, ch.secretKey, "test-channel", "self echo", nostr.Now())
|
|
ch.onEvent(selfEvt)
|
|
expectNoInbound(t, msgBus)
|
|
|
|
// The store should have no entry for this channel because self-messages
|
|
// return before markHandled.
|
|
if ch.seen.seen("test-channel", selfEvt.ID, selfEvt.CreatedAt) {
|
|
t.Fatal("self-message was recorded in the seen store; it should be skipped before recording")
|
|
}
|
|
}
|
|
|
|
// TestSeenStoreHighWaterMarkAcrossChannels verifies the high-water mark is the
|
|
// max across all channels, so the subscription Since filter uses a single
|
|
// global cutoff that skips history for every channel.
|
|
func TestSeenStoreHighWaterMarkAcrossChannels(t *testing.T) {
|
|
s, err := newSeenStore(filepath.Join(t.TempDir(), "seen.json"), "test")
|
|
if err != nil {
|
|
t.Fatalf("newSeenStore: %v", err)
|
|
}
|
|
|
|
if hw := s.highWaterMark(); hw != 0 {
|
|
t.Fatalf("empty store high-water = %d, want 0", hw)
|
|
}
|
|
|
|
if err := s.markHandled("chan-a", "id-a1", 100); err != nil {
|
|
t.Fatalf("markHandled a: %v", err)
|
|
}
|
|
if err := s.markHandled("chan-b", "id-b1", 150); err != nil {
|
|
t.Fatalf("markHandled b: %v", err)
|
|
}
|
|
if err := s.markHandled("chan-a", "id-a2", 120); err != nil {
|
|
t.Fatalf("markHandled a2: %v", err)
|
|
}
|
|
|
|
if hw := s.highWaterMark(); hw != 150 {
|
|
t.Fatalf("high-water = %d, want 150", hw)
|
|
}
|
|
|
|
// Reload from disk to confirm persistence.
|
|
s2, err := newSeenStore(s.path, "test")
|
|
if err != nil {
|
|
t.Fatalf("newSeenStore reload: %v", err)
|
|
}
|
|
if hw := s2.highWaterMark(); hw != 150 {
|
|
t.Fatalf("after reload, high-water = %d, want 150", hw)
|
|
}
|
|
if !s2.seen("chan-a", "id-a1", 100) || !s2.seen("chan-b", "id-b1", 150) {
|
|
t.Fatal("reloaded store lost a recorded event ID")
|
|
}
|
|
}
|
|
|
|
func TestReplaySuppression_EventsOlderThanSeededHighWaterAreDropped(t *testing.T) {
|
|
seenPath := filepath.Join(t.TempDir(), "seen.json")
|
|
ch, msgBus, _ := newTestBuzzChannel(t, seenPath)
|
|
if err := ch.seen.markHandled("test-channel", "seed", nostr.Now()); err != nil {
|
|
t.Fatalf("seed high-water: %v", err)
|
|
}
|
|
|
|
oldEvent := makeEvent(
|
|
t,
|
|
nostr.GeneratePrivateKey(),
|
|
"test-channel",
|
|
"replayed history",
|
|
nostr.Now()-60,
|
|
)
|
|
ch.onEvent(oldEvent)
|
|
expectNoInbound(t, msgBus)
|
|
}
|
|
|
|
// TestSeenStoreRingTrims verifies the recent-ID ring does not grow unbounded,
|
|
// keeping only the most recent recentIDWindow entries per channel.
|
|
func TestSeenStoreRingTrims(t *testing.T) {
|
|
s, err := newSeenStore(filepath.Join(t.TempDir(), "seen.json"), "test")
|
|
if err != nil {
|
|
t.Fatalf("newSeenStore: %v", err)
|
|
}
|
|
|
|
// Insert more IDs than the window; old IDs should be evicted.
|
|
for i := 0; i < recentIDWindow+50; i++ {
|
|
id := "id-" + strings.Repeat("x", i%4)
|
|
// Use unique IDs so they are not deduped by the ring's duplicate check.
|
|
id = "id-" + nip19Letter(i)
|
|
if err := s.markHandled("chan", id, nostr.Timestamp(i)); err != nil {
|
|
t.Fatalf("markHandled %d: %v", i, err)
|
|
}
|
|
}
|
|
|
|
hw, ok := s.highWater["chan"]
|
|
if !ok {
|
|
t.Fatal("channel entry missing")
|
|
}
|
|
if len(hw.RecentIDs) != recentIDWindow {
|
|
t.Fatalf("ring length = %d, want %d", len(hw.RecentIDs), recentIDWindow)
|
|
}
|
|
// The first 50 IDs must have been evicted; the oldest remaining must be id
|
|
// number 50 (0-indexed).
|
|
if hw.RecentIDs[0] != "id-"+nip19Letter(50) {
|
|
t.Fatalf("oldest remaining ID = %q, want id for index 50", hw.RecentIDs[0])
|
|
}
|
|
}
|
|
|
|
// nip19Letter converts n to a short distinct string for unique IDs in the ring
|
|
// test. Using a deterministic suffix keeps IDs unique and short.
|
|
func nip19Letter(n int) string {
|
|
const digits = "0123456789abcdefghijklmnopqrstuvwxyz"
|
|
var out []byte
|
|
if n == 0 {
|
|
return "0"
|
|
}
|
|
for n > 0 {
|
|
out = append([]byte{digits[n%36]}, out...)
|
|
n /= 36
|
|
}
|
|
return string(out)
|
|
}
|
|
|
|
// TestNewBuzzChannel_RejectsCorruptSeenState asserts that NewBuzzChannel fails
|
|
// when the persisted seen-state file is unparseable. Starting a channel whose
|
|
// replay state cannot be loaded would silently lose dedup memory and risk
|
|
// redelivering already-handled events, so the constructor must refuse.
|
|
func TestNewBuzzChannel_RejectsCorruptSeenState(t *testing.T) {
|
|
seenPath := filepath.Join(t.TempDir(), "seen.json")
|
|
if err := os.WriteFile(seenPath, []byte("{not valid json"), 0o600); err != nil {
|
|
t.Fatalf("write corrupt seen file: %v", err)
|
|
}
|
|
|
|
botSK := nostr.GeneratePrivateKey()
|
|
bc := &config.Channel{}
|
|
bc.SetName("buzz-corrupt")
|
|
cfg := &config.BuzzSettings{
|
|
RelayURL: "wss://relay.invalid",
|
|
PrivateKey: *config.NewSecureString(botSK),
|
|
Channels: config.FlexibleStringSlice{"test-channel"},
|
|
SeenStatePath: seenPath,
|
|
}
|
|
msgBus := bus.NewMessageBus()
|
|
t.Cleanup(msgBus.Close)
|
|
|
|
if _, err := NewBuzzChannel(bc, cfg, msgBus); err == nil {
|
|
t.Fatal("NewBuzzChannel unexpectedly succeeded with a corrupt seen-state file")
|
|
}
|
|
}
|
|
|
|
// TestNewSeenStore_RejectsCorruptState exercises the store constructor
|
|
// directly: a corrupt file must surface a load error, while a missing file
|
|
// must not.
|
|
func TestNewSeenStore_RejectsCorruptState(t *testing.T) {
|
|
seenPath := filepath.Join(t.TempDir(), "seen.json")
|
|
|
|
// Missing file is fine — fresh store starts empty.
|
|
s, err := newSeenStore(seenPath, "test")
|
|
if err != nil {
|
|
t.Fatalf("missing file should not error, got: %v", err)
|
|
}
|
|
if hw := s.highWaterMark(); hw != 0 {
|
|
t.Fatalf("fresh store high-water = %d, want 0", hw)
|
|
}
|
|
|
|
// Corrupt file must error.
|
|
if err := os.WriteFile(seenPath, []byte("::{unjson"), 0o600); err != nil {
|
|
t.Fatalf("write corrupt file: %v", err)
|
|
}
|
|
if _, err := newSeenStore(seenPath, "test"); err == nil {
|
|
t.Fatal("newSeenStore unexpectedly succeeded with a corrupt file")
|
|
}
|
|
}
|
|
|
|
// TestOnEvent_PersistenceFailurePreventsDispatch verifies that if marking an
|
|
// event as handled fails to persist, onEvent does NOT dispatch the event to
|
|
// the inbound bus. This preserves at-most-once delivery: a successful but
|
|
// un-persisted mark would let the same event be redelivered and re-handled on
|
|
// the next restart.
|
|
//
|
|
// The persistence failure is induced by replacing the store's parent directory
|
|
// with a regular file after load, so saveLocked's MkdirAll fails. This is
|
|
// deterministic even when tests run as root and exercises real I/O.
|
|
func TestOnEvent_PersistenceFailurePreventsDispatch(t *testing.T) {
|
|
parent := filepath.Join(t.TempDir(), "store-parent")
|
|
storeDir := filepath.Join(parent, "store")
|
|
if err := os.Mkdir(parent, 0o700); err != nil {
|
|
t.Fatalf("mkdir store parent: %v", err)
|
|
}
|
|
if err := os.Mkdir(storeDir, 0o700); err != nil {
|
|
t.Fatalf("mkdir store: %v", err)
|
|
}
|
|
seenPath := filepath.Join(storeDir, "seen.json")
|
|
// Valid empty state so load() succeeds; save() will fail once the dir
|
|
// is made read-only.
|
|
if err := os.WriteFile(seenPath, []byte("{}"), 0o600); err != nil {
|
|
t.Fatalf("seed seen file: %v", err)
|
|
}
|
|
|
|
ch, msgBus, _ := newTestBuzzChannel(t, seenPath)
|
|
|
|
if err := os.RemoveAll(storeDir); err != nil {
|
|
t.Fatalf("remove store directory: %v", err)
|
|
}
|
|
if err := os.WriteFile(storeDir, []byte("not a directory"), 0o600); err != nil {
|
|
t.Fatalf("replace store directory with file: %v", err)
|
|
}
|
|
|
|
senderSK := nostr.GeneratePrivateKey()
|
|
evt := makeEvent(t, senderSK, "test-channel", "should not be dispatched", nostr.Now())
|
|
|
|
ch.onEvent(evt)
|
|
expectNoInbound(t, msgBus)
|
|
|
|
// A second, fresh event must also be suppressed while persistence is
|
|
// broken — the channel must not "give up" suppressing after one failure.
|
|
second := makeEvent(t, senderSK, "test-channel", "also not dispatched", nostr.Now()+1)
|
|
ch.onEvent(second)
|
|
expectNoInbound(t, msgBus)
|
|
}
|
|
|
|
// TestOnEvent_PersistenceFailureThenRecoveryDispatches confirms that once
|
|
// persistence is restored, a fresh event is dispatched normally — the
|
|
// persistence-failure path does not wedge the channel permanently.
|
|
func TestOnEvent_PersistenceFailureThenRecoveryDispatches(t *testing.T) {
|
|
// Start with a working seen store on a fresh channel.
|
|
seenPath := filepath.Join(t.TempDir(), "seen.json")
|
|
ch, msgBus, _ := newTestBuzzChannel(t, seenPath)
|
|
senderSK := nostr.GeneratePrivateKey()
|
|
|
|
evt := makeEvent(t, senderSK, "test-channel", "recovered path delivers", nostr.Now())
|
|
ch.onEvent(evt)
|
|
expectInbound(t, msgBus, "recovered path delivers")
|
|
}
|