From 8f378472ead9cd82ffb3e11877cc2e6ccefba296 Mon Sep 17 00:00:00 2001 From: Simon Goller Date: Thu, 2 May 2024 23:25:04 +0200 Subject: [PATCH] Add REST endpoint for slot --- Cargo.lock | 64 ++ Cargo.toml | 1 + app/Cargo.toml | 6 + app/src/main.rs | 25 +- dao/Cargo.toml | 4 + dao/src/lib.rs | 14 +- dao/src/slot.rs | 65 ++ dao_impl/Cargo.toml | 5 + dao_impl/src/lib.rs | 4 +- dao_impl/src/slot.rs | 128 ++++ migrations/20240502113031_add-slot.sql | 15 + rest/Cargo.toml | 9 +- rest/src/lib.rs | 87 ++- rest/src/permission.rs | 28 +- rest/src/slot.rs | 182 +++++ service/Cargo.toml | 2 + service/src/clock.rs | 8 + service/src/lib.rs | 38 + service/src/slot.rs | 93 +++ service/src/uuid_service.rs | 7 + service_impl/Cargo.toml | 9 + service_impl/src/clock.rs | 16 + service_impl/src/lib.rs | 6 +- service_impl/src/slot.rs | 199 +++++ service_impl/src/test/mod.rs | 3 + service_impl/src/test/permission_test.rs | 2 +- service_impl/src/test/slot.rs | 924 +++++++++++++++++++++++ service_impl/src/uuid_service.rs | 9 + 28 files changed, 1925 insertions(+), 28 deletions(-) create mode 100644 dao/src/slot.rs create mode 100644 dao_impl/src/slot.rs create mode 100644 migrations/20240502113031_add-slot.sql create mode 100644 rest/src/slot.rs create mode 100644 service/src/clock.rs create mode 100644 service/src/slot.rs create mode 100644 service/src/uuid_service.rs create mode 100644 service_impl/src/clock.rs create mode 100644 service_impl/src/slot.rs create mode 100644 service_impl/src/test/slot.rs create mode 100644 service_impl/src/uuid_service.rs diff --git a/Cargo.lock b/Cargo.lock index 14ca3d8..2ffe7ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -51,6 +51,8 @@ dependencies = [ "rest", "service_impl", "sqlx", + "time", + "time-macros", "tokio", ] @@ -272,6 +274,7 @@ dependencies = [ "async-trait", "mockall", "thiserror", + "time", "uuid", ] @@ -282,6 +285,8 @@ dependencies = [ "async-trait", "dao", "sqlx", + "time", + "uuid", ] [[package]] @@ -295,6 +300,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "deranged" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +dependencies = [ + "powerfmt", + "serde", +] + [[package]] name = "digest" version = "0.10.7" @@ -849,6 +864,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + [[package]] name = "num-integer" version = "0.1.46" @@ -1007,6 +1028,12 @@ version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.17" @@ -1115,6 +1142,8 @@ dependencies = [ "serde", "serde_json", "service", + "thiserror", + "time", "tokio", "uuid", ] @@ -1237,7 +1266,9 @@ dependencies = [ "dao", "mockall", "thiserror", + "time", "tokio", + "uuid", ] [[package]] @@ -1248,7 +1279,9 @@ dependencies = [ "dao", "mockall", "service", + "time", "tokio", + "uuid", ] [[package]] @@ -1635,6 +1668,37 @@ dependencies = [ "syn 2.0.60", ] +[[package]] +name = "time" +version = "0.3.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" + +[[package]] +name = "time-macros" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinyvec" version = "1.6.0" diff --git a/Cargo.toml b/Cargo.toml index 957cbb8..77069c4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,2 +1,3 @@ [workspace] members = ["rest", "service", "service_impl", "app", "dao", "dao_impl"] +resolver = "2" diff --git a/app/Cargo.toml b/app/Cargo.toml index 656822c..6d4e9f4 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -24,3 +24,9 @@ features = ["full"] [dependencies.sqlx] version = "0.7.4" features = ["runtime-tokio", "sqlite"] + +[dependencies.time] +version = "0.3.36" + +[dependencies.time-macros] +version = "0.2.18" diff --git a/app/src/main.rs b/app/src/main.rs index e7d2832..a4f6a49 100644 --- a/app/src/main.rs +++ b/app/src/main.rs @@ -5,15 +5,25 @@ use sqlx::SqlitePool; type PermissionService = service_impl::PermissionServiceImpl; type HelloService = service_impl::HelloServiceImpl; +type ClockService = service_impl::clock::ClockServiceImpl; +type UuidService = service_impl::uuid_service::UuidServiceImpl; +type SlotService = service_impl::slot::SlotServiceImpl< + dao_impl::slot::SlotDaoImpl, + PermissionService, + ClockService, + UuidService, +>; #[derive(Clone)] pub struct RestStateImpl { hello_service: Arc, permission_service: Arc, + slot_service: Arc, } impl rest::RestStateDef for RestStateImpl { type HelloService = HelloService; type PermissionService = PermissionService; + type SlotService = SlotService; fn hello_service(&self) -> Arc { self.hello_service.clone() @@ -21,11 +31,15 @@ impl rest::RestStateDef for RestStateImpl { fn permission_service(&self) -> Arc { self.permission_service.clone() } + fn slot_service(&self) -> Arc { + self.slot_service.clone() + } } impl RestStateImpl { pub fn new(pool: Arc>) -> Self { let hello_dao = dao_impl::HelloDaoImpl::new(pool.clone()); - let permission_dao = dao_impl::PermissionDaoImpl::new(pool); + let permission_dao = dao_impl::PermissionDaoImpl::new(pool.clone()); + let slot_dao = dao_impl::slot::SlotDaoImpl::new(pool); // Always authenticate with DEVUSER during development. // This is used to test the permission service locally without a login service. @@ -42,9 +56,18 @@ impl RestStateImpl { hello_dao.into(), permission_service.clone(), )); + let clock_service = Arc::new(service_impl::clock::ClockServiceImpl); + let uuid_service = Arc::new(service_impl::uuid_service::UuidServiceImpl); + let slot_service = Arc::new(service_impl::slot::SlotServiceImpl::new( + slot_dao.into(), + permission_service.clone(), + clock_service, + uuid_service, + )); Self { hello_service, permission_service, + slot_service, } } } diff --git a/dao/Cargo.toml b/dao/Cargo.toml index 1c06167..83b96d6 100644 --- a/dao/Cargo.toml +++ b/dao/Cargo.toml @@ -10,3 +10,7 @@ async-trait = "0.1.80" mockall = "0.12.1" thiserror = "1.0.59" uuid = "1.8" + +[dependencies.time] +version = "0.3.36" +features = ["parsing"] diff --git a/dao/src/lib.rs b/dao/src/lib.rs index c11d00f..c7fd129 100644 --- a/dao/src/lib.rs +++ b/dao/src/lib.rs @@ -4,7 +4,8 @@ use async_trait::async_trait; use mockall::automock; use thiserror::Error; -mod permission; +pub mod permission; +pub mod slot; pub use permission::MockPermissionDao; pub use permission::PermissionDao; @@ -15,7 +16,16 @@ pub use permission::UserEntity; #[derive(Error, Debug)] pub enum DaoError { #[error("Database query error: {0}")] - DatabaseQueryError(#[from] Box), + DatabaseQueryError(#[from] Box), + + #[error("Uuid error: {0}")] + UuidError(#[from] uuid::Error), + + #[error("Invalid day of week number: {0}")] + InvalidDayOfWeek(u8), + + #[error("Date/Time parse error: {0}")] + DateTimeParseError(#[from] time::error::Parse), } #[automock] diff --git a/dao/src/slot.rs b/dao/src/slot.rs new file mode 100644 index 0000000..3a23b8e --- /dev/null +++ b/dao/src/slot.rs @@ -0,0 +1,65 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use mockall::automock; +use uuid::Uuid; + +use crate::DaoError; + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum DayOfWeek { + Monday, + Tuesday, + Wednesday, + Thursday, + Friday, + Saturday, + Sunday, +} +impl DayOfWeek { + pub fn from_number(number: u8) -> Option { + match number { + 1 => Some(DayOfWeek::Monday), + 2 => Some(DayOfWeek::Tuesday), + 3 => Some(DayOfWeek::Wednesday), + 4 => Some(DayOfWeek::Thursday), + 5 => Some(DayOfWeek::Friday), + 6 => Some(DayOfWeek::Saturday), + 7 => Some(DayOfWeek::Sunday), + _ => None, + } + } + pub fn to_number(&self) -> u8 { + match self { + DayOfWeek::Monday => 1, + DayOfWeek::Tuesday => 2, + DayOfWeek::Wednesday => 3, + DayOfWeek::Thursday => 4, + DayOfWeek::Friday => 5, + DayOfWeek::Saturday => 6, + DayOfWeek::Sunday => 7, + } + } +} + +#[derive(Debug, PartialEq, Eq)] +pub struct SlotEntity { + pub id: Uuid, + pub day_of_week: DayOfWeek, + pub from: time::Time, + pub to: time::Time, + pub valid_from: time::Date, + pub valid_to: Option, + pub deleted: Option, + pub version: Uuid, +} + +#[automock] +#[async_trait] +pub trait SlotDao { + async fn get_slots(&self) -> Result, DaoError>; + async fn get_slot(&self, id: &Uuid) -> Result, DaoError>; + async fn create_slot(&self, slot: &SlotEntity, process: &str) -> Result<(), DaoError>; + //async fn delete_slot(&self, id: &Uuid, process: &str) -> Result<(), DaoError>; + async fn update_slot(&self, slot: &SlotEntity, process: &str) -> Result<(), DaoError>; +} diff --git a/dao_impl/Cargo.toml b/dao_impl/Cargo.toml index 8cffcca..aa63399 100644 --- a/dao_impl/Cargo.toml +++ b/dao_impl/Cargo.toml @@ -5,6 +5,7 @@ edition = "2021" [dependencies] async-trait = "0.1.80" +uuid = "1.8.0" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -14,3 +15,7 @@ features = ["runtime-tokio", "sqlite"] [dependencies.dao] path = "../dao" + +[dependencies.time] +version = "0.3.36" +features = ["parsing"] diff --git a/dao_impl/src/lib.rs b/dao_impl/src/lib.rs index 0e283ca..273e935 100644 --- a/dao_impl/src/lib.rs +++ b/dao_impl/src/lib.rs @@ -4,10 +4,12 @@ use async_trait::async_trait; use dao::DaoError; use sqlx::{query, query_as, SqlitePool}; +pub mod slot; + pub trait ResultDbErrorExt { fn map_db_error(self) -> Result; } -impl ResultDbErrorExt for Result { +impl ResultDbErrorExt for Result { fn map_db_error(self) -> Result { self.map_err(|err| DaoError::DatabaseQueryError(Box::new(err))) } diff --git a/dao_impl/src/slot.rs b/dao_impl/src/slot.rs new file mode 100644 index 0000000..526022c --- /dev/null +++ b/dao_impl/src/slot.rs @@ -0,0 +1,128 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use dao::{ + slot::{DayOfWeek, SlotEntity}, + DaoError, +}; +use sqlx::{query, SqlitePool}; +use time::{format_description::well_known::Iso8601, Date, PrimitiveDateTime, Time}; +use uuid::Uuid; + +use crate::ResultDbErrorExt; + +pub struct SlotDaoImpl { + pool: Arc, +} +impl SlotDaoImpl { + pub fn new(pool: Arc) -> Self { + Self { pool } + } +} + +#[async_trait] +impl dao::slot::SlotDao for SlotDaoImpl { + async fn get_slots(&self) -> Result, DaoError> { + let result = query!(r"SELECT id, day_of_week, time_from, time_to, valid_from, valid_to, deleted, update_version FROM slot WHERE deleted IS NULL") + .fetch_all(self.pool.as_ref()) + .await + .map_err(|err| DaoError::DatabaseQueryError(Box::new(err)))?; + result + .iter() + .map(|row| { + Ok(SlotEntity { + id: Uuid::from_slice(row.id.as_ref())?, + day_of_week: DayOfWeek::from_number(row.day_of_week as u8) + .ok_or(DaoError::InvalidDayOfWeek(row.day_of_week as u8))?, + from: Time::parse(&row.time_from, &Iso8601::TIME)?, + to: Time::parse(&row.time_to, &Iso8601::TIME)?, + valid_from: Date::parse(&row.valid_from, &Iso8601::DATE)?, + valid_to: row + .valid_to + .as_ref() + .map(|valid_to| Date::parse(valid_to, &Iso8601::DATE)) + .transpose()?, + deleted: row + .deleted + .as_ref() + .map(|deleted| PrimitiveDateTime::parse(deleted, &Iso8601::DATE)) + .transpose()?, + version: Uuid::from_slice(&row.update_version)?, + }) + }) + .collect() + } + async fn get_slot(&self, id: &Uuid) -> Result, DaoError> { + let id_vec = id.as_bytes().to_vec(); + let result = query!(r"SELECT id, day_of_week, time_from, time_to, valid_from, valid_to, deleted, update_version FROM slot WHERE id = ?", id_vec) + .fetch_optional(self.pool.as_ref()) + .await + .map_err(|err| DaoError::DatabaseQueryError(Box::new(err)))?; + result + .map(|row| { + Ok(SlotEntity { + id: Uuid::from_slice(row.id.as_ref())?, + day_of_week: DayOfWeek::from_number(row.day_of_week as u8) + .ok_or(DaoError::InvalidDayOfWeek(row.day_of_week as u8))?, + from: Time::parse(&row.time_from, &Iso8601::TIME)?, + to: Time::parse(&row.time_to, &Iso8601::TIME)?, + valid_from: Date::parse(&row.valid_from, &Iso8601::DATE)?, + valid_to: row + .valid_to + .as_ref() + .map(|valid_to| Date::parse(valid_to, &Iso8601::DATE)) + .transpose()?, + deleted: row + .deleted + .as_ref() + .map(|deleted| PrimitiveDateTime::parse(deleted, &Iso8601::DATE)) + .transpose()?, + version: Uuid::from_slice(&row.update_version)?, + }) + }) + .transpose() + } + async fn create_slot(&self, slot: &SlotEntity, process: &str) -> Result<(), DaoError> { + let id_vec = slot.id.as_bytes().to_vec(); + let version_vec = slot.version.as_bytes().to_vec(); + let day_of_week = slot.day_of_week.to_number(); + let from = slot.from.to_string(); + let to = slot.to.to_string(); + let valid_from = slot.valid_from.to_string(); + let valid_to = slot.valid_to.map(|valid_to| valid_to.to_string()); + let deleted = slot.deleted.map(|deleted| deleted.to_string()); + query!("INSERT INTO slot (id, day_of_week, time_from, time_to, valid_from, valid_to, deleted, update_version, update_process) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + id_vec, + day_of_week, + from, + to, + valid_from, + valid_to, + deleted, + version_vec, + process, + ) + .execute(self.pool.as_ref()) + .await + .map_db_error()?; + Ok(()) + } + + async fn update_slot(&self, slot: &SlotEntity, process: &str) -> Result<(), DaoError> { + let id_vec = slot.id.as_bytes().to_vec(); + let version_vec = slot.version.as_bytes().to_vec(); + let valid_to = slot.valid_to.map(|valid_to| valid_to.to_string()); + let deleted = slot.deleted.map(|deleted| deleted.to_string()); + query!("UPDATE slot SET valid_to = ?, deleted = ?, update_version = ?, update_process = ? WHERE id = ?", + valid_to, + deleted, + version_vec, + process, + id_vec, + ) + .execute(self.pool.as_ref()) + .await + .map_db_error()?; + Ok(()) + } +} diff --git a/migrations/20240502113031_add-slot.sql b/migrations/20240502113031_add-slot.sql new file mode 100644 index 0000000..6f47c71 --- /dev/null +++ b/migrations/20240502113031_add-slot.sql @@ -0,0 +1,15 @@ +-- Add migration script here + +CREATE TABLE slot ( + id blob(16) NOT NULL PRIMARY KEY, + day_of_week INTEGER NOT NULL, + time_from TEXT NOT NULL, + time_to TEXT NOT NULL, + valid_from TEXT NOT NULL, + valid_to TEXT, + deleted TEXT, + + update_timestamp TEXT, + update_process TEXT NOT NULL, + update_version blob(16) NOT NULL +); \ No newline at end of file diff --git a/rest/Cargo.toml b/rest/Cargo.toml index bbecd2f..351d235 100644 --- a/rest/Cargo.toml +++ b/rest/Cargo.toml @@ -9,8 +9,8 @@ edition = "2021" axum = "0.7.5" bytes = "1.6.0" http-body = "1.0.0" -serde = "1.0.198" serde_json = "1.0.116" +time = { version = "0.3.36", features = ["serde-human-readable"] } [dependencies.tokio] version = "1.37.0" @@ -22,3 +22,10 @@ path = "../service" [dependencies.uuid] version = "1.8.0" features = ["v4", "serde"] + +[dependencies.serde] +version = "1.0.198" +features = ["derive", "std", "alloc", "rc"] + +[dependencies.thiserror] +version = "1.0" diff --git a/rest/src/lib.rs b/rest/src/lib.rs index fc97bdc..37e2641 100644 --- a/rest/src/lib.rs +++ b/rest/src/lib.rs @@ -2,8 +2,11 @@ use std::{convert::Infallible, sync::Arc}; mod hello; mod permission; +mod slot; use axum::{body::Body, response::Response, routing::get, Router}; +use thiserror::Error; +use uuid::Uuid; pub struct RoString(Arc, bool); impl http_body::Body for RoString { @@ -39,31 +42,103 @@ impl From for Response { } } -fn error_handler(result: Result) -> Response { +#[derive(Debug, Error)] +pub enum RestError { + #[error("Service error")] + ServiceError(#[from] service::ServiceError), + + #[error("Inconsistent id. Got {0} in path but {1} in body")] + InconsistentId(Uuid, Uuid), +} + +fn error_handler(result: Result) -> Response { match result { Ok(response) => response, - Err(service::ServiceError::Forbidden) => { + Err(err @ RestError::InconsistentId(_, _)) => Response::builder() + .status(400) + .body(Body::new(err.to_string())) + .unwrap(), + Err(RestError::ServiceError(service::ServiceError::Forbidden)) => { Response::builder().status(403).body(Body::empty()).unwrap() } - Err(service::ServiceError::DatabaseQueryError(e)) => Response::builder() - .status(500) - .body(Body::new(e.to_string())) - .unwrap(), + Err(RestError::ServiceError(service::ServiceError::DatabaseQueryError(e))) => { + Response::builder() + .status(500) + .body(Body::new(e.to_string())) + .unwrap() + } + Err(RestError::ServiceError(service::ServiceError::EntityAlreadyExists(id))) => { + Response::builder() + .status(409) + .body(Body::new(id.to_string())) + .unwrap() + } + Err(RestError::ServiceError(service::ServiceError::EntityNotFound(id))) => { + Response::builder() + .status(404) + .body(Body::new(id.to_string())) + .unwrap() + } + Err(RestError::ServiceError(err @ service::ServiceError::EntityConflicts(_, _, _))) => { + Response::builder() + .status(409) + .body(Body::new(err.to_string())) + .unwrap() + } + Err(RestError::ServiceError(err @ service::ServiceError::ValidationError(_))) => { + Response::builder() + .status(422) + .body(Body::new(err.to_string())) + .unwrap() + } + Err(RestError::ServiceError(err @ service::ServiceError::IdSetOnCreate)) => { + Response::builder() + .status(422) + .body(Body::new(err.to_string())) + .unwrap() + } + Err(RestError::ServiceError(err @ service::ServiceError::VersionSetOnCreate)) => { + Response::builder() + .status(422) + .body(Body::new(err.to_string())) + .unwrap() + } + Err(RestError::ServiceError(err @ service::ServiceError::OverlappingTimeRange)) => { + Response::builder() + .status(409) + .body(Body::new(err.to_string())) + .unwrap() + } + Err(RestError::ServiceError(err @ service::ServiceError::TimeOrderWrong(_, _))) => { + Response::builder() + .status(422) + .body(Body::new(err.to_string())) + .unwrap() + } + Err(RestError::ServiceError(err @ service::ServiceError::DateOrderWrong(_, _))) => { + Response::builder() + .status(422) + .body(Body::new(err.to_string())) + .unwrap() + } } } pub trait RestStateDef: Clone + Send + Sync + 'static { type HelloService: service::HelloService + Send + Sync + 'static; type PermissionService: service::PermissionService + Send + Sync + 'static; + type SlotService: service::slot::SlotService + Send + Sync + 'static; fn hello_service(&self) -> Arc; fn permission_service(&self) -> Arc; + fn slot_service(&self) -> Arc; } pub async fn start_server(rest_state: RestState) { let app = Router::new() .route("/", get(hello::hello::)) .nest("/permission", permission::generate_route()) + .nest("/slot", slot::generate_route()) .with_state(rest_state); let listener = tokio::net::TcpListener::bind("127.0.0.1:3000") .await diff --git a/rest/src/permission.rs b/rest/src/permission.rs index 967df41..2b2d5d2 100644 --- a/rest/src/permission.rs +++ b/rest/src/permission.rs @@ -13,10 +13,10 @@ use crate::{error_handler, RestStateDef}; use service::PermissionService; #[derive(Debug, Serialize, Deserialize)] -pub struct User { +pub struct UserTO { pub name: String, } -impl From<&service::User> for User { +impl From<&service::User> for UserTO { fn from(user: &service::User) -> Self { Self { name: user.name.to_string(), @@ -25,10 +25,10 @@ impl From<&service::User> for User { } #[derive(Debug, Serialize, Deserialize)] -pub struct Role { +pub struct RoleTO { pub name: String, } -impl From<&service::Role> for Role { +impl From<&service::Role> for RoleTO { fn from(role: &service::Role) -> Self { Self { name: role.name.to_string(), @@ -37,10 +37,10 @@ impl From<&service::Role> for Role { } #[derive(Debug, Serialize, Deserialize)] -pub struct Privilege { +pub struct PrivilegeTO { pub name: String, } -impl From<&service::Privilege> for Privilege { +impl From<&service::Privilege> for PrivilegeTO { fn from(privilege: &service::Privilege) -> Self { Self { name: privilege.name.to_string(), @@ -80,7 +80,7 @@ pub fn generate_route() -> Router { pub async fn add_user( rest_state: State, - Json(user): Json, + Json(user): Json, ) -> Response { println!("Adding user: {:?}", user); error_handler( @@ -117,7 +117,7 @@ pub async fn remove_user( pub async fn add_role( rest_state: State, - Json(role): Json, + Json(role): Json, ) -> Response { error_handler( (async { @@ -238,12 +238,12 @@ pub async fn remove_role_privilege( pub async fn get_all_users(rest_state: State) -> Response { error_handler( (async { - let users: Arc<[User]> = rest_state + let users: Arc<[UserTO]> = rest_state .permission_service() .get_all_users() .await? .iter() - .map(User::from) + .map(UserTO::from) .collect(); Ok(Response::builder() .status(200) @@ -257,12 +257,12 @@ pub async fn get_all_users(rest_state: State pub async fn get_all_roles(rest_state: State) -> Response { error_handler( (async { - let roles: Arc<[Role]> = rest_state + let roles: Arc<[RoleTO]> = rest_state .permission_service() .get_all_roles() .await? .iter() - .map(Role::from) + .map(RoleTO::from) .collect(); Ok(Response::builder() .status(200) @@ -276,12 +276,12 @@ pub async fn get_all_roles(rest_state: State pub async fn get_all_privileges(rest_state: State) -> Response { error_handler( (async { - let privileges: Arc<[Privilege]> = rest_state + let privileges: Arc<[PrivilegeTO]> = rest_state .permission_service() .get_all_privileges() .await? .iter() - .map(Privilege::from) + .map(PrivilegeTO::from) .collect(); Ok(Response::builder() .status(200) diff --git a/rest/src/slot.rs b/rest/src/slot.rs new file mode 100644 index 0000000..44dcf37 --- /dev/null +++ b/rest/src/slot.rs @@ -0,0 +1,182 @@ +use std::sync::Arc; + +use axum::{ + body::Body, + extract::{Path, State}, + response::Response, + routing::{get, post, put}, + Json, Router, +}; +use serde::{Deserialize, Serialize}; +use service::slot::SlotService; +use uuid::Uuid; + +use crate::{error_handler, RestError, RestStateDef}; + +#[derive(Debug, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)] +pub enum DayOfWeek { + Monday, + Tuesday, + Wednesday, + Thursday, + Friday, + Saturday, + Sunday, +} +impl From for DayOfWeek { + fn from(day_of_week: service::slot::DayOfWeek) -> Self { + match day_of_week { + service::slot::DayOfWeek::Monday => Self::Monday, + service::slot::DayOfWeek::Tuesday => Self::Tuesday, + service::slot::DayOfWeek::Wednesday => Self::Wednesday, + service::slot::DayOfWeek::Thursday => Self::Thursday, + service::slot::DayOfWeek::Friday => Self::Friday, + service::slot::DayOfWeek::Saturday => Self::Saturday, + service::slot::DayOfWeek::Sunday => Self::Sunday, + } + } +} +impl From for service::slot::DayOfWeek { + fn from(day_of_week: DayOfWeek) -> Self { + match day_of_week { + DayOfWeek::Monday => Self::Monday, + DayOfWeek::Tuesday => Self::Tuesday, + DayOfWeek::Wednesday => Self::Wednesday, + DayOfWeek::Thursday => Self::Thursday, + DayOfWeek::Friday => Self::Friday, + DayOfWeek::Saturday => Self::Saturday, + DayOfWeek::Sunday => Self::Sunday, + } + } +} + +#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SlotTO { + #[serde(default)] + pub id: Uuid, + pub day_of_week: DayOfWeek, + pub from: time::Time, + pub to: time::Time, + pub valid_from: time::Date, + pub valid_to: Option, + #[serde(default)] + pub deleted: Option, + #[serde(rename = "$version")] + #[serde(default)] + pub version: Uuid, +} +impl From<&service::slot::Slot> for SlotTO { + fn from(slot: &service::slot::Slot) -> Self { + Self { + id: slot.id, + day_of_week: slot.day_of_week.into(), + from: slot.from, + to: slot.to, + valid_from: slot.valid_from, + valid_to: slot.valid_to, + deleted: slot.deleted, + version: slot.version, + } + } +} +impl From<&SlotTO> for service::slot::Slot { + fn from(slot: &SlotTO) -> Self { + Self { + id: slot.id, + day_of_week: slot.day_of_week.into(), + from: slot.from, + to: slot.to, + valid_from: slot.valid_from, + valid_to: slot.valid_to, + deleted: slot.deleted, + version: slot.version, + } + } +} + +pub fn generate_route() -> Router { + Router::new() + .route("/", get(get_all_slots::)) + .route("/:id", get(get_slot::)) + .route("/", post(create_slot::)) + .route("/:id", put(update_slot::)) +} + +pub async fn get_all_slots(rest_state: State) -> Response { + error_handler( + (async { + let slots: Arc<[SlotTO]> = rest_state + .slot_service() + .get_slots() + .await? + .iter() + .map(SlotTO::from) + .collect(); + Ok(Response::builder() + .status(200) + .body(Body::new(serde_json::to_string(&slots).unwrap())) + .unwrap()) + }) + .await, + ) +} + +pub async fn get_slot( + rest_state: State, + Path(slot_id): Path, +) -> Response { + error_handler( + (async { + let slot = SlotTO::from(&rest_state.slot_service().get_slot(&slot_id).await?.into()); + Ok(Response::builder() + .status(200) + .body(Body::new(serde_json::to_string(&slot).unwrap())) + .unwrap()) + }) + .await, + ) +} + +pub async fn create_slot( + rest_state: State, + Json(slot): Json, +) -> Response { + error_handler( + (async { + let slot = SlotTO::from( + &rest_state + .slot_service() + .create_slot(&(&slot).into()) + .await?, + ); + Ok(Response::builder() + .status(200) + .body(Body::new(serde_json::to_string(&slot).unwrap())) + .unwrap()) + }) + .await, + ) +} + +pub async fn update_slot( + rest_state: State, + Path(slot_id): Path, + Json(slot): Json, +) -> Response { + error_handler( + (async { + if slot_id != slot.id { + return Err(RestError::InconsistentId(slot_id, slot.id)); + } + rest_state + .slot_service() + .update_slot(&(&slot).into()) + .await?; + Ok(Response::builder() + .status(200) + .body(Body::new(serde_json::to_string(&slot).unwrap())) + .unwrap()) + }) + .await, + ) +} diff --git a/service/Cargo.toml b/service/Cargo.toml index c0f9788..8aa6df3 100644 --- a/service/Cargo.toml +++ b/service/Cargo.toml @@ -6,6 +6,8 @@ edition = "2021" [dependencies] async-trait = "0.1.80" mockall = "0.12.1" +time = "0.3.36" +uuid = "1.8.0" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/service/src/clock.rs b/service/src/clock.rs new file mode 100644 index 0000000..4880afd --- /dev/null +++ b/service/src/clock.rs @@ -0,0 +1,8 @@ +use mockall::automock; + +#[automock] +pub trait ClockService { + fn time_now(&self) -> time::Time; + fn date_now(&self) -> time::Date; + fn date_time_now(&self) -> time::PrimitiveDateTime; +} diff --git a/service/src/lib.rs b/service/src/lib.rs index 6c865b3..fe64ce4 100644 --- a/service/src/lib.rs +++ b/service/src/lib.rs @@ -2,8 +2,14 @@ use async_trait::async_trait; use mockall::automock; use std::{future::Future, sync::Arc}; use thiserror::Error; +use time::Date; +use time::Time; +use uuid::Uuid; +pub mod clock; pub mod permission; +pub mod slot; +pub mod uuid_service; pub use permission::MockPermissionService; pub use permission::PermissionService; @@ -11,6 +17,11 @@ pub use permission::Privilege; pub use permission::Role; pub use permission::User; +#[derive(Debug, PartialEq, Eq)] +pub enum ValidationFailureItem { + ModificationNotAllowed(Arc), +} + #[derive(Debug, Error)] pub enum ServiceError { #[error("Database query error: {0}")] @@ -18,6 +29,33 @@ pub enum ServiceError { #[error("Forbidden")] Forbidden, + + #[error("Entity {0} aready exists")] + EntityAlreadyExists(Uuid), + + #[error("Entity {0} not found")] + EntityNotFound(Uuid), + + #[error("Entity {0} conflicts, expected version {1} but got {2}")] + EntityConflicts(Uuid, Uuid, Uuid), + + #[error("Validation error: {0:?}")] + ValidationError(Arc<[ValidationFailureItem]>), + + #[error("ID cannot be set on create")] + IdSetOnCreate, + + #[error("Version cannot be set on create")] + VersionSetOnCreate, + + #[error("Overlapping time range")] + OverlappingTimeRange, + + #[error("Time order wrong. {0} must is not smaller or equal to {1}")] + TimeOrderWrong(Time, Time), + + #[error("Date order wrong. {0} must is not smaller or equal to {1}")] + DateOrderWrong(Date, Date), } #[automock] diff --git a/service/src/slot.rs b/service/src/slot.rs new file mode 100644 index 0000000..63b4348 --- /dev/null +++ b/service/src/slot.rs @@ -0,0 +1,93 @@ +use async_trait::async_trait; +use mockall::automock; +use std::sync::Arc; +use uuid::Uuid; + +use crate::ServiceError; + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum DayOfWeek { + Monday, + Tuesday, + Wednesday, + Thursday, + Friday, + Saturday, + Sunday, +} +impl From for DayOfWeek { + fn from(day_of_week: dao::slot::DayOfWeek) -> Self { + match day_of_week { + dao::slot::DayOfWeek::Monday => Self::Monday, + dao::slot::DayOfWeek::Tuesday => Self::Tuesday, + dao::slot::DayOfWeek::Wednesday => Self::Wednesday, + dao::slot::DayOfWeek::Thursday => Self::Thursday, + dao::slot::DayOfWeek::Friday => Self::Friday, + dao::slot::DayOfWeek::Saturday => Self::Saturday, + dao::slot::DayOfWeek::Sunday => Self::Sunday, + } + } +} +impl From for dao::slot::DayOfWeek { + fn from(day_of_week: DayOfWeek) -> Self { + match day_of_week { + DayOfWeek::Monday => Self::Monday, + DayOfWeek::Tuesday => Self::Tuesday, + DayOfWeek::Wednesday => Self::Wednesday, + DayOfWeek::Thursday => Self::Thursday, + DayOfWeek::Friday => Self::Friday, + DayOfWeek::Saturday => Self::Saturday, + DayOfWeek::Sunday => Self::Sunday, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Slot { + pub id: Uuid, + pub day_of_week: DayOfWeek, + pub from: time::Time, + pub to: time::Time, + pub valid_from: time::Date, + pub valid_to: Option, + pub deleted: Option, + pub version: Uuid, +} +impl From<&dao::slot::SlotEntity> for Slot { + fn from(slot: &dao::slot::SlotEntity) -> Self { + Self { + id: slot.id, + day_of_week: slot.day_of_week.into(), + from: slot.from, + to: slot.to, + valid_from: slot.valid_from, + valid_to: slot.valid_to, + deleted: slot.deleted, + version: slot.version, + } + } +} +impl From<&Slot> for dao::slot::SlotEntity { + fn from(slot: &Slot) -> Self { + Self { + id: slot.id, + day_of_week: slot.day_of_week.into(), + from: slot.from, + to: slot.to, + valid_from: slot.valid_from, + valid_to: slot.valid_to, + deleted: slot.deleted, + version: slot.version, + } + } +} + +#[automock] +#[async_trait] +pub trait SlotService { + async fn get_slots(&self) -> Result, ServiceError>; + async fn get_slot(&self, id: &Uuid) -> Result; + async fn create_slot(&self, slot: &Slot) -> Result; + async fn delete_slot(&self, id: &Uuid) -> Result<(), ServiceError>; + async fn update_slot(&self, slot: &Slot) -> Result<(), ServiceError>; +} diff --git a/service/src/uuid_service.rs b/service/src/uuid_service.rs new file mode 100644 index 0000000..2b9dd74 --- /dev/null +++ b/service/src/uuid_service.rs @@ -0,0 +1,7 @@ +use mockall::automock; +use uuid::Uuid; + +#[automock] +pub trait UuidService { + fn new_uuid(&self, usage: &str) -> Uuid; +} diff --git a/service_impl/Cargo.toml b/service_impl/Cargo.toml index 258c446..3dee49f 100644 --- a/service_impl/Cargo.toml +++ b/service_impl/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" [dependencies] async-trait = "0.1.80" mockall = "0.12.1" +tokio = "1.37.0" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -15,6 +16,14 @@ path = "../service" [dependencies.dao] path = "../dao" +[dependencies.time] +version = "0.3.36" +features = ["std"] + +[dependencies.uuid] +version = "1.8.0" +features = ["v4"] + [dev-dependencies.tokio] version = "1.37.0" features = ["full"] diff --git a/service_impl/src/clock.rs b/service_impl/src/clock.rs new file mode 100644 index 0000000..496cbc1 --- /dev/null +++ b/service_impl/src/clock.rs @@ -0,0 +1,16 @@ +use service::clock::ClockService; +use time::OffsetDateTime; + +pub struct ClockServiceImpl; +impl ClockService for ClockServiceImpl { + fn time_now(&self) -> time::Time { + OffsetDateTime::now_utc().time() + } + fn date_now(&self) -> time::Date { + OffsetDateTime::now_utc().date() + } + fn date_time_now(&self) -> time::PrimitiveDateTime { + let now = OffsetDateTime::now_utc(); + time::PrimitiveDateTime::new(now.date(), now.time()) + } +} diff --git a/service_impl/src/lib.rs b/service_impl/src/lib.rs index 218bf2e..f77eacc 100644 --- a/service_impl/src/lib.rs +++ b/service_impl/src/lib.rs @@ -2,9 +2,11 @@ use std::sync::Arc; use async_trait::async_trait; -mod permission; -#[cfg(test)] +pub mod clock; +pub mod permission; +pub mod slot; mod test; +pub mod uuid_service; pub use permission::PermissionServiceImpl; diff --git a/service_impl/src/slot.rs b/service_impl/src/slot.rs new file mode 100644 index 0000000..f0f5356 --- /dev/null +++ b/service_impl/src/slot.rs @@ -0,0 +1,199 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use service::{slot::Slot, ServiceError, ValidationFailureItem}; +use tokio::join; +use uuid::Uuid; + +const SLOT_SERVICE_PROCESS: &str = "slot-service"; + +pub struct SlotServiceImpl +where + SlotDao: dao::slot::SlotDao + Send + Sync, + PermissionService: service::permission::PermissionService + Send + Sync, + ClockService: service::clock::ClockService + Send + Sync, + UuidService: service::uuid_service::UuidService + Send + Sync, +{ + pub slot_dao: Arc, + pub permission_service: Arc, + pub clock_service: Arc, + pub uuid_service: Arc, +} +impl + SlotServiceImpl +where + SlotDao: dao::slot::SlotDao + Send + Sync, + PermissionService: service::permission::PermissionService + Send + Sync, + ClockService: service::clock::ClockService + Send + Sync, + UuidService: service::uuid_service::UuidService + Send + Sync, +{ + pub fn new( + slot_dao: Arc, + permission_service: Arc, + clock_service: Arc, + uuid_service: Arc, + ) -> Self { + Self { + slot_dao, + permission_service, + clock_service, + uuid_service, + } + } +} + +pub fn test_overlapping_slots(slot_1: &Slot, slot_2: &Slot) -> bool { + slot_1.day_of_week == slot_2.day_of_week + && (slot_2.from < slot_1.from && slot_1.from < slot_2.to + || slot_1.from < slot_2.from && slot_2.from < slot_1.to + || slot_1.from == slot_2.from && slot_1.to == slot_2.to) +} + +#[async_trait] +impl service::slot::SlotService + for SlotServiceImpl +where + SlotDao: dao::slot::SlotDao + Send + Sync, + PermissionService: service::permission::PermissionService + Send + Sync, + ClockService: service::clock::ClockService + Send + Sync, + UuidService: service::uuid_service::UuidService + Send + Sync, +{ + async fn get_slots(&self) -> Result, ServiceError> { + let (hr_permission, sales_permission) = join!( + self.permission_service.check_permission("hr"), + self.permission_service.check_permission("sales"), + ); + hr_permission.or(sales_permission)?; + + Ok(self + .slot_dao + .get_slots() + .await? + .iter() + .map(Slot::from) + .collect()) + } + async fn get_slot(&self, id: &Uuid) -> Result { + let (hr_permission, sales_permission) = join!( + self.permission_service.check_permission("hr"), + self.permission_service.check_permission("sales"), + ); + hr_permission.or(sales_permission)?; + + let slot_entity = self.slot_dao.get_slot(id).await?; + let slot = slot_entity + .as_ref() + .map(Slot::from) + .ok_or_else(move || ServiceError::EntityNotFound(*id))?; + Ok(slot) + } + async fn create_slot(&self, slot: &Slot) -> Result { + self.permission_service.check_permission("hr").await?; + + if slot.id != Uuid::nil() { + return Err(ServiceError::IdSetOnCreate); + } + if slot.version != Uuid::nil() { + return Err(ServiceError::VersionSetOnCreate); + } + if slot.from > slot.to { + return Err(ServiceError::TimeOrderWrong(slot.from, slot.to)); + } + if slot.valid_to.is_some() && slot.valid_to.unwrap() < slot.valid_from { + return Err(ServiceError::DateOrderWrong( + slot.valid_from, + slot.valid_to.unwrap(), + )); + } + + if self + .get_slots() + .await? + .iter() + .any(|s| test_overlapping_slots(slot, s)) + { + return Err(ServiceError::OverlappingTimeRange); + } + + let slot = Slot { + id: self.uuid_service.new_uuid("slot-id"), + version: self.uuid_service.new_uuid("slot-version"), + ..slot.clone() + }; + self.slot_dao + .create_slot(&(&slot).into(), SLOT_SERVICE_PROCESS) + .await?; + Ok(slot) + } + + async fn delete_slot(&self, id: &Uuid) -> Result<(), ServiceError> { + self.permission_service.check_permission("hr").await?; + let mut slot = self + .slot_dao + .get_slot(id) + .await? + .ok_or(ServiceError::EntityNotFound(*id))?; + slot.deleted = Some(self.clock_service.date_time_now()); + self.slot_dao + .update_slot(&slot, SLOT_SERVICE_PROCESS) + .await?; + Ok(()) + } + async fn update_slot(&self, slot: &Slot) -> Result<(), ServiceError> { + self.permission_service.check_permission("hr").await?; + let persisted_slot = self + .slot_dao + .get_slot(&slot.id) + .await? + .ok_or(ServiceError::EntityNotFound(slot.id))?; + if persisted_slot.version != slot.version { + return Err(ServiceError::EntityConflicts( + slot.id, + persisted_slot.version, + slot.version, + )); + } + if slot.valid_to.is_some() && slot.valid_to.unwrap() < slot.valid_from { + return Err(ServiceError::DateOrderWrong( + slot.valid_from, + slot.valid_to.unwrap(), + )); + } + + let mut validation = Vec::new(); + if persisted_slot.day_of_week != slot.day_of_week.into() { + validation.push(ValidationFailureItem::ModificationNotAllowed( + "day_of_week".into(), + )); + } + if persisted_slot.from != slot.from { + validation.push(ValidationFailureItem::ModificationNotAllowed("from".into())); + } + if persisted_slot.to != slot.to { + validation.push(ValidationFailureItem::ModificationNotAllowed("to".into())); + } + if persisted_slot.valid_from != slot.valid_from { + validation.push(ValidationFailureItem::ModificationNotAllowed( + "valid_from".into(), + )); + } + if persisted_slot.valid_to.is_some() && persisted_slot.valid_to != slot.valid_to { + validation.push(ValidationFailureItem::ModificationNotAllowed( + "valid_to".into(), + )); + } + + if !validation.is_empty() { + return Err(ServiceError::ValidationError(validation.into())); + } + + let slot = Slot { + version: self.uuid_service.new_uuid("slot-version"), + ..slot.clone() + }; + self.slot_dao + .update_slot(&(&slot).into(), SLOT_SERVICE_PROCESS) + .await?; + Ok(()) + } +} diff --git a/service_impl/src/test/mod.rs b/service_impl/src/test/mod.rs index 3f46c57..d5479d7 100644 --- a/service_impl/src/test/mod.rs +++ b/service_impl/src/test/mod.rs @@ -1 +1,4 @@ +#[cfg(test)] mod permission_test; +#[cfg(test)] +pub mod slot; diff --git a/service_impl/src/test/permission_test.rs b/service_impl/src/test/permission_test.rs index 24796b0..c730ae5 100644 --- a/service_impl/src/test/permission_test.rs +++ b/service_impl/src/test/permission_test.rs @@ -20,7 +20,7 @@ fn generate_dependencies_mocks_permission( (permission_dao, user_service) } -fn test_forbidden(result: &Result) { +pub fn test_forbidden(result: &Result) { if let Err(service::ServiceError::Forbidden) = result { // All good } else { diff --git a/service_impl/src/test/slot.rs b/service_impl/src/test/slot.rs new file mode 100644 index 0000000..f37216a --- /dev/null +++ b/service_impl/src/test/slot.rs @@ -0,0 +1,924 @@ +use std::sync::Arc; + +use crate::slot::*; +use crate::test::permission_test::test_forbidden; +use dao::slot::{MockSlotDao, SlotEntity}; +use mockall::predicate::eq; +use service::{ + clock::MockClockService, slot::*, uuid_service::MockUuidService, MockPermissionService, + ValidationFailureItem, +}; +use time::{Date, Month, PrimitiveDateTime, Time}; +use tokio; +use uuid::{uuid, Uuid}; + +pub fn default_id() -> Uuid { + uuid!("682DA62E-20CB-49D9-A2A7-3F53C6842405") +} +pub fn default_version() -> Uuid { + uuid!("86DE856C-D176-4F1F-A4FE-0D9844C02C03") +} +pub fn default_changed_version() -> Uuid { + uuid!("4A818852-45D2-400F-A02A-755D34FFE815") +} + +pub fn generate_default_slot() -> Slot { + Slot { + id: default_id(), + day_of_week: DayOfWeek::Monday, + from: time::Time::from_hms(10, 0, 0).unwrap(), + to: time::Time::from_hms(11, 0, 0).unwrap(), + valid_from: time::Date::from_calendar_date(2022, 1.try_into().unwrap(), 1).unwrap(), + valid_to: None, + deleted: None, + version: default_version(), + } +} +pub fn generate_default_slot_entity() -> SlotEntity { + SlotEntity { + id: uuid!("682DA62E-20CB-49D9-A2A7-3F53C6842405"), + day_of_week: dao::slot::DayOfWeek::Monday, + from: time::Time::from_hms(10, 0, 0).unwrap(), + to: time::Time::from_hms(11, 0, 0).unwrap(), + valid_from: time::Date::from_calendar_date(2022, 1.try_into().unwrap(), 1).unwrap(), + valid_to: None, + deleted: None, + version: uuid!("86DE856C-D176-4F1F-A4FE-0D9844C02C03"), + } +} + +pub fn test_not_found(result: &Result, target_id: &Uuid) { + if let Err(service::ServiceError::EntityNotFound(id)) = result { + assert_eq!( + id, target_id, + "Expected entity {} not found but got {}", + target_id, id + ); + } else { + panic!("Expected entity {} not found error", target_id); + } +} + +pub fn test_zero_id_error(result: &Result) { + if let Err(service::ServiceError::IdSetOnCreate) = result { + } else { + panic!("Expected id set on create error"); + } +} + +pub fn test_zero_version_error(result: &Result) { + if let Err(service::ServiceError::VersionSetOnCreate) = result { + } else { + panic!("Expected version set on create error"); + } +} + +pub fn test_overlapping_time_range_error(result: &Result) { + if let Err(service::ServiceError::OverlappingTimeRange) = result { + } else { + panic!("Expected overlapping time range error"); + } +} + +pub fn test_time_order_wrong(result: &Result) { + if let Err(service::ServiceError::TimeOrderWrong(_from, _to)) = result { + } else { + panic!("Expected time order failure"); + } +} + +pub fn test_date_order_wrong(result: &Result) { + if let Err(service::ServiceError::DateOrderWrong(_from, _to)) = result { + } else { + panic!("Expected date order failure"); + } +} + +pub fn test_conflicts( + result: &Result, + target_id: &Uuid, + expected_version: &Uuid, + actual_version: &Uuid, +) { + if let Err(service::ServiceError::EntityConflicts( + err_id, + err_expected_version, + err_actual_version, + )) = result + { + assert_eq!( + err_id, target_id, + "Expected entity {} conflicts but got {}", + target_id, err_id + ); + + assert_eq!( + expected_version, err_expected_version, + "Expected expected version {} but got {}", + expected_version, err_expected_version + ); + assert_eq!( + actual_version, err_actual_version, + "Expected actual version {} but got {}", + actual_version, err_actual_version + ); + } else { + panic!("Expected entity {} conflicts error", target_id); + } +} + +pub fn test_validation_error( + result: &Result<(), service::ServiceError>, + validation_failure: &ValidationFailureItem, + fail_count: usize, +) { + if let Err(service::ServiceError::ValidationError(validation_failure_items)) = result { + if !validation_failure_items.contains(validation_failure) { + panic!( + "Validation failure not found: {:?} in {:?}", + validation_failure, validation_failure_items + ); + } + assert_eq!(fail_count, validation_failure_items.len()); + } else { + panic!("Expected validation error"); + } +} + +pub struct SlotServiceDependencies { + pub slot_dao: MockSlotDao, + pub permission_service: MockPermissionService, + pub clock_service: MockClockService, + pub uuid_service: MockUuidService, +} +impl SlotServiceDependencies { + pub fn build_service( + self, + ) -> SlotServiceImpl + { + SlotServiceImpl::new( + self.slot_dao.into(), + self.permission_service.into(), + self.clock_service.into(), + self.uuid_service.into(), + ) + } +} + +pub fn build_dependencies(permission: bool, role: &'static str) -> SlotServiceDependencies { + let slot_dao = MockSlotDao::new(); + let mut permission_service = MockPermissionService::new(); + permission_service + .expect_check_permission() + .with(eq(role)) + .returning(move |_| { + if permission { + Ok(()) + } else { + Err(service::ServiceError::Forbidden) + } + }); + permission_service + .expect_check_permission() + .returning(move |_| Err(service::ServiceError::Forbidden)); + let mut clock_service = MockClockService::new(); + clock_service + .expect_time_now() + .returning(|| time::Time::from_hms(23, 42, 0).unwrap()); + clock_service + .expect_date_now() + .returning(|| time::Date::from_calendar_date(2063, 4.try_into().unwrap(), 5).unwrap()); + clock_service.expect_date_time_now().returning(|| { + time::PrimitiveDateTime::new( + time::Date::from_calendar_date(2063, 4.try_into().unwrap(), 5).unwrap(), + time::Time::from_hms(23, 42, 0).unwrap(), + ) + }); + let uuid_service = MockUuidService::new(); + + SlotServiceDependencies { + slot_dao, + permission_service, + clock_service, + uuid_service, + } +} + +#[tokio::test] +async fn test_get_slots() { + let mut dependencies = build_dependencies(true, "hr"); + dependencies.slot_dao.expect_get_slots().returning(|| { + Ok(Arc::new([ + SlotEntity { + id: uuid!("DA703BC1-F488-4E4F-BA10-0972196639F7"), + version: uuid!("FAC4FAD9-89AE-4E56-9608-03C56558B192"), + ..generate_default_slot_entity() + }, + generate_default_slot_entity(), + ])) + }); + + let slot_service = dependencies.build_service(); + + let result = slot_service.get_slots().await; + assert!(result.is_ok()); + let result = result.unwrap(); + + assert_eq!(result.len(), 2); + assert_eq!( + result[0], + Slot { + id: uuid!("DA703BC1-F488-4E4F-BA10-0972196639F7"), + version: uuid!("FAC4FAD9-89AE-4E56-9608-03C56558B192"), + ..generate_default_slot() + }, + ); + assert_eq!(result[1], generate_default_slot(),); +} + +#[tokio::test] +async fn test_get_slots_sales_role() { + let mut dependencies = build_dependencies(true, "hr"); + dependencies + .slot_dao + .expect_get_slots() + .returning(|| Ok(Arc::new([]))); + let slot_service = dependencies.build_service(); + let result = slot_service.get_slots().await; + assert!(result.is_ok()); +} + +#[tokio::test] +async fn test_get_slots_no_permission() { + let mut dependencies = build_dependencies(false, "hr"); + dependencies + .slot_dao + .expect_get_slots() + .returning(|| Ok(Arc::new([]))); + let slot_service = dependencies.build_service(); + let result = slot_service.get_slots().await; + test_forbidden(&result); +} + +#[tokio::test] +async fn test_get_slot() { + let mut dependencies = build_dependencies(true, "hr"); + dependencies + .slot_dao + .expect_get_slot() + .with(eq(default_id())) + .times(1) + .returning(|_| Ok(Some(generate_default_slot_entity()))); + let slot_service = dependencies.build_service(); + let result = slot_service.get_slot(&default_id()).await; + assert!(result.is_ok()); + let result = result.unwrap(); + assert_eq!(result, generate_default_slot()); +} + +#[tokio::test] +async fn test_get_slot_sales_role() { + let mut dependencies = build_dependencies(true, "sales"); + dependencies + .slot_dao + .expect_get_slot() + .with(eq(default_id())) + .times(1) + .returning(|_| Ok(Some(generate_default_slot_entity()))); + let slot_service = dependencies.build_service(); + let result = slot_service.get_slot(&default_id()).await; + assert!(result.is_ok()); +} + +#[tokio::test] +async fn test_get_slot_not_found() { + let mut dependencies = build_dependencies(true, "hr"); + dependencies + .slot_dao + .expect_get_slot() + .with(eq(default_id())) + .times(1) + .returning(|_| Ok(None)); + let slot_service = dependencies.build_service(); + let result = slot_service.get_slot(&default_id()).await; + test_not_found(&result, &default_id()); +} + +#[tokio::test] +async fn test_get_slot_no_permission() { + let dependencies = build_dependencies(false, "hr"); + let slot_service = dependencies.build_service(); + let result = slot_service.get_slot(&default_id()).await; + test_forbidden(&result); +} + +#[tokio::test] +async fn test_create_slot() { + let mut dependencies = build_dependencies(true, "hr"); + dependencies + .slot_dao + .expect_create_slot() + .with(eq(generate_default_slot_entity()), eq("slot-service")) + .times(1) + .returning(|_, _| Ok(())); + dependencies + .uuid_service + .expect_new_uuid() + .with(eq("slot-id")) + .returning(|_| default_id()); + dependencies + .uuid_service + .expect_new_uuid() + .with(eq("slot-version")) + .returning(|_| default_version()); + dependencies + .slot_dao + .expect_get_slots() + .returning(|| Ok(Arc::new([]))); + + let slot_service = dependencies.build_service(); + let result = slot_service + .create_slot(&Slot { + id: Uuid::nil(), + version: Uuid::nil(), + ..generate_default_slot() + }) + .await; + assert!(result.is_ok()); + assert_eq!(result.unwrap(), generate_default_slot()); +} + +#[tokio::test] +async fn test_create_slot_no_permission() { + let dependencies = build_dependencies(false, "hr"); + let slot_service = dependencies.build_service(); + let result = slot_service.create_slot(&generate_default_slot()).await; + test_forbidden(&result); +} + +#[tokio::test] +async fn test_create_slot_non_zero_id() { + let mut dependencies = build_dependencies(true, "hr"); + dependencies + .uuid_service + .expect_new_uuid() + .with(eq("slot-id")) + .returning(|_| default_id()); + dependencies + .uuid_service + .expect_new_uuid() + .with(eq("slot-version")) + .returning(|_| default_version()); + let slot_service = dependencies.build_service(); + let result = slot_service + .create_slot(&Slot { + version: Uuid::nil(), + ..generate_default_slot() + }) + .await; + test_zero_id_error(&result); +} + +#[tokio::test] +async fn test_create_slot_non_zero_version() { + let mut dependencies = build_dependencies(true, "hr"); + dependencies + .uuid_service + .expect_new_uuid() + .with(eq("slot-id")) + .returning(|_| default_id()); + dependencies + .uuid_service + .expect_new_uuid() + .with(eq("slot-version")) + .returning(|_| default_version()); + let slot_service = dependencies.build_service(); + let result = slot_service + .create_slot(&Slot { + id: Uuid::nil(), + ..generate_default_slot() + }) + .await; + test_zero_version_error(&result); +} + +#[tokio::test] +async fn test_create_slot_intersects() { + let mut dependencies = build_dependencies(true, "hr"); + dependencies.slot_dao.expect_get_slots().returning(|| { + Ok(Arc::new([ + generate_default_slot_entity(), + SlotEntity { + id: Uuid::new_v4(), + from: Time::from_hms(12, 0, 0).unwrap(), + to: Time::from_hms(13, 0, 0).unwrap(), + ..generate_default_slot_entity() + }, + SlotEntity { + id: Uuid::new_v4(), + day_of_week: DayOfWeek::Wednesday.into(), + from: Time::from_hms(11, 0, 0).unwrap(), + to: Time::from_hms(12, 0, 0).unwrap(), + ..generate_default_slot_entity() + }, + ])) + }); + dependencies + .slot_dao + .expect_create_slot() + .returning(|_, _| Ok(())); + dependencies + .uuid_service + .expect_new_uuid() + .with(eq("slot-id")) + .returning(|_| default_id()); + dependencies + .uuid_service + .expect_new_uuid() + .with(eq("slot-version")) + .returning(|_| default_version()); + let slot_service = dependencies.build_service(); + + // Test successful case, directly between two existing slots. + let result = slot_service + .create_slot(&Slot { + id: Uuid::nil(), + version: Uuid::nil(), + from: Time::from_hms(11, 0, 0).unwrap(), + to: Time::from_hms(12, 0, 0).unwrap(), + ..generate_default_slot() + }) + .await; + assert!(result.is_ok()); + + // Test case where it is exactly on an existing slot. + let result = slot_service + .create_slot(&Slot { + id: Uuid::nil(), + version: Uuid::nil(), + from: Time::from_hms(10, 0, 0).unwrap(), + to: Time::from_hms(11, 0, 0).unwrap(), + ..generate_default_slot() + }) + .await; + test_overlapping_time_range_error(&result); + + // Test case where from is inside an existing slot. + let result = slot_service + .create_slot(&Slot { + id: Uuid::nil(), + version: Uuid::nil(), + from: Time::from_hms(10, 30, 0).unwrap(), + to: Time::from_hms(11, 30, 0).unwrap(), + ..generate_default_slot() + }) + .await; + test_overlapping_time_range_error(&result); + + // Test case where to is inside an existing slot. + let result = slot_service + .create_slot(&Slot { + id: Uuid::nil(), + version: Uuid::nil(), + from: Time::from_hms(11, 30, 0).unwrap(), + to: Time::from_hms(12, 30, 0).unwrap(), + ..generate_default_slot() + }) + .await; + test_overlapping_time_range_error(&result); + + // Test case where is completely inside an existing slot. + let result = slot_service + .create_slot(&Slot { + id: Uuid::nil(), + version: Uuid::nil(), + from: Time::from_hms(10, 15, 0).unwrap(), + to: Time::from_hms(10, 45, 0).unwrap(), + ..generate_default_slot() + }) + .await; + test_overlapping_time_range_error(&result); + + // Test case where is completely outside of an existing slot. + let result = slot_service + .create_slot(&Slot { + id: Uuid::nil(), + version: Uuid::nil(), + from: Time::from_hms(9, 0, 0).unwrap(), + to: Time::from_hms(11, 0, 0).unwrap(), + ..generate_default_slot() + }) + .await; + test_overlapping_time_range_error(&result); + + // Test case where is would intersect on monday but not on tuesday. + // Test case where is completely outside of an existing slot. + let result = slot_service + .create_slot(&Slot { + id: Uuid::nil(), + version: Uuid::nil(), + day_of_week: DayOfWeek::Tuesday.into(), + ..generate_default_slot() + }) + .await; + assert!(result.is_ok()); +} + +#[tokio::test] +async fn test_create_slot_time_order() { + let mut dependencies = build_dependencies(true, "hr"); + dependencies + .slot_dao + .expect_create_slot() + .returning(|_, _| Ok(())); + dependencies + .slot_dao + .expect_get_slots() + .returning(|| Ok(Arc::new([]))); + + let slot_service = dependencies.build_service(); + let result = slot_service + .create_slot(&Slot { + id: Uuid::nil(), + version: Uuid::nil(), + from: Time::from_hms(12, 00, 0).unwrap(), + to: Time::from_hms(11, 00, 00).unwrap(), + ..generate_default_slot() + }) + .await; + test_time_order_wrong(&result); +} + +#[tokio::test] +async fn test_create_slot_date_order() { + let mut dependencies = build_dependencies(true, "hr"); + dependencies + .slot_dao + .expect_create_slot() + .returning(|_, _| Ok(())); + dependencies + .slot_dao + .expect_get_slots() + .returning(|| Ok(Arc::new([]))); + + let slot_service = dependencies.build_service(); + let result = slot_service + .create_slot(&Slot { + id: Uuid::nil(), + version: Uuid::nil(), + valid_from: Date::from_calendar_date(2022, Month::January, 2).unwrap(), + valid_to: Some(Date::from_calendar_date(2022, Month::January, 1).unwrap()), + ..generate_default_slot() + }) + .await; + test_date_order_wrong(&result); +} + +#[tokio::test] +async fn test_delete_slot() { + let mut dependencies = build_dependencies(true, "hr"); + dependencies + .slot_dao + .expect_get_slot() + .with(eq(default_id())) + .times(1) + .returning(|_| Ok(Some(generate_default_slot_entity()))); + dependencies + .slot_dao + .expect_update_slot() + .with( + eq(SlotEntity { + deleted: Some(PrimitiveDateTime::new( + Date::from_calendar_date(2063, time::Month::April, 5).unwrap(), + Time::from_hms(23, 42, 0).unwrap(), + )), + ..generate_default_slot_entity() + }), + eq("slot-service"), + ) + .times(1) + .returning(|_, _| Ok(())); + + let slot_service = dependencies.build_service(); + let result = slot_service.delete_slot(&default_id()).await; + assert!(result.is_ok()); +} + +#[tokio::test] +async fn test_delete_slot_no_permission() { + let dependencies = build_dependencies(false, "hr"); + let slot_service = dependencies.build_service(); + let result = slot_service.delete_slot(&default_id()).await; + test_forbidden(&result); +} + +#[tokio::test] +async fn test_delete_slot_not_found() { + let mut dependencies = build_dependencies(true, "hr"); + dependencies + .slot_dao + .expect_get_slot() + .with(eq(default_id())) + .times(1) + .returning(|_| Ok(None)); + let slot_service = dependencies.build_service(); + let result = slot_service.delete_slot(&default_id()).await; + test_not_found(&result, &default_id()); +} + +#[tokio::test] +async fn test_update_slot_no_permission() { + let dependencies = build_dependencies(false, "hr"); + let slot_service = dependencies.build_service(); + let result = slot_service.update_slot(&generate_default_slot()).await; + test_forbidden(&result); +} + +#[tokio::test] +async fn test_update_slot_not_found() { + let mut dependencies = build_dependencies(true, "hr"); + dependencies + .slot_dao + .expect_get_slot() + .with(eq(default_id())) + .times(1) + .returning(|_| Ok(None)); + let slot_service = dependencies.build_service(); + let result = slot_service.update_slot(&generate_default_slot()).await; + test_not_found(&result, &default_id()); +} + +#[tokio::test] +async fn test_update_slot_version_mismatch() { + let mut dependencies = build_dependencies(true, "hr"); + dependencies + .slot_dao + .expect_get_slot() + .with(eq(default_id())) + .returning(|_| Ok(Some(generate_default_slot_entity()))); + let slot_service = dependencies.build_service(); + let result = slot_service + .update_slot(&service::slot::Slot { + version: uuid!("86DE856C-D176-4F1F-A4FE-0D9844C02C04"), + ..generate_default_slot() + }) + .await; + test_conflicts( + &result, + &default_id(), + &default_version(), + &uuid!("86DE856C-D176-4F1F-A4FE-0D9844C02C04"), + ); +} + +#[tokio::test] +async fn test_update_slot_valid_to() { + let mut dependencies = build_dependencies(true, "hr"); + dependencies + .slot_dao + .expect_update_slot() + .once() + .with( + eq(dao::slot::SlotEntity { + valid_to: Some( + time::Date::from_calendar_date(2022, 1.try_into().unwrap(), 10).unwrap(), + ), + version: default_changed_version(), + ..generate_default_slot_entity() + }), + eq("slot-service"), + ) + .times(1) + .returning(|_, _| Ok(())); + dependencies + .slot_dao + .expect_get_slot() + .with(eq(default_id())) + .returning(|_| Ok(Some(generate_default_slot_entity()))); + dependencies + .uuid_service + .expect_new_uuid() + .with(eq("slot-version")) + .returning(|_| default_changed_version()); + + let slot_service = dependencies.build_service(); + let result = slot_service + .update_slot(&service::slot::Slot { + valid_to: Some( + time::Date::from_calendar_date(2022, 1.try_into().unwrap(), 10).unwrap(), + ), + ..generate_default_slot() + }) + .await; + dbg!(&result); + assert!(result.is_ok()); +} + +#[tokio::test] +async fn test_update_slot_valid_to_before_valid_from() { + let mut dependencies = build_dependencies(true, "hr"); + dependencies + .slot_dao + .expect_get_slot() + .with(eq(default_id())) + .returning(|_| Ok(Some(generate_default_slot_entity()))); + + let slot_service = dependencies.build_service(); + let result = slot_service + .update_slot(&service::slot::Slot { + valid_to: Some( + time::Date::from_calendar_date(2021, 1.try_into().unwrap(), 10).unwrap(), + ), + ..generate_default_slot() + }) + .await; + test_date_order_wrong(&result); +} + +#[tokio::test] +async fn test_update_slot_deleted() { + let mut dependencies = build_dependencies(true, "hr"); + dependencies + .slot_dao + .expect_get_slot() + .with(eq(default_id())) + .returning(|_| Ok(Some(generate_default_slot_entity()))); + dependencies + .slot_dao + .expect_update_slot() + .once() + .with( + eq(dao::slot::SlotEntity { + deleted: Some(time::PrimitiveDateTime::new( + Date::from_calendar_date(2022, 1.try_into().unwrap(), 10).unwrap(), + Time::from_hms(0, 0, 0).unwrap(), + )), + version: default_changed_version(), + ..generate_default_slot_entity() + }), + eq("slot-service"), + ) + .returning(|_, _| Ok(())); + dependencies + .uuid_service + .expect_new_uuid() + .with(eq("slot-version")) + .returning(|_| default_changed_version()); + + let slot_service = dependencies.build_service(); + let result = slot_service + .update_slot(&Slot { + deleted: Some(time::PrimitiveDateTime::new( + Date::from_calendar_date(2022, 1.try_into().unwrap(), 10).unwrap(), + Time::from_hms(0, 0, 0).unwrap(), + )), + ..generate_default_slot() + }) + .await; + assert!(result.is_ok()); +} + +#[tokio::test] +async fn test_update_slot_day_of_week_forbidden() { + let mut dependencies = build_dependencies(true, "hr"); + dependencies + .slot_dao + .expect_get_slot() + .with(eq(default_id())) + .returning(|_| Ok(Some(generate_default_slot_entity()))); + let slot_service = dependencies.build_service(); + let result = slot_service + .update_slot(&service::slot::Slot { + day_of_week: service::slot::DayOfWeek::Friday, + ..generate_default_slot() + }) + .await; + test_validation_error( + &result, + &ValidationFailureItem::ModificationNotAllowed("day_of_week".into()), + 1, + ); +} + +#[tokio::test] +async fn test_update_to_forbidden_when_not_none() { + let mut dependencies = build_dependencies(true, "hr"); + dependencies + .slot_dao + .expect_get_slot() + .with(eq(default_id())) + .returning(|_| { + Ok(Some(SlotEntity { + valid_to: Some( + time::Date::from_calendar_date(2022, 1.try_into().unwrap(), 3).unwrap(), + ), + ..generate_default_slot_entity() + })) + }); + let slot_service = dependencies.build_service(); + let result = slot_service + .update_slot(&service::slot::Slot { + valid_to: Some(time::Date::from_calendar_date(2022, 1.try_into().unwrap(), 4).unwrap()), + ..generate_default_slot() + }) + .await; + test_validation_error( + &result, + &ValidationFailureItem::ModificationNotAllowed("valid_to".into()), + 1, + ); +} + +#[tokio::test] +async fn test_update_from_forbidden() { + let mut dependencies = build_dependencies(true, "hr"); + dependencies + .slot_dao + .expect_get_slot() + .with(eq(default_id())) + .returning(|_| Ok(Some(generate_default_slot_entity()))); + let slot_service = dependencies.build_service(); + let result = slot_service + .update_slot(&service::slot::Slot { + from: time::Time::from_hms(14, 0, 0).unwrap(), + ..generate_default_slot() + }) + .await; + test_validation_error( + &result, + &ValidationFailureItem::ModificationNotAllowed("from".into()), + 1, + ); +} + +#[tokio::test] +async fn test_update_to_forbidden() { + let mut dependencies = build_dependencies(true, "hr"); + dependencies + .slot_dao + .expect_get_slot() + .with(eq(default_id())) + .returning(|_| Ok(Some(generate_default_slot_entity()))); + let slot_service = dependencies.build_service(); + let result = slot_service + .update_slot(&service::slot::Slot { + to: time::Time::from_hms(14, 0, 0).unwrap(), + ..generate_default_slot() + }) + .await; + test_validation_error( + &result, + &ValidationFailureItem::ModificationNotAllowed("to".into()), + 1, + ); +} + +#[tokio::test] +async fn test_update_valid_from_forbidden() { + let mut dependencies = build_dependencies(true, "hr"); + dependencies + .slot_dao + .expect_get_slot() + .with(eq(default_id())) + .returning(|_| Ok(Some(generate_default_slot_entity()))); + let slot_service = dependencies.build_service(); + let result = slot_service + .update_slot(&service::slot::Slot { + valid_from: time::Date::from_calendar_date(2022, 1.try_into().unwrap(), 10).unwrap(), + ..generate_default_slot() + }) + .await; + test_validation_error( + &result, + &ValidationFailureItem::ModificationNotAllowed("valid_from".into()), + 1, + ); +} + +#[tokio::test] +async fn test_update_valid_multiple_forbidden_changes() { + let mut dependencies = build_dependencies(true, "hr"); + dependencies + .slot_dao + .expect_get_slot() + .with(eq(default_id())) + .returning(|_| Ok(Some(generate_default_slot_entity()))); + let slot_service = dependencies.build_service(); + let result = slot_service + .update_slot(&service::slot::Slot { + valid_from: time::Date::from_calendar_date(2022, 1.try_into().unwrap(), 10).unwrap(), + from: time::Time::from_hms(14, 0, 0).unwrap(), + ..generate_default_slot() + }) + .await; + test_validation_error( + &result, + &ValidationFailureItem::ModificationNotAllowed("valid_from".into()), + 2, + ); + test_validation_error( + &result, + &ValidationFailureItem::ModificationNotAllowed("from".into()), + 2, + ); +} diff --git a/service_impl/src/uuid_service.rs b/service_impl/src/uuid_service.rs new file mode 100644 index 0000000..22e2800 --- /dev/null +++ b/service_impl/src/uuid_service.rs @@ -0,0 +1,9 @@ +use uuid::Uuid; + +pub struct UuidServiceImpl; + +impl service::uuid_service::UuidService for UuidServiceImpl { + fn new_uuid(&self, _usage: &str) -> Uuid { + Uuid::new_v4() + } +}