diff --git a/crates/apotheke/src/repo/audiobook.rs b/crates/apotheke/src/repo/audiobook.rs index 64214101..451bed85 100644 --- a/crates/apotheke/src/repo/audiobook.rs +++ b/crates/apotheke/src/repo/audiobook.rs @@ -118,28 +118,30 @@ pub async fn update_audiobook( quality_score: Option, file_path: Option<&str>, ) -> Result<(), DbError> { - sqlx::query("UPDATE audiobooks SET title = ?, quality_score = ?, file_path = ? WHERE id = ?") - .bind(title) - .bind(quality_score) - .bind(file_path) - .bind(id) - .execute(pool) - .await - .context(QuerySnafu { - table: "audiobooks", - })?; - Ok(()) + let result = sqlx::query( + "UPDATE audiobooks SET title = ?, quality_score = ?, file_path = ? WHERE id = ?", + ) + .bind(title) + .bind(quality_score) + .bind(file_path) + .bind(id) + .execute(pool) + .await + .context(QuerySnafu { + table: "audiobooks", + })?; + super::require_affected(result, "audiobooks", super::id_hex(id)) } pub async fn delete_audiobook(pool: &SqlitePool, id: &[u8]) -> Result<(), DbError> { - sqlx::query("DELETE FROM audiobooks WHERE id = ?") + let result = sqlx::query("DELETE FROM audiobooks WHERE id = ?") .bind(id) .execute(pool) .await .context(QuerySnafu { table: "audiobooks", })?; - Ok(()) + super::require_affected(result, "audiobooks", super::id_hex(id)) } pub async fn insert_chapter(pool: &SqlitePool, chapter: &AudiobookChapter) -> Result<(), DbError> { @@ -200,28 +202,30 @@ pub async fn update_chapter( start_ms: i64, end_ms: i64, ) -> Result<(), DbError> { - sqlx::query("UPDATE audiobook_chapters SET title = ?, start_ms = ?, end_ms = ? WHERE id = ?") - .bind(title) - .bind(start_ms) - .bind(end_ms) - .bind(id) - .execute(pool) - .await - .context(QuerySnafu { - table: "audiobook_chapters", - })?; - Ok(()) + let result = sqlx::query( + "UPDATE audiobook_chapters SET title = ?, start_ms = ?, end_ms = ? WHERE id = ?", + ) + .bind(title) + .bind(start_ms) + .bind(end_ms) + .bind(id) + .execute(pool) + .await + .context(QuerySnafu { + table: "audiobook_chapters", + })?; + super::require_affected(result, "audiobook_chapters", super::id_hex(id)) } pub async fn delete_chapter(pool: &SqlitePool, id: &[u8]) -> Result<(), DbError> { - sqlx::query("DELETE FROM audiobook_chapters WHERE id = ?") + let result = sqlx::query("DELETE FROM audiobook_chapters WHERE id = ?") .bind(id) .execute(pool) .await .context(QuerySnafu { table: "audiobook_chapters", })?; - Ok(()) + super::require_affected(result, "audiobook_chapters", super::id_hex(id)) } #[cfg(test)] @@ -324,4 +328,20 @@ mod tests { let results = list_audiobooks(&pool, 10, 0).await.unwrap(); assert!(results.is_empty()); } + + #[tokio::test] + async fn update_audiobook_nonexistent_returns_not_found() { + let pool = setup().await; + let err = update_audiobook(&pool, &make_id(), "Ghost", None, None) + .await + .unwrap_err(); + assert!(matches!(err, DbError::NotFound { .. })); + } + + #[tokio::test] + async fn delete_chapter_nonexistent_returns_not_found() { + let pool = setup().await; + let err = delete_chapter(&pool, &make_id()).await.unwrap_err(); + assert!(matches!(err, DbError::NotFound { .. })); + } } diff --git a/crates/apotheke/src/repo/book.rs b/crates/apotheke/src/repo/book.rs index f8296df9..d97e8c5c 100644 --- a/crates/apotheke/src/repo/book.rs +++ b/crates/apotheke/src/repo/book.rs @@ -125,7 +125,7 @@ pub async fn update_book( file_path: Option<&str>, file_format: Option<&str>, ) -> Result<(), DbError> { - sqlx::query( + let result = sqlx::query( "UPDATE books SET title = ?, quality_score = ?, file_path = ?, file_format = ? WHERE id = ?", ) @@ -137,16 +137,16 @@ pub async fn update_book( .execute(pool) .await .context(QuerySnafu { table: "books" })?; - Ok(()) + super::require_affected(result, "books", super::id_hex(id)) } pub async fn delete_book(pool: &SqlitePool, id: &[u8]) -> Result<(), DbError> { - sqlx::query("DELETE FROM books WHERE id = ?") + let result = sqlx::query("DELETE FROM books WHERE id = ?") .bind(id) .execute(pool) .await .context(QuerySnafu { table: "books" })?; - Ok(()) + super::require_affected(result, "books", super::id_hex(id)) } pub async fn search_books( @@ -227,4 +227,20 @@ mod tests { let results = list_books(&pool, 10, 0).await.unwrap(); assert!(results.is_empty()); } + + #[tokio::test] + async fn update_book_nonexistent_returns_not_found() { + let pool = setup().await; + let err = update_book(&pool, &make_id(), "Ghost", None, None, None) + .await + .unwrap_err(); + assert!(matches!(err, DbError::NotFound { .. })); + } + + #[tokio::test] + async fn delete_book_nonexistent_returns_not_found() { + let pool = setup().await; + let err = delete_book(&pool, &make_id()).await.unwrap_err(); + assert!(matches!(err, DbError::NotFound { .. })); + } } diff --git a/crates/apotheke/src/repo/comic.rs b/crates/apotheke/src/repo/comic.rs index c9b13d27..0d6992d1 100644 --- a/crates/apotheke/src/repo/comic.rs +++ b/crates/apotheke/src/repo/comic.rs @@ -110,24 +110,25 @@ pub async fn update_comic( quality_score: Option, file_path: Option<&str>, ) -> Result<(), DbError> { - sqlx::query("UPDATE comics SET title = ?, quality_score = ?, file_path = ? WHERE id = ?") - .bind(title) - .bind(quality_score) - .bind(file_path) - .bind(id) - .execute(pool) - .await - .context(QuerySnafu { table: "comics" })?; - Ok(()) + let result = + sqlx::query("UPDATE comics SET title = ?, quality_score = ?, file_path = ? WHERE id = ?") + .bind(title) + .bind(quality_score) + .bind(file_path) + .bind(id) + .execute(pool) + .await + .context(QuerySnafu { table: "comics" })?; + super::require_affected(result, "comics", super::id_hex(id)) } pub async fn delete_comic(pool: &SqlitePool, id: &[u8]) -> Result<(), DbError> { - sqlx::query("DELETE FROM comics WHERE id = ?") + let result = sqlx::query("DELETE FROM comics WHERE id = ?") .bind(id) .execute(pool) .await .context(QuerySnafu { table: "comics" })?; - Ok(()) + super::require_affected(result, "comics", super::id_hex(id)) } pub async fn search_comics( @@ -215,4 +216,20 @@ mod tests { let results = list_comics(&pool, 10, 0).await.unwrap(); assert!(results.is_empty()); } + + #[tokio::test] + async fn update_comic_nonexistent_returns_not_found() { + let pool = setup().await; + let err = update_comic(&pool, &make_id(), Some("Ghost"), None, None) + .await + .unwrap_err(); + assert!(matches!(err, DbError::NotFound { .. })); + } + + #[tokio::test] + async fn delete_comic_nonexistent_returns_not_found() { + let pool = setup().await; + let err = delete_comic(&pool, &make_id()).await.unwrap_err(); + assert!(matches!(err, DbError::NotFound { .. })); + } } diff --git a/crates/apotheke/src/repo/mod.rs b/crates/apotheke/src/repo/mod.rs index 3f87c56c..35b3490f 100644 --- a/crates/apotheke/src/repo/mod.rs +++ b/crates/apotheke/src/repo/mod.rs @@ -14,3 +14,82 @@ pub mod tv; pub mod user; pub mod want; pub mod zone; + +use snafu::ResultExt; +use sqlx::SqlitePool; +use sqlx::sqlite::SqliteQueryResult; + +use crate::error::{DbError, NotFoundSnafu, QuerySnafu}; + +// WHY: DbError::NotFound carries a displayable id — raw UUID bytes are not. +pub(crate) fn id_hex(id: &[u8]) -> String { + id.iter() + .fold(String::with_capacity(id.len() * 2), |mut s, b| { + use std::fmt::Write; + // WHY: fmt::Write on String is infallible; ok() avoids unused-result warning + write!(s, "{b:02x}").ok(); + s + }) +} + +// WHY: a single-row UPDATE/DELETE that matches zero rows hit a missing target; +// returning Ok would report success for a write that changed nothing. +pub(crate) fn require_affected( + result: SqliteQueryResult, + table: &'static str, + id: impl Into, +) -> Result<(), DbError> { + if result.rows_affected() == 0 { + return NotFoundSnafu { table, id }.fail(); + } + Ok(()) +} + +/// Total row count of `table`, for pagination metadata. +/// +/// WARNING: `table` is interpolated into the SQL text — pass compile-time +/// table-name literals only, never caller-supplied input. +pub async fn count_rows(pool: &SqlitePool, table: &'static str) -> Result { + let sql = format!("SELECT COUNT(*) FROM {table}"); + sqlx::query_scalar(&sql) + .fetch_one(pool) + .await + .context(QuerySnafu { table }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::migrate::MIGRATOR; + + async fn setup() -> SqlitePool { + let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); + MIGRATOR.run(&pool).await.unwrap(); + pool + } + + #[tokio::test] + async fn count_rows_tracks_inserts() { + let pool = setup().await; + assert_eq!(count_rows(&pool, "zones").await.unwrap(), 0); + for i in 0..3 { + zone::create_zone(&pool, &format!("z{i}"), &format!("Zone {i}")) + .await + .unwrap(); + } + assert_eq!(count_rows(&pool, "zones").await.unwrap(), 3); + } + + #[tokio::test] + async fn count_rows_unknown_table_errors() { + let pool = setup().await; + let err = count_rows(&pool, "no_such_table").await.unwrap_err(); + assert!(matches!(err, DbError::Query { .. })); + } + + #[tokio::test] + async fn id_hex_formats_bytes() { + assert_eq!(id_hex(&[0x00, 0xff, 0x0a]), "00ff0a"); + assert_eq!(id_hex(&[]), ""); + } +} diff --git a/crates/apotheke/src/repo/movie.rs b/crates/apotheke/src/repo/movie.rs index 2e67ce09..ae832337 100644 --- a/crates/apotheke/src/repo/movie.rs +++ b/crates/apotheke/src/repo/movie.rs @@ -103,24 +103,25 @@ pub async fn update_movie( quality_score: Option, file_path: Option<&str>, ) -> Result<(), DbError> { - sqlx::query("UPDATE movies SET title = ?, quality_score = ?, file_path = ? WHERE id = ?") - .bind(title) - .bind(quality_score) - .bind(file_path) - .bind(id) - .execute(pool) - .await - .context(QuerySnafu { table: "movies" })?; - Ok(()) + let result = + sqlx::query("UPDATE movies SET title = ?, quality_score = ?, file_path = ? WHERE id = ?") + .bind(title) + .bind(quality_score) + .bind(file_path) + .bind(id) + .execute(pool) + .await + .context(QuerySnafu { table: "movies" })?; + super::require_affected(result, "movies", super::id_hex(id)) } pub async fn delete_movie(pool: &SqlitePool, id: &[u8]) -> Result<(), DbError> { - sqlx::query("DELETE FROM movies WHERE id = ?") + let result = sqlx::query("DELETE FROM movies WHERE id = ?") .bind(id) .execute(pool) .await .context(QuerySnafu { table: "movies" })?; - Ok(()) + super::require_affected(result, "movies", super::id_hex(id)) } #[cfg(test)] @@ -177,4 +178,20 @@ mod tests { let results = list_movies(&pool, 10, 0).await.unwrap(); assert!(results.is_empty()); } + + #[tokio::test] + async fn update_movie_nonexistent_returns_not_found() { + let pool = setup().await; + let err = update_movie(&pool, &make_id(), "Ghost", None, None) + .await + .unwrap_err(); + assert!(matches!(err, DbError::NotFound { .. })); + } + + #[tokio::test] + async fn delete_movie_nonexistent_returns_not_found() { + let pool = setup().await; + let err = delete_movie(&pool, &make_id()).await.unwrap_err(); + assert!(matches!(err, DbError::NotFound { .. })); + } } diff --git a/crates/apotheke/src/repo/music.rs b/crates/apotheke/src/repo/music.rs index f7996111..0b37aeae 100644 --- a/crates/apotheke/src/repo/music.rs +++ b/crates/apotheke/src/repo/music.rs @@ -138,7 +138,7 @@ pub async fn update_release_group( rg_type: &str, quality_profile_id: Option, ) -> Result<(), DbError> { - sqlx::query( + let result = sqlx::query( "UPDATE music_release_groups SET title = ?, rg_type = ?, quality_profile_id = ? WHERE id = ?", ) @@ -151,18 +151,18 @@ pub async fn update_release_group( .context(QuerySnafu { table: "music_release_groups", })?; - Ok(()) + super::require_affected(result, "music_release_groups", super::id_hex(id)) } pub async fn delete_release_group(pool: &SqlitePool, id: &[u8]) -> Result<(), DbError> { - sqlx::query("DELETE FROM music_release_groups WHERE id = ?") + let result = sqlx::query("DELETE FROM music_release_groups WHERE id = ?") .bind(id) .execute(pool) .await .context(QuerySnafu { table: "music_release_groups", })?; - Ok(()) + super::require_affected(result, "music_release_groups", super::id_hex(id)) } // --- releases --- @@ -228,28 +228,30 @@ pub async fn update_release( release_date: Option<&str>, label: Option<&str>, ) -> Result<(), DbError> { - sqlx::query("UPDATE music_releases SET title = ?, release_date = ?, label = ? WHERE id = ?") - .bind(title) - .bind(release_date) - .bind(label) - .bind(id) - .execute(pool) - .await - .context(QuerySnafu { - table: "music_releases", - })?; - Ok(()) + let result = sqlx::query( + "UPDATE music_releases SET title = ?, release_date = ?, label = ? WHERE id = ?", + ) + .bind(title) + .bind(release_date) + .bind(label) + .bind(id) + .execute(pool) + .await + .context(QuerySnafu { + table: "music_releases", + })?; + super::require_affected(result, "music_releases", super::id_hex(id)) } pub async fn delete_release(pool: &SqlitePool, id: &[u8]) -> Result<(), DbError> { - sqlx::query("DELETE FROM music_releases WHERE id = ?") + let result = sqlx::query("DELETE FROM music_releases WHERE id = ?") .bind(id) .execute(pool) .await .context(QuerySnafu { table: "music_releases", })?; - Ok(()) + super::require_affected(result, "music_releases", super::id_hex(id)) } // --- media --- @@ -306,7 +308,7 @@ pub async fn update_medium( format: &str, title: Option<&str>, ) -> Result<(), DbError> { - sqlx::query("UPDATE music_media SET format = ?, title = ? WHERE id = ?") + let result = sqlx::query("UPDATE music_media SET format = ?, title = ? WHERE id = ?") .bind(format) .bind(title) .bind(id) @@ -315,18 +317,18 @@ pub async fn update_medium( .context(QuerySnafu { table: "music_media", })?; - Ok(()) + super::require_affected(result, "music_media", super::id_hex(id)) } pub async fn delete_medium(pool: &SqlitePool, id: &[u8]) -> Result<(), DbError> { - sqlx::query("DELETE FROM music_media WHERE id = ?") + let result = sqlx::query("DELETE FROM music_media WHERE id = ?") .bind(id) .execute(pool) .await .context(QuerySnafu { table: "music_media", })?; - Ok(()) + super::require_affected(result, "music_media", super::id_hex(id)) } // --- tracks --- @@ -408,28 +410,30 @@ pub async fn update_track( quality_score: Option, file_path: Option<&str>, ) -> Result<(), DbError> { - sqlx::query("UPDATE music_tracks SET title = ?, quality_score = ?, file_path = ? WHERE id = ?") - .bind(title) - .bind(quality_score) - .bind(file_path) - .bind(id) - .execute(pool) - .await - .context(QuerySnafu { - table: "music_tracks", - })?; - Ok(()) + let result = sqlx::query( + "UPDATE music_tracks SET title = ?, quality_score = ?, file_path = ? WHERE id = ?", + ) + .bind(title) + .bind(quality_score) + .bind(file_path) + .bind(id) + .execute(pool) + .await + .context(QuerySnafu { + table: "music_tracks", + })?; + super::require_affected(result, "music_tracks", super::id_hex(id)) } pub async fn delete_track(pool: &SqlitePool, id: &[u8]) -> Result<(), DbError> { - sqlx::query("DELETE FROM music_tracks WHERE id = ?") + let result = sqlx::query("DELETE FROM music_tracks WHERE id = ?") .bind(id) .execute(pool) .await .context(QuerySnafu { table: "music_tracks", })?; - Ok(()) + super::require_affected(result, "music_tracks", super::id_hex(id)) } // --- track artists --- @@ -1022,6 +1026,22 @@ mod tests { assert!(beyond.is_empty()); } + #[tokio::test] + async fn update_release_group_nonexistent_returns_not_found() { + let pool = setup().await; + let err = update_release_group(&pool, &make_id(), "Ghost", "album", None) + .await + .unwrap_err(); + assert!(matches!(err, DbError::NotFound { .. })); + } + + #[tokio::test] + async fn delete_track_nonexistent_returns_not_found() { + let pool = setup().await; + let err = delete_track(&pool, &make_id()).await.unwrap_err(); + assert!(matches!(err, DbError::NotFound { .. })); + } + #[tokio::test] async fn count_tracks_returns_full_match_count() { let pool = setup().await; diff --git a/crates/apotheke/src/repo/news.rs b/crates/apotheke/src/repo/news.rs index 652d8373..282c0ded 100644 --- a/crates/apotheke/src/repo/news.rs +++ b/crates/apotheke/src/repo/news.rs @@ -106,7 +106,7 @@ pub async fn update_feed( last_fetched_at: Option<&str>, updated_at: &str, ) -> Result<(), DbError> { - sqlx::query( + let result = sqlx::query( "UPDATE news_feeds SET title = ?, is_active = ?, last_fetched_at = ?, updated_at = ? WHERE id = ?", ) @@ -120,18 +120,18 @@ pub async fn update_feed( .context(QuerySnafu { table: "news_feeds", })?; - Ok(()) + super::require_affected(result, "news_feeds", super::id_hex(id)) } pub async fn delete_feed(pool: &SqlitePool, id: &[u8]) -> Result<(), DbError> { - sqlx::query("DELETE FROM news_feeds WHERE id = ?") + let result = sqlx::query("DELETE FROM news_feeds WHERE id = ?") .bind(id) .execute(pool) .await .context(QuerySnafu { table: "news_feeds", })?; - Ok(()) + super::require_affected(result, "news_feeds", super::id_hex(id)) } pub async fn insert_article(pool: &SqlitePool, article: &NewsArticle) -> Result<(), DbError> { @@ -204,7 +204,7 @@ pub async fn update_article( is_read: i64, is_starred: i64, ) -> Result<(), DbError> { - sqlx::query("UPDATE news_articles SET is_read = ?, is_starred = ? WHERE id = ?") + let result = sqlx::query("UPDATE news_articles SET is_read = ?, is_starred = ? WHERE id = ?") .bind(is_read) .bind(is_starred) .bind(id) @@ -213,18 +213,18 @@ pub async fn update_article( .context(QuerySnafu { table: "news_articles", })?; - Ok(()) + super::require_affected(result, "news_articles", super::id_hex(id)) } pub async fn delete_article(pool: &SqlitePool, id: &[u8]) -> Result<(), DbError> { - sqlx::query("DELETE FROM news_articles WHERE id = ?") + let result = sqlx::query("DELETE FROM news_articles WHERE id = ?") .bind(id) .execute(pool) .await .context(QuerySnafu { table: "news_articles", })?; - Ok(()) + super::require_affected(result, "news_articles", super::id_hex(id)) } pub async fn article_guid_exists( @@ -408,6 +408,22 @@ mod tests { assert!(results.is_empty()); } + #[tokio::test] + async fn update_feed_nonexistent_returns_not_found() { + let pool = setup().await; + let err = update_feed(&pool, &make_id(), "Ghost", 1, None, &now()) + .await + .unwrap_err(); + assert!(matches!(err, DbError::NotFound { .. })); + } + + #[tokio::test] + async fn delete_article_nonexistent_returns_not_found() { + let pool = setup().await; + let err = delete_article(&pool, &make_id()).await.unwrap_err(); + assert!(matches!(err, DbError::NotFound { .. })); + } + async fn seed_feed(pool: &SqlitePool, url: &str) -> Vec { let id = make_id(); let feed = NewsFeed { diff --git a/crates/apotheke/src/repo/podcast.rs b/crates/apotheke/src/repo/podcast.rs index fa45f21f..d5639757 100644 --- a/crates/apotheke/src/repo/podcast.rs +++ b/crates/apotheke/src/repo/podcast.rs @@ -113,7 +113,7 @@ pub async fn update_subscription( auto_download: i64, last_checked_at: Option<&str>, ) -> Result<(), DbError> { - sqlx::query( + let result = sqlx::query( "UPDATE podcast_subscriptions SET title = ?, auto_download = ?, last_checked_at = ? WHERE id = ?", ) @@ -126,18 +126,18 @@ pub async fn update_subscription( .context(QuerySnafu { table: "podcast_subscriptions", })?; - Ok(()) + super::require_affected(result, "podcast_subscriptions", super::id_hex(id)) } pub async fn delete_subscription(pool: &SqlitePool, id: &[u8]) -> Result<(), DbError> { - sqlx::query("DELETE FROM podcast_subscriptions WHERE id = ?") + let result = sqlx::query("DELETE FROM podcast_subscriptions WHERE id = ?") .bind(id) .execute(pool) .await .context(QuerySnafu { table: "podcast_subscriptions", })?; - Ok(()) + super::require_affected(result, "podcast_subscriptions", super::id_hex(id)) } pub async fn insert_episode(pool: &SqlitePool, ep: &PodcastEpisode) -> Result<(), DbError> { @@ -218,7 +218,7 @@ pub async fn update_episode( file_path: Option<&str>, quality_score: Option, ) -> Result<(), DbError> { - sqlx::query( + let result = sqlx::query( "UPDATE podcast_episodes SET listened = ?, file_path = ?, quality_score = ? WHERE id = ?", ) @@ -231,18 +231,18 @@ pub async fn update_episode( .context(QuerySnafu { table: "podcast_episodes", })?; - Ok(()) + super::require_affected(result, "podcast_episodes", super::id_hex(id)) } pub async fn delete_episode(pool: &SqlitePool, id: &[u8]) -> Result<(), DbError> { - sqlx::query("DELETE FROM podcast_episodes WHERE id = ?") + let result = sqlx::query("DELETE FROM podcast_episodes WHERE id = ?") .bind(id) .execute(pool) .await .context(QuerySnafu { table: "podcast_episodes", })?; - Ok(()) + super::require_affected(result, "podcast_episodes", super::id_hex(id)) } pub async fn episode_guid_exists( @@ -411,6 +411,22 @@ mod tests { assert!(results.is_empty()); } + #[tokio::test] + async fn update_subscription_nonexistent_returns_not_found() { + let pool = setup().await; + let err = update_subscription(&pool, &make_id(), Some("Ghost"), 1, None) + .await + .unwrap_err(); + assert!(matches!(err, DbError::NotFound { .. })); + } + + #[tokio::test] + async fn delete_episode_nonexistent_returns_not_found() { + let pool = setup().await; + let err = delete_episode(&pool, &make_id()).await.unwrap_err(); + assert!(matches!(err, DbError::NotFound { .. })); + } + async fn seed_subscription(pool: &SqlitePool, url: &str) -> Vec { let id = make_id(); let sub = PodcastSubscription { diff --git a/crates/apotheke/src/repo/quality.rs b/crates/apotheke/src/repo/quality.rs index f06300a4..7b09ce3b 100644 --- a/crates/apotheke/src/repo/quality.rs +++ b/crates/apotheke/src/repo/quality.rs @@ -103,7 +103,7 @@ pub async fn update_profile( upgrade_until_score: i64, upgrades_allowed: i64, ) -> Result<(), DbError> { - sqlx::query( + let result = sqlx::query( "UPDATE quality_profiles SET min_quality_score = ?, upgrade_until_score = ?, upgrades_allowed = ? WHERE id = ?", @@ -117,18 +117,18 @@ pub async fn update_profile( .context(QuerySnafu { table: "quality_profiles", })?; - Ok(()) + super::require_affected(result, "quality_profiles", id.to_string()) } pub async fn delete_profile(pool: &SqlitePool, id: i64) -> Result<(), DbError> { - sqlx::query("DELETE FROM quality_profiles WHERE id = ?") + let result = sqlx::query("DELETE FROM quality_profiles WHERE id = ?") .bind(id) .execute(pool) .await .context(QuerySnafu { table: "quality_profiles", })?; - Ok(()) + super::require_affected(result, "quality_profiles", id.to_string()) } /// Look up the score for a given format in the appropriate rank table for the media type. @@ -299,6 +299,42 @@ mod tests { } } + #[tokio::test] + async fn update_profile_nonexistent_returns_not_found() { + let pool = setup().await; + let err = update_profile(&pool, 999_999, 50, 100, 1) + .await + .unwrap_err(); + assert!(matches!(err, DbError::NotFound { .. })); + } + + #[tokio::test] + async fn delete_profile_nonexistent_returns_not_found() { + let pool = setup().await; + let err = delete_profile(&pool, 999_999).await.unwrap_err(); + assert!(matches!(err, DbError::NotFound { .. })); + } + + #[tokio::test] + async fn update_profile_existing_succeeds() { + let pool = setup().await; + let profile = QualityProfile { + id: 0, + name: "Adjustable".to_string(), + media_type: "music".to_string(), + min_quality_score: 10, + upgrade_until_score: 20, + min_custom_format_score: 0, + upgrade_until_format_score: 0, + upgrades_allowed: 0, + }; + let id = insert_profile(&pool, &profile).await.unwrap(); + update_profile(&pool, id, 50, 100, 1).await.unwrap(); + let fetched = get_profile(&pool, id).await.unwrap().unwrap(); + assert_eq!(fetched.min_quality_score, 50); + assert_eq!(fetched.upgrades_allowed, 1); + } + #[test] fn rank_table_for_unknown_returns_none() { for media_type in ["news", "", "Music", "music_quality_ranks; DROP TABLE users"] { diff --git a/crates/apotheke/src/repo/tv.rs b/crates/apotheke/src/repo/tv.rs index 9581f7a8..45de833a 100644 --- a/crates/apotheke/src/repo/tv.rs +++ b/crates/apotheke/src/repo/tv.rs @@ -115,24 +115,26 @@ pub async fn update_series( status: &str, quality_profile_id: Option, ) -> Result<(), DbError> { - sqlx::query("UPDATE tv_series SET title = ?, status = ?, quality_profile_id = ? WHERE id = ?") - .bind(title) - .bind(status) - .bind(quality_profile_id) - .bind(id) - .execute(pool) - .await - .context(QuerySnafu { table: "tv_series" })?; - Ok(()) + let result = sqlx::query( + "UPDATE tv_series SET title = ?, status = ?, quality_profile_id = ? WHERE id = ?", + ) + .bind(title) + .bind(status) + .bind(quality_profile_id) + .bind(id) + .execute(pool) + .await + .context(QuerySnafu { table: "tv_series" })?; + super::require_affected(result, "tv_series", super::id_hex(id)) } pub async fn delete_series(pool: &SqlitePool, id: &[u8]) -> Result<(), DbError> { - sqlx::query("DELETE FROM tv_series WHERE id = ?") + let result = sqlx::query("DELETE FROM tv_series WHERE id = ?") .bind(id) .execute(pool) .await .context(QuerySnafu { table: "tv_series" })?; - Ok(()) + super::require_affected(result, "tv_series", super::id_hex(id)) } // --- seasons --- @@ -190,7 +192,7 @@ pub async fn update_season( title: Option<&str>, episode_count: Option, ) -> Result<(), DbError> { - sqlx::query("UPDATE tv_seasons SET title = ?, episode_count = ? WHERE id = ?") + let result = sqlx::query("UPDATE tv_seasons SET title = ?, episode_count = ? WHERE id = ?") .bind(title) .bind(episode_count) .bind(id) @@ -199,18 +201,18 @@ pub async fn update_season( .context(QuerySnafu { table: "tv_seasons", })?; - Ok(()) + super::require_affected(result, "tv_seasons", super::id_hex(id)) } pub async fn delete_season(pool: &SqlitePool, id: &[u8]) -> Result<(), DbError> { - sqlx::query("DELETE FROM tv_seasons WHERE id = ?") + let result = sqlx::query("DELETE FROM tv_seasons WHERE id = ?") .bind(id) .execute(pool) .await .context(QuerySnafu { table: "tv_seasons", })?; - Ok(()) + super::require_affected(result, "tv_seasons", super::id_hex(id)) } // --- episodes --- @@ -285,28 +287,30 @@ pub async fn update_episode( quality_score: Option, file_path: Option<&str>, ) -> Result<(), DbError> { - sqlx::query("UPDATE tv_episodes SET title = ?, quality_score = ?, file_path = ? WHERE id = ?") - .bind(title) - .bind(quality_score) - .bind(file_path) - .bind(id) - .execute(pool) - .await - .context(QuerySnafu { - table: "tv_episodes", - })?; - Ok(()) + let result = sqlx::query( + "UPDATE tv_episodes SET title = ?, quality_score = ?, file_path = ? WHERE id = ?", + ) + .bind(title) + .bind(quality_score) + .bind(file_path) + .bind(id) + .execute(pool) + .await + .context(QuerySnafu { + table: "tv_episodes", + })?; + super::require_affected(result, "tv_episodes", super::id_hex(id)) } pub async fn delete_episode(pool: &SqlitePool, id: &[u8]) -> Result<(), DbError> { - sqlx::query("DELETE FROM tv_episodes WHERE id = ?") + let result = sqlx::query("DELETE FROM tv_episodes WHERE id = ?") .bind(id) .execute(pool) .await .context(QuerySnafu { table: "tv_episodes", })?; - Ok(()) + super::require_affected(result, "tv_episodes", super::id_hex(id)) } #[cfg(test)] @@ -400,4 +404,20 @@ mod tests { let results = list_series(&pool, 10, 0).await.unwrap(); assert!(results.is_empty()); } + + #[tokio::test] + async fn update_series_nonexistent_returns_not_found() { + let pool = setup().await; + let err = update_series(&pool, &make_id(), "Ghost", "ended", None) + .await + .unwrap_err(); + assert!(matches!(err, DbError::NotFound { .. })); + } + + #[tokio::test] + async fn delete_season_nonexistent_returns_not_found() { + let pool = setup().await; + let err = delete_season(&pool, &make_id()).await.unwrap_err(); + assert!(matches!(err, DbError::NotFound { .. })); + } } diff --git a/crates/apotheke/src/repo/user.rs b/crates/apotheke/src/repo/user.rs index a9aee7a9..8a542fc6 100644 --- a/crates/apotheke/src/repo/user.rs +++ b/crates/apotheke/src/repo/user.rs @@ -1,18 +1,7 @@ use snafu::ResultExt; use sqlx::SqlitePool; -use crate::error::{DbError, NotFoundSnafu, QuerySnafu}; - -// WHY: DbError::NotFound carries a displayable id — raw UUID bytes are not. -fn id_hex(id: &[u8]) -> String { - id.iter() - .fold(String::with_capacity(id.len() * 2), |mut s, b| { - use std::fmt::Write; - // WHY: fmt::Write on String is infallible; ok() avoids unused-result warning - write!(s, "{b:02x}").ok(); - s - }) -} +use crate::error::{DbError, QuerySnafu}; #[derive(Clone, sqlx::FromRow)] pub struct User { @@ -196,14 +185,7 @@ pub async fn update_user( .await .context(QuerySnafu { table: "users" })?; - if result.rows_affected() == 0 { - return Err(NotFoundSnafu { - table: "users", - id: id_hex(id), - } - .build()); - } - Ok(()) + super::require_affected(result, "users", super::id_hex(id)) } pub async fn deactivate_user(pool: &SqlitePool, id: &[u8]) -> Result<(), DbError> { @@ -213,14 +195,7 @@ pub async fn deactivate_user(pool: &SqlitePool, id: &[u8]) -> Result<(), DbError .await .context(QuerySnafu { table: "users" })?; - if result.rows_affected() == 0 { - return Err(NotFoundSnafu { - table: "users", - id: id_hex(id), - } - .build()); - } - Ok(()) + super::require_affected(result, "users", super::id_hex(id)) } pub async fn record_login( @@ -235,14 +210,7 @@ pub async fn record_login( .await .context(QuerySnafu { table: "users" })?; - if result.rows_affected() == 0 { - return Err(NotFoundSnafu { - table: "users", - id: id_hex(id), - } - .build()); - } - Ok(()) + super::require_affected(result, "users", super::id_hex(id)) } pub async fn delete_user(pool: &SqlitePool, id: &[u8]) -> Result<(), DbError> { @@ -252,14 +220,7 @@ pub async fn delete_user(pool: &SqlitePool, id: &[u8]) -> Result<(), DbError> { .await .context(QuerySnafu { table: "users" })?; - if result.rows_affected() == 0 { - return Err(NotFoundSnafu { - table: "users", - id: id_hex(id), - } - .build()); - } - Ok(()) + super::require_affected(result, "users", super::id_hex(id)) } // --- refresh tokens --- @@ -319,14 +280,7 @@ where table: "refresh_tokens", })?; - if result.rows_affected() == 0 { - return Err(NotFoundSnafu { - table: "refresh_tokens", - id: id_hex(id), - } - .build()); - } - Ok(()) + super::require_affected(result, "refresh_tokens", super::id_hex(id)) } pub async fn delete_refresh_tokens_for_user( @@ -410,14 +364,7 @@ pub async fn revoke_api_key_for_user( .await .context(QuerySnafu { table: "api_keys" })?; - if result.rows_affected() == 0 { - return Err(NotFoundSnafu { - table: "api_keys", - id: id_hex(id), - } - .build()); - } - Ok(()) + super::require_affected(result, "api_keys", super::id_hex(id)) } pub async fn revoke_api_keys_for_user(pool: &SqlitePool, user_id: &[u8]) -> Result<(), DbError> { @@ -441,14 +388,7 @@ pub async fn update_api_key_last_used( .await .context(QuerySnafu { table: "api_keys" })?; - if result.rows_affected() == 0 { - return Err(NotFoundSnafu { - table: "api_keys", - id: id_hex(id), - } - .build()); - } - Ok(()) + super::require_affected(result, "api_keys", super::id_hex(id)) } pub async fn delete_api_key(pool: &SqlitePool, id: &[u8]) -> Result<(), DbError> { @@ -458,14 +398,7 @@ pub async fn delete_api_key(pool: &SqlitePool, id: &[u8]) -> Result<(), DbError> .await .context(QuerySnafu { table: "api_keys" })?; - if result.rows_affected() == 0 { - return Err(NotFoundSnafu { - table: "api_keys", - id: id_hex(id), - } - .build()); - } - Ok(()) + super::require_affected(result, "api_keys", super::id_hex(id)) } #[cfg(test)] diff --git a/crates/apotheke/src/repo/zone.rs b/crates/apotheke/src/repo/zone.rs index 145de24b..2caf834c 100644 --- a/crates/apotheke/src/repo/zone.rs +++ b/crates/apotheke/src/repo/zone.rs @@ -59,15 +59,7 @@ pub async fn delete_zone(pool: &SqlitePool, id: &str) -> Result<(), DbError> { .execute(pool) .await .context(QuerySnafu { table: "zones" })?; - - if result.rows_affected() == 0 { - return Err(NotFoundSnafu { - table: "zones", - id: id.to_string(), - } - .build()); - } - Ok(()) + super::require_affected(result, "zones", id) } pub async fn add_member( @@ -99,15 +91,7 @@ pub async fn remove_member( .context(QuerySnafu { table: "zone_members", })?; - - if result.rows_affected() == 0 { - return Err(NotFoundSnafu { - table: "zone_members", - id: format!("{zone_id}/{renderer_id}"), - } - .build()); - } - Ok(()) + super::require_affected(result, "zone_members", format!("{zone_id}/{renderer_id}")) } // PERF: one LEFT JOIN instead of a per-zone member query (N+1); rows are