news-flash 3.0.0

Base library for a modern feed reader
Documentation
use std::sync::Arc;
use std::time::Duration;

use crate::models::{Category, CategoryID, CategoryMapping, Feed, FeedID, FeedMapping, NEWSFLASH_TOPLEVEL, Url};
use crate::util::opml;
use reqwest::{Client, ClientBuilder};
use test_log::test;
use tokio::sync::Semaphore;

#[test]
fn opml_export_1() {
    let mut categories: Vec<Category> = Vec::new();
    let mut category_mappings: Vec<CategoryMapping> = Vec::new();
    let mut feeds: Vec<Feed> = Vec::new();
    let mut feed_mappings: Vec<FeedMapping> = Vec::new();

    categories.push(Category {
        category_id: CategoryID::new("cat1"),
        label: "Category 1".to_owned(),
    });

    categories.push(Category {
        category_id: CategoryID::new("cat2"),
        label: "Category 2".to_owned(),
    });

    categories.push(Category {
        category_id: CategoryID::new("cat3"),
        label: "Category 3".to_owned(),
    });

    feeds.push(Feed {
        feed_id: FeedID::new("feed1"),
        label: "Feed 1".to_owned(),
        website: Some(Url::parse("http://golem.de").unwrap()),
        feed_url: Some(Url::parse("https://rss.golem.de/rss.php?feed=RSS2.0").unwrap()),
        icon_url: None,
        error_count: 0,
        error_message: None,
    });

    feeds.push(Feed {
        feed_id: FeedID::new("feed2"),
        label: "Feed 2".to_owned(),
        website: Some(Url::parse("http://golem.de").unwrap()),
        feed_url: Some(Url::parse("https://rss.golem.de/rss.php?feed=ATOM1.0").unwrap()),
        icon_url: None,
        error_count: 0,
        error_message: None,
    });

    feeds.push(Feed {
        feed_id: FeedID::new("feed3"),
        label: "Feed 3".to_owned(),
        website: Some(Url::parse("http://heise.de").unwrap()),
        feed_url: Some(Url::parse("https://www.heise.de/newsticker/heise-atom.xml").unwrap()),
        icon_url: None,
        error_count: 0,
        error_message: None,
    });

    feed_mappings.push(FeedMapping {
        feed_id: FeedID::new("feed1"),
        category_id: NEWSFLASH_TOPLEVEL.clone(),
        sort_index: Some(0),
    });

    feed_mappings.push(FeedMapping {
        feed_id: FeedID::new("feed1"),
        category_id: CategoryID::new("cat1"),
        sort_index: Some(0),
    });

    feed_mappings.push(FeedMapping {
        feed_id: FeedID::new("feed2"),
        category_id: CategoryID::new("cat2"),
        sort_index: Some(0),
    });

    feed_mappings.push(FeedMapping {
        feed_id: FeedID::new("feed3"),
        category_id: CategoryID::new("cat3"),
        sort_index: Some(0),
    });

    category_mappings.push(CategoryMapping {
        parent_id: NEWSFLASH_TOPLEVEL.clone(),
        category_id: CategoryID::new("cat1"),
        sort_index: Some(0),
    });

    category_mappings.push(CategoryMapping {
        parent_id: NEWSFLASH_TOPLEVEL.clone(),
        category_id: CategoryID::new("cat2"),
        sort_index: Some(0),
    });

    category_mappings.push(CategoryMapping {
        parent_id: CategoryID::new("cat1"),
        category_id: CategoryID::new("cat3"),
        sort_index: Some(0),
    });

    let opml_string = opml::generate_opml(&categories, &category_mappings, &feeds, &feed_mappings).unwrap();
    let opml_reference = r#"<opml version="2.0"><head><title>NewsFlash OPML export</title></head><body><outline text="Category 1" title="Category 1"><outline text="Category 3" title="Category 3"><outline text="Feed 3" type="rss" xmlUrl="https://www.heise.de/newsticker/heise-atom.xml" htmlUrl="http://heise.de/" title="Feed 3"/></outline><outline text="Feed 1" type="rss" xmlUrl="https://rss.golem.de/rss.php?feed=RSS2.0" htmlUrl="http://golem.de/" title="Feed 1"/></outline><outline text="Category 2" title="Category 2"><outline text="Feed 2" type="rss" xmlUrl="https://rss.golem.de/rss.php?feed=ATOM1.0" htmlUrl="http://golem.de/" title="Feed 2"/></outline><outline text="Feed 1" type="rss" xmlUrl="https://rss.golem.de/rss.php?feed=RSS2.0" htmlUrl="http://golem.de/" title="Feed 1"/></body></opml>"#;

    assert_eq!(&opml_string, opml_reference);
}

