Files
rezervovator/src/backend/data.rs
T
2026-10-12 22:27:29 +02:00

1370 lines
40 KiB
Rust

#![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<T> {
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<T> {
page: Page,
record_count: i64,
data: Vec<T>
}
impl<T: Clone> PagedResponse<T> {
pub fn new(data: Vec<T>, 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<T> {
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<String>,
#[validate(email(message = "Enter valid email address"))]
pub email: Option<String>,
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<String>,
password_ver: Option<String>,
full_name: String,
#[validate(email(message = "Enter valid email address"))]
email: String,
get_emails: Option<String>,
admin: Option<String>
}
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<String> {
&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<i32>
}
impl OpeningHour {
pub fn id(&self) -> i32 {
self.id
}
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct DayHours(pub Vec<DayHour>);
impl DayHours {
pub fn try_new(hours: &str) -> Result<Self, AppError> {
if hours.is_empty() {
return Ok(Self(Vec::new()))
}
let times = hours.split(",")
.map(|h| h.trim())
.map(|h| DayHour::try_from(h))
.collect::<Vec<_>>();
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<DayHour> {
&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::<Vec<String>>()
.join(", ");
write!(f, "{}", str)
}
}
#[derive(Eq, PartialEq, Serialize, Deserialize, Clone, Debug)]
pub struct DayHour {
from: NaiveTime,
to: NaiveTime,
discount: Option<i32>
}
impl DayHour {
pub fn new(from: NaiveTime, to: NaiveTime, discount: Option<i32>) -> Self {
Self { from, to, discount }
}
pub fn from(&self) -> NaiveTime {
self.from
}
pub fn to(&self) -> NaiveTime {
self.to
}
pub fn discount(&self) -> Option<i32> {
self.discount
}
}
impl TryFrom<String> for DayHour {
type Error = AppError;
fn try_from(value: String) -> Result<Self, Self::Error> {
value.as_str().try_into()
}
}
impl TryFrom<&str> for DayHour {
type Error = AppError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
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::<i32>().map_err(|_| AppError::HourParseError)?))
}
None => (value, None)
};
let times = interval.split("-")
.map(|t| t.trim()).collect::<Vec<_>>();
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<DayHour>) -> 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<DayHour>,
pub reservations: Vec<Reservation>,
pub closing_days: Vec<ClosingTime>
}
fn empty_slots() -> Vec<String> {
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<String>,
#[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<Self, Self::Err> {
let times = s.split("-").collect::<Vec<_>>();
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<String> {
&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<Reservation>);
// 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<Reservation> {
&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<i32>,
pub note: Option<String>,
}
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<ResWithProperty>
}
#[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<String>,
pub text: Option<String>,
pub title: Option<String>,
// 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<String>,
#[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<String> {
stored_image_url(&self.banner)
}
/// URL of the logo, if there is one
pub fn logo_url(&self) -> Option<String> {
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<String>, 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<String>) -> Option<String> {
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<T: Validate>(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::<i32>::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::<Vec<_>>();
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<_>>(), 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);
}
}