Added unit tests for pure logic. Refs #2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 17:01:51 +02:00
co-authored by Claude Opus 5.5
parent 5dbe7c36b1
commit 2babe19299
7 changed files with 689 additions and 5 deletions
+446
View File
@@ -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);
}
}