Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 46 additions & 26 deletions crates/apotheke/src/repo/audiobook.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,28 +118,30 @@ pub async fn update_audiobook(
quality_score: Option<i64>,
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> {
Expand Down Expand Up @@ -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)]
Expand Down Expand Up @@ -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 { .. }));
}
}
24 changes: 20 additions & 4 deletions crates/apotheke/src/repo/book.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ?",
)
Expand All @@ -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(
Expand Down Expand Up @@ -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 { .. }));
}
}
39 changes: 28 additions & 11 deletions crates/apotheke/src/repo/comic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,24 +110,25 @@ pub async fn update_comic(
quality_score: Option<i64>,
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(
Expand Down Expand Up @@ -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 { .. }));
}
}
79 changes: 79 additions & 0 deletions crates/apotheke/src/repo/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
) -> 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<i64, DbError> {
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(&[]), "");
}
}
39 changes: 28 additions & 11 deletions crates/apotheke/src/repo/movie.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,24 +103,25 @@ pub async fn update_movie(
quality_score: Option<i64>,
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)]
Expand Down Expand Up @@ -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 { .. }));
}
}
Loading