use ammonia; use diesel; use diesel::prelude::*; use rss; use errors::DataError; use models::Show; use models::{Index, Insert, Update}; use schema::shows; use database::connection; use dbqueries; use utils::url_cleaner; #[derive(Insertable, AsChangeset)] #[table_name = "shows"] #[derive(Debug, Clone, Default, Builder, PartialEq)] #[builder(default)] #[builder(derive(Debug))] #[builder(setter(into))] pub(crate) struct NewShow { title: String, link: String, description: String, image_uri: Option, source_id: i32, } impl Insert<()> for NewShow { type Error = DataError; fn insert(&self) -> Result<(), Self::Error> { use schema::shows::dsl::*; let db = connection(); let con = db.get()?; diesel::insert_into(shows) .values(self) .execute(&con) .map(|_| ()) .map_err(From::from) } } impl Update<()> for NewShow { type Error = DataError; fn update(&self, show_id: i32) -> Result<(), Self::Error> { use schema::shows::dsl::*; let db = connection(); let con = db.get()?; info!("Updating {}", self.title); diesel::update(shows.filter(id.eq(show_id))) .set(self) .execute(&con) .map(|_| ()) .map_err(From::from) } } // TODO: Maybe return an Enum Instead. // It would make unti testing better too. impl Index<()> for NewShow { type Error = DataError; fn index(&self) -> Result<(), DataError> { let exists = dbqueries::podcast_exists(self.source_id)?; if exists { let other = dbqueries::get_podcast_from_source_id(self.source_id)?; if self != &other { self.update(other.id()) } else { Ok(()) } } else { self.insert() } } } impl PartialEq for NewShow { fn eq(&self, other: &Show) -> bool { (self.link() == other.link()) && (self.title() == other.title()) && (self.image_uri() == other.image_uri()) && (self.description() == other.description()) && (self.source_id() == other.source_id()) } } impl NewShow { /// Parses a `rss::Channel` into a `NewShow` Struct. pub(crate) fn new(chan: &rss::Channel, source_id: i32) -> NewShow { let title = chan.title().trim(); let link = url_cleaner(chan.link().trim()); let description = ammonia::Builder::new() // Remove `rel` attributes from `` tags .link_rel(None) .clean(chan.description().trim()) .to_string(); // Try to get the itunes img first let itunes_img = chan .itunes_ext() .and_then(|s| s.image().map(|url| url.trim())) .map(|s| s.to_owned()); // If itunes is None, try to get the channel.image from the rss spec let image_uri = itunes_img.or_else(|| chan.image().map(|s| s.url().trim().to_owned())); NewShowBuilder::default() .title(title) .description(description) .link(link) .image_uri(image_uri) .source_id(source_id) .build() .unwrap() } // Look out for when tryinto lands into stable. pub(crate) fn to_podcast(&self) -> Result { self.index()?; dbqueries::get_podcast_from_source_id(self.source_id).map_err(From::from) } } // Ignore the following geters. They are used in unit tests mainly. impl NewShow { #[allow(dead_code)] pub(crate) fn source_id(&self) -> i32 { self.source_id } pub(crate) fn title(&self) -> &str { &self.title } pub(crate) fn link(&self) -> &str { &self.link } pub(crate) fn description(&self) -> &str { &self.description } pub(crate) fn image_uri(&self) -> Option<&str> { self.image_uri.as_ref().map(|s| s.as_str()) } } #[cfg(test)] mod tests { use super::*; // use tokio_core::reactor::Core; use failure::Error; use rss::Channel; use database::truncate_db; use models::NewShowBuilder; use std::fs::File; use std::io::BufReader; // Pre-built expected NewShow structs. lazy_static! { static ref EXPECTED_INTERCEPTED: NewShow = { let descr = "The people behind The Intercept’s fearless reporting and incisive \ commentary—Jeremy Scahill, Glenn Greenwald, Betsy Reed and \ others—discuss the crucial issues of our time: national security, civil \ liberties, foreign policy, and criminal justice. Plus interviews with \ artists, thinkers, and newsmakers who challenge our preconceptions about \ the world we live in."; NewShowBuilder::default() .title("Intercepted with Jeremy Scahill") .link("https://theintercept.com/podcasts") .description(descr) .image_uri(Some(String::from( "http://static.megaphone.fm/podcasts/d5735a50-d904-11e6-8532-73c7de466ea6/image/\ uploads_2F1484252190700-qhn5krasklbce3dh-a797539282700ea0298a3a26f7e49b0b_\ 2FIntercepted_COVER%2B_281_29.png") )) .source_id(42) .build() .unwrap() }; static ref EXPECTED_LUP: NewShow = { let descr = "An open show powered by community LINUX Unplugged takes the best \ attributes of open collaboration and focuses them into a weekly \ lifestyle show about Linux."; NewShowBuilder::default() .title("LINUX Unplugged Podcast") .link("http://www.jupiterbroadcasting.com/") .description(descr) .image_uri(Some(String::from( "http://www.jupiterbroadcasting.com/images/LASUN-Badge1400.jpg", ))) .source_id(42) .build() .unwrap() }; static ref EXPECTED_TIPOFF: NewShow = { let desc = "

