From 833ff9f0bd6b61f9483ace678da9aee8cdffd95f 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 --- .forgejo/workflows/build.yaml | 2 +- .gitignore | 1 + .tarpaulin.toml | 16 +++++ flake.nix | 71 +++++++++++++++---- src/database/items/add.rs | 129 ++++++++++++++++++++++++++++++++++ src/database/items/delete.rs | 45 ++++++++++++ src/database/items/edit.rs | 86 +++++++++++++++++++++++ src/database/items/show.rs | 2 +- src/lib.rs | 2 + src/test_utils.rs | 11 +++ tests/fixtures/default.sql | 33 +++++++++ 11 files changed, 382 insertions(+), 16 deletions(-) create mode 100644 .tarpaulin.toml create mode 100644 src/test_utils.rs create mode 100644 tests/fixtures/default.sql diff --git a/.forgejo/workflows/build.yaml b/.forgejo/workflows/build.yaml index e8a7d12..a086796 100644 --- a/.forgejo/workflows/build.yaml +++ b/.forgejo/workflows/build.yaml @@ -20,7 +20,7 @@ jobs: git config --unset "http.${GITHUB_SERVER_URL}/.extraHeader" git lfs install --local git lfs pull - - name: Build + - name: Build and test run: nix build -L .#li7y .#li7y-oci - name: Push OCI image if: github.ref == 'refs/heads/master' 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..812d8bc 100644 --- a/flake.nix +++ b/flake.nix @@ -74,31 +74,74 @@ }; }; - packages = rec { - li7y = naersk'.buildPackage { - src = self; - }; - default = li7y; + packages = + let + # naersk does not easily allow overrideAttrs + commonNaerskConfigurarion = { + src = self; - li7y-oci = pkgs.dockerTools.buildLayeredImage { - name = "li7y"; - tag = "latest"; + checkInputs = with pkgs; [ + postgresql + postgresqlTestHook + ]; - contents = [ - li7y - ]; + doCheck = true; - config = { - Cmd = [ "${li7y}/bin/li7y" ]; + # 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; + }; + in + rec { + li7y = naersk'.buildPackage commonNaerskConfigurarion; + default = li7y; + + li7y-tarpaulin = naersk'.buildPackage (commonNaerskConfigurarion // { + name = "li7y-tarpaulin"; + + checkInputs = commonNaerskConfigurarion.checkInputs ++ (with pkgs; [ + cargo-tarpaulin + ]); + + dontBuild = true; + singleStep = true; # tarpaulin uses different options anyway + + cargoTestCommands = _: [ "cargo tarpaulin" ]; + + postInstall = '' + rm -r $out + cp -r target/tarpaulin $out + ''; + }); + + li7y-oci = pkgs.dockerTools.buildLayeredImage { + name = "li7y"; + tag = "latest"; + + contents = [ + li7y + ]; + + config = { + Cmd = [ "${li7y}/bin/li7y" ]; + }; }; }; - }; devShells.default = pkgs.mkShell { buildInputs = [ 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..05f34d7 100644 --- a/src/database/items/add.rs +++ b/src/database/items/add.rs @@ -65,3 +65,132 @@ impl ItemRepository { } } } + +#[cfg(test)] +mod tests { + use uuid::{uuid, Uuid}; + + use crate::database::{items::ItemAddForm, ItemRepository}; + use crate::test_utils::*; + + #[sqlx::test(fixtures(path = "../../../tests/fixtures", scripts("default")))] + async fn simple_success(pool: sqlx::PgPool) -> sqlx::Result<()> { + let repo = ItemRepository::new(pool.clone()); + + let added_item = repo + .add(ItemAddForm { + quantity: 1, + name: None, + parent: None, + class: uuid!("e993e21c-8558-49e7-a993-2a6a61c1d55c"), + 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 = 'e993e21c-8558-49e7-a993-2a6a61c1d55c' + AND items.original_packaging IS NULL + AND items.description = 'descr' + FROM items + WHERE items.id = $1" + ) + .bind(added_item) + .fetch_one(&pool) + .await? + ); + + Ok(()) + } + + #[sqlx::test(fixtures(path = "../../../tests/fixtures", scripts("default")))] + async fn complex_success(pool: sqlx::PgPool) -> sqlx::Result<()> { + let repo = ItemRepository::new(pool.clone()); + + let added_items = repo + .add(ItemAddForm { + quantity: 7, + name: Some("Yeeeet".to_string()), + parent: Some(uuid!("4fc0f5f4-4dca-4c24-844d-1f464cb32afa")), + class: uuid!("e993e21c-8558-49e7-a993-2a6a61c1d55c"), + original_packaging: Some(uuid!("554b11ce-fecb-4020-981e-acabbf7b5913")), + description: "Lorem ipsum.".to_string(), + }) + .await?; + + assert_eq!(added_items.len(), 7); + + assert!( + sqlx::query_scalar( + "SELECT + items.name = 'Yeeeet' + AND items.parent = '4fc0f5f4-4dca-4c24-844d-1f464cb32afa' + AND items.class = 'e993e21c-8558-49e7-a993-2a6a61c1d55c' + AND items.original_packaging = '554b11ce-fecb-4020-981e-acabbf7b5913' + AND items.description = 'Lorem ipsum.' + FROM items + WHERE items.id = ANY ($1)" + ) + .bind(added_items) + .fetch_one(&pool) + .await? + ); + + Ok(()) + } + + #[sqlx::test(fixtures(path = "../../../tests/fixtures", scripts("default")))] + async fn invalid_references(pool: sqlx::PgPool) -> sqlx::Result<()> { + let repo = ItemRepository::new(pool.clone()); + + let item_count_before = item_count(&pool).await?; + + assert!(repo + .add(ItemAddForm { + quantity: 1, + name: None, + parent: Some(Uuid::new_v4()), + class: uuid!("e993e21c-8558-49e7-a993-2a6a61c1d55c"), + 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: uuid!("e993e21c-8558-49e7-a993-2a6a61c1d55c"), + original_packaging: Some(Uuid::new_v4()), + description: "".to_string(), + }) + .await + .is_err()); + + assert_eq!(item_count(&pool).await?, item_count_before); + + Ok(()) + } +} diff --git a/src/database/items/delete.rs b/src/database/items/delete.rs index 758a8d4..217b68b 100644 --- a/src/database/items/delete.rs +++ b/src/database/items/delete.rs @@ -16,3 +16,48 @@ impl ItemRepository { Ok(()) } } + +#[cfg(test)] +mod tests { + use uuid::{uuid, Uuid}; + + use crate::database::ItemRepository; + use crate::test_utils::*; + + #[sqlx::test(fixtures(path = "../../../tests/fixtures", scripts("default")))] + async fn success(pool: sqlx::PgPool) -> sqlx::Result<()> { + let item_count_before = item_count(&pool).await?; + + let repo = ItemRepository::new(pool.clone()); + repo.delete(uuid!("663f45e6-b11a-4197-8ce4-c784ac9ee617")) + .await?; + + assert_eq!(item_count(&pool).await?, item_count_before - 1); + + Ok(()) + } + + #[sqlx::test(fixtures(path = "../../../tests/fixtures", scripts("default")))] + async fn prevented_by_constraint(pool: sqlx::PgPool) -> sqlx::Result<()> { + let repo = ItemRepository::new(pool.clone()); + + assert!(repo + .delete(uuid!("3003e61f-0824-4625-9b72-eeb9f11a6a26")) + .await + .is_err()); + + repo.delete(uuid!("554b11ce-fecb-4020-981e-acabbf7b5913")) + .await?; + repo.delete(uuid!("3003e61f-0824-4625-9b72-eeb9f11a6a26")) + .await?; + + 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..b0f9a85 100644 --- a/src/database/items/edit.rs +++ b/src/database/items/edit.rs @@ -59,3 +59,89 @@ impl ItemRepository { Ok(()) } } + +#[cfg(test)] +mod tests { + use uuid::uuid; + + use crate::database::{items::ItemEditForm, ItemRepository}; + + #[sqlx::test(fixtures(path = "../../../tests/fixtures", scripts("default")))] + async fn prefill_form(pool: sqlx::PgPool) -> sqlx::Result<()> { + let repo = ItemRepository::new(pool.clone()); + + let form = repo + .edit_form(uuid!("663f45e6-b11a-4197-8ce4-c784ac9ee617")) + .await?; + + assert_eq!(form.name, Some("Item 2".to_string())); + assert_eq!( + form.parent, + Some(uuid!("4fc0f5f4-4dca-4c24-844d-1f464cb32afa")) + ); + assert_eq!(form.class, uuid!("8a979306-b4c6-4ef8-900d-68f64abb2975")); + assert_eq!( + form.original_packaging, + Some(uuid!("049298e2-73db-42fb-957d-a741655648b1")) + ); + assert_eq!(form.description, "Lorem ipsum 3".to_string()); + + Ok(()) + } + + #[sqlx::test(fixtures(path = "../../../tests/fixtures", scripts("default")))] + async fn edit(pool: sqlx::PgPool) -> sqlx::Result<()> { + let repo = ItemRepository::new(pool.clone()); + + repo.edit( + uuid!("554b11ce-fecb-4020-981e-acabbf7b5913"), + &ItemEditForm { + name: Some("Totally new name".to_string()), + parent: Some(uuid!("4fc0f5f4-4dca-4c24-844d-1f464cb32afa")), + class: uuid!("04527cc8-2fbf-4a99-aa0a-361252c8f6d3"), + original_packaging: Some(uuid!("3003e61f-0824-4625-9b72-eeb9f11a6a26")), + description: "never seen before".to_string(), + }, + ) + .await?; + + assert!( + sqlx::query_scalar( + "SELECT + items.name = 'Totally new name' + AND items.parent = '4fc0f5f4-4dca-4c24-844d-1f464cb32afa' + AND items.class = '04527cc8-2fbf-4a99-aa0a-361252c8f6d3' + AND items.original_packaging = '3003e61f-0824-4625-9b72-eeb9f11a6a26' + AND items.description = 'never seen before' + FROM items + WHERE items.id = '554b11ce-fecb-4020-981e-acabbf7b5913'" + ) + .fetch_one(&pool) + .await? + ); + + Ok(()) + } + + #[sqlx::test(fixtures(path = "../../../tests/fixtures", scripts("default")))] + async fn invalid_parameters(pool: sqlx::PgPool) -> sqlx::Result<()> { + let repo = ItemRepository::new(pool.clone()); + + // cycle + assert!(repo + .edit( + uuid!("4fc0f5f4-4dca-4c24-844d-1f464cb32afa"), + &ItemEditForm { + name: None, + parent: Some(uuid!("4fc0f5f4-4dca-4c24-844d-1f464cb32afa")), + class: uuid!("8a979306-b4c6-4ef8-900d-68f64abb2975"), + 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..e5b2a54 --- /dev/null +++ b/src/test_utils.rs @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2024 Simon Bruder +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use sqlx::query_scalar; + +pub async fn item_count(pool: &sqlx::PgPool) -> sqlx::Result { + query_scalar("SELECT count(id) FROM items") + .fetch_one(pool) + .await +} diff --git a/tests/fixtures/default.sql b/tests/fixtures/default.sql new file mode 100644 index 0000000..4e620a1 --- /dev/null +++ b/tests/fixtures/default.sql @@ -0,0 +1,33 @@ +-- SPDX-FileCopyrightText: 2024 Simon Bruder +-- +-- SPDX-License-Identifier: AGPL-3.0-or-later + +INSERT INTO item_classes (id, name, parent, description) VALUES + ('e993e21c-8558-49e7-a993-2a6a61c1d55c', 'Class 1', NULL, 'Lorem ipsum 1'), + ('04527cc8-2fbf-4a99-aa0a-361252c8f6d3', 'Class 2', NULL, 'Lorem ipsum 2'), + ('9d760792-ddb0-47a0-bed1-c27dc41b285b', 'Class 3', NULL, 'Lorem ipsum 3'), + ('8a979306-b4c6-4ef8-900d-68f64abb2975', 'Subclass 1.1', 'e993e21c-8558-49e7-a993-2a6a61c1d55c', 'Lorem ipsum 4'), + ('042fe283-f645-401c-9079-3bd3ab1c3dc9', 'Subclass 1.2', 'e993e21c-8558-49e7-a993-2a6a61c1d55c', 'Lorem ipsum 5'), + ('01ad10ec-d3be-4346-9d44-4ebb7297a14d', 'Subclass 2.1', '04527cc8-2fbf-4a99-aa0a-361252c8f6d3', 'Lorem ipsum 6'); + +INSERT INTO items (id, name, parent, class, original_packaging, description) VALUES + ('4fc0f5f4-4dca-4c24-844d-1f464cb32afa', 'Item 1', NULL, 'e993e21c-8558-49e7-a993-2a6a61c1d55c', NULL, 'Lorem ipsum 1'), + ('049298e2-73db-42fb-957d-a741655648b1', 'Original Packaging of Item 2', NULL, '01ad10ec-d3be-4346-9d44-4ebb7297a14d', NULL, 'Lorem ipsum 2'), + ('663f45e6-b11a-4197-8ce4-c784ac9ee617', 'Item 2', '4fc0f5f4-4dca-4c24-844d-1f464cb32afa', '8a979306-b4c6-4ef8-900d-68f64abb2975', '049298e2-73db-42fb-957d-a741655648b1', 'Lorem ipsum 3'), + ('3003e61f-0824-4625-9b72-eeb9f11a6a26', 'Item 3', '4fc0f5f4-4dca-4c24-844d-1f464cb32afa', 'e993e21c-8558-49e7-a993-2a6a61c1d55c', NULL, 'Lorem ipsum 4'), + ('554b11ce-fecb-4020-981e-acabbf7b5913', 'Item 4', '3003e61f-0824-4625-9b72-eeb9f11a6a26', 'e993e21c-8558-49e7-a993-2a6a61c1d55c', NULL, 'Lorem ipsum 5'), + ('b9fce434-faa4-4242-bd06-9d3589fa41e7', 'Borrowed Item', NULL, '9d760792-ddb0-47a0-bed1-c27dc41b285b', NULL, 'Lorem ipsum 6'), + ('2683d77f-2d9c-4a5c-b87f-6e1a99c69db0', 'Loaned Item', NULL, '9d760792-ddb0-47a0-bed1-c27dc41b285b', NULL, 'Lorem ipsum 7'), + ('5ca9ed99-2e70-4723-9ae4-0bb5ab274366', 'Inactive Item', NULL, '9d760792-ddb0-47a0-bed1-c27dc41b285b', NULL, 'Lorem ipsum 8'), + ('2da2643d-c759-48ab-8cdf-e4d46c8ecc69', 'Owned Item (bought)', NULL, '9d760792-ddb0-47a0-bed1-c27dc41b285b', NULL, 'Lorem ipsum 9'); + +DELETE FROM item_events WHERE item = ANY (ARRAY[ + 'b9fce434-faa4-4242-bd06-9d3589fa41e7', + '2da2643d-c759-48ab-8cdf-e4d46c8ecc69' +]::uuid[]); + +INSERT INTO item_events (item, event, description) VALUES + ('b9fce434-faa4-4242-bd06-9d3589fa41e7', 'borrow', 'from Jane Person'), + ('2683d77f-2d9c-4a5c-b87f-6e1a99c69db0', 'loan', 'to Joe Person'), + ('5ca9ed99-2e70-4723-9ae4-0bb5ab274366', 'gift', 'to Jude Person'), + ('2da2643d-c759-48ab-8cdf-e4d46c8ecc69', 'buy', 'from garage sale');