From df6fb2207905ac38bd8fcf2b45e916bfd3be488a Mon Sep 17 00:00:00 2001 From: Simon Bruder Date: Thu, 25 Jul 2024 22:43:58 +0200 Subject: [PATCH] WIP: Add unit tests TODO: - add fixture to avoid code duplication --- .gitignore | 1 + .tarpaulin.toml | 16 ++++ flake.nix | 20 +++++ src/database/items/add.rs | 144 +++++++++++++++++++++++++++++++++++ src/database/items/delete.rs | 61 +++++++++++++++ src/database/items/edit.rs | 101 ++++++++++++++++++++++++ src/database/items/show.rs | 2 +- src/lib.rs | 2 + src/test_utils.rs | 37 +++++++++ 9 files changed, 383 insertions(+), 1 deletion(-) create mode 100644 .tarpaulin.toml create mode 100644 src/test_utils.rs diff --git a/.gitignore b/.gitignore index 2ac9df7..f885903 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,6 @@ # SPDX-License-Identifier: AGPL-3.0-or-later /target +/.tarpaulin_target result* .pre-commit-config.yaml diff --git a/.tarpaulin.toml b/.tarpaulin.toml new file mode 100644 index 0000000..9d116e5 --- /dev/null +++ b/.tarpaulin.toml @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: 2024 Simon Bruder +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +[default] +# Tarpaulin uses custom options +# that are incompatible with the default options. +# This sets a different target directory, +# so the files from tarpaulin do not interfere with the regular outputs. +target-dir = ".tarpaulin_target" +# Do not recompile everything on every run +skip-clean = true + +[report] +out = ["Html"] +output-dir = "target/tarpaulin" diff --git a/flake.nix b/flake.nix index b7a2d7f..a5946f3 100644 --- a/flake.nix +++ b/flake.nix @@ -77,6 +77,25 @@ packages = rec { li7y = naersk'.buildPackage { src = self; + + checkInputs = with pkgs; [ + postgresql + postgresqlTestHook + ]; + + doCheck = true; + + # tests need to be able to create and drop databases + postgresqlTestUserOptions = "LOGIN SUPERUSER"; + + postgresqlTestSetupPost = '' + export DATABASE_URL="postgres://''${PGUSER}/''${PGDATABASE}?port=5432&host=''${PGHOST}" + ''; + + # Otherwise SQLx tries to infer the databse schema from an empty database + # (as it can only run the migrations once the test binary is built). + # Also, this enforces that the full query cache is included in the repository. + SQLX_OFFLINE = true; }; default = li7y; @@ -99,6 +118,7 @@ rustPackageDev ] ++ (with pkgs; [ cargo-deny + cargo-tarpaulin cargo-watch clippy graphviz diff --git a/src/database/items/add.rs b/src/database/items/add.rs index ef30ad0..06d6579 100644 --- a/src/database/items/add.rs +++ b/src/database/items/add.rs @@ -65,3 +65,147 @@ impl ItemRepository { } } } + +#[cfg(test)] +mod tests { + use uuid::Uuid; + + use crate::database::{items::ItemAddForm, ItemRepository}; + use crate::test_utils::*; + + #[sqlx::test] + async fn simple_success(pool: sqlx::PgPool) -> sqlx::Result<()> { + let repo = ItemRepository::new(pool.clone()); + + let class = create_class(pool.clone()).await?; + + let added_item = repo + .add(ItemAddForm { + quantity: 1, + name: None, + parent: None, + class, + original_packaging: None, + description: "descr".to_string(), + }) + .await?; + + assert_eq!(added_item.len(), 1); + + let added_item = added_item.first(); + + assert!( + sqlx::query_scalar( + "SELECT + items.name IS NULL + AND items.parent IS NULL + AND items.class = $2 + AND items.original_packaging IS NULL + AND items.description = 'descr' + FROM items + WHERE items.id = $1" + ) + .bind(added_item) + .bind(class) + .fetch_one(&pool) + .await? + ); + + Ok(()) + } + + #[sqlx::test] + async fn complex_success(pool: sqlx::PgPool) -> sqlx::Result<()> { + let repo = ItemRepository::new(pool.clone()); + + let class = create_class(pool.clone()).await?; + + let parent = create_item(pool.clone()).await?; + let original_packaging = create_item(pool.clone()).await?; + + let added_items = repo + .add(ItemAddForm { + quantity: 7, + name: Some("Yeeeet".to_string()), + parent: Some(parent), + class, + original_packaging: Some(original_packaging), + description: "Lorem ipsum.".to_string(), + }) + .await?; + + assert_eq!(added_items.len(), 7); + + assert!( + sqlx::query_scalar( + "SELECT + items.name = 'Yeeeet' + AND items.parent = $2 + AND items.class = $3 + AND items.original_packaging = $4 + AND items.description = 'Lorem ipsum.' + FROM items + WHERE items.id = ANY ($1)" + ) + .bind(added_items) + .bind(parent) + .bind(class) + .bind(original_packaging) + .fetch_one(&pool) + .await? + ); + + Ok(()) + } + + #[sqlx::test] + async fn invalid_references(pool: sqlx::PgPool) -> sqlx::Result<()> { + let repo = ItemRepository::new(pool.clone()); + + let class = create_class(pool.clone()).await?; + + assert!(repo + .add(ItemAddForm { + quantity: 1, + name: None, + parent: Some(Uuid::new_v4()), + class, + original_packaging: None, + description: "".to_string(), + }) + .await + .is_err()); + + assert!(repo + .add(ItemAddForm { + quantity: 1, + name: None, + parent: None, + class: Uuid::new_v4(), + original_packaging: None, + description: "".to_string(), + }) + .await + .is_err()); + + assert!(repo + .add(ItemAddForm { + quantity: 1, + name: None, + parent: None, + class, + original_packaging: Some(Uuid::new_v4()), + description: "".to_string(), + }) + .await + .is_err()); + + let item_count: i64 = sqlx::query_scalar("SELECT count(id) FROM items") + .fetch_one(&pool) + .await?; + + assert_eq!(item_count, 0); + + Ok(()) + } +} diff --git a/src/database/items/delete.rs b/src/database/items/delete.rs index 758a8d4..fe15307 100644 --- a/src/database/items/delete.rs +++ b/src/database/items/delete.rs @@ -16,3 +16,64 @@ impl ItemRepository { Ok(()) } } + +#[cfg(test)] +mod tests { + use uuid::Uuid; + + use crate::database::ItemRepository; + use crate::test_utils::*; + + #[sqlx::test] + async fn success(pool: sqlx::PgPool) -> sqlx::Result<()> { + let repo = ItemRepository::new(pool.clone()); + + let item = create_item(pool.clone()).await?; + + let item_count: i64 = sqlx::query_scalar("SELECT count(id) FROM items") + .fetch_one(&pool) + .await?; + + assert_eq!(item_count, 1); + + repo.delete(item).await?; + + let item_count: i64 = sqlx::query_scalar("SELECT count(id) FROM items") + .fetch_one(&pool) + .await?; + + assert_eq!(item_count, 0); + + Ok(()) + } + + #[sqlx::test] + async fn prevented_by_constraint(pool: sqlx::PgPool) -> sqlx::Result<()> { + let repo = ItemRepository::new(pool.clone()); + + let item = create_item(pool.clone()).await?; + + let child = create_item(pool.clone()).await?; + + sqlx::query("UPDATE items SET parent = $2 WHERE id = $1") + .bind(child) + .bind(item) + .execute(&pool) + .await?; + + assert!(repo.delete(item).await.is_err()); + + assert!(repo.delete(child).await.is_ok()); + + assert!(repo.delete(item).await.is_ok()); + + Ok(()) + } + + #[sqlx::test] + async fn invalid_id(pool: sqlx::PgPool) { + let repo = ItemRepository::new(pool.clone()); + + assert!(repo.delete(Uuid::new_v4()).await.is_err()); + } +} diff --git a/src/database/items/edit.rs b/src/database/items/edit.rs index 061c3b9..dafb8d0 100644 --- a/src/database/items/edit.rs +++ b/src/database/items/edit.rs @@ -59,3 +59,104 @@ impl ItemRepository { Ok(()) } } + +#[cfg(test)] +mod tests { + use uuid::Uuid; + + use crate::database::{items::ItemEditForm, ItemRepository}; + use crate::test_utils::*; + + #[sqlx::test] + async fn prefill_form(pool: sqlx::PgPool) -> sqlx::Result<()> { + let repo = ItemRepository::new(pool.clone()); + + let item = create_item(pool.clone()).await?; + + let form = repo.edit_form(item).await?; + + let item_data: (Option, Option, Uuid, Option, String) = sqlx::query_as( + "SELECT name, parent, class, original_packaging, description + FROM items + WHERE id = $1", + ) + .bind(item) + .fetch_one(&pool) + .await?; + + assert_eq!(form.name, item_data.0); + assert_eq!(form.parent, item_data.1); + assert_eq!(form.class, item_data.2); + assert_eq!(form.original_packaging, item_data.3); + assert_eq!(form.description, item_data.4); + + Ok(()) + } + + #[sqlx::test] + async fn edit(pool: sqlx::PgPool) -> sqlx::Result<()> { + let repo = ItemRepository::new(pool.clone()); + + let item = create_item(pool.clone()).await?; + let parent = create_item(pool.clone()).await?; + let original_packaging = create_item(pool.clone()).await?; + let class = create_class(pool.clone()).await?; + + repo.edit( + item, + &ItemEditForm { + name: Some("Totally new name".to_string()), + parent: Some(parent), + class, + original_packaging: Some(original_packaging), + description: "never seen before".to_string(), + }, + ) + .await?; + + assert!( + sqlx::query_scalar( + "SELECT + items.name = 'Totally new name' + AND items.parent = $2 + AND items.class = $3 + AND items.original_packaging = $4 + AND items.description = 'never seen before' + FROM items + WHERE items.id = $1" + ) + .bind(item) + .bind(parent) + .bind(class) + .bind(original_packaging) + .fetch_one(&pool) + .await? + ); + + Ok(()) + } + + #[sqlx::test] + async fn invalid_parameters(pool: sqlx::PgPool) -> sqlx::Result<()> { + let repo = ItemRepository::new(pool.clone()); + + let item = create_item(pool.clone()).await?; + let class = create_class(pool.clone()).await?; + + assert!(repo + .edit( + item, + &ItemEditForm { + name: None, + parent: Some(item), + class, + original_packaging: None, + description: "".to_string(), + }, + ) + .await + .is_err()); + + Ok(()) + } +} diff --git a/src/database/items/show.rs b/src/database/items/show.rs index 87ccd43..f150d33 100644 --- a/src/database/items/show.rs +++ b/src/database/items/show.rs @@ -5,7 +5,7 @@ use sqlx::query; use uuid::Uuid; -use super::{ItemRepository, ItemName, ItemPreview}; +use super::{ItemName, ItemPreview, ItemRepository}; use crate::database::item_states::ItemState; pub struct ItemDetails { diff --git a/src/lib.rs b/src/lib.rs index 1869540..9ba3f8f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,5 +7,7 @@ pub mod database; pub mod frontend; pub mod label; pub mod middleware; +#[cfg(test)] +mod test_utils; pub use config::Config; diff --git a/src/test_utils.rs b/src/test_utils.rs new file mode 100644 index 0000000..e23628f --- /dev/null +++ b/src/test_utils.rs @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: 2024 Simon Bruder +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use uuid::Uuid; + +use crate::database::{ + item_classes::ItemClassAddForm, items::ItemAddForm, ItemClassRepository, ItemRepository, +}; + +pub async fn create_class(pool: sqlx::PgPool) -> sqlx::Result { + let class_repo = ItemClassRepository::new(pool); + + class_repo + .add(ItemClassAddForm { + name: "Foo".to_string(), + parent: None, + description: "".to_string(), + }) + .await +} + +pub async fn create_item(pool: sqlx::PgPool) -> sqlx::Result { + let item_repo = ItemRepository::new(pool.clone()); + + item_repo + .add(ItemAddForm { + quantity: 1, + name: Some("Baz".to_string()), + parent: None, + class: create_class(pool.clone()).await?, + original_packaging: None, + description: "descr".to_string(), + }) + .await + .map(|ids| *ids.first().unwrap()) +}