Welcome to The Tip Off- the podcast where we take you behind the \ scenes of some of the best investigative journalism from recent years. \ Each episode we’ll be digging into an investigative scoop- hearing from \ the journalists behind the work as they tell us about the leads, the \ dead-ends and of course, the tip offs. There’ll be car chases, slammed \ doors, terrorist cells, meetings in dimly lit bars and cafes, wrangling \ with despotic regimes and much more. So if you’re curious about the fun, \ complicated detective work that goes into doing great investigative \ journalism- then this is the podcast for you.

"; NewShowBuilder::default() .title("The Tip Off") .link("http://www.acast.com/thetipoff") .description(desc) .image_uri(Some(String::from( "https://imagecdn.acast.com/image?h=1500&w=1500&source=http%3A%2F%2Fi1.sndcdn.\ com%2Favatars-000317856075-a2coqz-original.jpg", ))).source_id(42) .build() .unwrap() }; static ref EXPECTED_STARS: NewShow = { let descr = "

The first audio drama from Tor Labs and Gideon Media, Steal the Stars \ is a gripping noir science fiction thriller in 14 episodes: Forbidden \ love, a crashed UFO, an alien body, and an impossible heist unlike any \ ever attempted - scripted by Mac Rogers, the award-winning playwright \ and writer of the multi-million download The Message and LifeAfter.