#[test]
fn opml_export_uncategorized_feeds() {
    let categories: Vec<Category> = Vec::new();
    let category_mappings: Vec<CategoryMapping> = Vec::new();
    let mut feeds: Vec<Feed> = Vec::new();
    let feed_mappings: Vec<FeedMapping> = Vec::new();

    feeds.push(Feed {
        feed_id: FeedID::new("feed1"),
        label: "Feed 1".to_owned(),
        website: Some(Url::parse("http://golem.de").unwrap()),
        feed_url: Some(Url::parse("https://rss.golem.de/rss.php?feed=RSS2.0").unwrap()),
        icon_url: None,
        error_count: 0,
        error_message: None,
    });

    feeds.push(Feed {
        feed_id: FeedID::new("feed2"),
        label: "Feed 2".to_owned(),
        website: Some(Url::parse("http://golem.de").unwrap()),
        feed_url: Some(Url::parse("https://rss.golem.de/rss.php?feed=ATOM1.0").unwrap()),
        icon_url: None,
        error_count: 0,
        error_message: None,
    });

    feeds.push(Feed {
        feed_id: FeedID::new("feed3"),
        label: "Feed 3".to_owned(),
        website: Some(Url::parse("http://heise.de").unwrap()),
        feed_url: Some(Url::parse("https://www.heise.de/newsticker/heise-atom.xml").unwrap()),
        icon_url: None,
        error_count: 0,
        error_message: None,
    });

    let opml_string = opml::generate_opml(&categories, &category_mappings, &feeds, &feed_mappings).unwrap();
    let opml_reference = r#"<opml version="2.0"><head><title>NewsFlash OPML export</title></head><body><outline text="Feed 1" type="rss" xmlUrl="https://rss.golem.de/rss.php?feed=RSS2.0" htmlUrl="http://golem.de/" title="Feed 1"/><outline text="Feed 2" type="rss" xmlUrl="https://rss.golem.de/rss.php?feed=ATOM1.0" htmlUrl="http://golem.de/" title="Feed 2"/><outline text="Feed 3" type="rss" xmlUrl="https://www.heise.de/newsticker/heise-atom.xml" htmlUrl="http://heise.de/" title="Feed 3"/></body></opml>"#;

    assert_eq!(&opml_string, opml_reference);
}

