#![allow(unused_variables)] use std::fmt::{Display, Formatter}; use std::str::FromStr; use chrono::{Local, NaiveDate, NaiveTime, Weekday}; use lazy_static::lazy_static; use crate::backend::banner::{is_stored_image_name, BannerOptions}; use crate::backend::color::Colors; use crate::backend::layout::LayoutOptions; use regex::Regex; use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; use uuid::Uuid; use validator::{Validate, ValidationError}; use crate::error::AppError; #[derive(Serialize, Deserialize, Clone, Debug)] pub enum ApiResponse { Data(T), Error(String) } #[derive(Serialize, Deserialize, Clone, Copy, Debug, Default)] pub struct Page { page_size: i32, page_num: i32 } impl Page { pub fn new(page_size: i32) -> Self { Self { page_size, page_num: 1 } } pub fn with_page(self, page_num: i32) -> Self { Self { page_num, ..self } } pub fn limit(&self) -> i32 { if self.page_size == 0 { i32::MAX } else { self.page_size } } pub fn offset(&self) -> i32 { (self.page_num - 1) * self.page_size } pub fn page_num(&self) -> i32 { self.page_num } pub fn first(&mut self) { self.page_num = 1; } pub fn prev(&mut self) { if self.page_num > 1 { self.page_num = self.page_num - 1; } } pub fn next(&mut self) { self.page_num = self.page_num + 1; } pub fn go_to(&mut self, page: i32) { self.page_num = page; } } #[derive(Serialize, Deserialize, Clone, Debug)] pub struct PagedResponse { page: Page, record_count: i64, data: Vec } impl PagedResponse { pub fn new(data: Vec, page: Page, record_count: i64) -> Self { Self { page, record_count, data } } pub fn is_first(&self) -> bool { self.page.page_num() == 1 } pub fn is_last(&self) -> bool { self.page.offset() as i64 + self.page.limit() as i64 >= self.record_count } pub fn total_pages(&self) -> i32 { (self.record_count as f64 / self.page.limit() as f64).ceil() as i32 } pub fn data(&self) -> Vec { self.data.clone() } } #[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq, Validate, Default)] #[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] pub struct Company { id: i32, #[validate(length(min = 1,message = "Name cannot be empty"))] pub name: String, #[validate(length(min = 1,message = "Street cannot be empty"))] pub street: String, #[validate(length(min = 1,message = "House number cannot be empty"))] pub house_number: String, pub zip_code: String, pub city: String, } impl Company { pub fn id(&self) -> i32 { self.id } } #[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq, Validate, Default)] #[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] pub struct User { id: i32, pub login: String, pub password: String, pub full_name: Option, #[validate(email(message = "Enter valid email address"))] pub email: Option, pub admin: bool, pub get_emails: bool, } impl User { pub fn id(&self) -> i32 { self.id } } #[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq, Validate, Default)] pub struct UserProfile { #[validate(length(min = 1,message = "Username cannot be empty"))] login: String, #[validate(must_match(other = "password_ver", message = "Passwords doesn't match"))] password: Option, password_ver: Option, full_name: String, #[validate(email(message = "Enter valid email address"))] email: String, get_emails: Option, admin: Option } impl UserProfile { pub fn login(&self) -> &str { &self.login } pub fn full_name(&self) -> &str { &self.full_name } pub fn email(&self) -> &str { &self.email } pub fn get_emails(&self) -> bool { self.get_emails.is_some() } pub fn admin(&self) -> bool { self.admin.is_some() } pub fn password(&self) -> &Option { &self.password } } #[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq, Validate, Default)] pub struct PwdChange { login: String, old_password: String, #[validate(length(min = 1, message = "Enter new password"), must_match(other = "password_ver", message = "Passwords doesn't match"))] password: String, password_ver: String } impl PwdChange { pub fn login(&self) -> &str { &self.login } pub fn old_password(&self) -> &str { &self.old_password } pub fn password(&self) -> &str { &self.password } pub fn password_ver(&self) -> &str { &self.password_ver } } #[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq, Default)] #[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] pub struct OpeningHour { id: i32, pub day: i32, pub from: NaiveTime, pub to: NaiveTime, pub discount: Option } impl OpeningHour { pub fn id(&self) -> i32 { self.id } } #[derive(Serialize, Deserialize, Clone, Debug)] pub struct DayHours(pub Vec); impl DayHours { pub fn try_new(hours: &str) -> Result { if hours.is_empty() { return Ok(Self(Vec::new())) } let times = hours.split(",") .map(|h| h.trim()) .map(|h| DayHour::try_from(h)) .collect::>(); if times.contains(&Err(AppError::HourParseError)) { return Err(AppError::HourParseError) } Ok(Self(times.into_iter().map(|h| h.unwrap()).collect())) } pub fn hours(&self) -> &Vec { &self.0 } } impl Display for DayHours { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { if self.0.is_empty() { return write!(f, "{}", "".to_string()) } let str = self.0.iter().map(|h| { let discount = if let Some(d) = h.discount() { format!(" ({})", d).to_string() } else { "".to_string() }; format!("{} - {}{}", h.from().format("%H:%M"), h.to().format("%H:%M"), discount).to_string() }) .collect::>() .join(", "); write!(f, "{}", str) } } #[derive(Eq, PartialEq, Serialize, Deserialize, Clone, Debug)] pub struct DayHour { from: NaiveTime, to: NaiveTime, discount: Option } impl DayHour { pub fn new(from: NaiveTime, to: NaiveTime, discount: Option) -> Self { Self { from, to, discount } } pub fn from(&self) -> NaiveTime { self.from } pub fn to(&self) -> NaiveTime { self.to } pub fn discount(&self) -> Option { self.discount } } impl TryFrom for DayHour { type Error = AppError; fn try_from(value: String) -> Result { value.as_str().try_into() } } impl TryFrom<&str> for DayHour { type Error = AppError; fn try_from(value: &str) -> Result { if value.is_empty() { return Err(AppError::HourParseError) } // Optional discount in parentheses after the interval: "08:00 - 12:00 (10)" let (interval, discount) = match value.trim().strip_suffix(")") { Some(rest) => { let (interval, discount) = rest.rsplit_once("(").ok_or(AppError::HourParseError)?; if discount.is_empty() || !discount.chars().all(|c| c.is_ascii_digit()) { return Err(AppError::HourParseError) } (interval, Some(discount.parse::().map_err(|_| AppError::HourParseError)?)) } None => (value, None) }; let times = interval.split("-") .map(|t| t.trim()).collect::>(); if times.len() != 2 { return Err(AppError::HourParseError) } let from = NaiveTime::parse_from_str(times.get(0).unwrap(), "%H:%M"); let to = NaiveTime::parse_from_str(times.get(1).unwrap(), "%H:%M"); if from.is_err() || to.is_err() { return Err(AppError::HourParseError) } Ok(DayHour { from: from.unwrap(), to: to.unwrap(), discount }) } } fn hours_error(code: &'static str, message: &'static str) -> ValidationError { let mut err = ValidationError::new(code); err.message = Some(message.into()); err } fn validate_hours(value: &WeekHours) -> Result<(), ValidationError> { // Wrong format is already reported by the regex on the field if value.hours().is_empty() || !RE_HOURS.is_match(value.hours()) { return Ok(()) } if let Ok(h) = DayHours::try_new(value.hours()) { for hr in h.hours() { if hr.from() >= hr.to() { return Err(hours_error("TO_BEFORE_FROM", "Time 'to' must be after time 'from'")) } } Ok(()) } else { Err(hours_error("HOURS_FORMAT", "Hours must be in HH:MM - HH:MM format")) } } lazy_static! { static ref RE_HOURS: Regex = Regex::new( r"^$|^\d{2}:\d{2} ?- ?\d{2}:\d{2}( ?\(\d+\))?( ?, ?\d{2}:\d{2} ?- ?\d{2}:\d{2}( ?\(\d+\))?)*$").unwrap(); } #[derive(Clone, Serialize, Deserialize, Debug, Validate)] #[validate(schema(function = "validate_hours"))] pub struct WeekHours { day: Weekday, #[validate(regex(path = "RE_HOURS", message = "Hours must be in HH:MM - HH:MM format"))] hours: String } impl Default for WeekHours { fn default() -> Self { Self { day: Weekday::Mon, hours: String::default() } } } impl WeekHours { pub fn new(day: Weekday, hours: Vec) -> Self { Self { day, hours: DayHours(hours).to_string() } } pub fn day(&self) -> Weekday { self.day } pub fn hours(&self) -> &str { &self.hours } } #[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq, Default)] #[cfg_attr(feature = "ssr", derive(sqlx::Type))] #[cfg_attr(feature = "ssr", sqlx(type_name = "slot_type"))] pub enum SlotType { Quarter, Half, #[default] Hour, Day } impl Display for SlotType { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { write!(f, "{}", match self { SlotType::Quarter => {"Quarter an hour"} SlotType::Half => {"Half an hour"} SlotType::Hour => {"Hour"} SlotType::Day => {"Day"} }) } } fn def_true() -> bool { true } #[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq, Validate, Default)] #[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] pub struct ResProperty { #[cfg_attr(feature = "ssr", sqlx(default))] id: i32, #[validate(length(min = 1,message = "Name cannot be empty"))] pub name: String, pub description: String, #[cfg_attr(feature = "ssr", sqlx(default))] pub price: Decimal, #[cfg_attr(feature = "ssr", sqlx(default))] pub slot: SlotType, #[serde(default = "def_true")] #[cfg_attr(feature = "ssr", sqlx(default))] pub allow_multi: bool, #[serde(default = "def_true")] #[cfg_attr(feature = "ssr", sqlx(default))] pub active: bool } impl ResProperty { pub fn id(&self) -> i32 { self.id } } #[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq, Default)] pub struct PublicFormData { pub property: ResProperty, pub hours: Vec, pub reservations: Vec, pub closing_days: Vec } fn empty_slots() -> Vec { vec![] } fn validate_date(date: &NaiveDate) -> Result<(), ValidationError> { if date < &Local::now().date_naive() { Err(ValidationError::new("date_in_past")) } else { Ok(()) } } #[derive(Clone, Serialize, Deserialize, Debug, Validate)] pub struct CrReservation { #[validate(custom(function = "validate_date", message = "Date can't be in past"))] date: NaiveDate, #[validate(length(min = 1,message = "Select at last one time slot"))] #[serde(default = "empty_slots")] slots: Vec, #[validate(length(min = 1,message = "Enter your full name"))] full_name: String, #[validate(email(message = "Enter valid email address"))] email: String, #[validate(length(min = 1,message = "Enter your phone number"))] phone: String, note: String, remember: bool } #[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] pub struct TmCheck { pub from: NaiveTime, pub to: NaiveTime } impl FromStr for TmCheck { type Err = AppError; fn from_str(s: &str) -> Result { let times = s.split("-").collect::>(); if times.len() != 2 { return Err(AppError::HourParseError); } Ok(TmCheck{ from: NaiveTime::from_str(times.get(0).unwrap_or(&""))?, to: NaiveTime::from_str(times.get(1).unwrap_or(&""))? }) } } impl CrReservation { pub fn date(&self) -> NaiveDate { self.date } pub fn slots(&self) -> &Vec { &self.slots } pub fn full_name(&self) -> &str { &self.full_name } pub fn email(&self) -> &str { &self.email } pub fn phone(&self) -> &str { &self.phone } pub fn note(&self) -> &str { &self.note } pub fn remember(&self) -> bool { self.remember } } #[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq, Default)] #[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] pub struct Customer { #[cfg_attr(feature = "ssr", sqlx(default))] id: i32, pub full_name: String, pub email: String, pub phone: String, #[cfg_attr(feature = "ssr", sqlx(default))] pub discount: i32 } impl Customer { pub fn id(&self) -> i32 { self.id } pub fn new(id: i32, full_name: String, email: String, phone: String, discount: i32) -> Self { Self { id, full_name, email, phone, discount } } } #[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq, Default)] #[cfg_attr(feature = "ssr", derive(sqlx::Type))] #[cfg_attr(feature = "ssr", sqlx(type_name = "reservation_state"))] pub enum ReservationState { #[default] New, Approved, Canceled, } impl Display for ReservationState { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { write!(f, "{}", match self { ReservationState::New => {"New"} ReservationState::Approved => {"Approved"} ReservationState::Canceled => {"Canceled"} }) } } #[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq, Default)] #[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] pub struct Reservation { #[cfg_attr(feature = "ssr", sqlx(default))] id: i32, pub from: NaiveTime, pub to: NaiveTime, #[cfg_attr(feature = "ssr", sqlx(default))] pub property: i32, #[cfg_attr(feature = "ssr", sqlx(default))] pub summary: i32, } #[derive(Clone, Serialize, Deserialize, Debug, Default)] #[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] pub struct ResWithProperty { #[cfg_attr(feature = "ssr", sqlx(flatten))] pub reservation: Reservation, #[cfg_attr(feature = "ssr", sqlx(flatten))] pub property: ResProperty } pub struct Reservations(Vec); // Transform slots to reservations impl Reservations { pub fn new() -> Self { Reservations(vec![]) } pub fn is_empty(&self) -> bool { self.0.is_empty() } pub fn add_slot(&mut self, check: &TmCheck, property: i32) { let mut added = false; self.0.iter_mut().for_each(|r| { if r.property == property && r.to == check.from { r.to = check.to; added = true; } }); if !added { self.0.push(Reservation { from: check.from, to: check.to, property, id: 0, summary: 0, }); } } pub fn reservations(&self) -> &Vec { &self.0 } } impl Reservation { pub fn new(from: NaiveTime, to: NaiveTime, property: i32) -> Self { Self { id: 0, from, to, property, summary: 0 } } pub fn id(&self) -> i32 { self.id } } #[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq, Default)] #[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] pub struct ReservationSum { id: i32, pub uuid: Uuid, pub date: NaiveDate, pub customer: i32, pub price: Decimal, pub state: ReservationState, pub date_create: NaiveDate, pub edited_by: Option, pub note: Option, } impl ReservationSum { pub fn id(&self) -> i32 { self.id } } #[derive(Clone, Serialize, Deserialize, Debug, Default)] pub struct ResSumWithItems { pub summary: ReservationSum, pub customer: Customer, pub reservations: Vec } #[derive(Clone, Serialize, Deserialize, Debug, Default)] #[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] pub struct ResAllView { #[cfg_attr(feature = "ssr", sqlx(flatten))] pub reservation: ResWithProperty, #[cfg_attr(feature = "ssr", sqlx(flatten))] pub summary: ReservationSum, #[cfg_attr(feature = "ssr", sqlx(flatten))] pub customer: Customer, } #[derive(Clone, Serialize, Deserialize, Debug, Default)] #[cfg_attr(feature = "ssr", derive(sqlx::Type))] #[cfg_attr(feature = "ssr", sqlx(type_name = "message_type"))] pub enum MessageType { #[default] NewReservation, NewReservationCust, ReservationApp, ReservationCanceled, } impl Display for MessageType { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", match self { MessageType::NewReservation => {"NewReservation"} MessageType::NewReservationCust => {"NewReservationCust"} MessageType::ReservationApp => {"ReservationApp"} MessageType::ReservationCanceled => {"ReservationCanceled"} }) } } #[derive(Clone, Serialize, Deserialize, Debug, Default, Validate)] #[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] pub struct Message { id: i32, pub msg_type: MessageType, #[validate(length(min = 1,message = "Enter mail subject"))] pub subject: String, #[validate(length(min = 1,message = "Enter text"))] pub text: String, } impl Message { pub fn id(&self) -> i32 { self.id } } #[derive(Clone, Debug, Default)] #[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] pub struct ChartData { pub count: i64, pub period: f64 } #[derive(Clone, Debug, Default, Serialize, Deserialize, Validate)] #[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] pub struct Appearance { id: i32, pub banner: Option, pub text: Option, pub title: Option, // Forms that edit the title or the text don't send the colours #[serde(default)] #[cfg_attr(feature = "ssr", sqlx(flatten))] pub colors: Colors, pub logo: Option, #[serde(default)] #[cfg_attr(feature = "ssr", sqlx(flatten))] pub banner_options: BannerOptions, #[serde(default)] #[cfg_attr(feature = "ssr", sqlx(flatten))] pub layout: LayoutOptions } impl Appearance { pub fn id(&self) -> i32 { self.id } /// URL of the banner image, if there is one pub fn banner_url(&self) -> Option { stored_image_url(&self.banner) } /// URL of the logo, if there is one pub fn logo_url(&self) -> Option { stored_image_url(&self.logo) } /// The public page has a header when there is anything to show in it pub fn has_banner(&self) -> bool { self.banner_url().is_some() || self.logo_url().is_some() || !self.title.clone().unwrap_or_default().trim().is_empty() } /// CSS variables for the banner of the public page, used as its `style` attribute. /// The styles themselves are in `assets/css/public.css`. pub fn banner_style(&self) -> String { banner_style(self.banner_url(), &self.banner_options) } } /// Style of a banner with the given image; the preview in the appearance settings uses it /// with an image that is not uploaded yet pub fn banner_style(image_url: Option, options: &BannerOptions) -> String { let options = options.style(image_url.is_some()); match image_url { Some(url) => format!("--rz-banner-image: url('{}'); {}", url, options), None => options } } /// The value is put into a `style` attribute, so it must not be able to end the `url()` or the declaration fn stored_image_url(name: &Option) -> Option { name.as_ref() .filter(|name| is_stored_image_name(name)) .map(|name| format!("/data/{}", name)) } pub fn validate_closing_time(time: &ClosingTime) -> Result<(), ValidationError> { if time.from_date > time.to_date { let mut err = ValidationError::new("to date in past"); err.message = Some("To date must be after from date".into()); Err(err) } else { Ok(()) } } #[derive(Clone, Debug, Default, Serialize, Deserialize, Validate, Eq, PartialEq)] #[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] #[validate(schema(function = "validate_closing_time"))] pub struct ClosingTime { id: i32, pub from_date: NaiveDate, pub to_date: NaiveDate } impl ClosingTime { pub fn id(&self) -> i32 { self.id } } #[cfg(test)] mod tests { use super::*; use chrono::Duration; fn time(h: u32, m: u32) -> NaiveTime { NaiveTime::from_hms_opt(h, m, 0).unwrap() } fn date(y: i32, m: u32, d: u32) -> NaiveDate { NaiveDate::from_ymd_opt(y, m, d).unwrap() } fn has_field_error(entity: &T, field: &str) -> bool { match entity.validate() { Ok(_) => false, Err(e) => e.field_errors().contains_key(field) } } // Page #[test] fn page_starts_on_first_page() { let page = Page::new(10); assert_eq!(page.page_num(), 1); assert_eq!(page.limit(), 10); assert_eq!(page.offset(), 0); } #[test] fn page_offset_follows_page_number() { let page = Page::new(10).with_page(3); assert_eq!(page.page_num(), 3); assert_eq!(page.offset(), 20); } #[test] fn page_with_zero_size_is_unlimited() { let page = Page::new(0).with_page(5); assert_eq!(page.limit(), i32::MAX); assert_eq!(page.offset(), 0); } #[test] fn page_navigation() { let mut page = Page::new(10); page.prev(); assert_eq!(page.page_num(), 1); page.next(); page.next(); assert_eq!(page.page_num(), 3); page.prev(); assert_eq!(page.page_num(), 2); page.go_to(7); assert_eq!(page.page_num(), 7); page.first(); assert_eq!(page.page_num(), 1); } // PagedResponse #[test] fn paged_response_first_and_last() { let first = PagedResponse::new(vec![1, 2], Page::new(2), 5); assert!(first.is_first()); assert!(!first.is_last()); let middle = PagedResponse::new(vec![3, 4], Page::new(2).with_page(2), 5); assert!(!middle.is_first()); assert!(!middle.is_last()); let last = PagedResponse::new(vec![5], Page::new(2).with_page(3), 5); assert!(!last.is_first()); assert!(last.is_last()); } #[test] fn paged_response_exactly_full_last_page() { let last = PagedResponse::new(vec![3, 4], Page::new(2).with_page(2), 4); assert!(last.is_last()); } #[test] fn paged_response_total_pages() { assert_eq!(PagedResponse::new(Vec::::new(), Page::new(10), 0).total_pages(), 0); assert_eq!(PagedResponse::new(vec![1], Page::new(10), 1).total_pages(), 1); assert_eq!(PagedResponse::new(vec![1], Page::new(10), 10).total_pages(), 1); assert_eq!(PagedResponse::new(vec![1], Page::new(10), 11).total_pages(), 2); } #[test] fn paged_response_unlimited_is_single_page() { let resp = PagedResponse::new(vec![1, 2, 3], Page::new(0), 3); assert!(resp.is_first()); assert!(resp.is_last()); assert_eq!(resp.total_pages(), 1); assert_eq!(resp.data(), vec![1, 2, 3]); } // DayHour, DayHours #[test] fn day_hour_parses_with_and_without_spaces() { let expected = DayHour::new(time(8, 0), time(12, 30), None); assert_eq!(DayHour::try_from("08:00 - 12:30"), Ok(expected.clone())); assert_eq!(DayHour::try_from("08:00-12:30"), Ok(expected.clone())); assert_eq!(DayHour::try_from("08:00 - 12:30".to_string()), Ok(expected)); } #[test] fn day_hour_rejects_malformed_input() { for input in ["", "08:00", "08:00 - ", "08:00 - 12:00 - 14:00", "aa:bb - cc:dd", "25:00 - 26:00", "08:00 12:00"] { assert_eq!(DayHour::try_from(input), Err(AppError::HourParseError), "input: {:?}", input); } } #[test] fn day_hour_parses_discount() { let expected = DayHour::new(time(8, 0), time(12, 30), Some(10)); assert_eq!(DayHour::try_from("08:00 - 12:30 (10)"), Ok(expected.clone())); assert_eq!(DayHour::try_from("08:00-12:30(10)"), Ok(expected)); assert_eq!(DayHour::try_from("08:00 - 12:30 (0)"), Ok(DayHour::new(time(8, 0), time(12, 30), Some(0)))); } #[test] fn day_hour_rejects_malformed_discount() { for input in ["08:00 - 12:00 ()", "08:00 - 12:00 (x)", "08:00 - 12:00 (-5)", "08:00 - 12:00 (1.5)", "08:00 - 12:00 (10", "08:00 - 12:00 10)", "(10) 08:00 - 12:00", "08:00 (10) - 12:00", "08:00 - 12:00 (10) (20)", "(10)", "08:00 - 12:00 (99999999999)"] { assert_eq!(DayHour::try_from(input), Err(AppError::HourParseError), "input: {:?}", input); } } #[test] fn day_hours_empty_string_is_closed_day() { let hours = DayHours::try_new("").unwrap(); assert!(hours.hours().is_empty()); assert_eq!(hours.to_string(), ""); } #[test] fn day_hours_parses_list() { let hours = DayHours::try_new("08:00 - 12:00, 13:00 - 17:00").unwrap(); assert_eq!(hours.hours(), &vec![ DayHour::new(time(8, 0), time(12, 0), None), DayHour::new(time(13, 0), time(17, 0), None) ]); } #[test] fn day_hours_rejects_list_with_invalid_item() { assert!(DayHours::try_new("08:00 - 12:00, nonsense").is_err()); assert!(DayHours::try_new("08:00 - 12:00,").is_err()); } #[test] fn day_hours_display() { let hours = DayHours(vec![ DayHour::new(time(8, 0), time(12, 0), None), DayHour::new(time(13, 0), time(17, 0), Some(10)) ]); assert_eq!(hours.to_string(), "08:00 - 12:00, 13:00 - 17:00 (10)"); } #[test] fn day_hours_display_round_trip() { let hours = DayHours(vec![ DayHour::new(time(8, 0), time(12, 0), None), DayHour::new(time(13, 0), time(17, 0), Some(10)), DayHour::new(time(18, 0), time(20, 0), Some(0)) ]); let parsed = DayHours::try_new(&hours.to_string()).unwrap(); assert_eq!(parsed.hours(), hours.hours()); } // WeekHours #[test] fn week_hours_new_formats_hours() { let hours = WeekHours::new(Weekday::Tue, vec![DayHour::new(time(9, 0), time(18, 0), None)]); assert_eq!(hours.day(), Weekday::Tue); assert_eq!(hours.hours(), "09:00 - 18:00"); } #[test] fn week_hours_accepts_valid_hours() { for input in ["", "08:00 - 12:00", "08:00-12:00", "08:00 - 12:00, 13:00 - 17:00", "08:00 - 12:00 (10)", "08:00 - 12:00 (10), 13:00 - 17:00", "08:00 - 12:00, 13:00 - 17:00(20)"] { let hours = WeekHours { day: Weekday::Mon, hours: input.to_string() }; assert!(hours.validate().is_ok(), "input: {:?}", input); assert!(DayHours::try_new(input).is_ok(), "input: {:?}", input); } } #[test] fn week_hours_rejects_wrong_format() { for input in ["8-12", "8:00 - 12:00", "morning", "08.00 - 12.00", "08:00 - 12:00, nonsense", "08:00 - 12:00 garbage", "08:00 - 12:00,", ", 08:00 - 12:00"] { let hours = WeekHours { day: Weekday::Mon, hours: input.to_string() }; assert!(has_field_error(&hours, "hours"), "input: {:?}", input); assert!(!has_field_error(&hours, "__all__"), "input: {:?}", input); } } #[test] fn week_hours_rejects_hours_that_cannot_be_parsed() { // Matches the format, but is not a valid time for input in ["25:00 - 26:00", "08:00 - 12:60", "08:00 - 12:00, 13:00 - 24:00"] { let hours = WeekHours { day: Weekday::Mon, hours: input.to_string() }; assert!(has_field_error(&hours, "__all__"), "input: {:?}", input); } } #[test] fn week_hours_validation_agrees_with_parser() { for input in ["", "08:00 - 12:00", "08:00-12:00", "08:00 - 12:00,13:00 - 17:00", "08:00 - 12:00 , 13:00 - 17:00", "08:00 - 12:00 (10)", "08:00 - 12:00 (10) , 13:00 - 17:00 (5)", "08:00 - 12:00 (99999999999)", "08:00 - 12:00, nonsense", "08:00 - 12:00,", "25:00 - 26:00", "08:00 - 12:00 garbage"] { let hours = WeekHours { day: Weekday::Mon, hours: input.to_string() }; if hours.validate().is_ok() { assert!(DayHours::try_new(input).is_ok(), "input: {:?}", input); } } } #[test] fn week_hours_error_messages() { let message = |input: &str| { let hours = WeekHours { day: Weekday::Mon, hours: input.to_string() }; let errors = hours.validate().unwrap_err(); let all = errors.field_errors().values().flat_map(|e| e.iter()).map(|e| e.message.clone().unwrap().to_string()).collect::>(); assert_eq!(all.len(), 1, "input: {:?}", input); all[0].clone() }; assert_eq!(message("nonsense"), "Hours must be in HH:MM - HH:MM format"); assert_eq!(message("25:00 - 26:00"), "Hours must be in HH:MM - HH:MM format"); assert_eq!(message("12:00 - 08:00"), "Time 'to' must be after time 'from'"); } #[test] fn week_hours_rejects_to_before_from() { for input in ["12:00 - 08:00", "08:00 - 08:00", "08:00 - 12:00, 17:00 - 13:00", "12:00 - 08:00 (10)"] { let hours = WeekHours { day: Weekday::Mon, hours: input.to_string() }; assert!(has_field_error(&hours, "__all__"), "input: {:?}", input); } } // TmCheck #[test] fn tm_check_parses_slot() { let check = TmCheck::from_str("08:00:00-08:30:00").unwrap(); assert_eq!(check, TmCheck { from: time(8, 0), to: time(8, 30) }); } #[test] fn tm_check_rejects_malformed_slot() { for input in ["", "08:00:00", "08:00:00-", "08:00:00-09:00:00-10:00:00", "a-b"] { assert_eq!(TmCheck::from_str(input), Err(AppError::HourParseError), "input: {:?}", input); } } #[test] fn tm_check_sorts_by_time() { let mut checks = vec![ TmCheck::from_str("10:00:00-11:00:00").unwrap(), TmCheck::from_str("08:00:00-09:00:00").unwrap(), TmCheck::from_str("09:00:00-10:00:00").unwrap() ]; checks.sort(); assert_eq!(checks.iter().map(|c| c.from).collect::>(), vec![time(8, 0), time(9, 0), time(10, 0)]); } // Reservations fn slot(from: u32, to: u32) -> TmCheck { TmCheck { from: time(from, 0), to: time(to, 0) } } #[test] fn reservations_starts_empty() { let reservations = Reservations::new(); assert!(reservations.is_empty()); assert!(reservations.reservations().is_empty()); } #[test] fn reservations_merges_adjacent_slots() { let mut reservations = Reservations::new(); reservations.add_slot(&slot(8, 9), 1); reservations.add_slot(&slot(9, 10), 1); reservations.add_slot(&slot(10, 11), 1); assert_eq!(reservations.reservations(), &vec![Reservation::new(time(8, 0), time(11, 0), 1)]); } #[test] fn reservations_keeps_gap_between_slots() { let mut reservations = Reservations::new(); reservations.add_slot(&slot(8, 9), 1); reservations.add_slot(&slot(10, 11), 1); assert_eq!(reservations.reservations(), &vec![ Reservation::new(time(8, 0), time(9, 0), 1), Reservation::new(time(10, 0), time(11, 0), 1) ]); } #[test] fn reservations_does_not_merge_across_properties() { let mut reservations = Reservations::new(); reservations.add_slot(&slot(8, 9), 1); reservations.add_slot(&slot(9, 10), 2); assert_eq!(reservations.reservations(), &vec![ Reservation::new(time(8, 0), time(9, 0), 1), Reservation::new(time(9, 0), time(10, 0), 2) ]); } // Entity validation #[test] fn company_requires_name_street_and_house_number() { let company = Company::default(); assert!(has_field_error(&company, "name")); assert!(has_field_error(&company, "street")); assert!(has_field_error(&company, "house_number")); assert!(!has_field_error(&company, "zip_code")); assert!(!has_field_error(&company, "city")); } #[test] fn company_valid() { let company = Company { name: "Sport club".to_string(), street: "Main".to_string(), house_number: "1".to_string(), ..Company::default() }; assert!(company.validate().is_ok()); } #[test] fn res_property_requires_name() { let mut property = ResProperty::default(); assert!(has_field_error(&property, "name")); property.name = "Court 1".to_string(); assert!(property.validate().is_ok()); } #[test] fn user_profile_validation() { let valid = UserProfile { login: "joe".to_string(), password: Some("secret".to_string()), password_ver: Some("secret".to_string()), full_name: "Joe Doe".to_string(), email: "joe@example.com".to_string(), get_emails: None, admin: None }; assert!(valid.validate().is_ok()); assert!(has_field_error(&UserProfile { login: "".to_string(), ..valid.clone() }, "login")); assert!(has_field_error(&UserProfile { email: "not an email".to_string(), ..valid.clone() }, "email")); assert!(has_field_error(&UserProfile { password_ver: Some("other".to_string()), ..valid.clone() }, "password")); } #[test] fn user_profile_checkboxes() { let unchecked = UserProfile::default(); assert!(!unchecked.admin()); assert!(!unchecked.get_emails()); let checked = UserProfile { admin: Some("on".to_string()), get_emails: Some("on".to_string()), ..UserProfile::default() }; assert!(checked.admin()); assert!(checked.get_emails()); } #[test] fn pwd_change_validation() { let valid = PwdChange { login: "joe".to_string(), old_password: "old".to_string(), password: "new".to_string(), password_ver: "new".to_string() }; assert!(valid.validate().is_ok()); assert!(has_field_error(&PwdChange { password_ver: "different".to_string(), ..valid.clone() }, "password")); assert!(has_field_error(&PwdChange { password: "".to_string(), password_ver: "".to_string(), ..valid.clone() }, "password")); } #[test] fn message_requires_subject_and_text() { let mut message = Message::default(); assert!(has_field_error(&message, "subject")); assert!(has_field_error(&message, "text")); message.subject = "Booking".to_string(); message.text = "Hello".to_string(); assert!(message.validate().is_ok()); } fn valid_reservation() -> CrReservation { CrReservation { date: Local::now().date_naive(), slots: vec!["08:00:00-09:00:00|1".to_string()], full_name: "Joe Doe".to_string(), email: "joe@example.com".to_string(), phone: "123456789".to_string(), note: "".to_string(), remember: false } } #[test] fn cr_reservation_valid_for_today_and_future() { assert!(valid_reservation().validate().is_ok()); let future = CrReservation { date: Local::now().date_naive() + Duration::days(30), ..valid_reservation() }; assert!(future.validate().is_ok()); } #[test] fn cr_reservation_rejects_past_date() { let past = CrReservation { date: Local::now().date_naive() - Duration::days(1), ..valid_reservation() }; assert!(has_field_error(&past, "date")); } #[test] fn cr_reservation_requires_slot_and_contact() { assert!(has_field_error(&CrReservation { slots: vec![], ..valid_reservation() }, "slots")); assert!(has_field_error(&CrReservation { full_name: "".to_string(), ..valid_reservation() }, "full_name")); assert!(has_field_error(&CrReservation { email: "joe".to_string(), ..valid_reservation() }, "email")); assert!(has_field_error(&CrReservation { phone: "".to_string(), ..valid_reservation() }, "phone")); } #[test] fn closing_time_validation() { let one_day = ClosingTime { id: 0, from_date: date(2030, 12, 24), to_date: date(2030, 12, 24) }; assert!(one_day.validate().is_ok()); let range = ClosingTime { id: 0, from_date: date(2030, 12, 24), to_date: date(2031, 1, 1) }; assert!(range.validate().is_ok()); let reversed = ClosingTime { id: 0, from_date: date(2031, 1, 1), to_date: date(2030, 12, 24) }; assert!(has_field_error(&reversed, "__all__")); } // Enums stored in the database must keep these names #[test] fn enum_display() { assert_eq!(ReservationState::New.to_string(), "New"); assert_eq!(ReservationState::Approved.to_string(), "Approved"); assert_eq!(ReservationState::Canceled.to_string(), "Canceled"); assert_eq!(MessageType::NewReservation.to_string(), "NewReservation"); assert_eq!(MessageType::NewReservationCust.to_string(), "NewReservationCust"); assert_eq!(MessageType::ReservationApp.to_string(), "ReservationApp"); assert_eq!(MessageType::ReservationCanceled.to_string(), "ReservationCanceled"); assert_eq!(SlotType::Quarter.to_string(), "Quarter an hour"); assert_eq!(SlotType::Half.to_string(), "Half an hour"); assert_eq!(SlotType::Hour.to_string(), "Hour"); assert_eq!(SlotType::Day.to_string(), "Day"); } #[test] fn banner_style_sets_image_variable() { let appearance = Appearance { banner: Some("my-banner_1.jpg".to_string()), ..Default::default() }; assert_eq!(appearance.banner_style(), format!("--rz-banner-image: url('/data/my-banner_1.jpg'); {}", BannerOptions::default().style(true))); assert!(appearance.has_banner()); } #[test] fn banner_style_without_image_has_only_options() { assert_eq!(Appearance::default().banner_style(), BannerOptions::default().style(false)); } #[test] fn banner_style_ignores_unsafe_file_names() { for name in ["", "a'); color: red; x: url('.jpg", "a b.jpg", "../config.toml", "a/b.jpg", ".hidden"] { let appearance = Appearance { banner: Some(name.to_string()), logo: Some(name.to_string()), ..Default::default() }; assert_eq!(appearance.banner_style(), BannerOptions::default().style(false), "name: {:?}", name); assert_eq!(appearance.banner_url(), None, "name: {:?}", name); assert_eq!(appearance.logo_url(), None, "name: {:?}", name); assert!(!appearance.has_banner(), "name: {:?}", name); } } #[test] fn banner_is_shown_for_title_logo_or_image() { assert!(!Appearance::default().has_banner()); assert!(!Appearance { title: Some(" ".to_string()), ..Default::default() }.has_banner()); assert!(Appearance { title: Some("Tennis".to_string()), ..Default::default() }.has_banner()); let with_logo = Appearance { logo: Some("logo-1a2b3c4d.png".to_string()), ..Default::default() }; assert!(with_logo.has_banner()); assert_eq!(with_logo.logo_url(), Some("/data/logo-1a2b3c4d.png".to_string())); } #[test] fn enum_defaults() { assert_eq!(ReservationState::default(), ReservationState::New); assert_eq!(SlotType::default(), SlotType::Hour); } }