"; let img = "https://dfkfj8j276wwv.cloudfront.net/images/2c/5f/a0/1a/2c5fa01a-ae78-4a8c-\ b183-7311d2e436c3/b3a4aa57a576bb662191f2a6bc2a436c8c4ae256ecffaff5c4c54fd42e\ 923914941c264d01efb1833234b52c9530e67d28a8cebbe3d11a4bc0fbbdf13ecdf1c3.jpeg"; NewShowBuilder::default() .title("Steal the Stars") .link("http://tor-labs.com/") .description(descr) .image_uri(Some(String::from(img))) .source_id(42) .build() .unwrap() }; static ref EXPECTED_CODE: NewShow = { let descr = "A podcast about humans and technology. Panelists: Coraline Ada Ehmke, \ David Brady, Jessica Kerr, Jay Bobo, Astrid Countee and Sam \ Livingston-Gray. Brought to you by @therubyrep."; NewShowBuilder::default() .title("Greater Than Code") .link("https://www.greaterthancode.com/") .description(descr) .image_uri(Some(String::from( "http://www.greaterthancode.com/wp-content/uploads/2016/10/code1400-4.jpg", ))) .source_id(42) .build() .unwrap() }; static ref EXPECTED_ELLINOFRENEIA: NewShow = { NewShowBuilder::default() .title("Ελληνοφρένεια") .link("https://ellinofreneia.sealabs.net/feed.rss") .description("Ανεπίσημο feed της Ελληνοφρένειας") .image_uri(Some("https://ellinofreneia.sealabs.net/logo.png".into())) .source_id(42) .build() .unwrap() }; static ref UPDATED_DESC_INTERCEPTED: NewShow = { NewShowBuilder::default() .title("Intercepted with Jeremy Scahill") .link("https://theintercept.com/podcasts") .description("New Description") .image_uri(Some(String::from( "http://static.megaphone.fm/podcasts/d5735a50-d904-11e6-8532-73c7de466ea6/image/\ uploads_2F1484252190700-qhn5krasklbce3dh-a797539282700ea0298a3a26f7e49b0b_\ 2FIntercepted_COVER%2B_281_29.png") )) .source_id(42) .build() .unwrap() }; } #[test] fn test_new_podcast_intercepted() -> Result<(), Error> { let file = File::open("tests/feeds/2018-01-20-Intercepted.xml")?; let channel = Channel::read_from(BufReader::new(file))?; let pd = NewShow::new(&channel, 42); assert_eq!(*EXPECTED_INTERCEPTED, pd); Ok(()) } #[test] fn test_new_podcast_lup() -> Result<(), Error> { let file = File::open("tests/feeds/2018-01-20-LinuxUnplugged.xml")?; let channel = Channel::read_from(BufReader::new(file))?; let pd = NewShow::new(&channel, 42); assert_eq!(*EXPECTED_LUP, pd); Ok(()) } #[test] fn test_new_podcast_thetipoff() -> Result<(), Error> { let file = File::open("tests/feeds/2018-01-20-TheTipOff.xml")?; let channel = Channel::read_from(BufReader::new(file))?; let pd = NewShow::new(&channel, 42); assert_eq!(*EXPECTED_TIPOFF, pd); Ok(()) } #[test] fn test_new_podcast_steal_the_stars() -> Result<(), Error> { let file = File::open("tests/feeds/2018-01-20-StealTheStars.xml")?; let channel = Channel::read_from(BufReader::new(file))?; let pd = NewShow::new(&channel, 42); assert_eq!(*EXPECTED_STARS, pd); Ok(()) } #[test] fn test_new_podcast_greater_than_code() -> Result<(), Error> { let file = File::open("tests/feeds/2018-01-20-GreaterThanCode.xml")?; let channel = Channel::read_from(BufReader::new(file))?; let pd = NewShow::new(&channel, 42); assert_eq!(*EXPECTED_CODE, pd); Ok(()) } #[test] fn test_new_podcast_ellinofreneia() -> Result<(), Error> { let file = File::open("tests/feeds/2018-03-28-Ellinofreneia.xml")?; let channel = Channel::read_from(BufReader::new(file))?; let pd = NewShow::new(&channel, 42); assert_eq!(*EXPECTED_ELLINOFRENEIA, pd); Ok(()) } #[test] // This maybe could be a doc test on insert. fn test_new_podcast_insert() -> Result<(), Error> { truncate_db()?; let file = File::open("tests/feeds/2018-01-20-Intercepted.xml")?; let channel = Channel::read_from(BufReader::new(file))?; let npd = NewShow::new(&channel, 42); npd.insert()?; let pd = dbqueries::get_podcast_from_source_id(42)?; assert_eq!(npd, pd); assert_eq!(*EXPECTED_INTERCEPTED, npd); assert_eq!(&*EXPECTED_INTERCEPTED, &pd); Ok(()) } #[test] // TODO: Add more test/checks // Currently there's a test that only checks new description or title. // If you have time and want to help, implement the test for the other fields // too. fn test_new_podcast_update() -> Result<(), Error> { truncate_db()?; let old = EXPECTED_INTERCEPTED.to_podcast()?; let updated = &*UPDATED_DESC_INTERCEPTED; updated.update(old.id())?; let new = dbqueries::get_podcast_from_source_id(42)?; assert_ne!(old, new); assert_eq!(old.id(), new.id()); assert_eq!(old.source_id(), new.source_id()); assert_eq!(updated, &new); assert_ne!(updated, &old); Ok(()) } #[test] fn test_new_podcast_index() -> Result<(), Error> { truncate_db()?; // First insert assert!(EXPECTED_INTERCEPTED.index().is_ok()); // Second identical, This should take the early return path assert!(EXPECTED_INTERCEPTED.index().is_ok()); // Get the podcast let old = dbqueries::get_podcast_from_source_id(42)?; // Assert that NewShow is equal to the Indexed one assert_eq!(&*EXPECTED_INTERCEPTED, &old); let updated = &*UPDATED_DESC_INTERCEPTED; // Update the podcast assert!(updated.index().is_ok()); // Get the new Show let new = dbqueries::get_podcast_from_source_id(42)?; // Assert it's diff from the old one. assert_ne!(new, old); assert_eq!(new.id(), old.id()); assert_eq!(new.source_id(), old.source_id()); Ok(()) } #[test] fn test_to_podcast() -> Result<(), Error> { // Assert insert() produces the same result that you would get with to_podcast() truncate_db()?; EXPECTED_INTERCEPTED.insert()?; let old = dbqueries::get_podcast_from_source_id(42)?; let pd = EXPECTED_INTERCEPTED.to_podcast()?; assert_eq!(old, pd); // Same as above, diff order truncate_db()?; let pd = EXPECTED_INTERCEPTED.to_podcast()?; // This should error as a unique constrain violation assert!(EXPECTED_INTERCEPTED.insert().is_err()); let old = dbqueries::get_podcast_from_source_id(42)?; assert_eq!(old, pd); Ok(()) } }