#[test(tokio::test)]
async fn opml_import_1() {
    let opml_string = r#"<?xml version="1.0" encoding="UTF-8"?>
    <opml version="1.0">
        <head>
            <title>Sample OPML file for RSSReader</title>
        </head>
        <body>
            <outline title="News" text="News">
                <outline text="Big News Finland" title="Big News Finland" type="rss" xmlUrl="http://www.bignewsnetwork.com/?rss=37e8860164ce009a"/>
                <outline text="Euronews" title="Euronews" type="rss" xmlUrl="http://feeds.feedburner.com/euronews/en/news/"/>
                <outline text="Reuters Top News" title="Reuters Top News" type="rss" xmlUrl="http://feeds.reuters.com/reuters/topNews"/>
                <outline text="Yahoo Europe" title="Yahoo Europe" type="rss" xmlUrl="http://rss.news.yahoo.com/rss/europe"/>
            </outline>

            <outline title="Leisure" text="Leisure">
                <outline text="CNN Entertainment" title="CNN Entertainment" type="rss" xmlUrl="http://rss.cnn.com/rss/edition_entertainment.rss"/>
                <outline text="E! News" title="E! News" type="rss" xmlUrl="http://uk.eonline.com/syndication/feeds/rssfeeds/topstories.xml"/>
                <outline text="Hollywood Reporter" title="Hollywood Reporter" type="rss" xmlUrl="http://feeds.feedburner.com/thr/news"/>
                <outline text="Reuters Entertainment" title="Reuters Entertainment" type="rss"  xmlUrl="http://feeds.reuters.com/reuters/entertainment"/>
                <outline text="Reuters Music News" title="Reuters Music News" type="rss" xmlUrl="http://feeds.reuters.com/reuters/musicNews"/>
                <outline text="Yahoo Entertainment" title="Yahoo Entertainment" type="rss" xmlUrl="http://rss.news.yahoo.com/rss/entertainment"/>
            </outline>

            <outline title="Sports" text="Sports">
                <outline text="Formula 1" title="Formula 1" type="rss" xmlUrl="http://www.formula1.com/rss/news/latest.rss"/>
                <outline text="MotoGP" title="MotoGP" type="rss" xmlUrl="http://rss.crash.net/crash_motogp.xml"/>
                <outline text="N.Y.Times Track And Field" title="N.Y.Times Track And Field" type="rss" xmlUrl="http://topics.nytimes.com/topics/reference/timestopics/subjects/t/track_and_field/index.html?rss=1"/>
                <outline text="Reuters Sports" title="Reuters Sports" type="rss" xmlUrl="http://feeds.reuters.com/reuters/sportsNews"/>
                <outline text="Yahoo Sports NHL" title="Yahoo Sports NHL" type="rss" xmlUrl="http://sports.yahoo.com/nhl/rss.xml"/>
                <outline text="Yahoo Sports" title="Yahoo Sports" type="rss" xmlUrl="http://rss.news.yahoo.com/rss/sports"/>
            </outline>

            <outline title="Tech" text="Tech">
                <outline text="Coding Horror" title="Coding Horror" type="rss" xmlUrl="http://feeds.feedburner.com/codinghorror/"/>
                <outline text="Gadget Lab" title="Gadget Lab" type="rss" xmlUrl="http://www.wired.com/gadgetlab/feed/"/>
                <outline text="Gizmodo" title="Gizmodo" type="rss" xmlUrl="http://gizmodo.com/index.xml"/>
                <outline text="Reuters Technology" title="Reuters Technology" type="rss" xmlUrl="http://feeds.reuters.com/reuters/technologyNews"/>
            </outline>
        </body>
    </opml>
    "#;

    let res = opml::parse_opml(opml_string, false, Arc::new(Semaphore::new(20)), &Client::new())
        .await
        .unwrap();

    assert_eq!(res.categories.len(), 4);
    assert_eq!(res.feeds.len(), 20);
    assert_eq!(res.feed_mappings.len(), res.feeds.len());
    assert_eq!(res.categories.get(2).unwrap().label, "Sports");
    assert_eq!(res.feeds.get(6).unwrap().label, "Hollywood Reporter");
}

#[test(tokio::test)]
async fn opml_import_2() {
    let opml_string = r#"<?xml version="1.0" encoding="utf-8"?>
    <opml version="1.1">
        <head>
            <title>Blogtrottr OPML export</title>
            <dateCreated>Tue, 14 Apr 2015 20:43:49 +0000</dateCreated>
        </head>
        <body>
            <outline text="Subscriptions">
                <outline title="Create your own gamesĀ» Game Creation Blog by Koonsolo" text="A blog on how to create your own computer games (by Koen Witters)" type="rss" xmlUrl="http://dev.koonsolo.com/feed/"/>
                <outline title="Digitanks" text="" type="rss" xmlUrl="http://digitanks.com/feed/"/>
                <outline title="Jacques Mattheij" text="" type="rss" xmlUrl="http://jacquesmattheij.com/rss.xml"/>
                <outline title="jwz" text="jwz - LiveJournal.com" type="rss" xmlUrl="http://jwz.livejournal.com/data/rss"/>
                <outline title="Pyevolve" text="by Christian S. Perone" type="rss" xmlUrl="http://feeds2.feedburner.com/pyevolve"/>
                <outline title="Yipit Django Blog" text="Django Tips and Best Practices" type="rss" xmlUrl="http://feeds.feedburner.com/YipitDjangoBlog?format=xml"/>
                <outline title="802-BIKEGUY" text="Bike pedals before gas pedals!" type="rss" xmlUrl="http://www.802bikeguy.com/feed/"/>
                <outline title="A Smart Bear" type="rss" xmlUrl="http://feeds.feedburner.com/blogspot/smartbear"/>
                <outline title="AVC" text="Musings of a VC in NYC" type="rss" xmlUrl="http://feeds.feedburner.com/avc"/>
                <outline title="ActiveBlog:  Insights on Code, the Cloud and More" text="" type="rss" xmlUrl="http://blogs.activestate.com/feed"/>
                <outline title="Algorithm.co.il" text="Algorithms, for the heck of it" type="rss" xmlUrl="http://www.algorithm.co.il/blogs/index.php/feed/"/>
            </outline>
        </body>
    </opml>
    "#;

    // timeout to avoid slow feed from blocking tests
    let client = ClientBuilder::new().timeout(Duration::from_secs(5)).build().unwrap();
    let res = opml::parse_opml(opml_string, false, Arc::new(Semaphore::new(20)), &client).await.unwrap();

    assert_eq!(res.categories.len(), 1);
    assert_eq!(res.feeds.len(), 11);
    assert_eq!(res.feed_mappings.len(), res.feeds.len());
    assert_eq!(res.categories.first().unwrap().label, "Subscriptions");
    assert_eq!(res.feeds.get(8).unwrap().label, "AVC");
}

