use std::collections::{BTreeMap, HashMap};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use crate::algorithms::ptd;
use crate::mf2::types;
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
pub enum MicrosubError {
#[error("Channel not found: {0}")]
#[diagnostic(code(microsub::channel_not_found))]
ChannelNotFound(String),
#[error("Feed not found: {0}")]
#[diagnostic(code(microsub::feed_not_found))]
FeedNotFound(String),
#[error("Entry not found: {0}")]
#[diagnostic(code(microsub::entry_not_found))]
EntryNotFound(String),
#[error("Invalid URL: {0}")]
#[diagnostic(code(microsub::invalid_url))]
InvalidUrl(String),
#[error("HTTP error: {0}")]
#[diagnostic(code(microsub::http_error))]
Http(String),
#[error("Network error: {0}")]
#[diagnostic(code(microsub::network_error))]
Network(#[from] Box<dyn std::error::Error + Send + Sync>),
}
pub type Result<T> = std::result::Result<T, MicrosubError>;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Channel {
pub uid: String,
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Feed {
#[serde(rename = "type")]
pub feed_type: String,
pub url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub photo: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub author: Option<Card>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Card {
#[serde(rename = "type")]
pub card_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub photo: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Entry {
#[serde(rename = "type")]
pub entry_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub published: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub uid: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<Content>,
#[serde(skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub author: Option<Card>,
#[serde(skip_serializing_if = "Option::is_none")]
pub category: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub photo: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub video: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub audio: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub syndication: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub checkin: Option<Card>,
#[serde(skip_serializing_if = "Option::is_none")]
pub like_of: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub repost_of: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bookmark_of: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub in_reply_to: Option<Vec<String>>,
#[serde(rename = "_kind")]
#[serde(skip_serializing_if = "Option::is_none")]
pub kind: Option<String>,
#[serde(rename = "_id")]
#[serde(skip_serializing_if = "Option::is_none")]
pub _id: Option<String>,
#[serde(rename = "_is_read")]
#[serde(skip_serializing_if = "Option::is_none")]
pub _is_read: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Content {
#[serde(skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub html: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TimelineResponse {
pub items: Vec<Entry>,
pub paging: Paging,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SearchResponse {
pub results: Vec<Feed>,
}
pub type PreviewResponse = TimelineResponse;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ChannelsResponse {
pub channels: Vec<Channel>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct FollowResponse {
pub items: Vec<Feed>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct MutedResponse {
pub items: Vec<Card>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Paging {
#[serde(skip_serializing_if = "Option::is_none")]
pub before: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub after: Option<String>,
}
#[async_trait]
pub trait MicrosubServer {
async fn channels(&self) -> Result<ChannelsResponse>;
async fn create_channel(&mut self, name: &str) -> Result<Channel>;
async fn update_channel(&mut self, uid: &str, name: &str) -> Result<Channel>;
async fn delete_channel(&mut self, uid: &str) -> Result<()>;
async fn order_channels(&mut self, channel_uids: &[String]) -> Result<()>;
async fn timeline(
&self,
channel: &str,
before: Option<&str>,
after: Option<&str>,
limit: Option<usize>,
source: Option<&str>,
) -> Result<TimelineResponse>;
async fn mark_read(
&mut self,
channel: &str,
entry_ids: &[String],
last_read_entry: Option<&str>,
) -> Result<()>;
async fn remove_entry(&mut self, channel: &str, entry_id: &str) -> Result<()>;
async fn search(&self, query: &str) -> Result<SearchResponse>;
async fn preview(&self, url: &str) -> Result<PreviewResponse>;
async fn followed(&self, channel: &str) -> Result<FollowResponse>;
async fn follow(&mut self, channel: &str, url: &str) -> Result<Feed>;
async fn unfollow(&mut self, channel: &str, url: &str) -> Result<()>;
async fn muted(&self, channel: &str) -> Result<MutedResponse>;
async fn mute(&mut self, channel: &str, url: &str) -> Result<()>;
async fn unmute(&mut self, channel: &str, url: &str) -> Result<()>;
async fn blocked(&self, channel: &str) -> Result<MutedResponse>;
async fn block(&mut self, channel: &str, url: &str) -> Result<()>;
async fn unblock(&mut self, channel: &str, url: &str) -> Result<()>;
}
#[derive(Debug)]
pub struct InMemoryMicrosub {
channels: HashMap<String, Channel>,
channel_feeds: HashMap<String, Vec<Feed>>,
channel_entries: HashMap<String, Vec<EntryWithSource>>,
channel_muted: HashMap<String, Vec<Card>>,
channel_blocked: HashMap<String, Vec<Card>>,
next_channel_id: u32,
}
#[derive(Debug, Clone)]
struct EntryWithSource {
entry: Entry,
source_url: String, }
impl InMemoryMicrosub {
pub fn add_entry_with_source(&mut self, channel: &str, mut entry: Entry, source_url: &str) {
let mf2_item = types::Item {
r#type: vec![types::Class::Known(types::KnownClass::Entry)],
properties: {
let mut props = BTreeMap::new();
if let Some(name) = &entry.name {
props.insert("name".to_string(), vec![types::PropertyValue::Plain(name.clone().into())]);
}
if let Some(content) = &entry.content
&& let Some(text) = &content.text {
props.insert("content".to_string(), vec![types::PropertyValue::Plain(text.clone().into())]);
}
if entry.like_of.is_some() {
props.insert("like-of".to_string(), vec![types::PropertyValue::Plain("https://example.com/liked".to_string().into())]);
}
if entry.repost_of.is_some() {
props.insert("repost-of".to_string(), vec![types::PropertyValue::Plain("https://example.com/reposted".to_string().into())]);
}
if entry.in_reply_to.is_some() {
props.insert("in-reply-to".to_string(), vec![types::PropertyValue::Plain("https://example.com/replied".to_string().into())]);
}
props
},
..Default::default()
};
entry.kind = ptd::resolve_from_object(mf2_item).map(|t| t.to_string());
self.channel_entries.entry(channel.to_string())
.or_default()
.push(EntryWithSource {
entry,
source_url: source_url.to_string(),
});
}
pub fn new() -> Self {
let mut server = Self {
channels: HashMap::new(),
channel_feeds: HashMap::new(),
channel_entries: HashMap::new(),
channel_muted: HashMap::new(),
channel_blocked: HashMap::new(),
next_channel_id: 1,
};
let notifications = Channel {
uid: "notifications".to_string(),
name: "Notifications".to_string(),
};
server.channels.insert(notifications.uid.clone(), notifications);
server
}
fn generate_channel_uid(&mut self) -> String {
let uid = format!("channel_{}", self.next_channel_id);
self.next_channel_id += 1;
uid
}
}
impl Default for InMemoryMicrosub {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl MicrosubServer for InMemoryMicrosub {
async fn channels(&self) -> Result<ChannelsResponse> {
let mut channels: Vec<_> = self.channels.values().cloned().collect();
channels.sort_by(|a, b| {
if a.uid == "notifications" {
std::cmp::Ordering::Less
} else if b.uid == "notifications" {
std::cmp::Ordering::Greater
} else {
a.uid.cmp(&b.uid)
}
});
Ok(ChannelsResponse { channels })
}
async fn create_channel(&mut self, name: &str) -> Result<Channel> {
let uid = self.generate_channel_uid();
let channel = Channel {
uid: uid.clone(),
name: name.to_string(),
};
self.channels.insert(uid, channel.clone());
Ok(channel)
}
async fn update_channel(&mut self, uid: &str, name: &str) -> Result<Channel> {
if let Some(channel) = self.channels.get_mut(uid) {
channel.name = name.to_string();
Ok(channel.clone())
} else {
Err(MicrosubError::ChannelNotFound(uid.to_string()))
}
}
async fn delete_channel(&mut self, uid: &str) -> Result<()> {
if uid == "notifications" {
return Err(MicrosubError::ChannelNotFound(uid.to_string()));
}
self.channels.remove(uid);
self.channel_feeds.remove(uid);
self.channel_entries.remove(uid);
self.channel_muted.remove(uid);
self.channel_blocked.remove(uid);
Ok(())
}
async fn order_channels(&mut self, _channel_uids: &[String]) -> Result<()> {
Ok(())
}
async fn timeline(
&self,
channel: &str,
_before: Option<&str>,
_after: Option<&str>,
_limit: Option<usize>,
source: Option<&str>,
) -> Result<TimelineResponse> {
if !self.channels.contains_key(channel) {
return Err(MicrosubError::ChannelNotFound(channel.to_string()));
}
let entries_with_source = self.channel_entries.get(channel)
.cloned()
.unwrap_or_default();
let filtered_entries: Vec<Entry> = if let Some(source_url) = source {
entries_with_source.into_iter()
.filter(|e| e.source_url == source_url)
.map(|e| e.entry)
.collect()
} else {
entries_with_source.into_iter()
.map(|e| e.entry)
.collect()
};
Ok(TimelineResponse {
items: filtered_entries,
paging: Paging {
before: Some("cursor_before".to_string()),
after: Some("cursor_after".to_string()),
},
})
}
async fn mark_read(
&mut self,
channel: &str,
entry_ids: &[String],
_last_read_entry: Option<&str>,
) -> Result<()> {
if !self.channels.contains_key(channel) {
return Err(MicrosubError::ChannelNotFound(channel.to_string()));
}
if let Some(entries) = self.channel_entries.get_mut(channel) {
for entry_with_source in entries {
if let Some(id) = &entry_with_source.entry._id && entry_ids.contains(id) {
entry_with_source.entry._is_read = Some(true);
}
}
}
Ok(())
}
async fn remove_entry(&mut self, channel: &str, entry_id: &str) -> Result<()> {
if !self.channels.contains_key(channel) {
return Err(MicrosubError::ChannelNotFound(channel.to_string()));
}
if let Some(entries) = self.channel_entries.get_mut(channel) {
entries.retain(|e| e.entry._id.as_ref() != Some(&entry_id.to_string()));
}
Ok(())
}
async fn search(&self, _query: &str) -> Result<SearchResponse> {
let results = vec![
Feed {
feed_type: "feed".to_string(),
url: "https://example.com/feed".to_string(),
name: Some("Example Feed".to_string()),
photo: Some("https://example.com/avatar.jpg".to_string()),
description: Some("A sample feed".to_string()),
author: Some(Card {
card_type: "card".to_string(),
name: Some("Example Author".to_string()),
url: Some("https://example.com".to_string()),
photo: Some("https://example.com/avatar.jpg".to_string()),
}),
}
];
Ok(SearchResponse { results })
}
async fn preview(&self, _url: &str) -> Result<PreviewResponse> {
let mut entry = Entry {
entry_type: "entry".to_string(),
published: Some("2024-01-01T10:00:00Z".to_string()),
url: Some("https://example.com/post1".to_string()),
name: Some("Preview Post".to_string()),
content: Some(Content {
text: Some("This is a preview post.".to_string()),
html: Some("<p>This is a preview post.</p>".to_string()),
}),
author: Some(Card {
card_type: "card".to_string(),
name: Some("Example Author".to_string()),
url: Some("https://example.com".to_string()),
photo: Some("https://example.com/avatar.jpg".to_string()),
}),
_id: Some("preview_1".to_string()),
_is_read: Some(false),
uid: None,
summary: None,
category: None,
photo: None,
video: None,
audio: None,
syndication: None,
checkin: None,
like_of: None,
repost_of: None,
bookmark_of: None,
in_reply_to: None,
kind: None, };
let mf2_item = types::Item {
r#type: vec![types::Class::Known(types::KnownClass::Entry)],
properties: {
let mut props = BTreeMap::new();
if let Some(name) = &entry.name {
props.insert("name".to_string(), vec![types::PropertyValue::Plain(name.clone().into())]);
}
if let Some(content) = &entry.content
&& let Some(text) = &content.text {
props.insert("content".to_string(), vec![types::PropertyValue::Plain(text.clone().into())]);
}
if entry.like_of.is_some() {
props.insert("like-of".to_string(), vec![types::PropertyValue::Plain("https://example.com/liked".to_string().into())]);
}
if entry.repost_of.is_some() {
props.insert("repost-of".to_string(), vec![types::PropertyValue::Plain("https://example.com/reposted".to_string().into())]);
}
if entry.in_reply_to.is_some() {
props.insert("in-reply-to".to_string(), vec![types::PropertyValue::Plain("https://example.com/replied".to_string().into())]);
}
props
},
..Default::default()
};
entry.kind = ptd::resolve_from_object(mf2_item).map(|t| t.to_string());
let items = vec![entry];
Ok(PreviewResponse {
items,
paging: Paging { before: None, after: None },
})
}
async fn followed(&self, channel: &str) -> Result<FollowResponse> {
if !self.channels.contains_key(channel) {
return Err(MicrosubError::ChannelNotFound(channel.to_string()));
}
let items = self.channel_feeds.get(channel)
.cloned()
.unwrap_or_default();
Ok(FollowResponse { items })
}
async fn follow(&mut self, channel: &str, url: &str) -> Result<Feed> {
if !self.channels.contains_key(channel) {
return Err(MicrosubError::ChannelNotFound(channel.to_string()));
}
let name = if let Some(domain) = url.split("://").nth(1) {
let domain = domain.split('/').next().unwrap_or(domain);
let domain = domain.split('.').next().unwrap_or(domain);
Some(format!("{} Feed", domain.to_uppercase()))
} else {
Some("New Feed".to_string())
};
let photo = if url.contains("github.com") {
Some("https://github.com/favicon.ico".to_string())
} else if url.contains("twitter.com") || url.contains("x.com") {
Some("https://abs.twimg.com/icons/apple-touch-icon-192x192.png".to_string())
} else {
None
};
let feed = Feed {
feed_type: "feed".to_string(),
url: url.to_string(),
name,
photo,
description: None,
author: None,
};
self.channel_feeds.entry(channel.to_string())
.or_default()
.push(feed.clone());
Ok(feed)
}
async fn unfollow(&mut self, channel: &str, url: &str) -> Result<()> {
if !self.channels.contains_key(channel) {
return Err(MicrosubError::ChannelNotFound(channel.to_string()));
}
if let Some(feeds) = self.channel_feeds.get_mut(channel) {
feeds.retain(|f| f.url != url);
}
Ok(())
}
async fn muted(&self, channel: &str) -> Result<MutedResponse> {
if !self.channels.contains_key(channel) {
return Err(MicrosubError::ChannelNotFound(channel.to_string()));
}
let items = self.channel_muted.get(channel)
.cloned()
.unwrap_or_default();
Ok(MutedResponse { items })
}
async fn mute(&mut self, channel: &str, url: &str) -> Result<()> {
if !self.channels.contains_key(channel) {
return Err(MicrosubError::ChannelNotFound(channel.to_string()));
}
let card = Card {
card_type: "card".to_string(),
name: Some("Muted User".to_string()),
url: Some(url.to_string()),
photo: None,
};
self.channel_muted.entry(channel.to_string())
.or_default()
.push(card);
Ok(())
}
async fn unmute(&mut self, channel: &str, url: &str) -> Result<()> {
if !self.channels.contains_key(channel) {
return Err(MicrosubError::ChannelNotFound(channel.to_string()));
}
if let Some(muted) = self.channel_muted.get_mut(channel) {
muted.retain(|c| c.url.as_ref() != Some(&url.to_string()));
}
Ok(())
}
async fn blocked(&self, channel: &str) -> Result<MutedResponse> {
if !self.channels.contains_key(channel) {
return Err(MicrosubError::ChannelNotFound(channel.to_string()));
}
let items = self.channel_blocked.get(channel)
.cloned()
.unwrap_or_default();
Ok(MutedResponse { items })
}
async fn block(&mut self, channel: &str, url: &str) -> Result<()> {
if !self.channels.contains_key(channel) {
return Err(MicrosubError::ChannelNotFound(channel.to_string()));
}
let card = Card {
card_type: "card".to_string(),
name: Some("Blocked User".to_string()),
url: Some(url.to_string()),
photo: None,
};
self.channel_blocked.entry(channel.to_string())
.or_default()
.push(card);
Ok(())
}
async fn unblock(&mut self, channel: &str, url: &str) -> Result<()> {
if !self.channels.contains_key(channel) {
return Err(MicrosubError::ChannelNotFound(channel.to_string()));
}
if let Some(blocked) = self.channel_blocked.get_mut(channel) {
blocked.retain(|c| c.url.as_ref() != Some(&url.to_string()));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_create_channel() {
let mut server = InMemoryMicrosub::new();
let channel = server.create_channel("Test Channel").await.unwrap();
assert_eq!(channel.name, "Test Channel");
assert!(channel.uid.starts_with("channel_"));
}
#[tokio::test]
async fn test_channels_includes_notifications() {
let server = InMemoryMicrosub::new();
let response = server.channels().await.unwrap();
assert!(response.channels.iter().any(|c| c.uid == "notifications"));
}
#[tokio::test]
async fn test_follow_feed() {
let mut server = InMemoryMicrosub::new();
let channel = server.create_channel("Test").await.unwrap();
let feed = server.follow(&channel.uid, "https://example.com/feed").await.unwrap();
assert_eq!(feed.url, "https://example.com/feed");
let followed = server.followed(&channel.uid).await.unwrap();
assert_eq!(followed.items.len(), 1);
assert_eq!(followed.items[0].url, "https://example.com/feed");
}
#[tokio::test]
async fn test_timeline_returns_empty_for_new_channel() {
let mut server = InMemoryMicrosub::new();
let channel = server.create_channel("Test").await.unwrap();
let timeline = server.timeline(&channel.uid, None, None, None, None).await.unwrap();
assert_eq!(timeline.items.len(), 0);
assert!(timeline.paging.before.is_some());
assert!(timeline.paging.after.is_some());
}
#[tokio::test]
async fn test_search_returns_results() {
let server = InMemoryMicrosub::new();
let response = server.search("example").await.unwrap();
assert!(!response.results.is_empty());
assert_eq!(response.results[0].feed_type, "feed");
}
#[tokio::test]
async fn test_preview_returns_entries() {
let server = InMemoryMicrosub::new();
let response = server.preview("https://example.com").await.unwrap();
assert!(!response.items.is_empty());
assert_eq!(response.items[0].entry_type, "entry");
}
#[tokio::test]
async fn test_preview_returns_entries_with_kind() {
let server = InMemoryMicrosub::new();
let response = server.preview("https://example.com").await.unwrap();
assert!(!response.items.is_empty());
let entry = &response.items[0];
assert_eq!(entry.kind, Some("article".to_string()));
}
#[tokio::test]
async fn test_ptd_detects_different_post_types() {
let mut server = InMemoryMicrosub::new();
let channel = server.create_channel("Test").await.unwrap();
let note_entry = Entry {
entry_type: "entry".to_string(),
content: Some(Content {
text: Some("This is just a note".to_string()),
html: None,
}),
uid: None,
published: None,
url: None,
name: None,
summary: None,
author: None,
category: None,
photo: None,
video: None,
audio: None,
syndication: None,
checkin: None,
like_of: None,
repost_of: None,
bookmark_of: None,
in_reply_to: None,
_id: Some("note_entry".to_string()),
_is_read: Some(false),
kind: None,
};
let like_entry = Entry {
entry_type: "entry".to_string(),
like_of: Some(vec!["https://example.com/liked-post".to_string()]),
uid: None,
published: None,
url: None,
name: None,
summary: None,
author: None,
category: None,
photo: None,
video: None,
audio: None,
syndication: None,
checkin: None,
repost_of: None,
bookmark_of: None,
in_reply_to: None,
_id: Some("like_entry".to_string()),
_is_read: Some(false),
kind: None,
content: None,
};
let reply_entry = Entry {
entry_type: "entry".to_string(),
in_reply_to: Some(vec!["https://example.com/replied-post".to_string()]),
content: Some(Content {
text: Some("This is a reply".to_string()),
html: None,
}),
uid: None,
published: None,
url: None,
name: None,
summary: None,
author: None,
category: None,
photo: None,
video: None,
audio: None,
syndication: None,
checkin: None,
like_of: None,
repost_of: None,
bookmark_of: None,
_id: Some("reply_entry".to_string()),
_is_read: Some(false),
kind: None,
};
server.add_entry_with_source(&channel.uid, note_entry, "https://feed1.com/feed.xml");
server.add_entry_with_source(&channel.uid, like_entry, "https://feed2.com/feed.xml");
server.add_entry_with_source(&channel.uid, reply_entry, "https://feed3.com/feed.xml");
let timeline = server.timeline(&channel.uid, None, None, None, None).await.unwrap();
assert_eq!(timeline.items.len(), 3);
let note = timeline.items.iter().find(|e| e._id == Some("note_entry".to_string())).unwrap();
assert_eq!(note.kind, Some("note".to_string()));
let like = timeline.items.iter().find(|e| e._id == Some("like_entry".to_string())).unwrap();
assert_eq!(like.kind, Some("like".to_string()));
let reply = timeline.items.iter().find(|e| e._id == Some("reply_entry".to_string())).unwrap();
assert_eq!(reply.kind, Some("reply".to_string()));
}
#[tokio::test]
async fn test_timeline_filters_by_source() {
let mut server = InMemoryMicrosub::new();
let channel = server.create_channel("Test").await.unwrap();
let entry1 = Entry {
entry_type: "entry".to_string(),
published: Some("2024-01-01T10:00:00Z".to_string()),
url: Some("https://feed1.com/post1".to_string()),
name: Some("Post from Feed 1".to_string()),
content: Some(Content {
text: Some("Content from feed 1".to_string()),
html: None,
}),
author: Some(Card {
card_type: "card".to_string(),
name: Some("Author 1".to_string()),
url: Some("https://feed1.com".to_string()),
photo: None,
}),
_id: Some("entry1".to_string()),
_is_read: Some(false),
uid: None,
summary: None,
category: None,
photo: None,
video: None,
audio: None,
syndication: None,
checkin: None,
like_of: None,
repost_of: None,
bookmark_of: None,
in_reply_to: None,
kind: Some("note".to_string()),
};
let entry2 = Entry {
entry_type: "entry".to_string(),
published: Some("2024-01-01T11:00:00Z".to_string()),
url: Some("https://feed2.com/post1".to_string()),
name: Some("Post from Feed 2".to_string()),
content: Some(Content {
text: Some("Content from feed 2".to_string()),
html: None,
}),
author: Some(Card {
card_type: "card".to_string(),
name: Some("Author 2".to_string()),
url: Some("https://feed2.com".to_string()),
photo: None,
}),
_id: Some("entry2".to_string()),
_is_read: Some(false),
uid: None,
summary: None,
category: None,
photo: None,
video: None,
audio: None,
syndication: None,
checkin: None,
like_of: None,
repost_of: None,
bookmark_of: None,
in_reply_to: None,
kind: Some("article".to_string()),
};
server.add_entry_with_source(&channel.uid, entry1, "https://feed1.com/feed.xml");
server.add_entry_with_source(&channel.uid, entry2, "https://feed2.com/feed.xml");
let timeline = server.timeline(&channel.uid, None, None, None, None).await.unwrap();
assert_eq!(timeline.items.len(), 2);
let timeline_feed1 = server.timeline(&channel.uid, None, None, None, Some("https://feed1.com/feed.xml")).await.unwrap();
assert_eq!(timeline_feed1.items.len(), 1);
assert_eq!(timeline_feed1.items[0]._id, Some("entry1".to_string()));
let timeline_feed2 = server.timeline(&channel.uid, None, None, None, Some("https://feed2.com/feed.xml")).await.unwrap();
assert_eq!(timeline_feed2.items.len(), 1);
assert_eq!(timeline_feed2.items[0]._id, Some("entry2".to_string()));
let timeline_empty = server.timeline(&channel.uid, None, None, None, Some("https://nonexistent.com/feed.xml")).await.unwrap();
assert_eq!(timeline_empty.items.len(), 0);
}
#[tokio::test]
async fn test_feed_metadata_enhancement() {
let mut server = InMemoryMicrosub::new();
let channel = server.create_channel("Test").await.unwrap();
let github_feed = server.follow(&channel.uid, "https://github.com/user/blog.atom").await.unwrap();
assert_eq!(github_feed.name, Some("GITHUB Feed".to_string()));
assert_eq!(github_feed.photo, Some("https://github.com/favicon.ico".to_string()));
let twitter_feed = server.follow(&channel.uid, "https://twitter.com/user/rss").await.unwrap();
assert_eq!(twitter_feed.name, Some("TWITTER Feed".to_string()));
assert_eq!(twitter_feed.photo, Some("https://abs.twimg.com/icons/apple-touch-icon-192x192.png".to_string()));
let generic_feed = server.follow(&channel.uid, "https://example.com/feed.xml").await.unwrap();
assert_eq!(generic_feed.name, Some("EXAMPLE Feed".to_string()));
assert_eq!(generic_feed.photo, None);
let followed = server.followed(&channel.uid).await.unwrap();
assert_eq!(followed.items.len(), 3);
let github = followed.items.iter().find(|f| f.url.contains("github.com")).unwrap();
assert_eq!(github.name, Some("GITHUB Feed".to_string()));
assert_eq!(github.photo, Some("https://github.com/favicon.ico".to_string()));
}
#[tokio::test]
async fn test_mute_user() {
let mut server = InMemoryMicrosub::new();
let channel = server.create_channel("Test").await.unwrap();
server.mute(&channel.uid, "https://bad.example.com").await.unwrap();
let muted = server.muted(&channel.uid).await.unwrap();
assert_eq!(muted.items.len(), 1);
assert_eq!(muted.items[0].url, Some("https://bad.example.com".to_string()));
}
#[tokio::test]
async fn test_block_user() {
let mut server = InMemoryMicrosub::new();
let channel = server.create_channel("Test").await.unwrap();
server.block(&channel.uid, "https://spam.example.com").await.unwrap();
let blocked = server.blocked(&channel.uid).await.unwrap();
assert_eq!(blocked.items.len(), 1);
assert_eq!(blocked.items[0].url, Some("https://spam.example.com".to_string()));
}
#[tokio::test]
async fn test_channel_not_found_error() {
let server = InMemoryMicrosub::new();
let result = server.followed("nonexistent").await;
assert!(matches!(result, Err(MicrosubError::ChannelNotFound(_))));
}
}