use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use nostr_sdk::prelude::*;
use tokio::sync::Notify;
use super::IncomingRequest;
use crate::core::constants::*;
use crate::core::error::{Error, Result};
use crate::core::types::*;
use crate::relay::RelayPoolTrait;
const LOG_TARGET: &str = "contextvm_sdk::transport::server::announcement";
#[cfg_attr(not(feature = "rmcp"), allow(dead_code))]
const ANNOUNCEMENT_INIT_TIMEOUT: Duration = Duration::from_secs(10);
pub(crate) struct AnnouncementManager {
relay_pool: Arc<dyn RelayPoolTrait>,
server_info: Option<ServerInfo>,
encryption_mode: EncryptionMode,
gift_wrap_mode: GiftWrapMode,
extra_common_tags: Vec<Tag>,
internal_common_tags: Vec<Tag>,
pricing_tags: Vec<Tag>,
cached_common_tags: Mutex<Option<Vec<Tag>>>,
dispatch_fn: Option<tokio::sync::mpsc::UnboundedSender<IncomingRequest>>,
init_notify: Arc<Notify>,
#[cfg_attr(not(feature = "rmcp"), allow(dead_code))]
initialized: Mutex<bool>,
relay_urls: Vec<String>,
relay_list_urls: Option<Vec<String>>,
bootstrap_relay_urls: Option<Vec<String>>,
should_publish_relay_list: bool,
profile_metadata: Option<ProfileMetadata>,
}
struct AnnouncementMapping {
matches: fn(&serde_json::Value) -> bool,
kind: u16,
}
const ANNOUNCEMENT_MAPPINGS: &[AnnouncementMapping] = &[
AnnouncementMapping {
matches: |r| r.get("protocolVersion").is_some_and(|v| v.is_string()),
kind: SERVER_ANNOUNCEMENT_KIND,
},
AnnouncementMapping {
matches: |r| r.get("capabilities").is_some_and(|v| v.is_object()),
kind: SERVER_ANNOUNCEMENT_KIND,
},
AnnouncementMapping {
matches: |r| r.get("tools").is_some_and(|v| v.is_array()),
kind: TOOLS_LIST_KIND,
},
AnnouncementMapping {
matches: |r| r.get("resources").is_some_and(|v| v.is_array()),
kind: RESOURCES_LIST_KIND,
},
AnnouncementMapping {
matches: |r| r.get("resourceTemplates").is_some_and(|v| v.is_array()),
kind: RESOURCETEMPLATES_LIST_KIND,
},
AnnouncementMapping {
matches: |r| r.get("prompts").is_some_and(|v| v.is_array()),
kind: PROMPTS_LIST_KIND,
},
];
fn is_local_relay_url(url: &str) -> bool {
let without_proto = url
.strip_prefix("wss://")
.or_else(|| url.strip_prefix("ws://"))
.unwrap_or(url);
let lower = without_proto.to_lowercase();
for prefix in &["localhost", "127.0.0.1", "0.0.0.0", "[::1]"] {
if let Some(rest) = lower.strip_prefix(prefix) {
if rest.is_empty() || rest.starts_with(':') || rest.starts_with('/') {
return true;
}
}
}
false
}
impl AnnouncementManager {
#[allow(clippy::too_many_arguments)]
pub fn new(
relay_pool: Arc<dyn RelayPoolTrait>,
server_info: Option<ServerInfo>,
encryption_mode: EncryptionMode,
gift_wrap_mode: GiftWrapMode,
dispatch_fn: tokio::sync::mpsc::UnboundedSender<IncomingRequest>,
relay_urls: Vec<String>,
relay_list_urls: Option<Vec<String>>,
bootstrap_relay_urls: Option<Vec<String>>,
should_publish_relay_list: bool,
profile_metadata: Option<ProfileMetadata>,
) -> Self {
Self {
relay_pool,
server_info,
encryption_mode,
gift_wrap_mode,
extra_common_tags: Vec::new(),
internal_common_tags: Vec::new(),
pricing_tags: Vec::new(),
cached_common_tags: Mutex::new(None),
dispatch_fn: Some(dispatch_fn),
init_notify: Arc::new(Notify::new()),
initialized: Mutex::new(false),
relay_urls,
relay_list_urls,
bootstrap_relay_urls,
should_publish_relay_list,
profile_metadata,
}
}
pub fn get_server_info_tags(&self) -> Vec<Tag> {
let mut tags = Vec::new();
if let Some(ref info) = self.server_info {
if let Some(ref name) = info.name {
tags.push(Tag::custom(
TagKind::Custom(tags::NAME.into()),
vec![name.clone()],
));
}
if let Some(ref about) = info.about {
tags.push(Tag::custom(
TagKind::Custom(tags::ABOUT.into()),
vec![about.clone()],
));
}
if let Some(ref website) = info.website {
tags.push(Tag::custom(
TagKind::Custom(tags::WEBSITE.into()),
vec![website.clone()],
));
}
if let Some(ref picture) = info.picture {
tags.push(Tag::custom(
TagKind::Custom(tags::PICTURE.into()),
vec![picture.clone()],
));
}
}
tags
}
pub fn get_encryption_capability_tags(&self) -> Vec<Tag> {
let mut tags = Vec::new();
if self.encryption_mode != EncryptionMode::Disabled {
tags.push(Tag::custom(
TagKind::Custom(tags::SUPPORT_ENCRYPTION.into()),
Vec::<String>::new(),
));
if self.gift_wrap_mode.supports_ephemeral() {
tags.push(Tag::custom(
TagKind::Custom(tags::SUPPORT_ENCRYPTION_EPHEMERAL.into()),
Vec::<String>::new(),
));
}
}
tags
}
pub fn get_common_tags(&self) -> Vec<Tag> {
let mut cache = self
.cached_common_tags
.lock()
.unwrap_or_else(|e| e.into_inner());
if let Some(ref cached) = *cache {
return cached.clone();
}
let mut tags = self.get_server_info_tags();
tags.extend(self.get_encryption_capability_tags());
tags.extend(self.extra_common_tags.iter().cloned());
tags.extend(self.internal_common_tags.iter().cloned());
*cache = Some(tags.clone());
tags
}
#[allow(dead_code)] pub fn get_pricing_tags(&self) -> &[Tag] {
&self.pricing_tags
}
pub fn get_announcement_tags(&self, kind: u16) -> Vec<Tag> {
if kind == SERVER_ANNOUNCEMENT_KIND {
let mut tags = self.get_common_tags();
tags.extend(self.pricing_tags.iter().cloned());
tags
} else {
self.pricing_tags.clone()
}
}
pub fn set_extra_common_tags(&mut self, tags: Vec<Tag>) {
self.extra_common_tags = tags;
*self
.cached_common_tags
.lock()
.unwrap_or_else(|e| e.into_inner()) = None;
}
pub fn set_internal_common_tags(&mut self, tags: Vec<Tag>) {
self.internal_common_tags = tags;
*self
.cached_common_tags
.lock()
.unwrap_or_else(|e| e.into_inner()) = None;
}
pub fn set_pricing_tags(&mut self, tags: Vec<Tag>) {
self.pricing_tags = tags;
}
pub async fn announce(&self) -> Result<EventId> {
let info = self
.server_info
.as_ref()
.ok_or_else(|| Error::Other("No server info configured".to_string()))?;
let content = serde_json::to_string(info)?;
let tags = self.get_announcement_tags(SERVER_ANNOUNCEMENT_KIND);
let builder = EventBuilder::new(Kind::Custom(SERVER_ANNOUNCEMENT_KIND), content).tags(tags);
self.relay_pool.publish(builder).await
}
pub async fn publish_tools(&self, tools: Vec<serde_json::Value>) -> Result<EventId> {
let content = serde_json::json!({ "tools": tools });
let builder = EventBuilder::new(
Kind::Custom(TOOLS_LIST_KIND),
serde_json::to_string(&content)?,
)
.tags(self.pricing_tags.iter().cloned());
self.relay_pool.publish(builder).await
}
pub async fn publish_resources(&self, resources: Vec<serde_json::Value>) -> Result<EventId> {
let content = serde_json::json!({ "resources": resources });
let builder = EventBuilder::new(
Kind::Custom(RESOURCES_LIST_KIND),
serde_json::to_string(&content)?,
)
.tags(self.pricing_tags.iter().cloned());
self.relay_pool.publish(builder).await
}
pub async fn publish_prompts(&self, prompts: Vec<serde_json::Value>) -> Result<EventId> {
let content = serde_json::json!({ "prompts": prompts });
let builder = EventBuilder::new(
Kind::Custom(PROMPTS_LIST_KIND),
serde_json::to_string(&content)?,
)
.tags(self.pricing_tags.iter().cloned());
self.relay_pool.publish(builder).await
}
pub async fn publish_resource_templates(
&self,
templates: Vec<serde_json::Value>,
) -> Result<EventId> {
let content = serde_json::json!({ "resourceTemplates": templates });
let builder = EventBuilder::new(
Kind::Custom(RESOURCETEMPLATES_LIST_KIND),
serde_json::to_string(&content)?,
)
.tags(self.pricing_tags.iter().cloned());
self.relay_pool.publish(builder).await
}
pub async fn delete_announcements(&self, reason: &str) -> Result<()> {
let pubkey = self.relay_pool.public_key().await?;
for &kind in UNENCRYPTED_KINDS {
let filter = Filter::new().kind(Kind::Custom(kind)).author(pubkey);
let events = self
.relay_pool
.fetch_events(vec![filter], Duration::from_secs(10))
.await?;
if events.is_empty() {
continue;
}
let request = EventDeletionRequest::new()
.ids(events.iter().map(|e| e.id))
.reason(reason);
self.relay_pool
.publish(EventBuilder::delete(request))
.await?;
}
Ok(())
}
#[cfg(feature = "rmcp")]
pub async fn publish_tools_typed(&self, tools: Vec<rmcp::model::Tool>) -> Result<EventId> {
let tools = tools
.into_iter()
.map(serde_json::to_value)
.collect::<std::result::Result<Vec<_>, _>>()?;
self.publish_tools(tools).await
}
#[cfg(feature = "rmcp")]
pub async fn publish_resources_typed(
&self,
resources: Vec<rmcp::model::Resource>,
) -> Result<EventId> {
let resources = resources
.into_iter()
.map(serde_json::to_value)
.collect::<std::result::Result<Vec<_>, _>>()?;
self.publish_resources(resources).await
}
#[cfg(feature = "rmcp")]
pub async fn publish_prompts_typed(
&self,
prompts: Vec<rmcp::model::Prompt>,
) -> Result<EventId> {
let prompts = prompts
.into_iter()
.map(serde_json::to_value)
.collect::<std::result::Result<Vec<_>, _>>()?;
self.publish_prompts(prompts).await
}
#[cfg(feature = "rmcp")]
pub async fn publish_resource_templates_typed(
&self,
templates: Vec<rmcp::model::ResourceTemplate>,
) -> Result<EventId> {
let templates = templates
.into_iter()
.map(serde_json::to_value)
.collect::<std::result::Result<Vec<_>, _>>()?;
self.publish_resource_templates(templates).await
}
pub(crate) fn get_advertised_relay_urls(&self) -> &[String] {
self.relay_list_urls.as_deref().unwrap_or(&self.relay_urls)
}
pub(crate) fn get_discoverability_publish_relay_urls(&self) -> Vec<String> {
let advertised = self.get_advertised_relay_urls();
let has_explicit_bootstrap = self.bootstrap_relay_urls.is_some();
let should_skip_bootstrap = !has_explicit_bootstrap
&& !advertised.is_empty()
&& advertised.iter().all(|url| is_local_relay_url(url));
let mut seen = HashSet::new();
let mut result = Vec::new();
for url in advertised {
if seen.insert(url.clone()) {
result.push(url.clone());
}
}
if !should_skip_bootstrap {
let default_bootstrap: Vec<String> = DEFAULT_BOOTSTRAP_RELAY_URLS
.iter()
.map(|s| (*s).to_string())
.collect();
let bootstrap = self
.bootstrap_relay_urls
.as_deref()
.unwrap_or(&default_bootstrap);
for url in bootstrap {
if seen.insert(url.clone()) {
result.push(url.clone());
}
}
}
result
}
#[cfg(test)]
pub(crate) async fn publish_relay_list(&self) -> Result<()> {
if !self.should_publish_relay_list {
return Ok(());
}
let urls = self.get_advertised_relay_urls();
if urls.is_empty() {
tracing::warn!(target: LOG_TARGET, "No relay URLs to publish relay list");
return Ok(());
}
let tags: Vec<Tag> = urls
.iter()
.map(|url| Tag::custom(TagKind::Custom(tags::RELAY.into()), vec![url.clone()]))
.collect();
let builder = EventBuilder::new(Kind::Custom(RELAY_LIST_METADATA_KIND), "").tags(tags);
match self.publish_to_discoverability_relays(builder).await {
Ok(id) => tracing::info!(
target: LOG_TARGET,
event_id = %id,
"Published relay list (kind 10002)"
),
Err(e) => tracing::warn!(
target: LOG_TARGET,
error = %e,
"Failed to publish relay list"
),
}
Ok(())
}
#[cfg(test)]
pub(crate) async fn publish_profile_metadata(&self) -> Result<()> {
let metadata = match &self.profile_metadata {
Some(m) => m,
None => return Ok(()),
};
let content = serde_json::to_string(metadata)?;
let builder = EventBuilder::new(Kind::Custom(0), content);
match self.publish_to_discoverability_relays(builder).await {
Ok(id) => tracing::info!(
target: LOG_TARGET,
event_id = %id,
"Published profile metadata (kind 0)"
),
Err(e) => tracing::warn!(
target: LOG_TARGET,
error = %e,
"Failed to publish profile metadata"
),
}
Ok(())
}
#[cfg(test)]
async fn publish_to_discoverability_relays(&self, builder: EventBuilder) -> Result<EventId> {
let urls = self.get_discoverability_publish_relay_urls();
if urls.is_empty() {
self.relay_pool.publish(builder).await
} else {
self.relay_pool.publish_to(&urls, builder).await
}
}
#[cfg_attr(not(feature = "rmcp"), allow(dead_code))]
pub(crate) fn spawn_publish_discoverability(&self) -> tokio::task::JoinHandle<()> {
let relay_pool = Arc::clone(&self.relay_pool);
let target_urls = self.get_discoverability_publish_relay_urls();
let profile_event = self.profile_metadata.as_ref().and_then(|metadata| {
serde_json::to_string(metadata)
.ok()
.map(|content| EventBuilder::new(Kind::Custom(0), content))
});
let relay_list_event = if self.should_publish_relay_list {
let urls = self.get_advertised_relay_urls();
if urls.is_empty() {
None
} else {
let tags: Vec<Tag> = urls
.iter()
.map(|url| Tag::custom(TagKind::Custom(tags::RELAY.into()), vec![url.clone()]))
.collect();
Some(EventBuilder::new(Kind::Custom(RELAY_LIST_METADATA_KIND), "").tags(tags))
}
} else {
None
};
tokio::spawn(async move {
if let Some(builder) = profile_event {
let result = if target_urls.is_empty() {
relay_pool.publish(builder).await
} else {
relay_pool.publish_to(&target_urls, builder).await
};
match result {
Ok(id) => tracing::info!(
target: LOG_TARGET,
event_id = %id,
"Published profile metadata (kind 0)"
),
Err(e) => tracing::warn!(
target: LOG_TARGET,
error = %e,
"Failed to publish profile metadata"
),
}
}
if let Some(builder) = relay_list_event {
let result = if target_urls.is_empty() {
relay_pool.publish(builder).await
} else {
relay_pool.publish_to(&target_urls, builder).await
};
match result {
Ok(id) => tracing::info!(
target: LOG_TARGET,
event_id = %id,
"Published relay list (kind 10002)"
),
Err(e) => tracing::warn!(
target: LOG_TARGET,
error = %e,
"Failed to publish relay list"
),
}
}
tracing::info!(
target: LOG_TARGET,
"Discoverability event publishing complete"
);
})
}
pub fn common_tags_snapshot(&self) -> CommonTagsSnapshot {
CommonTagsSnapshot {
server_info: self.server_info.clone(),
extra_common_tags: self.extra_common_tags.clone(),
internal_common_tags: self.internal_common_tags.clone(),
encryption_mode: self.encryption_mode,
gift_wrap_mode: self.gift_wrap_mode,
}
}
pub(crate) fn shutdown(&mut self) {
self.dispatch_fn.take();
}
#[cfg_attr(not(feature = "rmcp"), allow(dead_code))]
pub(crate) async fn handle_announcement_response(
&self,
response: JsonRpcMessage,
) -> Result<()> {
let result = match &response {
JsonRpcMessage::Response(resp) => &resp.result,
JsonRpcMessage::ErrorResponse(resp) => {
tracing::warn!(
target: LOG_TARGET,
error_code = resp.error.code,
error_message = %resp.error.message,
"Announcement request returned error, skipping publish"
);
let mut flag = self.initialized.lock().unwrap_or_else(|e| e.into_inner());
if !*flag {
*flag = true;
drop(flag);
self.init_notify.notify_one();
}
return Ok(());
}
_ => return Ok(()),
};
let kind = ANNOUNCEMENT_MAPPINGS
.iter()
.find(|m| (m.matches)(result))
.map(|m| m.kind);
if kind.is_none() {
tracing::warn!(
target: LOG_TARGET,
"Announcement response has unrecognized schema, skipping publish"
);
}
if let Some(kind) = kind {
let content = serde_json::to_string(result)?;
let tags = self.get_announcement_tags(kind);
let builder = EventBuilder::new(Kind::Custom(kind), content).tags(tags);
match self.relay_pool.publish(builder).await {
Ok(id) => tracing::info!(
target: LOG_TARGET,
event_id = %id,
kind,
"Published announcement event"
),
Err(e) => tracing::warn!(
target: LOG_TARGET,
error = %e,
kind,
"Failed to publish announcement event"
),
}
if kind == SERVER_ANNOUNCEMENT_KIND {
if let Some(ref tx) = self.dispatch_fn {
let _ = tx.send(IncomingRequest {
message: JsonRpcMessage::Notification(JsonRpcNotification {
jsonrpc: "2.0".to_string(),
method: NOTIFICATIONS_INITIALIZED_METHOD.to_string(),
params: None,
}),
client_pubkey: ANNOUNCEMENT_REQUEST_ID.to_string(),
event_id: ANNOUNCEMENT_REQUEST_ID.to_string(),
is_encrypted: false,
event: None,
});
}
let mut flag = self.initialized.lock().unwrap_or_else(|e| e.into_inner());
*flag = true;
drop(flag);
self.init_notify.notify_one();
}
}
Ok(())
}
#[cfg_attr(not(feature = "rmcp"), allow(dead_code))]
pub(crate) fn spawn_publish_public_announcements(
&self,
cancel: tokio_util::sync::CancellationToken,
) -> tokio::task::JoinHandle<()> {
let dispatch_fn = self
.dispatch_fn
.clone()
.expect("dispatch_fn must be set before spawning announcements");
let init_notify = Arc::clone(&self.init_notify);
tokio::spawn(publish_public_announcements(
dispatch_fn,
init_notify,
cancel,
))
}
}
#[cfg_attr(not(feature = "rmcp"), allow(dead_code))]
async fn publish_public_announcements(
dispatch_fn: tokio::sync::mpsc::UnboundedSender<IncomingRequest>,
init_notify: Arc<Notify>,
cancel: tokio_util::sync::CancellationToken,
) {
tracing::info!(target: LOG_TARGET, "Starting auto-publish of server announcements");
let init_request = JsonRpcMessage::Request(JsonRpcRequest {
jsonrpc: "2.0".to_string(),
id: serde_json::json!(ANNOUNCEMENT_REQUEST_ID),
method: INITIALIZE_METHOD.to_string(),
params: Some(serde_json::json!({
"protocolVersion": crate::core::constants::mcp_protocol_version(),
"capabilities": {},
"clientInfo": {
"name": "contextvm-announcement-client",
"version": "0.1.0"
}
})),
});
if dispatch_fn
.send(IncomingRequest {
message: init_request,
client_pubkey: ANNOUNCEMENT_REQUEST_ID.to_string(),
event_id: ANNOUNCEMENT_REQUEST_ID.to_string(),
is_encrypted: false,
event: None,
})
.is_err()
{
tracing::warn!(
target: LOG_TARGET,
"Transport channel closed before init request could be sent"
);
return;
}
tokio::select! {
_ = cancel.cancelled() => {
tracing::info!(target: LOG_TARGET, "Announcement publish cancelled during init wait");
return;
}
result = tokio::time::timeout(ANNOUNCEMENT_INIT_TIMEOUT, init_notify.notified()) => {
match result {
Ok(()) => tracing::info!(
target: LOG_TARGET,
"Announcement init complete, dispatching capability list requests"
),
Err(_) => tracing::warn!(
target: LOG_TARGET,
timeout_secs = ANNOUNCEMENT_INIT_TIMEOUT.as_secs(),
"Announcement init timed out, proceeding with capability list requests"
),
}
}
}
for method in &[
"tools/list",
"resources/list",
"resources/templates/list",
"prompts/list",
] {
let request = JsonRpcMessage::Request(JsonRpcRequest {
jsonrpc: "2.0".to_string(),
id: serde_json::json!(ANNOUNCEMENT_REQUEST_ID),
method: method.to_string(),
params: None,
});
let _ = dispatch_fn.send(IncomingRequest {
message: request,
client_pubkey: ANNOUNCEMENT_REQUEST_ID.to_string(),
event_id: ANNOUNCEMENT_REQUEST_ID.to_string(),
is_encrypted: false,
event: None,
});
}
tracing::info!(
target: LOG_TARGET,
"Dispatched all announcement capability list requests"
);
}
#[derive(Clone)]
pub(crate) struct CommonTagsSnapshot {
pub server_info: Option<ServerInfo>,
pub extra_common_tags: Vec<Tag>,
pub internal_common_tags: Vec<Tag>,
pub encryption_mode: EncryptionMode,
pub gift_wrap_mode: GiftWrapMode,
}
impl CommonTagsSnapshot {
pub fn append_common_response_tags(&self, tags: &mut Vec<Tag>) {
if self.encryption_mode != EncryptionMode::Disabled {
tags.push(Tag::custom(
TagKind::Custom(tags::SUPPORT_ENCRYPTION.into()),
Vec::<String>::new(),
));
if self.gift_wrap_mode.supports_ephemeral() {
tags.push(Tag::custom(
TagKind::Custom(tags::SUPPORT_ENCRYPTION_EPHEMERAL.into()),
Vec::<String>::new(),
));
}
}
if let Some(ref info) = self.server_info {
if let Some(ref name) = info.name {
tags.push(Tag::custom(
TagKind::Custom(tags::NAME.into()),
vec![name.clone()],
));
}
}
tags.extend(self.extra_common_tags.iter().cloned());
tags.extend(self.internal_common_tags.iter().cloned());
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_manager(
encryption_mode: EncryptionMode,
gift_wrap_mode: GiftWrapMode,
server_info: Option<ServerInfo>,
) -> AnnouncementManager {
use crate::relay::mock::MockRelayPool;
let pool: Arc<dyn RelayPoolTrait> = Arc::new(MockRelayPool::new());
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
AnnouncementManager::new(
pool,
server_info,
encryption_mode,
gift_wrap_mode,
tx,
Vec::new(),
None,
None,
true,
None,
)
}
fn tag_name(tag: &Tag) -> String {
tag.clone().to_vec().first().cloned().unwrap_or_default()
}
#[test]
fn server_info_tags_all_fields() {
let info = ServerInfo {
name: Some("Test".into()),
about: Some("A test server".into()),
website: Some("https://example.com".into()),
picture: Some("https://example.com/pic.png".into()),
..Default::default()
};
let mgr = make_manager(EncryptionMode::Disabled, GiftWrapMode::Optional, Some(info));
let tags = mgr.get_server_info_tags();
assert_eq!(tags.len(), 4);
let names: Vec<String> = tags.iter().map(tag_name).collect();
assert!(names.contains(&"name".to_string()));
assert!(names.contains(&"about".to_string()));
assert!(names.contains(&"website".to_string()));
assert!(names.contains(&"picture".to_string()));
}
#[test]
fn server_info_tags_partial() {
let info = ServerInfo {
name: Some("OnlyName".into()),
..Default::default()
};
let mgr = make_manager(EncryptionMode::Disabled, GiftWrapMode::Optional, Some(info));
let tags = mgr.get_server_info_tags();
assert_eq!(tags.len(), 1);
}
#[test]
fn capability_tags_encryption_enabled() {
let mgr = make_manager(EncryptionMode::Optional, GiftWrapMode::Persistent, None);
let tags = mgr.get_encryption_capability_tags();
let names: Vec<String> = tags.iter().map(tag_name).collect();
assert!(names.contains(&tags::SUPPORT_ENCRYPTION.to_string()));
}
#[test]
fn capability_tags_ephemeral_enabled() {
let mgr = make_manager(EncryptionMode::Optional, GiftWrapMode::Optional, None);
let tags = mgr.get_encryption_capability_tags();
let names: Vec<String> = tags.iter().map(tag_name).collect();
assert!(names.contains(&tags::SUPPORT_ENCRYPTION_EPHEMERAL.to_string()));
}
#[test]
fn capability_tags_ephemeral_excluded() {
let mgr = make_manager(EncryptionMode::Optional, GiftWrapMode::Persistent, None);
let tags = mgr.get_encryption_capability_tags();
let names: Vec<String> = tags.iter().map(tag_name).collect();
assert!(
!names.contains(&tags::SUPPORT_ENCRYPTION_EPHEMERAL.to_string()),
"Persistent mode should not include ephemeral tag"
);
}
#[test]
fn capability_tags_encryption_disabled() {
let mgr = make_manager(EncryptionMode::Disabled, GiftWrapMode::Optional, None);
let tags = mgr.get_encryption_capability_tags();
assert!(
tags.is_empty(),
"Disabled encryption should produce no capability tags"
);
}
#[test]
fn common_tags_cached() {
let info = ServerInfo {
name: Some("Cache".into()),
..Default::default()
};
let mgr = make_manager(EncryptionMode::Optional, GiftWrapMode::Optional, Some(info));
let first = mgr.get_common_tags();
let second = mgr.get_common_tags();
assert_eq!(first.len(), second.len());
let cache = mgr.cached_common_tags.lock().unwrap();
assert!(
cache.is_some(),
"Cache should be populated after get_common_tags"
);
}
#[test]
fn set_extra_common_tags_invalidates_cache() {
let mgr_info = ServerInfo {
name: Some("Extra".into()),
..Default::default()
};
let mut mgr = make_manager(
EncryptionMode::Disabled,
GiftWrapMode::Optional,
Some(mgr_info),
);
let before = mgr.get_common_tags();
assert!(!before.is_empty());
let extra = vec![Tag::custom(
TagKind::Custom("pmi".into()),
vec!["lightning".to_string()],
)];
mgr.set_extra_common_tags(extra);
let after = mgr.get_common_tags();
assert_eq!(after.len(), before.len() + 1);
}
#[test]
fn pricing_tags_separate_from_common() {
let mut mgr = make_manager(EncryptionMode::Optional, GiftWrapMode::Optional, None);
mgr.set_pricing_tags(vec![Tag::custom(
TagKind::Custom(tags::CAPABILITY.into()),
vec![
"tool:echo".to_string(),
"100".to_string(),
"sats".to_string(),
],
)]);
let common = mgr.get_common_tags();
let names: Vec<String> = common.iter().map(tag_name).collect();
assert!(
!names.contains(&tags::CAPABILITY.to_string()),
"get_common_tags() should not include pricing tags"
);
}
#[test]
fn announcement_tags_kind_11316_includes_pricing() {
let info = ServerInfo {
name: Some("Ann".into()),
..Default::default()
};
let mut mgr = make_manager(EncryptionMode::Optional, GiftWrapMode::Optional, Some(info));
mgr.set_pricing_tags(vec![Tag::custom(
TagKind::Custom(tags::CAPABILITY.into()),
vec![
"tool:echo".to_string(),
"100".to_string(),
"sats".to_string(),
],
)]);
let ann_tags = mgr.get_announcement_tags(SERVER_ANNOUNCEMENT_KIND);
let ann_names: Vec<String> = ann_tags.iter().map(tag_name).collect();
assert!(
ann_names.contains(&tags::CAPABILITY.to_string()),
"Kind 11316 should include pricing tags"
);
let tools_tags = mgr.get_announcement_tags(TOOLS_LIST_KIND);
let tools_names: Vec<String> = tools_tags.iter().map(tag_name).collect();
assert!(
!tools_names.contains(&"name".to_string()),
"Kind 11317 should NOT include common tags, only pricing"
);
assert!(
tools_names.contains(&tags::CAPABILITY.to_string()),
"Kind 11317 should include pricing tags"
);
}
fn make_manager_with_pool(
server_info: Option<ServerInfo>,
) -> (
AnnouncementManager,
Arc<crate::relay::mock::MockRelayPool>,
tokio::sync::mpsc::UnboundedReceiver<IncomingRequest>,
) {
use crate::relay::mock::MockRelayPool;
let pool = Arc::new(MockRelayPool::new());
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
let mgr = AnnouncementManager::new(
Arc::clone(&pool) as Arc<dyn RelayPoolTrait>,
server_info,
EncryptionMode::Disabled,
GiftWrapMode::Optional,
tx,
Vec::new(),
None,
None,
true,
None,
);
(mgr, pool, rx)
}
#[tokio::test]
async fn handle_announcement_response_publishes_init_result() {
let (mgr, pool, mut rx) = make_manager_with_pool(None);
let response = JsonRpcMessage::Response(JsonRpcResponse {
jsonrpc: "2.0".to_string(),
id: serde_json::json!(ANNOUNCEMENT_REQUEST_ID),
result: serde_json::json!({
"protocolVersion": "2025-11-25",
"capabilities": {},
"serverInfo": { "name": "test-server", "version": "0.1.0" }
}),
});
mgr.handle_announcement_response(response).await.unwrap();
let events = pool.stored_events().await;
assert_eq!(events.len(), 1);
assert_eq!(events[0].kind, Kind::Custom(SERVER_ANNOUNCEMENT_KIND));
let notif = rx
.try_recv()
.expect("should dispatch notifications/initialized");
assert_eq!(
notif.message.method(),
Some(NOTIFICATIONS_INITIALIZED_METHOD)
);
assert_eq!(notif.client_pubkey, ANNOUNCEMENT_REQUEST_ID);
assert!(*mgr.initialized.lock().unwrap());
}
#[tokio::test]
async fn handle_announcement_response_publishes_tools_list() {
let (mgr, pool, _rx) = make_manager_with_pool(None);
let response = JsonRpcMessage::Response(JsonRpcResponse {
jsonrpc: "2.0".to_string(),
id: serde_json::json!(ANNOUNCEMENT_REQUEST_ID),
result: serde_json::json!({
"tools": [{ "name": "echo", "description": "Echo tool" }]
}),
});
mgr.handle_announcement_response(response).await.unwrap();
let events = pool.stored_events().await;
assert_eq!(events.len(), 1);
assert_eq!(events[0].kind, Kind::Custom(TOOLS_LIST_KIND));
}
#[tokio::test]
async fn handle_announcement_response_publishes_resources_list() {
let (mgr, pool, _rx) = make_manager_with_pool(None);
let response = JsonRpcMessage::Response(JsonRpcResponse {
jsonrpc: "2.0".to_string(),
id: serde_json::json!(ANNOUNCEMENT_REQUEST_ID),
result: serde_json::json!({ "resources": [] }),
});
mgr.handle_announcement_response(response).await.unwrap();
let events = pool.stored_events().await;
assert_eq!(events.len(), 1);
assert_eq!(events[0].kind, Kind::Custom(RESOURCES_LIST_KIND));
}
#[tokio::test]
async fn handle_announcement_response_publishes_resource_templates_list() {
let (mgr, pool, _rx) = make_manager_with_pool(None);
let response = JsonRpcMessage::Response(JsonRpcResponse {
jsonrpc: "2.0".to_string(),
id: serde_json::json!(ANNOUNCEMENT_REQUEST_ID),
result: serde_json::json!({ "resourceTemplates": [] }),
});
mgr.handle_announcement_response(response).await.unwrap();
let events = pool.stored_events().await;
assert_eq!(events.len(), 1);
assert_eq!(events[0].kind, Kind::Custom(RESOURCETEMPLATES_LIST_KIND));
}
#[tokio::test]
async fn handle_announcement_response_publishes_prompts_list() {
let (mgr, pool, _rx) = make_manager_with_pool(None);
let response = JsonRpcMessage::Response(JsonRpcResponse {
jsonrpc: "2.0".to_string(),
id: serde_json::json!(ANNOUNCEMENT_REQUEST_ID),
result: serde_json::json!({ "prompts": [] }),
});
mgr.handle_announcement_response(response).await.unwrap();
let events = pool.stored_events().await;
assert_eq!(events.len(), 1);
assert_eq!(events[0].kind, Kind::Custom(PROMPTS_LIST_KIND));
}
#[tokio::test]
async fn handle_announcement_response_error_signals_notify_without_publishing() {
let (mgr, pool, _rx) = make_manager_with_pool(None);
let response = JsonRpcMessage::ErrorResponse(JsonRpcErrorResponse {
jsonrpc: "2.0".to_string(),
id: serde_json::json!(ANNOUNCEMENT_REQUEST_ID),
error: JsonRpcError {
code: -32600,
message: "test error".to_string(),
data: None,
},
});
mgr.handle_announcement_response(response).await.unwrap();
assert!(pool.stored_events().await.is_empty());
assert!(*mgr.initialized.lock().unwrap());
}
#[tokio::test]
async fn handle_announcement_response_unknown_schema_no_publish() {
let (mgr, pool, _rx) = make_manager_with_pool(None);
let response = JsonRpcMessage::Response(JsonRpcResponse {
jsonrpc: "2.0".to_string(),
id: serde_json::json!(ANNOUNCEMENT_REQUEST_ID),
result: serde_json::json!({ "unknown": "data" }),
});
mgr.handle_announcement_response(response).await.unwrap();
assert!(pool.stored_events().await.is_empty());
}
#[tokio::test]
async fn publish_public_announcements_dispatches_init_then_capability_lists() {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<IncomingRequest>();
let init_notify = Arc::new(Notify::new());
let cancel = tokio_util::sync::CancellationToken::new();
let init_notify_clone = Arc::clone(&init_notify);
let handle = tokio::spawn(publish_public_announcements(tx, init_notify_clone, cancel));
let init_msg = tokio::time::timeout(Duration::from_secs(1), rx.recv())
.await
.expect("should receive init request within 1s")
.expect("channel should not be closed");
assert_eq!(init_msg.message.method(), Some(INITIALIZE_METHOD));
assert_eq!(init_msg.client_pubkey, ANNOUNCEMENT_REQUEST_ID);
assert_eq!(init_msg.event_id, ANNOUNCEMENT_REQUEST_ID);
assert!(!init_msg.is_encrypted);
init_notify.notify_one();
let expected_methods = [
"tools/list",
"resources/list",
"resources/templates/list",
"prompts/list",
];
for expected_method in &expected_methods {
let msg = tokio::time::timeout(Duration::from_secs(1), rx.recv())
.await
.expect("should receive capability request within 1s")
.expect("channel should not be closed");
assert_eq!(msg.message.method(), Some(*expected_method));
assert_eq!(msg.client_pubkey, ANNOUNCEMENT_REQUEST_ID);
}
handle.await.unwrap();
}
#[tokio::test]
async fn handle_init_result_dispatches_notification_before_notify_signal() {
let (mgr, _pool, mut rx) = make_manager_with_pool(None);
let init_notify = Arc::clone(&mgr.init_notify);
let notified = init_notify.notified();
let response = JsonRpcMessage::Response(JsonRpcResponse {
jsonrpc: "2.0".to_string(),
id: serde_json::json!(ANNOUNCEMENT_REQUEST_ID),
result: serde_json::json!({
"protocolVersion": "2025-11-25",
"capabilities": {},
"serverInfo": { "name": "test" }
}),
});
mgr.handle_announcement_response(response).await.unwrap();
tokio::time::timeout(Duration::from_millis(100), notified)
.await
.expect("init_notify should have been signaled");
let notif = rx
.try_recv()
.expect("notification should be queued before Notify");
assert_eq!(
notif.message.method(),
Some(NOTIFICATIONS_INITIALIZED_METHOD)
);
}
fn make_manager_with_discoverability(
relay_urls: Vec<String>,
relay_list_urls: Option<Vec<String>>,
bootstrap_relay_urls: Option<Vec<String>>,
publish_relay_list: bool,
profile_metadata: Option<ProfileMetadata>,
) -> (AnnouncementManager, Arc<crate::relay::mock::MockRelayPool>) {
use crate::relay::mock::MockRelayPool;
let pool = Arc::new(MockRelayPool::new());
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
let mgr = AnnouncementManager::new(
Arc::clone(&pool) as Arc<dyn RelayPoolTrait>,
None,
EncryptionMode::Disabled,
GiftWrapMode::Optional,
tx,
relay_urls,
relay_list_urls,
bootstrap_relay_urls,
publish_relay_list,
profile_metadata,
);
(mgr, pool)
}
#[test]
fn is_local_relay_url_detects_localhost() {
assert!(is_local_relay_url("ws://localhost:7777"));
assert!(is_local_relay_url("wss://localhost"));
assert!(is_local_relay_url("ws://127.0.0.1:8080"));
assert!(is_local_relay_url("ws://0.0.0.0:9999"));
assert!(is_local_relay_url("ws://[::1]:7777"));
}
#[test]
fn is_local_relay_url_rejects_remote() {
assert!(!is_local_relay_url("wss://relay.damus.io"));
assert!(!is_local_relay_url("wss://relay.example.com"));
assert!(!is_local_relay_url("ws://10.0.0.1:7777"));
}
#[test]
fn get_advertised_relay_urls_uses_relay_list_when_set() {
let (mgr, _pool) = make_manager_with_discoverability(
vec!["wss://connected.relay".into()],
Some(vec!["wss://advertised.relay".into()]),
None,
true,
None,
);
assert_eq!(mgr.get_advertised_relay_urls(), &["wss://advertised.relay"]);
}
#[test]
fn get_advertised_relay_urls_falls_back_to_relay_urls() {
let (mgr, _pool) = make_manager_with_discoverability(
vec!["wss://connected.relay".into()],
None,
None,
true,
None,
);
assert_eq!(mgr.get_advertised_relay_urls(), &["wss://connected.relay"]);
}
#[test]
fn get_discoverability_urls_merges_bootstrap() {
let (mgr, _pool) = make_manager_with_discoverability(
vec!["wss://my.relay".into()],
None,
None,
true,
None,
);
let urls = mgr.get_discoverability_publish_relay_urls();
assert!(urls.contains(&"wss://my.relay".to_string()));
assert!(urls.len() > 1);
assert!(urls.contains(&DEFAULT_BOOTSTRAP_RELAY_URLS[0].to_string()));
}
#[test]
fn get_discoverability_urls_skips_bootstrap_for_local_only() {
let (mgr, _pool) = make_manager_with_discoverability(
vec!["ws://127.0.0.1:7777".into()],
None,
None,
true,
None,
);
let urls = mgr.get_discoverability_publish_relay_urls();
assert_eq!(urls, vec!["ws://127.0.0.1:7777"]);
}
#[test]
fn get_discoverability_urls_keeps_explicit_bootstrap_even_for_local() {
let (mgr, _pool) = make_manager_with_discoverability(
vec!["ws://127.0.0.1:7777".into()],
None,
Some(vec!["wss://explicit-bootstrap.relay".into()]),
true,
None,
);
let urls = mgr.get_discoverability_publish_relay_urls();
assert!(urls.contains(&"ws://127.0.0.1:7777".to_string()));
assert!(urls.contains(&"wss://explicit-bootstrap.relay".to_string()));
}
#[test]
fn get_discoverability_urls_deduplicates() {
let (mgr, _pool) = make_manager_with_discoverability(
vec!["wss://relay.damus.io".into()],
None,
None,
true,
None,
);
let urls = mgr.get_discoverability_publish_relay_urls();
let damus_count = urls.iter().filter(|u| *u == "wss://relay.damus.io").count();
assert_eq!(damus_count, 1, "should be deduplicated");
}
#[tokio::test]
async fn publish_relay_list_event_shape() {
let (mgr, pool) = make_manager_with_discoverability(
vec![
"wss://relay1.example.com".into(),
"wss://relay2.example.com".into(),
],
None,
None,
true,
None,
);
mgr.publish_relay_list().await.unwrap();
let events = pool.stored_events().await;
assert_eq!(events.len(), 1);
assert_eq!(events[0].kind, Kind::Custom(RELAY_LIST_METADATA_KIND));
assert_eq!(events[0].content, "");
let tag_values: Vec<String> = events[0]
.tags
.iter()
.filter(|t| (*t).clone().to_vec().first().map(|s| s.as_str()) == Some("r"))
.filter_map(|t| (*t).clone().to_vec().get(1).cloned())
.collect();
assert_eq!(
tag_values,
vec!["wss://relay1.example.com", "wss://relay2.example.com"]
);
}
#[tokio::test]
async fn publish_relay_list_uses_relay_list_urls_override() {
let (mgr, pool) = make_manager_with_discoverability(
vec!["wss://connected.relay".into()],
Some(vec!["wss://override.relay".into()]),
None,
true,
None,
);
mgr.publish_relay_list().await.unwrap();
let events = pool.stored_events().await;
assert_eq!(events.len(), 1);
let tag_values: Vec<String> = events[0]
.tags
.iter()
.filter(|t| (*t).clone().to_vec().first().map(|s| s.as_str()) == Some("r"))
.filter_map(|t| (*t).clone().to_vec().get(1).cloned())
.collect();
assert_eq!(tag_values, vec!["wss://override.relay"]);
}
#[tokio::test]
async fn publish_relay_list_opt_out() {
let (mgr, pool) = make_manager_with_discoverability(
vec!["wss://relay.example.com".into()],
None,
None,
false,
None,
);
mgr.publish_relay_list().await.unwrap();
assert!(
pool.stored_events().await.is_empty(),
"should not publish when disabled"
);
}
#[tokio::test]
async fn publish_relay_list_empty_urls_no_publish() {
let (mgr, pool) = make_manager_with_discoverability(Vec::new(), None, None, true, None);
mgr.publish_relay_list().await.unwrap();
assert!(
pool.stored_events().await.is_empty(),
"should not publish with no URLs"
);
}
#[tokio::test]
async fn publish_profile_metadata_event_shape() {
let metadata = ProfileMetadata::default()
.with_name("Test Server")
.with_about("A test MCP server");
let (mgr, pool) =
make_manager_with_discoverability(Vec::new(), None, None, true, Some(metadata));
mgr.publish_profile_metadata().await.unwrap();
let events = pool.stored_events().await;
assert_eq!(events.len(), 1);
assert_eq!(events[0].kind, Kind::Custom(0));
assert!(events[0].tags.is_empty());
let parsed: ProfileMetadata = serde_json::from_str(&events[0].content).unwrap();
assert_eq!(parsed.name.as_deref(), Some("Test Server"));
assert_eq!(parsed.about.as_deref(), Some("A test MCP server"));
}
#[tokio::test]
async fn publish_profile_metadata_noop_when_unconfigured() {
let (mgr, pool) = make_manager_with_discoverability(Vec::new(), None, None, true, None);
mgr.publish_profile_metadata().await.unwrap();
assert!(
pool.stored_events().await.is_empty(),
"should not publish without profile metadata"
);
}
#[test]
fn announcement_mapping_table_covers_all_kinds() {
let kinds: HashSet<u16> = ANNOUNCEMENT_MAPPINGS.iter().map(|m| m.kind).collect();
assert!(kinds.contains(&SERVER_ANNOUNCEMENT_KIND));
assert!(kinds.contains(&TOOLS_LIST_KIND));
assert!(kinds.contains(&RESOURCES_LIST_KIND));
assert!(kinds.contains(&RESOURCETEMPLATES_LIST_KIND));
assert!(kinds.contains(&PROMPTS_LIST_KIND));
}
#[test]
fn announcement_mapping_resource_templates_regression() {
let result = serde_json::json!({ "resourceTemplates": [] });
let kind = ANNOUNCEMENT_MAPPINGS
.iter()
.find(|m| (m.matches)(&result))
.map(|m| m.kind);
assert_eq!(kind, Some(RESOURCETEMPLATES_LIST_KIND));
}
#[test]
fn announcement_mapping_rejects_null_field() {
let result = serde_json::json!({ "tools": null });
let kind = ANNOUNCEMENT_MAPPINGS
.iter()
.find(|m| (m.matches)(&result))
.map(|m| m.kind);
assert_eq!(kind, None, "null field should not match any schema");
}
#[test]
fn announcement_mapping_rejects_wrong_type() {
let result = serde_json::json!({ "tools": "not an array" });
let kind = ANNOUNCEMENT_MAPPINGS
.iter()
.find(|m| (m.matches)(&result))
.map(|m| m.kind);
assert_eq!(kind, None, "string field should not match array schema");
}
#[tokio::test]
async fn handle_announcement_response_rejects_malformed_payload() {
let (mgr, pool, _rx) = make_manager_with_pool(None);
let response = JsonRpcMessage::Response(JsonRpcResponse {
jsonrpc: "2.0".to_string(),
id: serde_json::json!(ANNOUNCEMENT_REQUEST_ID),
result: serde_json::json!({ "tools": null }),
});
mgr.handle_announcement_response(response).await.unwrap();
assert!(
pool.stored_events().await.is_empty(),
"malformed payload should not produce an event"
);
}
#[tokio::test]
async fn roundtrip_publish_discover_all_5_kinds() {
let info = ServerInfo {
name: Some("Roundtrip".into()),
..Default::default()
};
let (mgr, pool, _rx) = make_manager_with_pool(Some(info));
let responses = vec![
serde_json::json!({
"protocolVersion": "2025-11-25",
"capabilities": {"tools": {}},
"serverInfo": {"name": "Roundtrip", "version": "1.0"}
}),
serde_json::json!({"tools": [{"name": "echo"}]}),
serde_json::json!({"resources": [{"name": "config"}]}),
serde_json::json!({"resourceTemplates": [{"name": "tmpl"}]}),
serde_json::json!({"prompts": [{"name": "greet"}]}),
];
for result in responses {
mgr.handle_announcement_response(JsonRpcMessage::Response(JsonRpcResponse {
jsonrpc: "2.0".to_string(),
id: serde_json::json!(ANNOUNCEMENT_REQUEST_ID),
result,
}))
.await
.unwrap();
}
let pubkey = pool.mock_public_key();
for (kind, key) in [
(SERVER_ANNOUNCEMENT_KIND, "protocolVersion"),
(TOOLS_LIST_KIND, "tools"),
(RESOURCES_LIST_KIND, "resources"),
(RESOURCETEMPLATES_LIST_KIND, "resourceTemplates"),
(PROMPTS_LIST_KIND, "prompts"),
] {
let filter = Filter::new().kind(Kind::Custom(kind)).author(pubkey);
let events = pool
.fetch_events(vec![filter], Duration::from_secs(1))
.await
.unwrap();
assert_eq!(events.len(), 1, "kind {kind} should have exactly 1 event");
let content: serde_json::Value = serde_json::from_str(&events[0].content).unwrap();
assert!(
content.get(key).is_some(),
"kind {kind} content should contain '{key}'"
);
}
}
#[tokio::test]
async fn publish_profile_metadata_preserves_custom_fields() {
let mut metadata = ProfileMetadata::default()
.with_name("Full Server")
.with_about("All fields present")
.with_picture("https://example.com/pic.png")
.with_banner("https://example.com/banner.png")
.with_website("https://example.com")
.with_nip05("server@example.com")
.with_lud16("server@walletofsatoshi.com");
metadata
.extra
.insert("custom_flag".into(), serde_json::json!(true));
let (mgr, pool) =
make_manager_with_discoverability(Vec::new(), None, None, true, Some(metadata));
mgr.publish_profile_metadata().await.unwrap();
let events = pool.stored_events().await;
assert_eq!(events.len(), 1);
let parsed: ProfileMetadata = serde_json::from_str(&events[0].content).unwrap();
assert_eq!(parsed.name.as_deref(), Some("Full Server"));
assert_eq!(parsed.about.as_deref(), Some("All fields present"));
assert_eq!(
parsed.picture.as_deref(),
Some("https://example.com/pic.png")
);
assert_eq!(
parsed.banner.as_deref(),
Some("https://example.com/banner.png")
);
assert_eq!(parsed.website.as_deref(), Some("https://example.com"));
assert_eq!(parsed.nip05.as_deref(), Some("server@example.com"));
assert_eq!(parsed.lud16.as_deref(), Some("server@walletofsatoshi.com"));
assert_eq!(
parsed.extra.get("custom_flag"),
Some(&serde_json::json!(true)),
"custom field should survive publish round-trip"
);
}
#[tokio::test]
async fn publish_profile_metadata_publish_error_does_not_panic() {
use crate::relay::mock::MockRelayPool;
use std::sync::atomic::{AtomicBool, Ordering};
struct FailingPool {
inner: MockRelayPool,
should_fail: AtomicBool,
}
#[async_trait::async_trait]
impl RelayPoolTrait for FailingPool {
async fn connect(&self, urls: &[String]) -> Result<()> {
self.inner.connect(urls).await
}
async fn disconnect(&self) -> Result<()> {
self.inner.disconnect().await
}
async fn publish_event(&self, event: &Event) -> Result<EventId> {
self.inner.publish_event(event).await
}
async fn publish(&self, builder: EventBuilder) -> Result<EventId> {
if self.should_fail.load(Ordering::SeqCst) {
return Err(Error::Transport("injected publish failure".into()));
}
self.inner.publish(builder).await
}
async fn sign(&self, builder: EventBuilder) -> Result<Event> {
self.inner.sign(builder).await
}
async fn signer(&self) -> Result<Arc<dyn NostrSigner>> {
self.inner.signer().await
}
fn notifications(&self) -> tokio::sync::broadcast::Receiver<RelayPoolNotification> {
self.inner.notifications()
}
async fn public_key(&self) -> Result<PublicKey> {
self.inner.public_key().await
}
async fn subscribe(&self, filters: Vec<Filter>) -> Result<()> {
self.inner.subscribe(filters).await
}
async fn publish_to(&self, urls: &[String], builder: EventBuilder) -> Result<EventId> {
if self.should_fail.load(Ordering::SeqCst) {
return Err(Error::Transport("injected publish failure".into()));
}
self.inner.publish_to(urls, builder).await
}
async fn fetch_events(
&self,
filters: Vec<Filter>,
timeout: Duration,
) -> Result<Vec<Event>> {
self.inner.fetch_events(filters, timeout).await
}
}
let pool: Arc<dyn RelayPoolTrait> = Arc::new(FailingPool {
inner: MockRelayPool::new(),
should_fail: AtomicBool::new(true),
});
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
let metadata = ProfileMetadata::default().with_name("Err Server");
let mgr = AnnouncementManager::new(
pool,
None,
EncryptionMode::Disabled,
GiftWrapMode::Optional,
tx,
Vec::new(),
None,
None,
true,
Some(metadata),
);
let result = mgr.publish_profile_metadata().await;
assert!(
result.is_ok(),
"publish_profile_metadata should swallow publish errors"
);
}
#[tokio::test]
async fn private_server_publishes_relay_list_but_not_announcements() {
let (mgr, pool) = make_manager_with_discoverability(
vec!["wss://relay.example.com".into()],
None,
None,
true,
None,
);
mgr.publish_relay_list().await.unwrap();
let events = pool.stored_events().await;
let relay_list_events: Vec<_> = events
.iter()
.filter(|e| e.kind == Kind::Custom(RELAY_LIST_METADATA_KIND))
.collect();
assert_eq!(
relay_list_events.len(),
1,
"relay list (kind 10002) should be published"
);
let announcement_events: Vec<_> = events
.iter()
.filter(|e| e.kind == Kind::Custom(SERVER_ANNOUNCEMENT_KIND))
.collect();
assert!(
announcement_events.is_empty(),
"private server should have no kind 11316 announcements"
);
}
#[tokio::test]
async fn relay_list_advertises_different_urls_than_bootstrap() {
let (mgr, pool) = make_manager_with_discoverability(
vec!["wss://connected.relay".into()],
Some(vec![
"wss://public1.relay".into(),
"wss://public2.relay".into(),
]),
Some(vec!["wss://bootstrap.relay".into()]),
true,
None,
);
mgr.publish_relay_list().await.unwrap();
let events = pool.stored_events().await;
assert_eq!(events.len(), 1);
let tag_values: Vec<String> = events[0]
.tags
.iter()
.filter(|t| (*t).clone().to_vec().first().map(|s| s.as_str()) == Some("r"))
.filter_map(|t| (*t).clone().to_vec().get(1).cloned())
.collect();
assert_eq!(
tag_values,
vec!["wss://public1.relay", "wss://public2.relay"],
"kind 10002 tags should contain only relay_list_urls"
);
assert!(
!tag_values.contains(&"wss://bootstrap.relay".to_string()),
"bootstrap URLs must not appear in kind 10002 tags"
);
}
#[tokio::test]
async fn profile_metadata_published_regardless_of_announced_server() {
let metadata = ProfileMetadata::default()
.with_name("Private Server")
.with_about("Not publicly announced");
let (mgr, pool) =
make_manager_with_discoverability(Vec::new(), None, None, true, Some(metadata));
mgr.publish_profile_metadata().await.unwrap();
let events = pool.stored_events().await;
let profile_events: Vec<_> = events
.iter()
.filter(|e| e.kind == Kind::Custom(0))
.collect();
assert_eq!(
profile_events.len(),
1,
"kind 0 should be published even without announcements"
);
let announcement_events: Vec<_> = events
.iter()
.filter(|e| e.kind == Kind::Custom(SERVER_ANNOUNCEMENT_KIND))
.collect();
assert!(
announcement_events.is_empty(),
"no kind 11316 should exist when announce() not called"
);
}
#[tokio::test]
async fn delete_announcements_uses_e_tags() {
let info = ServerInfo {
name: Some("Del".into()),
..Default::default()
};
let (mgr, pool, _rx) = make_manager_with_pool(Some(info));
mgr.announce().await.unwrap();
let published = pool.stored_events().await;
let announcement_id = published[0].id;
mgr.delete_announcements("going offline").await.unwrap();
let all_events = pool.stored_events().await;
let deletion_events: Vec<_> = all_events
.iter()
.filter(|e| e.kind == Kind::Custom(5))
.collect();
assert!(!deletion_events.is_empty(), "should have deletion events");
let del = &deletion_events[0];
let tags: Vec<Vec<String>> = del.tags.iter().map(|t| (*t).clone().to_vec()).collect();
assert!(!tags.is_empty(), "deletion event should have tags");
for tag in &tags {
assert_eq!(tag[0], "e", "deletion tag should be 'e', not 'k'");
}
let ann_id_hex = announcement_id.to_hex();
assert!(
tags.iter()
.any(|t| t.get(1).map(|s| s.as_str()) == Some(ann_id_hex.as_str())),
"deletion should reference the published announcement event ID"
);
assert_eq!(del.content, "going offline");
assert_eq!(
deletion_events.len(),
1,
"only one kind was published so only one deletion event expected"
);
}
}