#[test(tokio::test)]
async fn opml_import_3() {
    let opml_string = r#"<?xml version="1.0" encoding="UTF-8"?>
    <opml version="2.0">
        <body>
            <outline text="FOSS">
                <outline title="freedesktop.org" text="freedesktop.org" xmlUrl="https://planet.freedesktop.org/rss20.xml" htmlUrl="http://planet.freedesktop.org"></outline>
                <outline title="elementary" text="elementary" xmlUrl="https://blog.elementary.io/feed.xml" htmlUrl="http://blog.elementary.io/"></outline>
                <outline title="GIMP" text="GIMP" xmlUrl="https://www.gimp.org/feeds/rss.xml" htmlUrl="https://www.gimp.org/"></outline>
                <outline title="GTK+ Dev" text="GTK+ Dev" xmlUrl="https://blog.gtk.org/feed/" htmlUrl="https://blog.gtk.org"></outline>
                <outline title="heise Open" text="heise Open" xmlUrl="https://www.heise.de/thema/Linux-und-Open-Source?view=atom" htmlUrl="https://www.heise.de/thema/Linux-und-Open-Source?view=atom"></outline>
                <outline title="Kodi" text="Kodi" xmlUrl="https://kodi.tv/feed" htmlUrl="https://kodi.tv/blog"></outline>
                <outline title="LibreELEC" text="LibreELEC" xmlUrl="https://libreelec.tv/feed/" htmlUrl="https://libreelec.tv"></outline>
                <outline title="LWN.net" text="LWN.net" xmlUrl="https://lwn.net/headlines/newrss" htmlUrl="https://lwn.net"></outline>
                <outline title="OMG! Ubuntu!" text="OMG! Ubuntu!" xmlUrl="http://feeds.feedburner.com/d0od" htmlUrl="https://www.omgubuntu.co.uk"></outline>
                <outline title="PCSX2" text="PCSX2" xmlUrl="https://pcsx2.net/?format=feed&amp;type=rss" htmlUrl="https://pcsx2.net/"></outline>
                <outline title="Phoronix" text="Phoronix" xmlUrl="https://www.phoronix.com/rss.php" htmlUrl="https://www.phoronix.com/"></outline>
                <outline title="Planet GNOME" text="Planet GNOME" xmlUrl="https://planet.gnome.org/rss20.xml" htmlUrl="http://planet.gnome.org/"></outline>
                <outline title="Planet KDE" text="Planet KDE" xmlUrl="http://planetkde.org/rss20.xml" htmlUrl="http://planetKDE.org/"></outline>
                <outline title="Planet Vala" text="Planet Vala" xmlUrl="http://planet.vala-project.org/atom.xml" htmlUrl="http://planet.vala-project.org/"></outline>
                <outline title="Planet WebKitGTK+" text="Planet WebKitGTK+" xmlUrl="https://planet.webkitgtk.org/rss20.xml" htmlUrl="https://planet.webkitgtk.org/"></outline>
                <outline title="Pro-Linux" text="Pro-Linux" xmlUrl="https://www.pro-linux.de/rss/index2.xml" htmlUrl="https://www.pro-linux.de"></outline>
                <outline title="soeren-hentzschel.at" text="soeren-hentzschel.at" xmlUrl="https://www.soeren-hentzschel.at/feed/" htmlUrl="https://www.soeren-hentzschel.at"></outline>
                <outline title="WebUpd8" text="WebUpd8" xmlUrl="http://feeds2.feedburner.com/webupd8" htmlUrl="http://www.webupd8.org/"></outline>
            </outline>
            <outline text="Hardware">
                <outline title="AnandTech" text="AnandTech" xmlUrl="https://www.anandtech.com/rss/" htmlUrl="http://www.anandtech.com"></outline>
                <outline title="areamobile" text="areamobile" xmlUrl="https://www.areamobile.de/feed.cfm?menu_alias=home/" htmlUrl="http://www.areamobile.de/"></outline>
                <outline title="Hardwareluxx" text="Hardwareluxx" xmlUrl="https://www.hardwareluxx.de/index.php/component/obrss/hardwareluxx-rss-feed.feed" htmlUrl="https://www.hardwareluxx.de/"></outline>
                <outline title="Planet 3DNow!" text="Planet 3DNow!" xmlUrl="http://www.planet3dnow.de/cms/feed/" htmlUrl="https://www.planet3dnow.de/cms"></outline>
                <outline title="vrzone" text="vrzone" xmlUrl="https://vrzone.com/feed" htmlUrl="http://vrzone.com"></outline>
            </outline>
            <outline text="Podcasts">
                <outline title="Besser als Sex" text="Besser als Sex" xmlUrl="http://feeds.soundcloud.com/users/soundcloud:users:368209919/sounds.rss" htmlUrl="http://soundcloud.com/besseralssex"></outline>
                <outline title="New Rustacean" text="New Rustacean" xmlUrl="https://newrustacean.com/feed.xml" htmlUrl="http://newrustacean.com"></outline>
                <outline title="Quarks &amp; Co" text="Quarks &amp; Co" xmlUrl="https://www1.wdr.de/mediathek/video/podcast/channel-quarks-und-co-100.podcast" htmlUrl="https://www1.wdr.de/mediathek/video/podcast/channel-quarks-und-co-100.html"></outline>
            </outline>
            <outline text="CG">
                <outline title="Blender Dev" text="Blender Dev" xmlUrl="https://code.blender.org/feed/" htmlUrl="https://code.blender.org"></outline>
                <outline title="BlenderNation" text="BlenderNation" xmlUrl="http://feeds.feedburner.com/Blendernation" htmlUrl="https://www.blendernation.com"></outline>
            </outline>
            <outline text="General IT">
                <outline title="ComputerBase" text="ComputerBase" xmlUrl="https://www.computerbase.de/rss/news.xml" htmlUrl="https://www.computerbase.de/"></outline>
                <outline title="ComputerBase Tests" text="ComputerBase Tests" xmlUrl="https://www.computerbase.de/rss/artikel.xml" htmlUrl="https://www.computerbase.de/tests/"></outline>
                <outline title="Golem.de" text="Golem.de" xmlUrl="http://rss.golem.de/rss.php?feed=RSS1.0" htmlUrl="http://rss.golem.de/rss.php?feed=RSS1.0"></outline>
                <outline title="heise" text="heise" xmlUrl="https://www.heise.de/rss/heise-atom.xml" htmlUrl="https://www.heise.de/newsticker/"></outline>
                <outline title="SoftMaker Office" text="SoftMaker Office" xmlUrl="https://www.softmaker.de/blog/softmaker-office?format=feed&amp;type=atom" htmlUrl="https://www.softmaker.de"></outline>
            </outline>
            <outline text="Entertainment">
                <outline title="filmstarts.de" text="filmstarts.de" xmlUrl="http://rss.filmstarts.de/fs/news/filmnachrichten?format=xml" htmlUrl="http://www.filmstarts.de"></outline>
                <outline title="Movie Trailers" text="Movie Trailers" xmlUrl="https://trailers.apple.com/trailers/home/rss/newtrailers.rss" htmlUrl="http://www.apple.com/trailers/"></outline>
                <outline title="Serienjunkies" text="Serienjunkies" xmlUrl="http://feeds.feedburner.com/sj?format=xml" htmlUrl="https://www.serienjunkies.de/news/"></outline>
            </outline>
            <outline text="Games">
                <outline title="GamingOnLinux" text="GamingOnLinux" xmlUrl="https://www.gamingonlinux.com/article_rss.php" htmlUrl="https://www.gamingonlinux.com/"></outline>
                <outline title="SC2Casts" text="SC2Casts" xmlUrl="http://sc2casts.com/rss" htmlUrl="http://sc2casts.com"></outline>
                <outline title="Supertuxkart" text="Supertuxkart" xmlUrl="http://blog.supertuxkart.net/feeds/posts/default" htmlUrl="http://blog.supertuxkart.net/"></outline>
                <outline title="TeamLiquid.net" text="TeamLiquid.net" xmlUrl="https://tl.net/rss/news.xml" htmlUrl="http://www.teamliquid.net/"></outline>
            </outline>
            <outline text="News">
                <outline title="SPIEGEL" text="SPIEGEL" xmlUrl="https://www.spiegel.de/schlagzeilen/tops/index.rss" htmlUrl="http://www.spiegel.de"></outline>
                <outline title="Sueddeutsche" text="Sueddeutsche" xmlUrl="https://rss.sueddeutsche.de/rss/Topthemen" htmlUrl="http://www.sueddeutsche.de"></outline>
            </outline>
        </body>
    </opml>
    "#;

    let res = opml::parse_opml(opml_string, false, Arc::new(Semaphore::new(20)), &Client::new())
        .await
        .unwrap();

    assert_eq!(res.categories.len(), 8);
    assert_eq!(res.feeds.len(), 42);
    assert_eq!(res.feed_mappings.len(), res.feeds.len());
    assert_eq!(res.categories.get(2).unwrap().label, "Podcasts");
    assert_eq!(res.feeds.get(6).unwrap().label, "LibreELEC");
}

