1use 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#[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
309pub 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(¬e));
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}