Skip to main content

max / balanced_breakfast

17.3 KB · 479 lines History Blame Raw
1 //! Item CRUD, upsert deduplication, detail conversion, combined filters,
2 //! empty state, and mark-read/star persisted state integration tests.
3
4 mod common;
5
6 use bb_db::{BusserId, CreateFeedItem, ItemId};
7 use bb_feed::{FeedFilter, FeedGenerator};
8 use chrono::Utc;
9
10 // ── Item CRUD Round-trip ─────────────────────────────────────────────
11
12 #[tokio::test]
13 async fn item_get_by_uuid() {
14 let db = common::test_db().await;
15 let feed = common::create_rss_feed(&db, "Feed", "https://example.com/rss").await;
16 let item = common::insert_item(&db, &feed, "rss:1", "Test Article", 1).await;
17
18 let fetched = db.items().get(item.id).await.unwrap().unwrap();
19 assert_eq!(fetched.id, item.id);
20 assert_eq!(fetched.title, Some("Test Article".to_string()));
21 assert_eq!(fetched.url, Some("https://example.com/rss:1".to_string()));
22 assert!(!fetched.is_read);
23 assert!(!fetched.is_starred);
24 }
25
26 #[tokio::test]
27 async fn item_get_invalid_uuid_returns_none() {
28 let db = common::test_db().await;
29 let result = db.items().get(ItemId::new()).await.unwrap();
30 assert!(result.is_none());
31 }
32
33 #[tokio::test]
34 async fn item_mark_read_unread_roundtrip() {
35 let db = common::test_db().await;
36 let feed = common::create_rss_feed(&db, "Feed", "https://example.com/rss").await;
37 let item = common::insert_item(&db, &feed, "rss:read1", "Article", 1).await;
38
39 // Initially unread
40 assert!(!item.is_read);
41
42 // Mark read
43 db.items().mark_read(item.id, true).await.unwrap();
44 let fetched = db.items().get(item.id).await.unwrap().unwrap();
45 assert!(fetched.is_read);
46
47 // Mark unread again
48 db.items().mark_read(item.id, false).await.unwrap();
49 let fetched = db.items().get(item.id).await.unwrap().unwrap();
50 assert!(!fetched.is_read);
51 }
52
53 #[tokio::test]
54 async fn item_star_unstar_roundtrip() {
55 let db = common::test_db().await;
56 let feed = common::create_rss_feed(&db, "Feed", "https://example.com/rss").await;
57 let item = common::insert_item(&db, &feed, "rss:star1", "Article", 1).await;
58
59 // Initially unstarred
60 assert!(!item.is_starred);
61
62 // Star
63 db.items().mark_starred(item.id, true).await.unwrap();
64 let fetched = db.items().get(item.id).await.unwrap().unwrap();
65 assert!(fetched.is_starred);
66
67 // Unstar
68 db.items().mark_starred(item.id, false).await.unwrap();
69 let fetched = db.items().get(item.id).await.unwrap().unwrap();
70 assert!(!fetched.is_starred);
71 }
72
73 #[tokio::test]
74 async fn item_read_and_star_are_independent() {
75 let db = common::test_db().await;
76 let feed = common::create_rss_feed(&db, "Feed", "https://example.com/rss").await;
77 let item = common::insert_item(&db, &feed, "rss:indep", "Article", 1).await;
78
79 // Star it but leave unread
80 db.items().mark_starred(item.id, true).await.unwrap();
81 let fetched = db.items().get(item.id).await.unwrap().unwrap();
82 assert!(fetched.is_starred);
83 assert!(!fetched.is_read);
84
85 // Now mark read -- starred should remain
86 db.items().mark_read(item.id, true).await.unwrap();
87 let fetched = db.items().get(item.id).await.unwrap().unwrap();
88 assert!(fetched.is_starred);
89 assert!(fetched.is_read);
90 }
91
92 #[tokio::test]
93 async fn item_unread_count() {
94 let db = common::test_db().await;
95 let feed = common::create_rss_feed(&db, "Feed", "https://example.com/rss").await;
96 let item1 = common::insert_item(&db, &feed, "rss:c1", "A", 1).await;
97 common::insert_item(&db, &feed, "rss:c2", "B", 2).await;
98 common::insert_item(&db, &feed, "rss:c3", "C", 3).await;
99
100 // All 3 unread initially
101 assert_eq!(db.items().count_unread().await.unwrap(), 3);
102
103 // Mark one as read
104 db.items().mark_read(item1.id, true).await.unwrap();
105 assert_eq!(db.items().count_unread().await.unwrap(), 2);
106 }
107
108 // ── Item List via FeedGenerator ──────────────────────────────────────
109
110 #[tokio::test]
111 async fn list_items_pagination() {
112 let db = common::test_db().await;
113 let feed = common::create_rss_feed(&db, "Feed", "https://example.com/rss").await;
114 for i in 0..7 {
115 common::insert_item(&db, &feed, &format!("rss:p{i}"), &format!("Item {i}"), i).await;
116 }
117
118 let fg = FeedGenerator::new(db).with_page_size(3);
119 let page0 = fg.get_items(0).await.unwrap();
120 assert_eq!(page0.items.len(), 3);
121 assert!(page0.has_more);
122
123 let page1 = fg.get_items(1).await.unwrap();
124 assert_eq!(page1.items.len(), 3);
125 assert!(page1.has_more);
126
127 let page2 = fg.get_items(2).await.unwrap();
128 assert_eq!(page2.items.len(), 1);
129 assert!(!page2.has_more);
130 }
131
132 #[tokio::test]
133 async fn list_items_with_source_filter() {
134 let db = common::test_db().await;
135 let feed_rss = common::create_rss_feed(&db, "RSS", "https://example.com/rss").await;
136 let feed_hn = common::create_other_feed(&db, "hn", "HN").await;
137 common::insert_item(&db, &feed_rss, "rss:1", "RSS Article", 1).await;
138 common::insert_item(&db, &feed_hn, "hn:1", "HN Article", 1).await;
139 common::insert_item(&db, &feed_hn, "hn:2", "HN Article 2", 2).await;
140
141 let fg = FeedGenerator::new(db).with_filter(FeedFilter::new().source("hn"));
142 let result = fg.get_items(0).await.unwrap();
143 assert_eq!(result.items.len(), 2);
144 for item in &result.items {
145 assert_eq!(item.id.source, "hn");
146 }
147 }
148
149 #[tokio::test]
150 async fn list_items_unread_only() {
151 let db = common::test_db().await;
152 let feed = common::create_rss_feed(&db, "Feed", "https://example.com/rss").await;
153 let item1 = common::insert_item(&db, &feed, "rss:u1", "A", 1).await;
154 common::insert_item(&db, &feed, "rss:u2", "B", 2).await;
155
156 db.items().mark_read(item1.id, true).await.unwrap();
157
158 let fg = FeedGenerator::new(db).with_filter(FeedFilter::new().unread_only());
159 let result = fg.get_items(0).await.unwrap();
160 assert_eq!(result.items.len(), 1);
161 assert!(!result.items[0].is_read);
162 }
163
164 #[tokio::test]
165 async fn list_items_starred_only() {
166 let db = common::test_db().await;
167 let feed = common::create_rss_feed(&db, "Feed", "https://example.com/rss").await;
168 common::insert_item(&db, &feed, "rss:s1", "A", 1).await;
169 let item2 = common::insert_item(&db, &feed, "rss:s2", "B", 2).await;
170
171 db.items().mark_starred(item2.id, true).await.unwrap();
172
173 let fg = FeedGenerator::new(db).with_filter(FeedFilter::new().starred_only());
174 let result = fg.get_items(0).await.unwrap();
175 assert_eq!(result.items.len(), 1);
176 assert!(result.items[0].is_starred);
177 }
178
179 // ── Item Detail Conversion ───────────────────────────────────────────
180
181 #[tokio::test]
182 async fn item_detail_fields_round_trip() {
183 let db = common::test_db().await;
184 let feed = common::create_rss_feed(&db, "Feed", "https://example.com/rss").await;
185
186 db.items()
187 .upsert(CreateFeedItem {
188 external_id: "rss:detail".to_string(),
189 feed_id: feed.id,
190 busser_id: BusserId::new("rss"),
191 bite_author: "John Doe".to_string(),
192 bite_text: "A fascinating article".to_string(),
193 bite_secondary: Some("42 points".to_string()),
194 bite_indicator: Some("hot".to_string()),
195 title: Some("Deep Dive into Rust".to_string()),
196 body: Some("<p>Full article body here</p>".to_string()),
197 url: Some("https://example.com/article".to_string()),
198 media: vec!["https://example.com/image.jpg".to_string()],
199 published_at: Utc::now(),
200 source_name: "Test Source".to_string(),
201 score: Some(42),
202 tags: vec!["rust".to_string(), "programming".to_string()],
203 actions: vec![],
204 })
205 .await
206 .unwrap();
207
208 let item = db
209 .items()
210 .get_by_external_id("rss:detail")
211 .await
212 .unwrap()
213 .unwrap();
214
215 // Verify all fields are stored and retrieved correctly
216 assert_eq!(item.bite_author, "John Doe");
217 assert_eq!(item.bite_text, "A fascinating article");
218 assert_eq!(item.bite_secondary, Some("42 points".to_string()));
219 assert_eq!(item.bite_indicator, Some("hot".to_string()));
220 assert_eq!(item.title, Some("Deep Dive into Rust".to_string()));
221 assert_eq!(
222 item.body,
223 Some("<p>Full article body here</p>".to_string())
224 );
225 assert_eq!(item.url, Some("https://example.com/article".to_string()));
226 assert_eq!(item.media_vec(), vec!["https://example.com/image.jpg"]);
227 assert_eq!(item.score, Some(42));
228 let mut tags = item.tags_vec();
229 tags.sort();
230 assert_eq!(tags, vec!["programming", "rust"]);
231
232 // Verify to_feed_item conversion preserves fields
233 let feed_item = item.to_feed_item();
234 assert_eq!(feed_item.bite.author, "John Doe");
235 assert_eq!(feed_item.bite.text, "A fascinating article");
236 assert_eq!(
237 feed_item.content.title,
238 Some("Deep Dive into Rust".to_string())
239 );
240 assert_eq!(
241 feed_item.content.body,
242 Some("<p>Full article body here</p>".to_string())
243 );
244 assert_eq!(feed_item.meta.score, Some(42));
245 }
246
247 // ── Item Upsert Deduplication ────────────────────────────────────────
248
249 #[tokio::test]
250 async fn upsert_same_external_id_updates_not_duplicates() {
251 let db = common::test_db().await;
252 let feed = common::create_rss_feed(&db, "Feed", "https://example.com/rss").await;
253
254 // Insert an item
255 common::insert_item(&db, &feed, "rss:dedup", "Original Title", 1).await;
256
257 // Upsert with same external_id but different title
258 db.items()
259 .upsert(CreateFeedItem {
260 external_id: "rss:dedup".to_string(),
261 feed_id: feed.id,
262 busser_id: BusserId::new("rss"),
263 bite_author: "author".to_string(),
264 bite_text: "Updated text".to_string(),
265 bite_secondary: None,
266 bite_indicator: None,
267 title: Some("Updated Title".to_string()),
268 body: None,
269 url: None,
270 media: vec![],
271 published_at: Utc::now(),
272 source_name: "test".to_string(),
273 score: None,
274 tags: vec![],
275 actions: vec![],
276 })
277 .await
278 .unwrap();
279
280 // Should still have only 1 item, not 2
281 assert_eq!(db.items().count_all().await.unwrap(), 1);
282
283 let item = db
284 .items()
285 .get_by_external_id("rss:dedup")
286 .await
287 .unwrap()
288 .unwrap();
289 assert_eq!(item.title, Some("Updated Title".to_string()));
290 }
291
292 // ── Combined Filters ─────────────────────────────────────────────────
293
294 #[tokio::test]
295 async fn list_items_combined_source_unread_starred() {
296 let db = common::test_db().await;
297 let feed_rss = common::create_rss_feed(&db, "RSS", "https://example.com/rss").await;
298 let feed_hn = common::create_other_feed(&db, "hn", "HN").await;
299
300 let rss1 = common::insert_item(&db, &feed_rss, "rss:1", "RSS Item 1", 1).await;
301 let rss2 = common::insert_item(&db, &feed_rss, "rss:2", "RSS Item 2", 2).await;
302 common::insert_item(&db, &feed_hn, "hn:1", "HN Item", 1).await;
303
304 // rss:1 is read, rss:2 is unread+starred
305 db.items().mark_read(rss1.id, true).await.unwrap();
306 db.items().mark_starred(rss2.id, true).await.unwrap();
307
308 // Filter: source=rss — gets both RSS items (source branch doesn't also
309 // filter by unread; only list_search combines source+unread)
310 let fg = FeedGenerator::new(db.clone())
311 .with_filter(FeedFilter::new().source("rss"));
312 let result = fg.get_items(0).await.unwrap();
313 assert_eq!(result.items.len(), 2);
314 for item in &result.items {
315 assert_eq!(item.id.source, "rss");
316 }
317
318 // Filter: starred only — should get rss:2 only
319 let fg2 = FeedGenerator::new(db.clone())
320 .with_filter(FeedFilter::new().starred_only());
321 let result2 = fg2.get_items(0).await.unwrap();
322 assert_eq!(result2.items.len(), 1);
323 assert!(result2.items[0].is_starred);
324
325 // Search + source + unread combines all filters in SQL
326 let fg3 = FeedGenerator::new(db)
327 .with_filter(FeedFilter::new().search("RSS").source("rss").unread_only());
328 let result3 = fg3.get_items(0).await.unwrap();
329 assert_eq!(result3.items.len(), 1);
330 assert!(!result3.items[0].is_read);
331 }
332
333 // ── Empty State Handling ─────────────────────────────────────────────
334
335 #[tokio::test]
336 async fn list_items_empty_database() {
337 let db = common::test_db().await;
338 let fg = FeedGenerator::new(db);
339 let result = fg.get_items(0).await.unwrap();
340 assert!(result.items.is_empty());
341 assert!(!result.has_more);
342 }
343
344 #[tokio::test]
345 async fn list_sources_empty_database() {
346 let db = common::test_db().await;
347 let fg = FeedGenerator::new(db);
348 let sources = fg.get_sources().await.unwrap();
349 assert!(sources.is_empty());
350 }
351
352 #[tokio::test]
353 async fn unread_count_empty_database() {
354 let db = common::test_db().await;
355 assert_eq!(db.items().count_unread().await.unwrap(), 0);
356
357 let fg = FeedGenerator::new(db);
358 assert_eq!(fg.unread_count().await.unwrap(), 0);
359 }
360
361 #[tokio::test]
362 async fn count_empty_database() {
363 let db = common::test_db().await;
364 let fg = FeedGenerator::new(db);
365 assert_eq!(fg.count().await.unwrap(), 0);
366 }
367
368 // ── Mark Read/Star: Verify Persisted State in Filtered Queries ───────
369
370 #[tokio::test]
371 async fn mark_read_excludes_from_unread_filter() {
372 let db = common::test_db().await;
373 let feed = common::create_rss_feed(&db, "Feed", "https://example.com/rss").await;
374 let item1 = common::insert_item(&db, &feed, "rss:ex1", "Article 1", 1).await;
375 common::insert_item(&db, &feed, "rss:ex2", "Article 2", 2).await;
376 common::insert_item(&db, &feed, "rss:ex3", "Article 3", 3).await;
377
378 let fg = FeedGenerator::new(db.clone())
379 .with_filter(FeedFilter::new().unread_only());
380
381 // All 3 visible
382 assert_eq!(fg.get_items(0).await.unwrap().items.len(), 3);
383
384 // Mark one read
385 db.items().mark_read(item1.id, true).await.unwrap();
386
387 // Only 2 visible now
388 assert_eq!(fg.get_items(0).await.unwrap().items.len(), 2);
389 // None of the returned items should be the read one
390 let ids: Vec<String> = fg
391 .get_items(0)
392 .await
393 .unwrap()
394 .items
395 .iter()
396 .map(|i| i.id.item_id.clone())
397 .collect();
398 assert!(!ids.contains(&"rss:ex1".to_string()));
399 }
400
401 #[tokio::test]
402 async fn star_item_appears_in_starred_filter() {
403 let db = common::test_db().await;
404 let feed = common::create_rss_feed(&db, "Feed", "https://example.com/rss").await;
405 common::insert_item(&db, &feed, "rss:st1", "Article 1", 1).await;
406 let item2 = common::insert_item(&db, &feed, "rss:st2", "Article 2", 2).await;
407 common::insert_item(&db, &feed, "rss:st3", "Article 3", 3).await;
408
409 let fg_starred = FeedGenerator::new(db.clone())
410 .with_filter(FeedFilter::new().starred_only());
411
412 // Nothing starred yet
413 assert!(fg_starred.get_items(0).await.unwrap().items.is_empty());
414
415 // Star one item
416 db.items().mark_starred(item2.id, true).await.unwrap();
417
418 let result = fg_starred.get_items(0).await.unwrap();
419 assert_eq!(result.items.len(), 1);
420 assert_eq!(result.items[0].id.item_id, "rss:st2");
421 assert!(result.items[0].is_starred);
422
423 // Unstar it
424 db.items().mark_starred(item2.id, false).await.unwrap();
425 assert!(fg_starred.get_items(0).await.unwrap().items.is_empty());
426 }
427
428 // ── Upsert Preserves User State ──────────────────────────────────────
429
430 #[tokio::test]
431 async fn upsert_preserves_read_and_starred_state() {
432 let db = common::test_db().await;
433 let feed = common::create_rss_feed(&db, "Feed", "https://example.com/rss").await;
434
435 // Insert initial item
436 let item = common::insert_item(&db, &feed, "rss:preserve", "Original", 1).await;
437
438 // Mark it read and starred
439 db.items().mark_read(item.id, true).await.unwrap();
440 db.items().mark_starred(item.id, true).await.unwrap();
441
442 // Re-upsert with updated content (simulating a feed re-fetch)
443 db.items()
444 .upsert(CreateFeedItem {
445 external_id: "rss:preserve".to_string(),
446 feed_id: feed.id,
447 busser_id: BusserId::new("rss"),
448 bite_author: "new_author".to_string(),
449 bite_text: "Updated text".to_string(),
450 bite_secondary: None,
451 bite_indicator: None,
452 title: Some("Updated Title".to_string()),
453 body: Some("Updated body".to_string()),
454 url: None,
455 media: vec![],
456 published_at: Utc::now(),
457 source_name: "test".to_string(),
458 score: Some(99),
459 tags: vec![],
460 actions: vec![],
461 })
462 .await
463 .unwrap();
464
465 // Verify user state preserved, content updated
466 let updated = db
467 .items()
468 .get_by_external_id("rss:preserve")
469 .await
470 .unwrap()
471 .unwrap();
472
473 assert!(updated.is_read, "read state should be preserved across upsert");
474 assert!(updated.is_starred, "starred state should be preserved across upsert");
475 assert_eq!(updated.title, Some("Updated Title".to_string()));
476 assert_eq!(updated.bite_author, "new_author");
477 assert_eq!(updated.score, Some(99));
478 }
479