#[test(tokio::test)]
async fn opml_import_no_title_no_html_url() {
    let opml_string = r#"<?xml version="1.0" encoding="UTF-8"?>
    <opml version="1.0">
        <head>
            <title>Sample OPML</title>
        </head>
        <body>
            <outline text="Some Category">
                <outline type="rss" xmlUrl="http://rss.computerworld.com.br/c/32184/f/499638/index.rss"/>
            </outline>
        </body>
    </opml>
    "#;

    let res = opml::parse_opml(opml_string, false, Arc::new(Semaphore::new(20)), &Client::new())
        .await
        .unwrap();

    assert_eq!(res.categories.len(), 1);
    assert_eq!(res.feeds.len(), 1);
    assert_eq!(res.feed_mappings.len(), res.feeds.len());
    assert_eq!(res.categories.first().unwrap().label, "Some Category");
    assert_eq!(res.feeds.first().unwrap().label, "No Title");
    assert_eq!(res.feeds.first().unwrap().website, None);
}

#[test(tokio::test)]
async fn opml_import_liferea_atom() {
    let opml_string = r#"<?xml version="1.0"?>
    <opml version="1.0">
      <head>
        <title>Liferea Feed List Export</title>
      </head>
      <body>
        <outline title="Software Updates" text="Software Updates" description="Software Updates" type="folder">
          <outline title="GTK" text="GTK" description="GTK" type="atom" xmlUrl="https://gitlab.gnome.org/GNOME/gtk/-/tags?format=atom" htmlUrl="https://gitlab.gnome.org/GNOME/gtk/-/tags"/>
          <outline title="NewsFlash GTK" text="NewsFlash GTK" description="NewsFlash GTK" type="atom" xmlUrl="https://gitlab.com/news-flash/news_flash_gtk/-/tags?format=atom" htmlUrl="https://gitlab.com/news-flash/news_flash_gtk/-/tags"/>
          <outline title="NewsFlash" text="NewsFlash" description="NewsFlash" type="atom" xmlUrl="https://gitlab.com/news-flash/news_flash/-/tags?format=atom" htmlUrl="https://gitlab.com/news-flash/news_flash/-/tags"/>
          <outline title="Rust Blog" text="Rust Blog" description="Rust Blog" type="atom" xmlUrl="https://blog.rust-lang.org/feed.xml" htmlUrl="https://blog.rust-lang.org/"/>
          <outline title="Rust" text="Rust" description="Rust" type="atom" xmlUrl="https://github.com/rust-lang/rust/releases.atom" htmlUrl="https://github.com/rust-lang/rust/releases.atom"/>
        </outline>
      </body>
    </opml>
    "#;

    let res = opml::parse_opml(opml_string, false, Arc::new(Semaphore::new(20)), &Client::new())
        .await
        .unwrap();

    assert_eq!(res.categories.len(), 1);
    assert_eq!(res.feeds.len(), 5);
    assert_eq!(res.feed_mappings.len(), res.feeds.len());
    assert_eq!(res.categories.first().unwrap().label, "Software Updates");
    assert_eq!(res.feeds.get(2).unwrap().label, "NewsFlash");
}