From 0639c5ca12424b743e739b5c68498cca0a7b1154 Mon Sep 17 00:00:00 2001 From: Julia Lange Date: Fri, 23 Jan 2026 16:35:43 -0800 Subject: [PATCH] Pull out feed_channel, add more tests --- koucha/src/db.rs | 4 +- koucha/src/db/channel.rs | 37 +--------- koucha/src/db/feed_channel.rs | 131 ++++++++++++++++++++++++++++++++++ koucha/src/db/item.rs | 54 ++++++++++++-- 4 files changed, 184 insertions(+), 42 deletions(-) create mode 100644 koucha/src/db/feed_channel.rs diff --git a/koucha/src/db.rs b/koucha/src/db.rs index f5e1372..79eaf50 100644 --- a/koucha/src/db.rs +++ b/koucha/src/db.rs @@ -2,6 +2,8 @@ mod user; pub use user::User; mod feed; pub use feed::Feed; +mod feed_channel; +pub use feed_channel::FeedChannel; mod channel; pub use channel::Channel; mod item; @@ -10,7 +12,7 @@ pub use item::Item; macro_rules! define_id { ($name:ident) => { - #[derive(Copy, Clone)] + #[derive(PartialEq, Debug, Copy, Clone)] pub struct $name(i64); impl From<$name> for i64 { fn from(id: $name) -> Self { id.0 } } }; diff --git a/koucha/src/db/channel.rs b/koucha/src/db/channel.rs index c837475..22bea40 100644 --- a/koucha/src/db/channel.rs +++ b/koucha/src/db/channel.rs @@ -6,7 +6,8 @@ use crate::{ db::{ ChannelId, Item, - FeedId, + FeedChannel, + feed_channel::UnparsedFeedChannel, item::UnparsedItem, }, fetch::FetchedRSSChannel, @@ -34,40 +35,6 @@ impl UnparsedChannel { } } -pub struct UnparsedFeedChannel { - pub channel_id: i64, - pub feed_id: i64, -} -impl UnparsedFeedChannel { - pub fn parse(self) -> Result { - Ok(FeedChannel { - channel_id: ChannelId(self.channel_id), - feed_id: FeedId(self.feed_id) - }) - } -} - -pub struct FeedChannel { - channel_id: ChannelId, - feed_id: FeedId, -} - -impl FeedChannel { - pub async fn add_item( - &self, pool: &AdapterPool, item: &Item - ) -> Result<()> { - let int_item_id = i64::from(item.id()); - let int_feed_id = i64::from(self.feed_id); - - sqlx::query!( - "INSERT OR IGNORE INTO feed_items (feed_id, item_id, score) - VALUES (?, ?, 5)", // TODO: Add in scoring featuress - int_feed_id, int_item_id - ).execute(&pool.0).await?; - Ok(()) - } -} - pub struct Channel { id: ChannelId, title: String, diff --git a/koucha/src/db/feed_channel.rs b/koucha/src/db/feed_channel.rs new file mode 100644 index 0000000..4421b88 --- /dev/null +++ b/koucha/src/db/feed_channel.rs @@ -0,0 +1,131 @@ +use crate::{ + Result, + AdapterPool, + db::{ + Channel, + ChannelId, + Feed, + FeedId, + Item, + }, +}; + +pub struct UnparsedFeedChannel { + pub channel_id: i64, + pub feed_id: i64, +} +impl UnparsedFeedChannel { + pub fn parse(self) -> Result { + Ok(FeedChannel { + channel_id: ChannelId(self.channel_id), + feed_id: FeedId(self.feed_id) + }) + } +} + +pub struct FeedChannel { + channel_id: ChannelId, + feed_id: FeedId, +} + +impl FeedChannel { + pub async fn get_channel(&self, pool: &AdapterPool) -> Result { + Channel::get(pool, self.channel_id).await + } + pub async fn get_feed(&self, pool: &AdapterPool) -> Result { + Feed::get(pool, self.feed_id).await + } + + pub async fn add_item( + &self, pool: &AdapterPool, item: &Item + ) -> Result<()> { + let int_item_id = i64::from(item.id()); + let int_feed_id = i64::from(self.feed_id); + + sqlx::query!( + "INSERT OR IGNORE INTO feed_items (feed_id, item_id, score) + VALUES (?, ?, 5)", // TODO: Add in scoring featuress + int_feed_id, int_item_id + ).execute(&pool.0).await?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use reqwest::Url; + use crate::{ + Adapter, + AdapterBuilder, + db::{ + Channel, + FeedId, + User + } + }; + + const FEED1: &str = "https://example.com/feed"; + + async fn setup_adapter() -> Adapter { + AdapterBuilder::new() + .database_url("sqlite::memory:") + .create().await.unwrap() + } + + // FeedChannel Tests + #[tokio::test] + async fn get_channel() { + let adapter = setup_adapter().await; + let pool = adapter.get_pool(); + + let url = Url::parse(FEED1).unwrap(); + let channel = Channel::get_or_create(pool, url).await.unwrap(); + + let fc = FeedChannel { + channel_id: channel.id(), + feed_id: FeedId(1), // Fake Feed + }; + + let channel_from_fc = fc.get_channel(pool).await.unwrap(); + assert_eq!(channel_from_fc.id(), channel.id()); + } + + #[tokio::test] + async fn get_feed() { + let adapter = setup_adapter().await; + let pool = adapter.get_pool(); + + let user = User::create(pool, "Alice").await.unwrap(); + let feed = Feed::create(pool, user.id(), "My Feed").await.unwrap(); + + let fc = FeedChannel { + channel_id: ChannelId(1), // Fake Channel + feed_id: feed.id(), + }; + + let feed_from_fc = fc.get_feed(pool).await.unwrap(); + assert_eq!(feed_from_fc.id(), feed.id()); + } + + #[tokio::test] + pub async fn add_item() { + let adapter = setup_adapter().await; + let pool = adapter.get_pool(); + + let user = User::create(pool, "Alice").await.unwrap(); + let feed = Feed::create(pool, user.id(), "My Feed").await.unwrap(); + let url = Url::parse(FEED1).unwrap(); + let channel = Channel::get_or_create(pool, url).await.unwrap(); + let fc = FeedChannel { + channel_id: channel.id(), + feed_id: feed.id(), + }; + + let item = Item::get_or_create(pool, channel.id(), "item-guid").await.unwrap(); + fc.add_item(pool, &item).await.unwrap(); + + let items = feed.get_items(pool, 1, 0).await.unwrap(); + assert_eq!(items[0].id(), item.id()); + } +} diff --git a/koucha/src/db/item.rs b/koucha/src/db/item.rs index 1cc7f6c..39a1b49 100644 --- a/koucha/src/db/item.rs +++ b/koucha/src/db/item.rs @@ -24,10 +24,11 @@ impl UnparsedItem { Ok(Item { id: ItemId(self.id), channel_id: ChannelId(self.channel_id), - fetched_at: self.fetched_at.as_deref() - .map(DateTime::parse_from_rfc2822) - .transpose()? - .map(|dt| dt.with_timezone(&Utc)), + fetched_at: match self.fetched_at { + Some(dt_str) => Some(DateTime::parse_from_rfc2822(&dt_str)? + .with_timezone(&Utc)), + None => None, + }, title: self.title, description: self.description, @@ -40,6 +41,7 @@ pub struct Item { id: ItemId, channel_id: ChannelId, + #[allow(dead_code)] // TODO: Use for score decay calculations later fetched_at: Option>, title: Option, description: Option, @@ -61,8 +63,8 @@ impl Item { let item = sqlx::query_as!( UnparsedItem, "INSERT INTO items (channel_id, guid) - VALUES(?, ?) - ON CONFLICT(id) DO UPDATE SET id = id + VALUES (?, ?) + ON CONFLICT(channel_id, guid) DO UPDATE SET channel_id = channel_id RETURNING id as `id!`, channel_id, fetched_at, title, description, content", int_channel_id, guid @@ -91,3 +93,43 @@ impl Item { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use reqwest::Url; + use crate::{ + Adapter, + AdapterBuilder, + db::{ + Channel, + } + }; + + const FEED1: &str = "https://example.com/feed"; + const ITEM_GUID: &str = "item-guid"; + + async fn setup_adapter() -> Adapter { + AdapterBuilder::new() + .database_url("sqlite::memory:") + .create().await.unwrap() + } + + async fn setup_channel(pool: &AdapterPool) -> Channel { + let url = Url::parse(FEED1).unwrap(); + Channel::get_or_create(pool, url).await.unwrap() + } + + // Item Tests + #[tokio::test] + pub async fn get_or_create_duplicate() { + let adapter = setup_adapter().await; + let pool = adapter.get_pool(); + let channel = setup_channel(pool).await; + + let item1 = Item::get_or_create(pool, channel.id(), ITEM_GUID).await.unwrap(); + let item2 = Item::get_or_create(pool, channel.id(), ITEM_GUID).await.unwrap(); + + assert_eq!(item1.id(), item2.id()); + } +}