diff options
| author | Benedikt Peetz <benedikt.peetz@b-peetz.de> | 2025-09-23 08:33:06 +0200 |
|---|---|---|
| committer | Benedikt Peetz <benedikt.peetz@b-peetz.de> | 2025-09-23 08:34:45 +0200 |
| commit | 2dc74d621399be454abbbff892fb46204ddc6e7b (patch) | |
| tree | f9525527fc09c465d4e2e4a4f665bfd444b889f8 | |
| parent | feat: Provide basic barcode handling support (diff) | |
| download | server-2dc74d621399be454abbbff892fb46204ddc6e7b.zip | |
feat(treewide): Add tests and barcode buying/consuming
Diffstat (limited to '')
35 files changed, 1323 insertions, 58 deletions
diff --git a/Cargo.lock b/Cargo.lock index 3c69813..fa06854 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1909,10 +1909,12 @@ dependencies = [ "clap", "env_logger", "log", + "rocie-client", "serde", "serde_json", "sqlx", "thiserror", + "tokio", "utoipa", "uuid", ] diff --git a/Cargo.toml b/Cargo.toml index 7cadc3c..9efc7da 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,7 @@ description = "An enterprise grocery management system" [workspace.dependencies] rocie-client = { path = "./crates/rocie-client" } +tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread"] } [profile.profiling] inherits = "release" diff --git a/crates/rocie-cli/Cargo.toml b/crates/rocie-cli/Cargo.toml index e68a7b4..5e878fd 100644 --- a/crates/rocie-cli/Cargo.toml +++ b/crates/rocie-cli/Cargo.toml @@ -30,7 +30,7 @@ workspace = true anyhow = "1.0.99" clap = { version = "4.5.47", features = ["derive"] } rocie-client.workspace = true -tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread"] } +tokio.workspace = true uuid = "1.18.1" diff --git a/crates/rocie-cli/src/cli.rs b/crates/rocie-cli/src/cli.rs index 3f2662b..29c284f 100644 --- a/crates/rocie-cli/src/cli.rs +++ b/crates/rocie-cli/src/cli.rs @@ -20,6 +20,12 @@ pub(crate) enum Command { #[command(subcommand)] command: UnitCommand, }, + + /// Deal with Barcodes + Barcode { + #[command(subcommand)] + command: BarcodeCommand, + }, } #[derive(Subcommand)] @@ -58,6 +64,32 @@ pub(crate) enum UnitCommand { } #[derive(Subcommand)] +#[expect(variant_size_differences, reason = "It's just a testing cli")] +pub(crate) enum BarcodeCommand { + /// Buy an barcode + Buy { + /// The numeric value of the barcode + #[arg(short, long)] + id: u32, + }, + + /// Consume an barcode + Consume { + /// The numeric value of the barcode + #[arg(short, long)] + id: u32, + + /// The amount to consume + #[arg(short, long)] + amount: u32, + + /// The unit of the consumed amount + #[arg(short, long)] + unit_id: Uuid, + }, +} + +#[derive(Subcommand)] pub(crate) enum ProductCommand { /// Register a new product Register { diff --git a/crates/rocie-cli/src/handle/mod.rs b/crates/rocie-cli/src/handle/mod.rs index 1d322f8..c39f7b1 100644 --- a/crates/rocie-cli/src/handle/mod.rs +++ b/crates/rocie-cli/src/handle/mod.rs @@ -1,10 +1,12 @@ -use crate::cli::{ProductCommand, UnitCommand}; +use crate::cli::{BarcodeCommand, ProductCommand, UnitCommand}; use anyhow::{Context, Result}; use rocie_client::{ apis::{ + api_get_inventory_api::amount_by_id, api_get_product_api::{product_by_id, products}, api_get_unit_api::{unit_by_id, units}, + api_set_barcode_api::{buy_barcode, consume_barcode}, api_set_product_api::{associate_barcode, register_product}, api_set_unit_api::register_unit, configuration::Configuration, @@ -46,19 +48,29 @@ pub(crate) async fn product(config: &Configuration, command: ProductCommand) -> barcode_number, amount_value, amount_unit_id, - } => associate_barcode( - config, - product_id.to_string().as_str(), - Barcode { - id: i64::from(barcode_number), - amount: Box::new(UnitAmount { - unit: amount_unit_id, - value: i64::from(amount_value), - }), - }, - ) - .await - .context("Failed to associated barcode")?, + } => { + associate_barcode( + config, + product_id.to_string().as_str(), + Barcode { + id: i32::try_from(barcode_number).unwrap(), + amount: Box::new(UnitAmount { + unit: amount_unit_id, + value: i64::from(amount_value), + }), + }, + ) + .await + .context("Failed to associated barcode")?; + + let unit = unit_by_id(config, amount_unit_id.to_string().as_str()).await?; + let product = product_by_id(config, product_id.to_string().as_str()).await?; + + println!( + "Associated barcode ({barcode_number} - {amount_value} {}) with product: {} ", + unit.short_name, product.name + ); + } ProductCommand::List {} => { let all = products(config) @@ -68,6 +80,19 @@ pub(crate) async fn product(config: &Configuration, command: ProductCommand) -> for product in all { print!("{}: {}", product.name, product.id); + { + let product_amount = amount_by_id(config, product.id.to_string().as_str()) + .await + .with_context(|| { + format!("Failed to get amount of product: {}", product.id) + })?; + + let unit = + unit_by_id(config, product_amount.amount.unit.to_string().as_str()).await?; + + print!(" available: {} {}", product_amount.amount.value, unit.short_name); + } + if let Some(description) = product .description .expect("Superflous Option wrapping in api") @@ -77,11 +102,15 @@ pub(crate) async fn product(config: &Configuration, command: ProductCommand) -> println!(); } + if !product.associated_bar_codes.is_empty() { + println!(" Barcodes:"); + } + for barcode in product.associated_bar_codes { let unit = unit_by_id(config, barcode.amount.unit.to_string().as_str()).await?; println!( - " - {}: {} {}", + " - {}: {} {}", barcode.id, barcode.amount.value, if barcode.amount.value == 1 { @@ -97,6 +126,31 @@ pub(crate) async fn product(config: &Configuration, command: ProductCommand) -> Ok(()) } +pub(crate) async fn barcode(config: &Configuration, command: BarcodeCommand) -> Result<()> { + match command { + BarcodeCommand::Buy { id } => { + buy_barcode(config, i32::try_from(id).unwrap()).await?; + } + BarcodeCommand::Consume { + id, + amount, + unit_id, + } => { + consume_barcode( + config, + i32::try_from(id).unwrap(), + UnitAmount { + unit: unit_id, + value: i64::from(amount), + }, + ) + .await?; + } + } + + Ok(()) +} + pub(crate) async fn unit(config: &Configuration, command: UnitCommand) -> Result<()> { match command { UnitCommand::Register { diff --git a/crates/rocie-cli/src/main.rs b/crates/rocie-cli/src/main.rs index ef81ad9..e3188c8 100644 --- a/crates/rocie-cli/src/main.rs +++ b/crates/rocie-cli/src/main.rs @@ -17,6 +17,7 @@ async fn main() -> Result<()> { match args.command { Command::Product { command } => handle::product(&config, command).await?, Command::Unit { command } => handle::unit(&config, command).await?, + Command::Barcode { command } => handle::barcode(&config, command).await?, } Ok(()) diff --git a/crates/rocie-server/Cargo.toml b/crates/rocie-server/Cargo.toml index 93dbcd4..2ea5f3d 100644 --- a/crates/rocie-server/Cargo.toml +++ b/crates/rocie-server/Cargo.toml @@ -27,11 +27,13 @@ publish = false workspace = true [dev-dependencies] +rocie-client.workspace = true +tokio.workspace = true [dependencies] actix-web = "4.11.0" chrono = "0.4.41" -clap = { version = "4.5.45", features = ["derive"] } +clap = { version = "4.5.45", features = ["derive", "env"] } env_logger = "0.11.8" log = "0.4.27" serde = { version = "1.0.219", features = ["derive"] } diff --git a/crates/rocie-server/src/api/get/inventory.rs b/crates/rocie-server/src/api/get/inventory.rs new file mode 100644 index 0000000..3011430 --- /dev/null +++ b/crates/rocie-server/src/api/get/inventory.rs @@ -0,0 +1,44 @@ +use actix_web::{HttpResponse, Responder, Result, get, web}; + +use crate::{ + app::App, + storage::sql::{product::ProductId, product_amount::ProductAmount}, +}; + +/// Get the amount of an product +#[utoipa::path( + responses( + ( + status = OK, + description = "Product found in database and amount fetched", + body = ProductAmount + ), + ( + status = NOT_FOUND, + description = "Product not found in database" + ), + ( + status = INTERNAL_SERVER_ERROR, + description = "Server encountered error", + body = String + ) + ), + params( + ( + "id" = ProductId, + description = "Product id" + ), + ) +)] +#[get("/inventory/{id}")] +pub(crate) async fn amount_by_id( + app: web::Data<App>, + id: web::Path<ProductId>, +) -> Result<impl Responder> { + let id = id.into_inner(); + + match ProductAmount::from_id(&app, id).await? { + Some(product) => Ok(HttpResponse::Ok().json(product)), + None => Ok(HttpResponse::NotFound().finish()), + } +} diff --git a/crates/rocie-server/src/api/get/mod.rs b/crates/rocie-server/src/api/get/mod.rs index ce39076..21684af 100644 --- a/crates/rocie-server/src/api/get/mod.rs +++ b/crates/rocie-server/src/api/get/mod.rs @@ -1,5 +1,6 @@ use actix_web::web; +pub(crate) mod inventory; pub(crate) mod product; pub(crate) mod unit; @@ -7,5 +8,6 @@ pub(crate) fn register_paths(cfg: &mut web::ServiceConfig) { cfg.service(product::product_by_id) .service(product::products) .service(unit::units) - .service(unit::unit_by_id); + .service(unit::unit_by_id) + .service(inventory::amount_by_id); } diff --git a/crates/rocie-server/src/api/set/barcode.rs b/crates/rocie-server/src/api/set/barcode.rs new file mode 100644 index 0000000..a89bf4f --- /dev/null +++ b/crates/rocie-server/src/api/set/barcode.rs @@ -0,0 +1,97 @@ +use actix_web::{HttpResponse, Responder, Result, post, web}; +use log::debug; + +use crate::{ + app::App, + storage::sql::{ + barcode::{Barcode, BarcodeId, UnitAmount}, + insert::Operations, + }, +}; + +/// Buy an barcode +#[utoipa::path( + responses( + ( + status = OK, + description = "Barcode successfully bought", + ), + ( + status = NOT_FOUND, + description = "Barcode id was not found", + ), + ( + status = INTERNAL_SERVER_ERROR, + description = "Server encountered error", + body = String, + ) + ), + params( + ("id" = BarcodeId, description = "The numeric value of the barcode"), + ) +)] +#[post("/barcode/{id}/buy")] +pub(crate) async fn buy_barcode( + app: web::Data<App>, + barcode_id: web::Path<BarcodeId>, +) -> Result<impl Responder> { + let mut ops = Operations::new("buy barcode unit"); + + let barcode = Barcode::from_id(&app, barcode_id.into_inner()).await?; + + match barcode { + Some(barcode) => { + barcode.buy(&mut ops); + + ops.apply(&app).await?; + + Ok(HttpResponse::Ok().finish()) + } + None => Ok(HttpResponse::NotFound().finish()), + } +} + +/// Consume an barcode +#[utoipa::path( + responses( + ( + status = OK, + description = "Barcode successfully consumed", + ), + ( + status = NOT_FOUND, + description = "Barcode id was not found", + ), + ( + status = INTERNAL_SERVER_ERROR, + description = "Server encountered error", + body = String, + ) + ), + params( + ("id" = BarcodeId, description = "The numeric value of the barcode"), + ), + request_body = UnitAmount, +)] +#[post("/barcode/{id}/consume")] +pub(crate) async fn consume_barcode( + app: web::Data<App>, + barcode_id: web::Path<BarcodeId>, + unit_amount: web::Json<UnitAmount>, +) -> Result<impl Responder> { + let mut ops = Operations::new("consume barcode unit"); + + let barcode = Barcode::from_id(&app, barcode_id.into_inner()).await?; + debug!("Starting consume for barcode: {barcode:?}"); + + match barcode { + Some(barcode) => { + barcode.consume(&app, unit_amount.into_inner(), &mut ops).await?; + + ops.apply(&app).await?; + + Ok(HttpResponse::Ok().finish()) + } + None => Ok(HttpResponse::NotFound().finish()), + } +} diff --git a/crates/rocie-server/src/api/set/mod.rs b/crates/rocie-server/src/api/set/mod.rs index 8a2a1df..a7ddab5 100644 --- a/crates/rocie-server/src/api/set/mod.rs +++ b/crates/rocie-server/src/api/set/mod.rs @@ -1,10 +1,13 @@ use actix_web::web; +pub(crate) mod barcode; pub(crate) mod product; pub(crate) mod unit; pub(crate) fn register_paths(cfg: &mut web::ServiceConfig) { cfg.service(product::register_product) .service(product::associate_barcode) - .service(unit::register_unit); + .service(unit::register_unit) + .service(barcode::consume_barcode) + .service(barcode::buy_barcode); } diff --git a/crates/rocie-server/src/api/set/product.rs b/crates/rocie-server/src/api/set/product.rs index 355f09a..19024c7 100644 --- a/crates/rocie-server/src/api/set/product.rs +++ b/crates/rocie-server/src/api/set/product.rs @@ -5,8 +5,9 @@ use utoipa::ToSchema; use crate::{ app::App, storage::sql::{ + barcode::Barcode, insert::Operations, - product::{Barcode, Product, ProductId}, + product::{Product, ProductId}, unit::Unit, }, }; diff --git a/crates/rocie-server/src/app.rs b/crates/rocie-server/src/app.rs index ab8f764..bb27470 100644 --- a/crates/rocie-server/src/app.rs +++ b/crates/rocie-server/src/app.rs @@ -1,4 +1,4 @@ -use std::{env, path::PathBuf}; +use std::path::PathBuf; use sqlx::{SqlitePool, sqlite::SqliteConnectOptions}; @@ -10,9 +10,7 @@ pub(crate) struct App { } impl App { - pub(crate) async fn new() -> Result<Self, app_create::Error> { - let db_path: PathBuf = PathBuf::from(env::var("ROCIE_DB_PATH")?); - + pub(crate) async fn new(db_path: PathBuf) -> Result<Self, app_create::Error> { let db = { let options = SqliteConnectOptions::new() .filename(&db_path) @@ -36,15 +34,12 @@ impl App { } pub(crate) mod app_create { - use std::{env, path::PathBuf}; + use std::path::PathBuf; use crate::storage::migrate::migrate_db; #[derive(thiserror::Error, Debug)] pub(crate) enum Error { - #[error("The `ROCIE_DB_PATH` variable is not accessible: {0}")] - MissingDbVariable(#[from] env::VarError), - #[error("Failed to connect to the sqlite database at `{db_path}`, because: {inner}")] DbConnectionFailed { inner: sqlx::Error, diff --git a/crates/rocie-server/src/cli.rs b/crates/rocie-server/src/cli.rs index 5961ab7..b2ec214 100644 --- a/crates/rocie-server/src/cli.rs +++ b/crates/rocie-server/src/cli.rs @@ -1,3 +1,5 @@ +use std::path::PathBuf; + use clap::{Parser, Subcommand}; #[derive(Parser)] @@ -8,8 +10,20 @@ pub(crate) struct CliArgs { #[derive(Subcommand)] pub(crate) enum Command { - /// Serve the server on the default ports. - Serve, + /// Serve the server. + Serve { + /// Which port to serve the server on. + #[arg(short, long, default_value = "8080")] + port: u16, + + /// Which host to serve the server on. + #[arg(short = 'b', long, default_value = "127.0.0.1")] + host: String, + + /// Path to the database to use to store data. + #[arg(short, long, env = "ROCIE_DB_PATH")] + db_path: PathBuf, + }, /// Print the `OpenAPI` API documentation to stdout. OpenApi, diff --git a/crates/rocie-server/src/main.rs b/crates/rocie-server/src/main.rs index c896c35..8e71034 100644 --- a/crates/rocie-server/src/main.rs +++ b/crates/rocie-server/src/main.rs @@ -22,9 +22,12 @@ async fn main() -> Result<(), std::io::Error> { api::get::product::products, api::get::unit::units, api::get::unit::unit_by_id, + api::get::inventory::amount_by_id, api::set::product::register_product, api::set::product::associate_barcode, api::set::unit::register_unit, + api::set::barcode::buy_barcode, + api::set::barcode::consume_barcode, ), // security( // (), @@ -39,11 +42,13 @@ async fn main() -> Result<(), std::io::Error> { let args = CliArgs::parse(); match args.command { - Command::Serve => { - let host = "127.0.0.1"; - let port = 8080; + Command::Serve { + host, + port, + db_path, + } => { let data = Data::new( - app::App::new() + app::App::new(db_path) .await .map_err(|err| std::io::Error::other(main::Error::AppInit(err)))?, ); @@ -52,7 +57,9 @@ async fn main() -> Result<(), std::io::Error> { HttpServer::new(move || { App::new() - .wrap(Logger::new(r#"%a "%r" -> %s %b ("%{Referer}i" "%{User-Agent}i" %T s)"#)) + .wrap(Logger::new( + r#"%a "%r" -> %s %b ("%{Referer}i" "%{User-Agent}i" %T s)"#, + )) .app_data(Data::clone(&data)) .configure(api::get::register_paths) .configure(api::set::register_paths) diff --git a/crates/rocie-server/src/storage/migrate/mod.rs b/crates/rocie-server/src/storage/migrate/mod.rs index 3fdc400..ae0732b 100644 --- a/crates/rocie-server/src/storage/migrate/mod.rs +++ b/crates/rocie-server/src/storage/migrate/mod.rs @@ -198,7 +198,7 @@ pub(crate) mod db_version_parse { } /// Returns the current data as UNIX time stamp. -fn get_current_date() -> i64 { +pub(crate) fn get_current_date() -> i64 { let start = SystemTime::now(); let seconds_since_epoch: TimeDelta = TimeDelta::from_std( start diff --git a/crates/rocie-server/src/storage/migrate/sql/0->1.sql b/crates/rocie-server/src/storage/migrate/sql/0->1.sql index 5aa497d..fd48c68 100644 --- a/crates/rocie-server/src/storage/migrate/sql/0->1.sql +++ b/crates/rocie-server/src/storage/migrate/sql/0->1.sql @@ -19,13 +19,45 @@ CREATE TABLE version ( -- TODO: Fix the possibility for cyclic parent-ship entries <2025-09-05> CREATE TABLE parents ( id TEXT UNIQUE NOT NULL PRIMARY KEY, - parent TEXT DEFAULT NULL CHECK (id IS NOT parent), + parent TEXT DEFAULT NULL CHECK ( + id IS NOT parent + ), FOREIGN KEY(parent) REFERENCES parents(id) ) STRICT; +-- Record with barcodes were bought, and how much of this buy is already used up. +CREATE TABLE buys ( + buy_id TEXT UNIQUE NOT NULL PRIMARY KEY, + barcode_id INTEGER NOT NULL, + used_amount INTEGER DEFAULT NULL CHECK (used_amount > 0), + timestamp INTEGER NOT NULL, + FOREIGN KEY(barcode_id) REFERENCES barcodes(id) +) STRICT; + +CREATE TRIGGER used_amount_lower_or_equal_than_actual_amount_update +BEFORE UPDATE OF used_amount ON buys +FOR EACH ROW +BEGIN + SELECT RAISE(FAIL, "Used amount higher than actual amount") + FROM barcodes + WHERE NEW.barcode_id = barcodes.id + AND NEW.used_amount > barcodes.amount; +END; + +CREATE TRIGGER used_amount_lower_or_equal_than_actual_amount_insert +BEFORE INSERT ON buys +FOR EACH ROW +BEGIN + SELECT RAISE(FAIL, "Used amount higher than actual amount") + FROM barcodes + WHERE NEW.barcode_id = barcodes.id + AND NEW.used_amount > barcodes.amount; +END; + + CREATE TABLE products ( id TEXT UNIQUE NOT NULL PRIMARY KEY, - name TEXT NOT NULL, + name TEXT NOT NULL, description TEXT, parent TEXT DEFAULT NULL, FOREIGN KEY(parent) REFERENCES parents(id) diff --git a/crates/rocie-server/src/storage/sql/barcode.rs b/crates/rocie-server/src/storage/sql/barcode.rs new file mode 100644 index 0000000..239ed8c --- /dev/null +++ b/crates/rocie-server/src/storage/sql/barcode.rs @@ -0,0 +1,30 @@ +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use crate::storage::sql::unit::UnitId; + +#[derive(ToSchema, Debug, Clone, Serialize, Deserialize)] +pub(crate) struct Barcode { + #[schema(format = Int64, minimum = 0)] + pub(crate) id: BarcodeId, + pub(crate) amount: UnitAmount, +} + +#[derive(ToSchema, Debug, Clone, Copy, Serialize, Deserialize)] +pub(crate) struct BarcodeId(u32); + +impl BarcodeId { + pub(crate) fn to_db(self) -> i64 { + i64::from(self.0) + } + pub(crate) fn from_db(val: i64) -> Self { + Self(u32::try_from(val).expect("Should be strictly positive")) + } +} + +#[derive(ToSchema, Debug, Clone, Copy, Serialize, Deserialize)] +pub(crate) struct UnitAmount { + #[schema(format = Int64, minimum = 0)] + pub(crate) value: u32, + pub(crate) unit: UnitId, +} diff --git a/crates/rocie-server/src/storage/sql/get/barcode/mod.rs b/crates/rocie-server/src/storage/sql/get/barcode/mod.rs new file mode 100644 index 0000000..7b656b1 --- /dev/null +++ b/crates/rocie-server/src/storage/sql/get/barcode/mod.rs @@ -0,0 +1,50 @@ +use crate::{ + app::App, + storage::sql::{ + barcode::{Barcode, BarcodeId, UnitAmount}, + unit::UnitId, + }, +}; + +use sqlx::query; + +impl Barcode { + pub(crate) async fn from_id(app: &App, id: BarcodeId) -> Result<Option<Self>, from_id::Error> { + let db_id = id.to_db(); + + let record = query!( + " + SELECT product_id, amount, unit + FROM barcodes + WHERE id = ? +", + db_id + ) + .fetch_optional(&app.db) + .await?; + + if let Some(record) = record { + Ok(Some(Self { + id, + amount: UnitAmount { + value: u32::try_from(record.amount).expect("Is strictly positive"), + unit: UnitId::from_db(&record.unit), + }, + })) + } else { + Ok(None) + } + } +} + +pub(crate) mod from_id { + use actix_web::ResponseError; + + #[derive(thiserror::Error, Debug)] + pub(crate) enum Error { + #[error("Failed to execute the sql query")] + SqlError(#[from] sqlx::Error), + } + + impl ResponseError for Error {} +} diff --git a/crates/rocie-server/src/storage/sql/get/mod.rs b/crates/rocie-server/src/storage/sql/get/mod.rs index fa22f81..048cb3d 100644 --- a/crates/rocie-server/src/storage/sql/get/mod.rs +++ b/crates/rocie-server/src/storage/sql/get/mod.rs @@ -1,2 +1,4 @@ pub(crate) mod product; +pub(crate) mod product_amount; pub(crate) mod unit; +pub(crate) mod barcode; diff --git a/crates/rocie-server/src/storage/sql/get/product/mod.rs b/crates/rocie-server/src/storage/sql/get/product/mod.rs index d23297a..541f388 100644 --- a/crates/rocie-server/src/storage/sql/get/product/mod.rs +++ b/crates/rocie-server/src/storage/sql/get/product/mod.rs @@ -1,7 +1,8 @@ use crate::{ app::App, storage::sql::{ - product::{Barcode, Product, ProductId, UnitAmount}, + barcode::{Barcode, BarcodeId, UnitAmount}, + product::{Product, ProductId}, unit::UnitId, }, }; @@ -63,7 +64,7 @@ impl Product { associated_bar_codes: barcodes .into_iter() .map(|record| Barcode { - id: u32::try_from(record.id).expect("Should be strictly positive"), + id: BarcodeId::from_db(record.id), amount: UnitAmount { value: u32::try_from(record.amount) .expect("Should be strictly positve"), diff --git a/crates/rocie-server/src/storage/sql/get/product_amount/mod.rs b/crates/rocie-server/src/storage/sql/get/product_amount/mod.rs new file mode 100644 index 0000000..7700274 --- /dev/null +++ b/crates/rocie-server/src/storage/sql/get/product_amount/mod.rs @@ -0,0 +1,56 @@ +use crate::{ + app::App, + storage::sql::{ + barcode::UnitAmount, product::ProductId, product_amount::ProductAmount, unit::UnitId, + }, +}; + +use sqlx::query; + +impl ProductAmount { + pub(crate) async fn from_id( + app: &App, + product_id: ProductId, + ) -> Result<Option<Self>, from_id::Error> { + let record = query!( + r#" +SELECT + SUM(barcodes.amount - IFNULL(buys.used_amount,0)) "available_amount: u32", + barcodes.unit +FROM barcodes + JOIN products ON products.id = ? + JOIN buys ON buys.barcode_id = barcodes.id +GROUP BY barcodes.unit; +"#, + product_id + ) + .fetch_all(&app.db) + .await?; + + assert!(record.len() <= 1, "We currently only support one unit"); + + if let Some(record) = record.first() { + Ok(Some(Self { + product_id, + amount: UnitAmount { + value: record.available_amount.expect("Should always be set"), + unit: UnitId::from_db(&record.unit), + }, + })) + } else { + Ok(None) + } + } +} + +pub(crate) mod from_id { + use actix_web::ResponseError; + + #[derive(thiserror::Error, Debug)] + pub(crate) enum Error { + #[error("Failed to execute the sql query")] + SqlError(#[from] sqlx::Error), + } + + impl ResponseError for Error {} +} diff --git a/crates/rocie-server/src/storage/sql/insert/barcode/mod.rs b/crates/rocie-server/src/storage/sql/insert/barcode/mod.rs new file mode 100644 index 0000000..62a2e11 --- /dev/null +++ b/crates/rocie-server/src/storage/sql/insert/barcode/mod.rs @@ -0,0 +1,241 @@ +use std::str::FromStr; + +use serde::{Deserialize, Serialize}; +use sqlx::query; +use uuid::Uuid; + +use crate::{ + app::App, + storage::{ + migrate::get_current_date, + sql::{ + barcode::{Barcode, BarcodeId, UnitAmount}, + insert::{Operations, Transactionable}, + unit::Unit, + }, + }, +}; + +#[derive(Debug, Deserialize, Serialize)] +pub(crate) enum Operation { + Buy { + buy_id: Uuid, + id: BarcodeId, + }, + Consume { + buy_id: Uuid, + id: BarcodeId, + amount: UnitAmount, + }, +} + +impl Transactionable for Operation { + type ApplyError = apply::Error; + type UndoError = undo::Error; + + async fn apply(self, txn: &mut sqlx::SqliteConnection) -> Result<(), apply::Error> { + match self { + Operation::Buy { buy_id, id } => { + let id = id.to_db(); + let buy_id = buy_id.to_string(); + let timestamp = get_current_date(); + + query!( + " + INSERT INTO buys (buy_id, barcode_id, timestamp) + VALUES (?, ?, ?) +", + buy_id, + id, + timestamp, + ) + .execute(txn) + .await?; + } + Operation::Consume { buy_id, id, amount } => { + let id = id.to_db(); + let buy_id = buy_id.to_string(); + + let old_amount = { + let record = query!( + " + SELECT used_amount + FROM buys + WHERE buy_id = ?; +", + buy_id + ) + .fetch_one(&mut *txn) + .await?; + + u32::try_from(record.used_amount.unwrap_or(0)) + .expect("Should be strictly positive") + }; + + // TODO: Check, that this does not overflow the maximum amount. <2025-09-21> + let new_amount = amount.value + old_amount; + + // TODO(@bpeetz): We need to add the amount. <2025-09-09> + query!( + " + UPDATE buys + SET used_amount = ? + WHERE barcode_id = ? AND buy_id = ? +", + new_amount, + id, + buy_id + ) + .execute(txn) + .await?; + } + } + Ok(()) + } + + async fn undo(self, txn: &mut sqlx::SqliteConnection) -> Result<(), undo::Error> { + match self { + Operation::Buy { buy_id, id } => { + let id = id.to_db(); + let buy_id = buy_id.to_string(); + + query!( + " + DELETE FROM buys + WHERE barcode_id = ? AND buy_id = ? +", + id, + buy_id + ) + .execute(txn) + .await?; + } + Operation::Consume { buy_id, id, amount } => { + todo!("We would need to subtract the amount."); + } + } + Ok(()) + } +} + +pub(crate) mod undo { + #[derive(thiserror::Error, Debug)] + pub(crate) enum Error { + #[error("Failed to execute undo sql statments: {0}")] + SqlError(#[from] sqlx::Error), + } +} +pub(crate) mod apply { + #[derive(thiserror::Error, Debug)] + pub(crate) enum Error { + #[error("Failed to execute apply sql statments: {0}")] + SqlError(#[from] sqlx::Error), + } +} + +impl Barcode { + pub(crate) fn buy(&self, ops: &mut Operations<Operation>) { + let id = Uuid::new_v4(); + ops.push(Operation::Buy { + buy_id: id, + id: self.id, + }); + } + + pub(crate) async fn consume( + &self, + app: &App, + amount: UnitAmount, + ops: &mut Operations<Operation>, + ) -> Result<(), consume::Error> { + assert_eq!( + self.amount.unit, amount.unit, + "We currently do not support unit conversions yet" + ); + + if amount.value > self.amount.value { + let foreign_amount_unit = Unit::from_id(app, amount.unit).await?; + + if let Some(foreign_amount_unit) = foreign_amount_unit { + let self_amount_unit = Unit::from_id(app, self.amount.unit) + .await? + .expect("This unit id should always exist"); + + return Err(consume::Error::ConsumedMoreThanAvailable { + consumed: Box::new((amount, foreign_amount_unit)), + available: Box::new((self.amount, self_amount_unit)), + }); + } + + return Err(consume::Error::UnitIdDoesNotExist(amount.unit)); + } + + let barcode_id = self.id.to_db(); + let buy_id = { + let record = query!( + " + SELECT buy_id + FROM buys + WHERE barcode_id = ? AND (used_amount IS NULL OR used_amount < ?) + ORDER BY timestamp DESC + LIMIT 1; +", + barcode_id, + self.amount.value + ) + .fetch_optional(&app.db) + .await?; + + if let Some(found) = record { + Uuid::from_str(&found.buy_id).expect("Was a uuid, should still be one") + } else { + return Err(consume::Error::NoMoreAvailable); + } + }; + + ops.push(Operation::Consume { + id: self.id, + amount, + buy_id, + }); + + Ok(()) + } +} + +pub(crate) mod consume { + use actix_web::ResponseError; + + use crate::storage::{ + self, + sql::{ + barcode::UnitAmount, + unit::{Unit, UnitId}, + }, + }; + + #[derive(thiserror::Error, Debug)] + pub(crate) enum Error { + #[error("Failed to execute apply sql statments: {0}")] + Sql(#[from] sqlx::Error), + + #[error("Failed to fetch an unit from a specified amount id value")] + UnitGet(#[from] storage::sql::get::unit::from_id::Error), + + #[error("The specified unit-id does not exist: {0}")] + UnitIdDoesNotExist(UnitId), + + #[error("No more of this product is available, buy more. ")] + NoMoreAvailable, + + #[error( + "Consumed more than available: consumed {} {}, but available: {} {}", consumed.0.value, consumed.1.short_name, available.0.value, available.1.short_name, + )] + ConsumedMoreThanAvailable { + consumed: Box<(UnitAmount, Unit)>, + available: Box<(UnitAmount, Unit)>, + }, + } + + impl ResponseError for Error {} +} diff --git a/crates/rocie-server/src/storage/sql/insert/mod.rs b/crates/rocie-server/src/storage/sql/insert/mod.rs index eec6ad2..e6728d9 100644 --- a/crates/rocie-server/src/storage/sql/insert/mod.rs +++ b/crates/rocie-server/src/storage/sql/insert/mod.rs @@ -7,6 +7,7 @@ use log::{debug, trace}; use serde::{Serialize, de::DeserializeOwned}; use sqlx::{SqliteConnection, query}; +pub(crate) mod barcode; pub(crate) mod product; pub(crate) mod unit; diff --git a/crates/rocie-server/src/storage/sql/insert/product/mod.rs b/crates/rocie-server/src/storage/sql/insert/product/mod.rs index b6dd604..e14d3f4 100644 --- a/crates/rocie-server/src/storage/sql/insert/product/mod.rs +++ b/crates/rocie-server/src/storage/sql/insert/product/mod.rs @@ -3,8 +3,9 @@ use sqlx::query; use uuid::Uuid; use crate::storage::sql::{ + barcode::Barcode, insert::{Operations, Transactionable}, - product::{Barcode, Product, ProductId}, + product::{Product, ProductId}, }; #[derive(Debug, Deserialize, Serialize)] @@ -47,7 +48,7 @@ impl Transactionable for Operation { .await?; } Operation::AssociateBarcode { id, barcode } => { - let barcode_id = i64::from(barcode.id); + let barcode_id = barcode.id.to_db(); let barcode_amount_value = i64::from(barcode.amount.value); let barcode_amount_unit = barcode.amount.unit; @@ -90,7 +91,7 @@ impl Transactionable for Operation { .await?; } Operation::AssociateBarcode { id, barcode } => { - let barcode_id = i64::from(barcode.id); + let barcode_id = barcode.id.to_db(); let barcode_amount_value = i64::from(barcode.amount.value); let barcode_amount_unit = barcode.amount.unit; diff --git a/crates/rocie-server/src/storage/sql/mod.rs b/crates/rocie-server/src/storage/sql/mod.rs index f5ad88a..edce187 100644 --- a/crates/rocie-server/src/storage/sql/mod.rs +++ b/crates/rocie-server/src/storage/sql/mod.rs @@ -3,4 +3,6 @@ pub(crate) mod insert; // Types pub(crate) mod product; +pub(crate) mod product_amount; pub(crate) mod unit; +pub(crate) mod barcode; diff --git a/crates/rocie-server/src/storage/sql/product.rs b/crates/rocie-server/src/storage/sql/product.rs index e2a4f0d..93cc6a0 100644 --- a/crates/rocie-server/src/storage/sql/product.rs +++ b/crates/rocie-server/src/storage/sql/product.rs @@ -5,7 +5,7 @@ use sqlx::{Database, Encode, Type}; use utoipa::ToSchema; use uuid::Uuid; -use crate::storage::sql::unit::{Unit, UnitId}; +use crate::storage::sql::barcode::Barcode; #[derive(Clone, ToSchema, Serialize, Deserialize)] pub(crate) struct Product { @@ -58,17 +58,3 @@ where <String as Type<DB>>::type_info() } } - -#[derive(ToSchema, Debug, Clone, Serialize, Deserialize)] -pub(crate) struct Barcode { - #[schema(format = Int64, minimum = 0)] - pub(crate) id: u32, - pub(crate) amount: UnitAmount, -} - -#[derive(ToSchema, Debug, Clone, Serialize, Deserialize)] -pub(crate) struct UnitAmount { - #[schema(format = Int64, minimum = 0)] - pub(crate) value: u32, - pub(crate) unit: UnitId, -} diff --git a/crates/rocie-server/src/storage/sql/product_amount.rs b/crates/rocie-server/src/storage/sql/product_amount.rs new file mode 100644 index 0000000..232c5db --- /dev/null +++ b/crates/rocie-server/src/storage/sql/product_amount.rs @@ -0,0 +1,10 @@ +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use crate::storage::sql::{barcode::UnitAmount, product::ProductId}; + +#[derive(Clone, ToSchema, Serialize, Deserialize)] +pub(crate) struct ProductAmount { + pub(crate) product_id: ProductId, + pub(crate) amount: UnitAmount, +} diff --git a/crates/rocie-server/tests/_testenv/init.rs b/crates/rocie-server/tests/_testenv/init.rs new file mode 100644 index 0000000..5309fea --- /dev/null +++ b/crates/rocie-server/tests/_testenv/init.rs @@ -0,0 +1,210 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use std::{ + env, + ffi::OsStr, + fmt::Write, + fs, io, mem, + path::{Path, PathBuf}, + process::{self, Stdio}, + thread::sleep, + time::Duration, +}; + +use rocie_client::apis::configuration::Configuration; + +use crate::{_testenv::Paths, testenv::TestEnv}; + +fn target_dir() -> PathBuf { + // Tests exe is in target/debug/deps, the *rocie-server* exe is in target/debug + env::current_exe() + .expect("./target/debug/deps/rocie-server-*") + .parent() + .expect("./target/debug/deps") + .parent() + .expect("./target/debug") + .parent() + .expect("./target") + .to_path_buf() +} + +fn test_dir(name: &'static str, port: u32) -> PathBuf { + target_dir().join("tests").join(name).join(port.to_string()) +} + +fn prepare_files_and_dirs(test_dir: &Path) -> io::Result<Paths> { + fs::create_dir_all(test_dir)?; + + let db_path = test_dir.join("database.sqlite"); + + { + // Remove all files, so that the test run stays pure + for entry in fs::read_dir(test_dir).unwrap() { + let entry = entry.unwrap(); + let entry_ft = entry.file_type().unwrap(); + + if entry_ft.is_dir() { + fs::remove_dir_all(entry.path())?; + } else if entry_ft.is_file() { + fs::remove_file(entry.path())?; + } else { + panic!("Unknown file: {} ({entry_ft:#?})", entry.path().display()); + } + } + } + + Ok(Paths { + db: db_path, + test_dir: test_dir.to_owned(), + }) +} + +fn find_server_exe() -> PathBuf { + let target = target_dir().join("debug"); + + let exe_name = if cfg!(windows) { + "rocie-server.exe" + } else { + "rocie-server" + }; + + target.join(exe_name) +} + +fn rocie_base_path(port: &str) -> String { + format!("http://127.0.0.1:{port}") +} + +fn rocie_server_args<'a>(paths: &'a Paths, port: &'a str) -> [&'a OsStr; 5] { + [ + OsStr::new("serve"), + OsStr::new("--db-path"), + paths.db.as_os_str(), + OsStr::new("--port"), + OsStr::new(port), + ] +} + +impl TestEnv { + pub(crate) fn new(name: &'static str, port: u32) -> TestEnv { + let test_dir = test_dir(name, port); + + let paths = prepare_files_and_dirs(&test_dir) + .inspect_err(|err| panic!("Error during test dir preparation: {err}")) + .unwrap(); + + let server_process = { + let server_exe = find_server_exe(); + let mut cmd = process::Command::new(&server_exe); + + cmd.current_dir(&paths.test_dir); + + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::piped()); + + cmd.args(rocie_server_args(&paths, port.to_string().as_str())); + + let child = cmd.spawn().expect("server spawn"); + + // Give the server time to start. + // TODO(@bpeetz): Use a better synchronization primitive <2025-09-11> + sleep(Duration::from_millis(240)); + + child + }; + + let config = { + let mut inner = Configuration::new(); + inner.base_path = rocie_base_path(port.to_string().as_str()); + inner.user_agent = Some(String::from("Rocie test driver")); + inner + }; + + let me = TestEnv { + name, + test_dir, + paths, + server_process: Some(server_process), + config, + port: port.to_string(), + }; + + me.log(format!("Starting test `{name}` on port `{port}`")); + + me + } +} + +impl Drop for TestEnv { + fn drop(&mut self) { + /// Format an error message for when the server did not exit successfully. + fn format_exit(args: &[&OsStr], output: &process::Output) -> String { + let mut base = String::new(); + + { + let args = args + .iter() + .map(|s| s.to_str().unwrap()) + .collect::<Vec<_>>() + .join(" "); + + if output.status.success() { + writeln!(base, "`rocie-server {args}` did exit successfully.") + .expect("In-memory"); + } else { + writeln!(base, "`rocie-server {args}` did not exit successfully.") + .expect("In-memory"); + } + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + if !stdout.is_empty() { + writeln!(base, "Stdout:\n---\n{stdout}\n---").expect("In-memory"); + } + if !stderr.is_empty() { + writeln!(base, "Stderr:\n---\n{stderr}\n---").expect("In-memory"); + } + + base + } + + { + // Stop the server process via SIGTERM. + let mut kill = process::Command::new("kill") + .args([ + "-s", + "TERM", + &self.server_process.as_ref().unwrap().id().to_string(), + ]) + .spawn() + .unwrap(); + + eprintln!("Killing the server process"); + + kill.wait().unwrap(); + } + + let output = mem::take(&mut self.server_process) + .expect("Is some at this point") + .wait_with_output() + .expect("server exit output"); + + eprintln!( + "{}", + format_exit( + rocie_server_args(&self.paths, &self.port).as_slice(), + &output + ) + ); + } +} diff --git a/crates/rocie-server/tests/_testenv/log.rs b/crates/rocie-server/tests/_testenv/log.rs new file mode 100644 index 0000000..9a07e78 --- /dev/null +++ b/crates/rocie-server/tests/_testenv/log.rs @@ -0,0 +1,72 @@ +use crate::testenv::TestEnv; + +macro_rules! request { + ($env:expr, $fn:ident ($($arg:expr),* $(,)?)) => {{ + match request!(@call, $env, $fn, $($arg),*) { + Ok(ok) => { + $env.log(format!("> {ok:?}")); + + ok + }, + + Err(err) => { + $env.log(format!(">! {err:?}")); + + panic!("Server request failed."); + } + } + + }}; + + (@expect_error $reason:literal $env:expr, $fn:ident ($($arg:expr),* $(,)?)) => {{ + match request!(@call, $env, $fn, $($arg),*) { + Err(err) => { + $env.log(format!("> `{err}` ({})", $reason)); + + err + }, + + Ok(ok) => { + $env.log(format!(">? {ok:?}")); + + panic!("Server request succeeded, but should fail."); + } + } + }}; + + + (@call, $env:expr, $fn:ident, $($arg:expr),*) => {{ + $env.log( + format!( + "< {}({})", + stringify!($fn), + request!(@format, $fn, $($arg),*) + ) + ); + + $fn(&$env.config, $($arg),*).await + }}; + + (@format, $fn:ident, $($arg:expr),*) => {{ + use std::fmt::Write; + + let mut base = String::new(); + $( + write!(base, "{:?}, ", $arg) + .expect("In memory write"); + )* + + base.trim().trim_end_matches(',').to_owned() + }}; +} + +pub(crate) use request; + +impl TestEnv { + pub(crate) fn log<A>(&self, message: A) + where + A: AsRef<str>, + { + eprintln!("{}: {}", self.name, message.as_ref()); + } +} diff --git a/crates/rocie-server/tests/_testenv/mod.rs b/crates/rocie-server/tests/_testenv/mod.rs new file mode 100644 index 0000000..a37925e --- /dev/null +++ b/crates/rocie-server/tests/_testenv/mod.rs @@ -0,0 +1,23 @@ +//! This code was taken from *fd* at 30-06-2025. + +use std::{path::PathBuf, process}; + +use rocie_client::apis::configuration::Configuration; + +mod init; +pub(crate) mod log; + +/// Environment for the integration tests. +pub(crate) struct TestEnv { + pub(crate) name: &'static str, + pub(crate) port: String, + pub(crate) test_dir: PathBuf, + pub(crate) paths: Paths, + pub(crate) server_process: Option<process::Child>, + pub(crate) config: Configuration, +} + +pub(crate) struct Paths { + pub(crate) db: PathBuf, + pub(crate) test_dir: PathBuf, +} diff --git a/crates/rocie-server/tests/products/barcode.rs b/crates/rocie-server/tests/products/barcode.rs new file mode 100644 index 0000000..c267006 --- /dev/null +++ b/crates/rocie-server/tests/products/barcode.rs @@ -0,0 +1,185 @@ +use rocie_client::{ + apis::{ + api_get_inventory_api::amount_by_id, + api_set_barcode_api::{buy_barcode, consume_barcode}, + }, + models::UnitAmount, +}; + +use crate::{ + products::create_associated_barcode, + testenv::{TestEnv, log::request}, +}; + +#[tokio::test] +async fn test_barcode_buy() { + let env = TestEnv::new(module_path!(), 8087); + + let barcode_id = 23; + let unit_value = 1; + + let (unit_id, product_id) = + create_associated_barcode(&env, "Liter", "Milk", unit_value, barcode_id).await; + + request!(env, buy_barcode(i32::try_from(barcode_id).unwrap())); + + let product_amount = request!(env, amount_by_id(product_id.to_string().as_str())); + + assert_eq!(product_amount.product_id, product_id); + assert_eq!(product_amount.amount.unit, unit_id); + assert_eq!(product_amount.amount.value, i64::from(unit_value)); +} + +#[tokio::test] +async fn test_barcode_consume() { + let env = TestEnv::new(module_path!(), 8083); + + let barcode_id = 23; + let unit_value = 1; + + let (unit_id, product_id) = + create_associated_barcode(&env, "Liter", "Milk", unit_value, barcode_id).await; + + request!(env, buy_barcode(i32::try_from(barcode_id).unwrap())); + + request!( + env, + consume_barcode( + i32::try_from(barcode_id).unwrap(), + UnitAmount { + unit: unit_id, + value: 1, + }, + ) + ); + + let product_amount = request!(env, amount_by_id(product_id.to_string().as_str())); + + assert_eq!(product_amount.product_id, product_id); + assert_eq!(product_amount.amount.unit, unit_id); + assert_eq!(product_amount.amount.value, 0); +} + +#[tokio::test] +async fn test_barcode_consume_error() { + let env = TestEnv::new(module_path!(), 8084); + + let barcode_id = 23; + let unit_value = 1; + + let (unit_id, product_id) = + create_associated_barcode(&env, "Liter", "Milk", unit_value, barcode_id).await; + + request!(env, buy_barcode(i32::try_from(barcode_id).unwrap())); + + request!( + @expect_error "We should not be able to consume more than available." + env, + consume_barcode( + i32::try_from(barcode_id).unwrap(), + UnitAmount { + unit: unit_id, + value: i64::from(unit_value + 1), + }, + ) + ); + + // Test, that the error does not actually go into the db. + let product_amount = request!(env, amount_by_id(product_id.to_string().as_str())); + + assert_eq!(product_amount.product_id, product_id); + assert_eq!(product_amount.amount.unit, unit_id); + assert_eq!(product_amount.amount.value, i64::from(unit_value)); +} + +#[tokio::test] +async fn test_barcode_consume_error_other() { + let env = TestEnv::new(module_path!(), 8085); + + let barcode_id = 23; + let unit_value = 1; + + let (unit_id, product_id) = + create_associated_barcode(&env, "Liter", "Milk", unit_value, barcode_id).await; + + request!(env, buy_barcode(i32::try_from(barcode_id).unwrap())); + + request!( + env, + consume_barcode( + i32::try_from(barcode_id).unwrap(), + UnitAmount { + unit: unit_id, + value: 1, + }, + ) + ); + + request!( + @expect_error "We already consumed everything, we bought" + env, + consume_barcode( + i32::try_from(barcode_id).unwrap(), + UnitAmount { + unit: unit_id, + value: 1, + }, + ) + ); + + // Test, that the error does not actually go into the db. + let product_amount = request!(env, amount_by_id(product_id.to_string().as_str())); + + assert_eq!(product_amount.product_id, product_id); + assert_eq!(product_amount.amount.unit, unit_id); + assert_eq!(product_amount.amount.value, i64::from(0)); +} + +#[tokio::test] +async fn test_barcode_multiple_buy_and_consume() { + let env = TestEnv::new(module_path!(), 8086); + + let barcode_id = 23; + let unit_value = 1; + + let (unit_id, product_id) = + create_associated_barcode(&env, "Liter", "Milk", unit_value, barcode_id).await; + + request!(env, buy_barcode(i32::try_from(barcode_id).unwrap())); + + request!(env, buy_barcode(i32::try_from(barcode_id).unwrap())); + + env.log("Bought both barcodes"); + + request!( + env, + consume_barcode( + i32::try_from(barcode_id).unwrap(), + UnitAmount { + unit: unit_id, + value: 1, + }, + ) + ); + + env.log("Consumed first barcode"); + + request!( + env, + consume_barcode( + i32::try_from(barcode_id).unwrap(), + UnitAmount { + unit: unit_id, + value: 1, + }, + ) + ); + + env.log("Consumed second barcode"); + + let product_amount = request!(env, amount_by_id(product_id.to_string().as_str())); + + assert_eq!(product_amount.product_id, product_id); + assert_eq!(product_amount.amount.unit, unit_id); + assert_eq!(product_amount.amount.value, i64::from(0)); +} diff --git a/crates/rocie-server/tests/products/mod.rs b/crates/rocie-server/tests/products/mod.rs new file mode 100644 index 0000000..e96ecc9 --- /dev/null +++ b/crates/rocie-server/tests/products/mod.rs @@ -0,0 +1,69 @@ +use rocie_client::{ + apis::{ + api_get_product_api::product_by_id, + api_set_product_api::{associate_barcode, register_product}, + api_set_unit_api::register_unit, + }, + models::{Barcode, Product, ProductStub, UnitAmount, UnitStub}, +}; +use uuid::Uuid; + +use crate::testenv::{TestEnv, log::request}; + +mod barcode; +mod register; + +async fn create_product(env: &TestEnv, name: &str) -> Uuid { + request!( + env, + register_product(ProductStub { + description: Some(None), + name: name.to_owned(), + parent: None, + },) + ) +} +async fn create_unit(env: &TestEnv, name: &str) -> Uuid { + request!( + env, + register_unit(UnitStub { + description: Some(None), + full_name_plural: name.to_owned(), + full_name_singular: name.to_owned(), + short_name: name.to_owned(), + },) + ) +} + +async fn create_associated_barcode( + env: &TestEnv, + unit_name: &str, + product_name: &str, + barcode_value: u32, + barcode_id: u32, +) -> (Uuid, Uuid) { + let unit_id = create_unit(env, unit_name).await; + let product_id = create_product(env, product_name).await; + + request!( + env, + associate_barcode( + product_id.to_string().as_str(), + Barcode { + amount: Box::new(UnitAmount { + unit: unit_id, + value: i64::from(barcode_value), + }), + id: i32::try_from(barcode_id).unwrap(), + }, + ) + ); + + (unit_id, product_id) +} + +async fn get_product(env: &TestEnv, id: Uuid) -> Product { + product_by_id(&env.config, id.to_string().as_str()) + .await + .unwrap() +} diff --git a/crates/rocie-server/tests/products/register.rs b/crates/rocie-server/tests/products/register.rs new file mode 100644 index 0000000..34f7b5c --- /dev/null +++ b/crates/rocie-server/tests/products/register.rs @@ -0,0 +1,33 @@ +use rocie_client::{ + apis::{api_get_product_api::product_by_id, api_set_product_api::register_product}, + models::ProductStub, +}; + +use crate::testenv::{TestEnv, log::request}; + +#[tokio::test] +async fn test_product_register_roundtrip() { + let env = TestEnv::new(module_path!(), 8081); + + let id = request!( + env, + register_product(ProductStub { + description: Some(Some("A soy based alternative to milk".to_owned())), + name: "Soy drink".to_owned(), + parent: None, + },) + ); + + let product = request!(env, product_by_id(id.to_string().as_str())); + + assert_eq!(&product.name, "Soy drink"); + assert_eq!( + product.description, + Some(Some("A soy based alternative to milk".to_owned())) + ); + assert_eq!(product.id, id,); + // assert_eq!( + // product.parent, + // None, + // ); +} diff --git a/crates/rocie-server/tests/tests.rs b/crates/rocie-server/tests/tests.rs new file mode 100644 index 0000000..34395a6 --- /dev/null +++ b/crates/rocie-server/tests/tests.rs @@ -0,0 +1,6 @@ +#![allow(unused_crate_dependencies)] + +mod _testenv; +pub(crate) use _testenv as testenv; + +mod products; |
