Skip to main content

nostr_pubsub/
lib.rs

1//! Minimal in-process pubsub primitives for Nostr event routing.
2
3use std::{fmt, str::FromStr};
4
5use async_trait::async_trait;
6use nostr::Event;
7pub use nostr::filter::MatchEventOptions;
8pub use nostr::{ClientMessage, EventId, Filter, PublicKey, RelayMessage, SubscriptionId};
9
10mod mesh;
11mod wire;
12
13mod inv_want;
14mod live_routes;
15mod memory;
16mod router;
17mod routes;
18mod subscriptions;
19pub use mesh::*;
20pub use wire::*;
21
22pub use inv_want::*;
23pub use live_routes::*;
24pub use memory::*;
25pub use router::*;
26pub use routes::*;
27pub use subscriptions::*;
28pub const CAP_HASHTREE_FETCH: &str = "hashtree.fetch";
29
30pub type Result<T> = std::result::Result<T, PubsubError>;
31
32#[derive(Debug, thiserror::Error)]
33pub enum PubsubError {
34    #[error("validation failed: {0}")]
35    Validation(String),
36    #[error("storage failed: {0}")]
37    Storage(String),
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct VerifiedEvent {
42    event: Event,
43}
44
45impl VerifiedEvent {
46    pub fn as_event(&self) -> &Event {
47        &self.event
48    }
49
50    #[must_use]
51    pub fn into_event(self) -> Event {
52        self.event
53    }
54}
55
56impl TryFrom<Event> for VerifiedEvent {
57    type Error = PubsubError;
58
59    fn try_from(event: Event) -> Result<Self> {
60        event
61            .verify()
62            .map_err(|error| PubsubError::Validation(format!("invalid Nostr event: {error}")))?;
63        Ok(Self { event })
64    }
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
68pub struct SourceId(pub String);
69
70impl SourceId {
71    #[must_use]
72    pub fn new(id: impl Into<String>) -> Self {
73        Self(id.into())
74    }
75
76    #[must_use]
77    pub fn as_str(&self) -> &str {
78        &self.0
79    }
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
83pub enum EventSourceKind {
84    LocalIndex,
85    Peer,
86    FipsEndpoint,
87    Relay,
88}
89
90impl EventSourceKind {
91    #[must_use]
92    pub fn default_priority(self) -> i32 {
93        match self {
94            Self::LocalIndex => SOURCE_PRIORITY_LOCAL_INDEX,
95            Self::FipsEndpoint => SOURCE_PRIORITY_FIPS_ENDPOINT,
96            Self::Peer => SOURCE_PRIORITY_PEER,
97            Self::Relay => SOURCE_PRIORITY_RELAY,
98        }
99    }
100}
101
102pub const SOURCE_PRIORITY_LOCAL_INDEX: i32 = 300;
103pub const SOURCE_PRIORITY_FIPS_ENDPOINT: i32 = 200;
104pub const SOURCE_PRIORITY_PEER: i32 = 100;
105pub const SOURCE_PRIORITY_RELAY: i32 = -100;
106
107#[derive(Debug, Clone, PartialEq, Eq, Hash)]
108pub struct EventSource {
109    pub id: SourceId,
110    pub kind: EventSourceKind,
111    pub url: Option<String>,
112}
113
114impl EventSource {
115    #[must_use]
116    pub fn local_index(id: impl Into<String>) -> Self {
117        Self {
118            id: SourceId::new(id),
119            kind: EventSourceKind::LocalIndex,
120            url: None,
121        }
122    }
123
124    #[must_use]
125    pub fn peer(id: impl Into<String>) -> Self {
126        Self {
127            id: SourceId::new(id),
128            kind: EventSourceKind::Peer,
129            url: None,
130        }
131    }
132
133    #[must_use]
134    pub fn fips_endpoint(id: impl Into<String>) -> Self {
135        Self {
136            id: SourceId::new(id),
137            kind: EventSourceKind::FipsEndpoint,
138            url: None,
139        }
140    }
141
142    #[must_use]
143    pub fn relay(url: impl Into<String>) -> Self {
144        let url = url.into();
145        Self {
146            id: SourceId::new(url.clone()),
147            kind: EventSourceKind::Relay,
148            url: Some(url),
149        }
150    }
151}
152
153#[derive(Debug, Clone, PartialEq, Eq)]
154pub enum PolicyDecision {
155    Allow { priority: i32 },
156    Throttle { priority: i32, reason: String },
157    Drop { reason: String },
158}
159
160impl PolicyDecision {
161    #[must_use]
162    pub fn allow_with_priority(priority: i32) -> Self {
163        Self::Allow { priority }
164    }
165
166    pub fn throttle(priority: i32, reason: impl Into<String>) -> Self {
167        Self::Throttle {
168            priority,
169            reason: reason.into(),
170        }
171    }
172
173    pub fn drop(reason: impl Into<String>) -> Self {
174        Self::Drop {
175            reason: reason.into(),
176        }
177    }
178}
179
180pub struct EventPolicyContext<'a> {
181    pub event: &'a VerifiedEvent,
182    pub source: &'a EventSource,
183}
184
185pub struct SourcePolicyContext<'a> {
186    pub candidate: &'a SourceCandidate,
187    pub author_pubkey: Option<&'a str>,
188    pub capabilities: &'a [String],
189}
190
191#[async_trait]
192pub trait PubsubPolicy: Send + Sync {
193    async fn check_event(&self, context: EventPolicyContext<'_>) -> Result<PolicyDecision>;
194    async fn check_source(&self, context: SourcePolicyContext<'_>) -> Result<PolicyDecision>;
195}
196
197#[derive(Debug, Clone, PartialEq, Eq, Default)]
198pub struct SourceHealth {}
199
200#[derive(Debug, Clone, PartialEq, Eq)]
201pub struct SourceCandidate {
202    pub source: EventSource,
203    pub priority: i32,
204    pub reason: Option<String>,
205    pub freshness_hint: Option<u64>,
206    pub health: SourceHealth,
207}
208
209#[derive(Debug, Clone, PartialEq, Eq)]
210pub struct PublishReport {
211    pub accepted: bool,
212    pub priority: i32,
213    pub reason: Option<String>,
214}
215
216#[derive(Debug, Clone)]
217pub struct QueryEvent {
218    pub event: VerifiedEvent,
219    pub source: EventSource,
220    pub priority: i32,
221}
222
223#[derive(Debug, Clone, Default)]
224pub struct QueryReport {
225    pub events: Vec<QueryEvent>,
226}
227
228#[derive(Debug, Clone, Copy, Default)]
229pub struct QueryOptions {
230    pub limit: Option<usize>,
231}
232
233#[derive(Debug, Clone, PartialEq, Eq)]
234pub struct EventRetentionPolicy {
235    pub filters: Vec<Filter>,
236    pub max_events: usize,
237}
238
239impl EventRetentionPolicy {
240    #[must_use]
241    pub fn new(max_events: usize, filters: Vec<Filter>) -> Self {
242        Self {
243            filters,
244            max_events,
245        }
246    }
247
248    #[must_use]
249    pub fn accepts(&self, event: &VerifiedEvent) -> bool {
250        self.accepts_event(event.as_event())
251    }
252
253    #[must_use]
254    pub fn accepts_event(&self, event: &Event) -> bool {
255        self.max_events > 0 && filters_match(&self.filters, event)
256    }
257}
258
259#[async_trait]
260pub trait EventBus: Send + Sync {
261    async fn publish(&self, event: VerifiedEvent, source: EventSource) -> Result<PublishReport>;
262    async fn query(&self, filters: Vec<Filter>, options: QueryOptions) -> Result<QueryReport>;
263}
264
265/// The explicitly selected provider class for a pubsub consumer.
266///
267/// Provider construction belongs to the application. `Router` means the
268/// application explicitly composed multiple backends; no mode falls back or
269/// opens a socket implicitly.
270#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
271pub enum PubsubProviderMode {
272    LocalOnly,
273    DirectRelay,
274    Router,
275}
276
277impl PubsubProviderMode {
278    #[must_use]
279    pub const fn as_str(self) -> &'static str {
280        match self {
281            Self::LocalOnly => "local-only",
282            Self::DirectRelay => "direct-relay",
283            Self::Router => "router",
284        }
285    }
286}
287
288impl fmt::Display for PubsubProviderMode {
289    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
290        formatter.write_str(self.as_str())
291    }
292}
293
294impl FromStr for PubsubProviderMode {
295    type Err = PubsubError;
296
297    fn from_str(value: &str) -> Result<Self> {
298        match value {
299            "local-only" => Ok(Self::LocalOnly),
300            "direct-relay" => Ok(Self::DirectRelay),
301            "router" => Ok(Self::Router),
302            _ => Err(PubsubError::Validation(format!(
303                "unknown pubsub provider mode {value:?}; expected local-only, direct-relay, or router"
304            ))),
305        }
306    }
307}
308
309/// A selected pubsub provider presented to transport-blind consumers.
310pub trait PubsubProvider: EventBus {
311    fn mode(&self) -> PubsubProviderMode;
312}
313
314pub const DEFAULT_INV_WANT_HOP_LIMIT: u8 = 16;
315fn report_parts(decision: &PolicyDecision) -> (bool, i32, Option<String>) {
316    match decision {
317        PolicyDecision::Allow { priority } => (true, *priority, None),
318        PolicyDecision::Throttle { priority, reason } => (true, *priority, Some(reason.clone())),
319        PolicyDecision::Drop { reason } => (false, 0, Some(reason.clone())),
320    }
321}
322
323fn filters_match(filters: &[Filter], event: &Event) -> bool {
324    filters.is_empty() || subscription_filters_match(filters, event)
325}
326
327fn subscription_filters_match(filters: &[Filter], event: &Event) -> bool {
328    !filters.is_empty()
329        && filters
330            .iter()
331            .any(|filter| filter.match_event(event, MatchEventOptions::new()))
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337    use nostr::{EventBuilder, Keys, Kind, Tag};
338
339    #[test]
340    fn retention_policy_accepts_matching_events_only() {
341        let keys = Keys::generate();
342        let note = signed_event(&keys, Kind::TextNote, "hello");
343        let metadata = signed_event(&keys, Kind::Metadata, "{}");
344        let policy = EventRetentionPolicy::new(8, vec![Filter::new().kind(Kind::TextNote)]);
345
346        assert!(policy.accepts(&note));
347        assert!(!policy.accepts(&metadata));
348    }
349
350    #[test]
351    fn provider_modes_parse_without_implicit_fallback() {
352        assert_eq!(
353            "local-only".parse::<PubsubProviderMode>().unwrap(),
354            PubsubProviderMode::LocalOnly
355        );
356        assert_eq!(
357            "direct-relay".parse::<PubsubProviderMode>().unwrap(),
358            PubsubProviderMode::DirectRelay
359        );
360        assert_eq!(
361            "router".parse::<PubsubProviderMode>().unwrap(),
362            PubsubProviderMode::Router
363        );
364        assert!("relay".parse::<PubsubProviderMode>().is_err());
365        assert!("".parse::<PubsubProviderMode>().is_err());
366    }
367
368    #[test]
369    fn retention_policy_matches_generic_tag_filters() {
370        let keys = Keys::generate();
371        let matching = signed_event_with_tags(
372            &keys,
373            Kind::Custom(37195),
374            "advert",
375            [Tag::identifier("fips-test")],
376        );
377        let other_app = signed_event_with_tags(
378            &keys,
379            Kind::Custom(37195),
380            "advert",
381            [Tag::identifier("other-app")],
382        );
383        let policy = EventRetentionPolicy::new(
384            8,
385            vec![
386                Filter::new()
387                    .kind(Kind::Custom(37195))
388                    .identifier("fips-test"),
389            ],
390        );
391
392        assert!(policy.accepts(&matching));
393        assert!(!policy.accepts(&other_app));
394    }
395
396    #[test]
397    fn retention_policy_with_zero_capacity_stores_nothing() {
398        let keys = Keys::generate();
399        let event = signed_event(&keys, Kind::TextNote, "hello");
400        let policy = EventRetentionPolicy::new(0, vec![Filter::new()]);
401
402        assert!(!policy.accepts(&event));
403    }
404
405    #[test]
406    fn retention_policy_without_filters_accepts_any_event_when_capacity_exists() {
407        let keys = Keys::generate();
408        let event = signed_event(&keys, Kind::TextNote, "hello");
409        let policy = EventRetentionPolicy::new(8, Vec::new());
410
411        assert!(policy.accepts(&event));
412    }
413
414    #[test]
415    fn delivery_policy_pushes_only_to_subscribed_peers() {
416        let policy = PubsubDeliveryPolicy::push_subscribed();
417
418        assert_eq!(
419            policy.action_for_peer(PubsubPeerInterest::Subscribed),
420            PubsubDeliveryAction::PushFrame
421        );
422        assert_eq!(
423            policy.action_for_peer(PubsubPeerInterest::Unsubscribed),
424            PubsubDeliveryAction::Skip
425        );
426        assert_eq!(
427            policy.action_for_peer(PubsubPeerInterest::Unknown),
428            PubsubDeliveryAction::Skip
429        );
430    }
431
432    #[test]
433    fn delivery_policy_can_inventory_only_subscribers() {
434        let policy = PubsubDeliveryPolicy::inventory_to_subscribers();
435
436        assert_eq!(
437            policy.action_for_peer(PubsubPeerInterest::Subscribed),
438            PubsubDeliveryAction::AnnounceInventory
439        );
440        assert_eq!(
441            policy.action_for_peer(PubsubPeerInterest::Unsubscribed),
442            PubsubDeliveryAction::Skip
443        );
444    }
445
446    #[test]
447    fn delivery_policy_requires_subscription_match_before_inventory() {
448        let policy = PubsubDeliveryPolicy::inventory_to_peers();
449
450        assert_eq!(
451            policy.action_for_peer(PubsubPeerInterest::Subscribed),
452            PubsubDeliveryAction::AnnounceInventory
453        );
454        assert_eq!(
455            policy.action_for_peer(PubsubPeerInterest::Unsubscribed),
456            PubsubDeliveryAction::Skip
457        );
458        assert_eq!(
459            policy.action_for_peer(PubsubPeerInterest::Unknown),
460            PubsubDeliveryAction::Skip
461        );
462    }
463
464    #[test]
465    fn peer_subscription_store_records_bounded_peer_subscriptions() {
466        let mut subscriptions = PubsubPeerSubscriptionStore::new(PubsubSubscriptionLimits {
467            max_peers: 2,
468            max_subscriptions_per_peer: 2,
469            max_filters_per_subscription: 2,
470        });
471        let peer_a = SourceId::new("peer-a");
472        let peer_b = SourceId::new("peer-b");
473        let peer_c = SourceId::new("peer-c");
474
475        subscriptions
476            .upsert_filters(
477                peer_a.clone(),
478                "sub-1",
479                vec![Filter::new().kind(Kind::TextNote)],
480            )
481            .unwrap();
482        subscriptions
483            .upsert_filters(
484                peer_a.clone(),
485                "sub-2",
486                vec![Filter::new().kind(Kind::Metadata)],
487            )
488            .unwrap();
489        let evicted = subscriptions
490            .upsert_filters(
491                peer_a.clone(),
492                "sub-3",
493                vec![Filter::new().kind(Kind::EncryptedDirectMessage)],
494            )
495            .unwrap()
496            .unwrap();
497
498        assert_eq!(evicted.subscription_id, "sub-1");
499        assert_eq!(subscriptions.peer_subscription_count(&peer_a), 2);
500
501        subscriptions
502            .upsert_filters(
503                peer_b.clone(),
504                "sub-1",
505                vec![Filter::new().kind(Kind::TextNote)],
506            )
507            .unwrap();
508        subscriptions
509            .upsert_filters(
510                peer_c.clone(),
511                "sub-1",
512                vec![Filter::new().kind(Kind::TextNote)],
513            )
514            .unwrap();
515
516        assert_eq!(subscriptions.peer_count(), 2);
517        assert_eq!(
518            subscriptions.peer_interest(
519                &peer_a,
520                &signed_event(&Keys::generate(), Kind::TextNote, "hello")
521            ),
522            PubsubPeerInterest::Unknown
523        );
524        assert_eq!(subscriptions.peer_subscription_count(&peer_b), 1);
525        assert_eq!(subscriptions.peer_subscription_count(&peer_c), 1);
526    }
527
528    #[test]
529    fn peer_subscription_store_rejects_filter_spam() {
530        let mut subscriptions = PubsubPeerSubscriptionStore::new(PubsubSubscriptionLimits {
531            max_peers: 8,
532            max_subscriptions_per_peer: 8,
533            max_filters_per_subscription: 1,
534        });
535        let result = subscriptions.upsert_filters(
536            SourceId::new("peer-a"),
537            "sub-1",
538            vec![
539                Filter::new().kind(Kind::TextNote),
540                Filter::new().kind(Kind::Metadata),
541            ],
542        );
543
544        assert!(matches!(result, Err(PubsubError::Validation(_))));
545    }
546
547    #[test]
548    fn peer_subscriptions_use_nostr_filter_matching() {
549        let keys = Keys::generate();
550        let matching = signed_event_with_tags(
551            &keys,
552            Kind::Custom(37195),
553            "advert",
554            [Tag::identifier("fips-test")],
555        );
556        let other = signed_event_with_tags(
557            &keys,
558            Kind::Custom(37195),
559            "advert",
560            [Tag::identifier("other-app")],
561        );
562        let peer_id = SourceId::new("peer-a");
563        let mut subscriptions = PubsubPeerSubscriptionStore::default();
564        subscriptions
565            .upsert_filters(
566                peer_id.clone(),
567                "fips",
568                vec![
569                    Filter::new()
570                        .kind(Kind::Custom(37195))
571                        .identifier("fips-test"),
572                ],
573            )
574            .unwrap();
575
576        assert_eq!(
577            subscriptions.peer_interest(&peer_id, &matching),
578            PubsubPeerInterest::Subscribed
579        );
580        assert_eq!(
581            subscriptions.peer_interest(&peer_id, &other),
582            PubsubPeerInterest::Unsubscribed
583        );
584    }
585
586    #[test]
587    fn nostr_client_messages_update_peer_subscriptions() {
588        let mut subscriptions = PubsubPeerSubscriptionStore::default();
589        let peer_id = SourceId::new("peer-a");
590        let subscription_id = SubscriptionId::new("sub-1");
591        let req = ClientMessage::req(
592            subscription_id.clone(),
593            vec![Filter::new().kind(Kind::TextNote)],
594        );
595        let close = ClientMessage::close(subscription_id);
596
597        assert_eq!(
598            subscriptions
599                .apply_client_message(peer_id.clone(), req)
600                .unwrap(),
601            PubsubSubscriptionUpdate::Subscribed
602        );
603        assert_eq!(subscriptions.peer_subscription_count(&peer_id), 1);
604        assert_eq!(
605            subscriptions
606                .apply_client_message(peer_id.clone(), close)
607                .unwrap(),
608            PubsubSubscriptionUpdate::Closed
609        );
610        assert_eq!(
611            subscriptions.peer_interest(
612                &peer_id,
613                &signed_event(&Keys::generate(), Kind::TextNote, "hello")
614            ),
615            PubsubPeerInterest::Unknown
616        );
617    }
618
619    #[test]
620    fn inventory_delivery_simulation_matches_peer_subscriptions_before_invwant() {
621        let keys = Keys::generate();
622        let fips_event = signed_event_with_tags(
623            &keys,
624            Kind::Custom(37195),
625            "fips advert",
626            [Tag::identifier("fips.peer")],
627        );
628        let hashtree_event = signed_event_with_tags(
629            &keys,
630            Kind::Custom(30078),
631            "hashtree root",
632            [Tag::identifier("hashtree.root")],
633        );
634        let social_event = signed_event(&keys, Kind::TextNote, "trusted status");
635        let fips_peer = SourceId::new("fips-node");
636        let hashtree_peer = SourceId::new("hashtree-node");
637        let social_peer = SourceId::new("social-graph-node");
638        let unrelated_peer = SourceId::new("unrelated-node");
639        let unknown_peer = SourceId::new("unknown-node");
640        let mut subscriptions = PubsubPeerSubscriptionStore::default();
641        let policy = PubsubDeliveryPolicy::inventory_to_peers();
642
643        subscriptions
644            .upsert_filters(
645                fips_peer.clone(),
646                "fips-adverts",
647                vec![
648                    Filter::new()
649                        .kind(Kind::Custom(37195))
650                        .identifier("fips.peer"),
651                ],
652            )
653            .unwrap();
654        subscriptions
655            .upsert_filters(
656                hashtree_peer.clone(),
657                "hashtree-roots",
658                vec![
659                    Filter::new()
660                        .kind(Kind::Custom(30078))
661                        .identifier("hashtree.root"),
662                ],
663            )
664            .unwrap();
665        subscriptions
666            .upsert_filters(
667                social_peer.clone(),
668                "trusted-notes",
669                vec![Filter::new().kind(Kind::TextNote).author(keys.public_key())],
670            )
671            .unwrap();
672        subscriptions
673            .upsert_filters(
674                unrelated_peer.clone(),
675                "cashu",
676                vec![
677                    Filter::new()
678                        .kind(Kind::Custom(37195))
679                        .identifier("cashu.mint"),
680                ],
681            )
682            .unwrap();
683
684        assert_eq!(
685            subscriptions.interested_peers(&fips_event),
686            vec![fips_peer.clone()]
687        );
688        assert_eq!(
689            subscriptions.interested_peers(&hashtree_event),
690            vec![hashtree_peer.clone()]
691        );
692        assert_eq!(
693            subscriptions.interested_peers(&social_event),
694            vec![social_peer.clone()]
695        );
696        assert_eq!(
697            policy.action_for_event(&subscriptions, &fips_peer, &fips_event),
698            PubsubDeliveryAction::AnnounceInventory
699        );
700        assert_eq!(
701            policy.action_for_event(&subscriptions, &hashtree_peer, &fips_event),
702            PubsubDeliveryAction::Skip
703        );
704        assert_eq!(
705            policy.action_for_event(&subscriptions, &unknown_peer, &fips_event),
706            PubsubDeliveryAction::Skip
707        );
708    }
709
710    #[test]
711    fn frame_inventory_and_want_share_content_key() {
712        let key = PubsubContentKey::new("author:alice", "publisher-a", 7);
713        let frame = PubsubFrame::new(key.clone(), b"hello".to_vec(), 4);
714
715        let inventory = frame.inventory();
716        assert_eq!(inventory.key, key);
717        assert_eq!(inventory.payload_bytes, 5);
718        assert_eq!(inventory.hop_limit, 4);
719
720        let want = inventory.want();
721        assert_eq!(want.key, frame.key);
722    }
723
724    #[test]
725    fn protocol_messages_expose_their_content_key() {
726        let key = PubsubContentKey::new("ratings:exit", "peer-a", 42);
727        let inventory = PubsubInventory::new(key.clone(), 512, DEFAULT_INV_WANT_HOP_LIMIT);
728        let want = PubsubWant::new(key.clone());
729        let frame = PubsubFrame::new(key.clone(), vec![1, 2, 3], DEFAULT_INV_WANT_HOP_LIMIT);
730
731        assert_eq!(InvWantMessage::Inventory(inventory).key(), &key);
732        assert_eq!(InvWantMessage::Want(want).key(), &key);
733        assert_eq!(InvWantMessage::Frame(frame).key(), &key);
734    }
735
736    #[test]
737    fn standard_subscriptions_use_nostr_protocol_messages() {
738        let subscription_id = SubscriptionId::new("author-alice");
739        let req = ClientMessage::req(subscription_id.clone(), vec![Filter::new()]);
740        let close = ClientMessage::close(subscription_id);
741
742        assert!(req.is_req());
743        assert!(close.is_close());
744    }
745
746    #[test]
747    fn verified_event_rejects_tampered_content() {
748        let keys = Keys::generate();
749        let mut event = EventBuilder::new(Kind::TextNote, "signed")
750            .sign_with_keys(&keys)
751            .unwrap();
752        event.content = "tampered".to_string();
753
754        assert!(VerifiedEvent::try_from(event).is_err());
755    }
756
757    #[test]
758    fn source_route_defaults_put_relay_after_local_sources() {
759        let local = SourceRoute::local_index("hashtree:events");
760        let fips = SourceRoute::fips_peer_default("npub1fips");
761        let peer = SourceRoute::peer("npub1peer");
762        let relay = SourceRoute::relay("wss://relay.example");
763
764        assert_eq!(local.priority, SOURCE_PRIORITY_LOCAL_INDEX);
765        assert_eq!(fips.priority, SOURCE_PRIORITY_FIPS_ENDPOINT);
766        assert_eq!(peer.priority, SOURCE_PRIORITY_PEER);
767        assert_eq!(relay.priority, SOURCE_PRIORITY_RELAY);
768        assert!(local.priority > fips.priority);
769        assert!(fips.priority > peer.priority);
770        assert!(peer.priority > relay.priority);
771    }
772
773    #[test]
774    fn route_defaults_sort_relay_last_by_priority() {
775        let mut routes = [
776            SourceRoute::relay("wss://relay.example"),
777            SourceRoute::peer("npub1peer"),
778            SourceRoute::local_index("hashtree:events"),
779            SourceRoute::fips_peer_default("npub1fips"),
780        ];
781        routes.sort_by_key(|route| std::cmp::Reverse(route.priority));
782
783        let attempted = routes
784            .iter()
785            .map(|route| route.id.as_str())
786            .collect::<Vec<_>>();
787        assert_eq!(
788            attempted,
789            vec![
790                "hashtree:events",
791                "npub1fips",
792                "npub1peer",
793                "wss://relay.example"
794            ]
795        );
796    }
797
798    #[test]
799    fn route_priority_can_be_overridden_explicitly() {
800        let relay = SourceRoute::relay("wss://relay.example").with_priority(400);
801
802        assert_eq!(relay.priority, 400);
803        assert_eq!(relay.source.kind, EventSourceKind::Relay);
804    }
805
806    fn signed_event(keys: &Keys, kind: Kind, content: &str) -> VerifiedEvent {
807        let event = EventBuilder::new(kind, content)
808            .sign_with_keys(keys)
809            .unwrap();
810        VerifiedEvent::try_from(event).unwrap()
811    }
812
813    fn signed_event_with_tags<I>(keys: &Keys, kind: Kind, content: &str, tags: I) -> VerifiedEvent
814    where
815        I: IntoIterator<Item = Tag>,
816    {
817        let event = EventBuilder::new(kind, content)
818            .tags(tags)
819            .sign_with_keys(keys)
820            .unwrap();
821        VerifiedEvent::try_from(event).unwrap()
822    }
823}