Homebrew RSS reader server
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

api/fever: add before parameter and group mark support

* implement `before` timestamp parameter for mark feed/group read operations
* add `mark_group_read` db function to mark all items in a group as read
* filter items by COALESCE(published_at, created_at) < before timestamp
* add integration and unit tests for new mark operations

The Fever API spec supports a `before` parameter that allows clients to
mark only items older than a given timestamp as read, which is useful
for partial sync operations.

+277 -3
+7 -1
src/api/fever.rs
··· 98 98 body_params.get("id"), 99 99 ) 100 100 && let Ok(id) = id_str.parse::<i64>() { 101 + let before: Option<i64> = body_params.get("before").and_then(|v| v.parse().ok()); 102 + debug!(?mark, ?action, id, ?before, "fever mark operation"); 103 + 101 104 match (mark.as_str(), action.as_str()) { 102 105 ("item", "read") => { 103 106 let _ = db::mark_item_read(pool, id).await; ··· 106 109 let _ = db::mark_item_unread(pool, id).await; 107 110 } 108 111 ("feed", "read") => { 109 - let _ = db::mark_feed_read(pool, id).await; 112 + let _ = db::mark_feed_read(pool, id, before).await; 113 + } 114 + ("group", "read") => { 115 + let _ = db::mark_group_read(pool, id, before).await; 110 116 } 111 117 _ => {} 112 118 }
+33 -2
src/db.rs
··· 321 321 Ok(()) 322 322 } 323 323 324 - pub async fn mark_feed_read(pool: &SqlitePool, feed_id: i64) -> Result<()> { 325 - sqlx::query("UPDATE items SET is_read = 1 WHERE feed_id = ?") 324 + pub async fn mark_feed_read(pool: &SqlitePool, feed_id: i64, before: Option<i64>) -> Result<()> { 325 + if let Some(ts) = before { 326 + sqlx::query( 327 + "UPDATE items SET is_read = 1 WHERE feed_id = ? AND COALESCE(published_at, created_at) < ?", 328 + ) 326 329 .bind(feed_id) 330 + .bind(ts) 327 331 .execute(pool) 328 332 .await?; 333 + } else { 334 + sqlx::query("UPDATE items SET is_read = 1 WHERE feed_id = ?") 335 + .bind(feed_id) 336 + .execute(pool) 337 + .await?; 338 + } 339 + Ok(()) 340 + } 341 + 342 + pub async fn mark_group_read(pool: &SqlitePool, group_id: i64, before: Option<i64>) -> Result<()> { 343 + if let Some(ts) = before { 344 + sqlx::query( 345 + "UPDATE items SET is_read = 1 WHERE feed_id IN (SELECT id FROM feeds WHERE group_id = ?) \ 346 + AND COALESCE(published_at, created_at) < ?", 347 + ) 348 + .bind(group_id) 349 + .bind(ts) 350 + .execute(pool) 351 + .await?; 352 + } else { 353 + sqlx::query( 354 + "UPDATE items SET is_read = 1 WHERE feed_id IN (SELECT id FROM feeds WHERE group_id = ?)", 355 + ) 356 + .bind(group_id) 357 + .execute(pool) 358 + .await?; 359 + } 329 360 Ok(()) 330 361 }
+199
tests/fever_integration.rs
··· 1 + //! Integration tests for Fever API mark operations 2 + 3 + use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; 4 + use sqlx::SqlitePool; 5 + use std::str::FromStr; 6 + 7 + async fn setup_test_db() -> SqlitePool { 8 + let options = SqliteConnectOptions::from_str("sqlite::memory:") 9 + .unwrap() 10 + .create_if_missing(true); 11 + 12 + let pool = SqlitePoolOptions::new() 13 + .max_connections(1) 14 + .connect_with(options) 15 + .await 16 + .unwrap(); 17 + 18 + // Create schema 19 + sqlx::query( 20 + r#" 21 + CREATE TABLE groups ( 22 + id INTEGER PRIMARY KEY AUTOINCREMENT, 23 + name TEXT NOT NULL UNIQUE 24 + ); 25 + CREATE TABLE feeds ( 26 + id INTEGER PRIMARY KEY AUTOINCREMENT, 27 + url TEXT NOT NULL UNIQUE, 28 + title TEXT NOT NULL DEFAULT '', 29 + group_id INTEGER NOT NULL REFERENCES groups(id) 30 + ); 31 + CREATE TABLE items ( 32 + id INTEGER PRIMARY KEY AUTOINCREMENT, 33 + feed_id INTEGER NOT NULL REFERENCES feeds(id), 34 + guid TEXT NOT NULL UNIQUE, 35 + title TEXT NOT NULL DEFAULT '', 36 + author TEXT NOT NULL DEFAULT '', 37 + url TEXT NOT NULL DEFAULT '', 38 + content TEXT NOT NULL DEFAULT '', 39 + published_at INTEGER, 40 + created_at INTEGER NOT NULL DEFAULT (unixepoch()), 41 + is_read INTEGER NOT NULL DEFAULT 0 42 + ); 43 + "#, 44 + ) 45 + .execute(&pool) 46 + .await 47 + .unwrap(); 48 + 49 + pool 50 + } 51 + 52 + async fn insert_test_data(pool: &SqlitePool) { 53 + // Create group 54 + sqlx::query("INSERT INTO groups (id, name) VALUES (1, 'Tech')") 55 + .execute(pool) 56 + .await 57 + .unwrap(); 58 + 59 + // Create feed 60 + sqlx::query("INSERT INTO feeds (id, url, title, group_id) VALUES (1, 'http://example.com/feed', 'Example', 1)") 61 + .execute(pool) 62 + .await 63 + .unwrap(); 64 + 65 + // Create items with different timestamps 66 + // Item 1: published at timestamp 1000 (old) 67 + // Item 2: published at timestamp 2000 (middle) 68 + // Item 3: published at timestamp 3000 (new) 69 + sqlx::query("INSERT INTO items (id, feed_id, guid, title, published_at, is_read) VALUES (1, 1, 'guid1', 'Old Article', 1000, 0)") 70 + .execute(pool) 71 + .await 72 + .unwrap(); 73 + sqlx::query("INSERT INTO items (id, feed_id, guid, title, published_at, is_read) VALUES (2, 1, 'guid2', 'Middle Article', 2000, 0)") 74 + .execute(pool) 75 + .await 76 + .unwrap(); 77 + sqlx::query("INSERT INTO items (id, feed_id, guid, title, published_at, is_read) VALUES (3, 1, 'guid3', 'New Article', 3000, 0)") 78 + .execute(pool) 79 + .await 80 + .unwrap(); 81 + } 82 + 83 + async fn get_unread_count(pool: &SqlitePool) -> i64 { 84 + let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM items WHERE is_read = 0") 85 + .fetch_one(pool) 86 + .await 87 + .unwrap(); 88 + count 89 + } 90 + 91 + async fn get_unread_ids(pool: &SqlitePool) -> Vec<i64> { 92 + let rows: Vec<(i64,)> = sqlx::query_as("SELECT id FROM items WHERE is_read = 0 ORDER BY id") 93 + .fetch_all(pool) 94 + .await 95 + .unwrap(); 96 + rows.into_iter().map(|(id,)| id).collect() 97 + } 98 + 99 + // Inline the fixed mark_feed_read function for testing 100 + async fn mark_feed_read(pool: &SqlitePool, feed_id: i64, before: Option<i64>) { 101 + if let Some(ts) = before { 102 + sqlx::query( 103 + "UPDATE items SET is_read = 1 WHERE feed_id = ? AND COALESCE(published_at, created_at) < ?", 104 + ) 105 + .bind(feed_id) 106 + .bind(ts) 107 + .execute(pool) 108 + .await 109 + .unwrap(); 110 + } else { 111 + sqlx::query("UPDATE items SET is_read = 1 WHERE feed_id = ?") 112 + .bind(feed_id) 113 + .execute(pool) 114 + .await 115 + .unwrap(); 116 + } 117 + } 118 + 119 + async fn mark_group_read(pool: &SqlitePool, group_id: i64, before: Option<i64>) { 120 + if let Some(ts) = before { 121 + sqlx::query( 122 + "UPDATE items SET is_read = 1 WHERE feed_id IN (SELECT id FROM feeds WHERE group_id = ?) \ 123 + AND COALESCE(published_at, created_at) < ?", 124 + ) 125 + .bind(group_id) 126 + .bind(ts) 127 + .execute(pool) 128 + .await 129 + .unwrap(); 130 + } else { 131 + sqlx::query( 132 + "UPDATE items SET is_read = 1 WHERE feed_id IN (SELECT id FROM feeds WHERE group_id = ?)", 133 + ) 134 + .bind(group_id) 135 + .execute(pool) 136 + .await 137 + .unwrap(); 138 + } 139 + } 140 + 141 + #[tokio::test] 142 + async fn test_mark_feed_read_with_before_marks_only_older_items() { 143 + let pool = setup_test_db().await; 144 + insert_test_data(&pool).await; 145 + 146 + // All 3 items should be unread 147 + assert_eq!(get_unread_count(&pool).await, 3); 148 + 149 + // Mark feed as read with before=2500 150 + // Should only mark items with published_at < 2500 (items 1 and 2) 151 + mark_feed_read(&pool, 1, Some(2500)).await; 152 + 153 + // Only item 3 (published_at=3000) should remain unread 154 + assert_eq!(get_unread_count(&pool).await, 1); 155 + assert_eq!(get_unread_ids(&pool).await, vec![3]); 156 + } 157 + 158 + #[tokio::test] 159 + async fn test_mark_feed_read_without_before_marks_all_items() { 160 + let pool = setup_test_db().await; 161 + insert_test_data(&pool).await; 162 + 163 + assert_eq!(get_unread_count(&pool).await, 3); 164 + 165 + // Mark feed as read without before parameter 166 + mark_feed_read(&pool, 1, None).await; 167 + 168 + // All items should be marked as read 169 + assert_eq!(get_unread_count(&pool).await, 0); 170 + } 171 + 172 + #[tokio::test] 173 + async fn test_mark_group_read_with_before() { 174 + let pool = setup_test_db().await; 175 + insert_test_data(&pool).await; 176 + 177 + assert_eq!(get_unread_count(&pool).await, 3); 178 + 179 + // Mark group as read with before=2500 180 + mark_group_read(&pool, 1, Some(2500)).await; 181 + 182 + // Only item 3 should remain unread 183 + assert_eq!(get_unread_count(&pool).await, 1); 184 + assert_eq!(get_unread_ids(&pool).await, vec![3]); 185 + } 186 + 187 + #[tokio::test] 188 + async fn test_mark_feed_read_before_boundary() { 189 + let pool = setup_test_db().await; 190 + insert_test_data(&pool).await; 191 + 192 + // Mark with before=2000 (exactly at item 2's timestamp) 193 + // Should only mark item 1 (published_at < 2000) 194 + mark_feed_read(&pool, 1, Some(2000)).await; 195 + 196 + // Items 2 and 3 should remain unread 197 + assert_eq!(get_unread_count(&pool).await, 2); 198 + assert_eq!(get_unread_ids(&pool).await, vec![2, 3]); 199 + }
+38
tests/fever_test.rs
··· 1 + //! Unit tests for Fever API parameter parsing 2 + 3 + use std::collections::HashMap; 4 + 5 + // Test that mark operations properly parse the `before` parameter 6 + #[test] 7 + fn test_mark_feed_before_param_parsing() { 8 + // Simulate body params from a Fever client 9 + let body = "api_key=test123&mark=feed&as=read&id=1&before=1738430000"; 10 + let body_params: HashMap<String, String> = serde_urlencoded::from_str(body).unwrap(); 11 + 12 + assert_eq!(body_params.get("mark"), Some(&"feed".to_string())); 13 + assert_eq!(body_params.get("as"), Some(&"read".to_string())); 14 + assert_eq!(body_params.get("id"), Some(&"1".to_string())); 15 + assert_eq!(body_params.get("before"), Some(&"1738430000".to_string())); 16 + } 17 + 18 + #[test] 19 + fn test_mark_group_before_param_parsing() { 20 + let body = "api_key=test123&mark=group&as=read&id=2&before=1738430000"; 21 + let body_params: HashMap<String, String> = serde_urlencoded::from_str(body).unwrap(); 22 + 23 + assert_eq!(body_params.get("mark"), Some(&"group".to_string())); 24 + assert_eq!(body_params.get("as"), Some(&"read".to_string())); 25 + assert_eq!(body_params.get("id"), Some(&"2".to_string())); 26 + assert_eq!(body_params.get("before"), Some(&"1738430000".to_string())); 27 + } 28 + 29 + #[test] 30 + fn test_mark_item_parsing() { 31 + let body = "api_key=test123&mark=item&as=read&id=42"; 32 + let body_params: HashMap<String, String> = serde_urlencoded::from_str(body).unwrap(); 33 + 34 + assert_eq!(body_params.get("mark"), Some(&"item".to_string())); 35 + assert_eq!(body_params.get("as"), Some(&"read".to_string())); 36 + assert_eq!(body_params.get("id"), Some(&"42".to_string())); 37 + assert!(body_params.get("before").is_none()); 38 + }