Skip to main content

max / balanced_breakfast

10.4 KB · 313 lines History Blame Raw
1 //! OPML export and import integration tests.
2
3 mod common;
4
5 use bb_db::{BusserId, CreateFeed, Database};
6
7 // ── OPML-specific helpers ────────────────────────────────────────────
8
9 /// Replicate the OPML export logic from `commands/opml.rs::export_opml`.
10 fn export_opml_from_feeds(feeds: &[bb_db::DbFeed]) -> String {
11 let mut outlines = String::new();
12 for feed in feeds {
13 if feed.busser_id != "rss" {
14 continue;
15 }
16 let config = feed.config_json();
17 let feed_url = config
18 .get("feed_url")
19 .and_then(|v| v.as_str())
20 .unwrap_or("");
21 if feed_url.is_empty() {
22 continue;
23 }
24 outlines.push_str(&format!(
25 " <outline text=\"{}\" type=\"rss\" xmlUrl=\"{}\" />\n",
26 xml_escape(&feed.name),
27 xml_escape(feed_url),
28 ));
29 }
30
31 format!(
32 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
33 <opml version=\"2.0\">\n\
34 \x20 <head>\n\
35 \x20 <title>Balanced Breakfast Feeds</title>\n\
36 \x20 </head>\n\
37 \x20 <body>\n\
38 {}\
39 \x20 </body>\n\
40 </opml>\n",
41 outlines
42 )
43 }
44
45 fn xml_escape(s: &str) -> String {
46 s.replace('&', "&amp;")
47 .replace('<', "&lt;")
48 .replace('>', "&gt;")
49 .replace('"', "&quot;")
50 .replace('\'', "&apos;")
51 }
52
53 /// Replicate the OPML import logic from `commands/opml.rs::import_opml`.
54 async fn import_opml(db: &Database, content: &str) -> Result<(usize, usize, Vec<String>), String> {
55 let doc = roxmltree::Document::parse(content)
56 .map_err(|e| format!("Invalid OPML: {}", e))?;
57
58 let existing_feeds = db.feeds().list_all().await.map_err(|e| e.to_string())?;
59
60 let existing_urls: std::collections::HashSet<String> = existing_feeds
61 .iter()
62 .filter(|f| f.busser_id == "rss")
63 .filter_map(|f| {
64 f.config_json()
65 .get("feed_url")
66 .and_then(|v| v.as_str())
67 .map(|s| s.to_string())
68 })
69 .collect();
70
71 let mut imported = 0;
72 let mut skipped = 0;
73 let mut errors = Vec::new();
74
75 for node in doc.descendants() {
76 if !node.is_element() || node.tag_name().name() != "outline" {
77 continue;
78 }
79 let xml_url = match node.attribute("xmlUrl") {
80 Some(url) if !url.is_empty() => url,
81 _ => continue,
82 };
83
84 if existing_urls.contains(xml_url) {
85 skipped += 1;
86 continue;
87 }
88
89 let name = node
90 .attribute("text")
91 .or_else(|| node.attribute("title"))
92 .unwrap_or(xml_url)
93 .to_string();
94
95 let config = serde_json::json!({ "feed_url": xml_url });
96 let create = CreateFeed {
97 busser_id: BusserId::new("rss"),
98 name,
99 config,
100 };
101
102 match db.feeds().create(create).await {
103 Ok(_) => imported += 1,
104 Err(e) => errors.push(format!("{}: {}", xml_url, e)),
105 }
106 }
107
108 Ok((imported, skipped, errors))
109 }
110
111 // ── OPML Export ──────────────────────────────────────────────────────
112
113 #[tokio::test]
114 async fn opml_export_empty_state() {
115 let db = common::test_db().await;
116 let feeds = db.feeds().list_all().await.unwrap();
117 let opml = export_opml_from_feeds(&feeds);
118
119 assert!(opml.contains("<opml version=\"2.0\">"));
120 assert!(opml.contains("<title>Balanced Breakfast Feeds</title>"));
121 // No outline elements
122 assert!(!opml.contains("<outline"));
123 }
124
125 #[tokio::test]
126 async fn opml_export_with_rss_feeds() {
127 let db = common::test_db().await;
128 common::create_rss_feed(&db, "Rust Blog", "https://blog.rust-lang.org/feed.xml").await;
129 common::create_rss_feed(&db, "Hacker News", "https://news.ycombinator.com/rss").await;
130
131 let feeds = db.feeds().list_all().await.unwrap();
132 let opml = export_opml_from_feeds(&feeds);
133
134 assert!(opml.contains("xmlUrl=\"https://blog.rust-lang.org/feed.xml\""));
135 assert!(opml.contains("xmlUrl=\"https://news.ycombinator.com/rss\""));
136 assert!(opml.contains("text=\"Rust Blog\""));
137 assert!(opml.contains("text=\"Hacker News\""));
138 }
139
140 #[tokio::test]
141 async fn opml_export_skips_non_rss_feeds() {
142 let db = common::test_db().await;
143 common::create_rss_feed(&db, "RSS Feed", "https://example.com/feed.xml").await;
144 common::create_other_feed(&db, "hn", "HN Top Stories").await;
145
146 let feeds = db.feeds().list_all().await.unwrap();
147 let opml = export_opml_from_feeds(&feeds);
148
149 // Only the RSS feed should appear
150 assert!(opml.contains("xmlUrl=\"https://example.com/feed.xml\""));
151 assert!(!opml.contains("HN Top Stories"));
152 }
153
154 #[tokio::test]
155 async fn opml_export_escapes_special_chars() {
156 let db = common::test_db().await;
157 common::create_rss_feed(&db, "Tom & Jerry's <Feed>", "https://example.com/rss?a=1&b=2").await;
158
159 let feeds = db.feeds().list_all().await.unwrap();
160 let opml = export_opml_from_feeds(&feeds);
161
162 assert!(opml.contains("text=\"Tom &amp; Jerry&apos;s &lt;Feed&gt;\""));
163 assert!(opml.contains("xmlUrl=\"https://example.com/rss?a=1&amp;b=2\""));
164 }
165
166 // ── OPML Import ──────────────────────────────────────────────────────
167
168 #[tokio::test]
169 async fn opml_import_valid_opml() {
170 let db = common::test_db().await;
171 let opml = r#"<?xml version="1.0" encoding="UTF-8"?>
172 <opml version="2.0">
173 <head><title>Test</title></head>
174 <body>
175 <outline text="Rust Blog" type="rss" xmlUrl="https://blog.rust-lang.org/feed.xml" />
176 <outline text="Lobsters" type="rss" xmlUrl="https://lobste.rs/rss" />
177 </body>
178 </opml>"#;
179
180 let (imported, skipped, errors) = import_opml(&db, opml).await.unwrap();
181 assert_eq!(imported, 2);
182 assert_eq!(skipped, 0);
183 assert!(errors.is_empty());
184
185 let feeds = db.feeds().list_all().await.unwrap();
186 assert_eq!(feeds.len(), 2);
187 let names: Vec<&str> = feeds.iter().map(|f| f.name.as_str()).collect();
188 assert!(names.contains(&"Rust Blog"));
189 assert!(names.contains(&"Lobsters"));
190 }
191
192 #[tokio::test]
193 async fn opml_import_invalid_xml() {
194 let db = common::test_db().await;
195 let result = import_opml(&db, "this is not XML").await;
196 assert!(result.is_err());
197 assert!(result.unwrap_err().contains("Invalid OPML"));
198 }
199
200 #[tokio::test]
201 async fn opml_import_skips_duplicates() {
202 let db = common::test_db().await;
203 // Pre-create a feed with this URL
204 common::create_rss_feed(&db, "Existing", "https://blog.rust-lang.org/feed.xml").await;
205
206 let opml = r#"<?xml version="1.0" encoding="UTF-8"?>
207 <opml version="2.0">
208 <body>
209 <outline text="Rust Blog" type="rss" xmlUrl="https://blog.rust-lang.org/feed.xml" />
210 <outline text="New Feed" type="rss" xmlUrl="https://new.example.com/rss" />
211 </body>
212 </opml>"#;
213
214 let (imported, skipped, errors) = import_opml(&db, opml).await.unwrap();
215 assert_eq!(imported, 1);
216 assert_eq!(skipped, 1);
217 assert!(errors.is_empty());
218
219 let feeds = db.feeds().list_all().await.unwrap();
220 assert_eq!(feeds.len(), 2); // original + 1 new
221 }
222
223 #[tokio::test]
224 async fn opml_import_uses_text_or_title_or_url_as_name() {
225 let db = common::test_db().await;
226 let opml = r#"<?xml version="1.0" encoding="UTF-8"?>
227 <opml version="2.0">
228 <body>
229 <outline text="Text Name" xmlUrl="https://a.com/rss" />
230 <outline title="Title Name" xmlUrl="https://b.com/rss" />
231 <outline xmlUrl="https://c.com/rss" />
232 </body>
233 </opml>"#;
234
235 let (imported, _skipped, _errors) = import_opml(&db, opml).await.unwrap();
236 assert_eq!(imported, 3);
237
238 let feeds = db.feeds().list_all().await.unwrap();
239 let names: Vec<&str> = feeds.iter().map(|f| f.name.as_str()).collect();
240 assert!(names.contains(&"Text Name"));
241 assert!(names.contains(&"Title Name"));
242 assert!(names.contains(&"https://c.com/rss"));
243 }
244
245 #[tokio::test]
246 async fn opml_import_ignores_outlines_without_xml_url() {
247 let db = common::test_db().await;
248 let opml = r#"<?xml version="1.0" encoding="UTF-8"?>
249 <opml version="2.0">
250 <body>
251 <outline text="Category folder" />
252 <outline text="Valid" xmlUrl="https://valid.com/rss" />
253 <outline text="Empty URL" xmlUrl="" />
254 </body>
255 </opml>"#;
256
257 let (imported, _skipped, _errors) = import_opml(&db, opml).await.unwrap();
258 assert_eq!(imported, 1);
259 }
260
261 #[tokio::test]
262 async fn opml_import_handles_nested_outlines() {
263 let db = common::test_db().await;
264 let opml = r#"<?xml version="1.0" encoding="UTF-8"?>
265 <opml version="2.0">
266 <body>
267 <outline text="Tech">
268 <outline text="Rust Blog" xmlUrl="https://blog.rust-lang.org/feed.xml" />
269 <outline text="Go Blog" xmlUrl="https://go.dev/blog/feed.atom" />
270 </outline>
271 <outline text="News">
272 <outline text="BBC" xmlUrl="https://bbc.com/rss" />
273 </outline>
274 </body>
275 </opml>"#;
276
277 let (imported, _skipped, _errors) = import_opml(&db, opml).await.unwrap();
278 assert_eq!(imported, 3);
279 }
280
281 #[tokio::test]
282 async fn opml_roundtrip_export_then_import() {
283 let db = common::test_db().await;
284 common::create_rss_feed(&db, "Feed A", "https://a.example.com/feed.xml").await;
285 common::create_rss_feed(&db, "Feed B", "https://b.example.com/rss").await;
286
287 // Export
288 let feeds = db.feeds().list_all().await.unwrap();
289 let opml = export_opml_from_feeds(&feeds);
290
291 // Import into a fresh DB
292 let db2 = common::test_db().await;
293 let (imported, skipped, errors) = import_opml(&db2, &opml).await.unwrap();
294 assert_eq!(imported, 2);
295 assert_eq!(skipped, 0);
296 assert!(errors.is_empty());
297
298 // Verify feed names and URLs match
299 let reimported = db2.feeds().list_all().await.unwrap();
300 assert_eq!(reimported.len(), 2);
301 let urls: Vec<String> = reimported
302 .iter()
303 .filter_map(|f| {
304 f.config_json()
305 .get("feed_url")
306 .and_then(|v| v.as_str())
307 .map(|s| s.to_string())
308 })
309 .collect();
310 assert!(urls.contains(&"https://a.example.com/feed.xml".to_string()));
311 assert!(urls.contains(&"https://b.example.com/rss".to_string()));
312 }
313