Added unit tests for pure logic. Refs #2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -752,3 +752,449 @@ impl ClosingTime {
|
||||
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_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), None)
|
||||
]);
|
||||
|
||||
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"] {
|
||||
let hours = WeekHours { day: Weekday::Mon, hours: input.to_string() };
|
||||
assert!(hours.validate().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"] {
|
||||
let hours = WeekHours { day: Weekday::Mon, hours: input.to_string() };
|
||||
assert!(has_field_error(&hours, "hours"), "input: {:?}", input);
|
||||
}
|
||||
}
|
||||
|
||||
#[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"] {
|
||||
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 enum_defaults() {
|
||||
assert_eq!(ReservationState::default(), ReservationState::New);
|
||||
assert_eq!(SlotType::default(), SlotType::Hour);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user