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
+5 -2
View File
@@ -16,11 +16,14 @@ cargo leptos watch # dev server with reload (needs config.toml +
cargo leptos build --release # production build -> target/release/rezervator + target/site
cargo check --features ssr # type-check the server side
cargo check --lib --features hydrate --target wasm32-unknown-unknown # type-check the WASM side
cargo test --features ssr # unit tests
```
Run both `cargo check` commands after any change; a plain `cargo check` (no features) checks neither side properly.
Run both `cargo check` commands and the tests after any change; a plain `cargo check` / `cargo test` (no features) checks neither side properly.
There are no unit tests, and `end2end/` is still the untouched Leptos template (it asserts "Welcome to Leptos") — it does not test this app. Don't claim something is tested because these pass.
Unit tests are `#[cfg(test)] mod tests` at the bottom of the file they test (`#[cfg(all(test, feature = "ssr"))]` for ssr-only code). They cover pure logic only — parsing, validation, paging, slot merging, mail templates, config. Nothing touches the database, so server functions, permissions and SQL are untested. Add tests for new pure logic; keep them free of DB and network.
`end2end/` is still the untouched Leptos template (it asserts "Welcome to Leptos") — it does not test this app.
## Running locally
+4
View File
@@ -17,6 +17,10 @@ You will need cargo-leptos tool:
`cargo leptos watch`
## Running tests
`cargo test --features ssr`
## Executing a Server on a Remote Machine Without the Toolchain
After running a `cargo leptos build --release` the minimum files needed are:
+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);
}
}
+83 -1
View File
@@ -259,4 +259,86 @@ impl ForValidation for UpdateMessage {
fn entity(&self) -> &dyn Validate {
&self.message
}
}
}
#[cfg(all(test, feature = "ssr"))]
mod tests {
use super::*;
use chrono::{NaiveDate, NaiveTime};
use crate::backend::data::{Reservation, ResProperty, ResWithProperty};
fn reservation() -> ResSumWithItems {
let mut property = ResProperty::default();
property.name = "Court 1".to_string();
let mut reservation = ResSumWithItems::default();
reservation.summary.date = NaiveDate::from_ymd_opt(2030, 3, 5).unwrap();
reservation.reservations.push(ResWithProperty {
reservation: Reservation::new(
NaiveTime::from_hms_opt(8, 0, 0).unwrap(),
NaiveTime::from_hms_opt(9, 30, 0).unwrap(),
1),
property
});
reservation
}
fn message(subject: &str, text: &str) -> Message {
let mut message = Message::default();
message.subject = subject.to_string();
message.text = text.to_string();
message
}
#[test]
fn mail_message_replaces_variables() {
let mail = MailMessage::new(
"admin@example.com".to_string(),
"customer@example.com".to_string(),
message("Booking", "Date: #date#, items: #summary#."),
&reservation());
assert_eq!(mail.subject, "Booking");
assert_eq!(mail.text, "Date: 05. 03. 2030, items: Court 1: 08:00 - 09:30.");
}
#[test]
fn mail_message_without_variables_is_unchanged() {
let mail = MailMessage::new(
"admin@example.com".to_string(),
"customer@example.com".to_string(),
message("Booking", "Plain text"),
&reservation());
assert_eq!(mail.text, "Plain text");
}
#[test]
fn build_mail_sets_headers() {
let mail = MailMessage::new(
"admin@example.com".to_string(),
"customer@example.com".to_string(),
message("Booking", "Hello"),
&reservation());
let built = String::from_utf8(mail.build_mail("noreply@example.com".to_string()).unwrap().formatted()).unwrap();
assert!(built.contains("From: noreply@example.com"));
assert!(built.contains("Reply-To: admin@example.com"));
assert!(built.contains("To: customer@example.com"));
assert!(built.contains("Subject: Booking"));
}
#[test]
fn build_mail_rejects_invalid_address() {
let mail = MailMessage::new(
"admin@example.com".to_string(),
"not an address".to_string(),
message("Booking", "Hello"),
&reservation());
assert!(matches!(mail.build_mail("noreply@example.com".to_string()), Err(AppError::MailAddrParseErr(_))));
}
}
+47 -1
View File
@@ -535,4 +535,50 @@ pub async fn reservations_in_month(year: i32, month: u32) -> Result<ApiResponse<
.await?;
Ok(ApiResponse::Data(ret.0))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn time(h: u32, m: u32) -> NaiveTime {
NaiveTime::from_hms_opt(h, m, 0).unwrap()
}
#[test]
fn is_reserved_inside_reservation() {
let reservations = vec![Reservation::new(time(8, 0), time(10, 0), 1)];
assert!(is_reserved(&reservations, &time(8, 0), 1));
assert!(is_reserved(&reservations, &time(9, 30), 1));
}
#[test]
fn is_reserved_end_is_exclusive() {
let reservations = vec![Reservation::new(time(8, 0), time(10, 0), 1)];
assert!(!is_reserved(&reservations, &time(10, 0), 1));
assert!(!is_reserved(&reservations, &time(7, 59), 1));
}
#[test]
fn is_reserved_checks_property() {
let reservations = vec![Reservation::new(time(8, 0), time(10, 0), 1)];
assert!(!is_reserved(&reservations, &time(9, 0), 2));
}
#[test]
fn is_reserved_with_more_reservations() {
let reservations = vec![
Reservation::new(time(8, 0), time(9, 0), 1),
Reservation::new(time(12, 0), time(13, 0), 2),
Reservation::new(time(15, 0), time(16, 0), 1)
];
assert!(is_reserved(&reservations, &time(15, 30), 1));
assert!(is_reserved(&reservations, &time(12, 0), 2));
assert!(!is_reserved(&reservations, &time(12, 0), 1));
assert!(!is_reserved(&vec![], &time(12, 0), 1));
}
}
+57
View File
@@ -154,3 +154,60 @@ pub fn load_config(path: &str) -> Configuration {
toml::from_str(read_to_string(path).expect("Can not open config file").as_str()).expect("Can not parse config file")
}
#[cfg(all(test, feature = "ssr"))]
mod tests {
use super::*;
fn sample() -> Configuration {
load_config(concat!(env!("CARGO_MANIFEST_DIR"), "/config_sample.toml"))
}
#[test]
fn sample_config_is_valid() {
let config = sample();
assert_eq!(config.network().bind_address(), SocketAddr::from_str("127.0.0.1:3000").unwrap());
assert_eq!(config.database().con_string(), "postgres://user:password@localhost:5432/rezervator");
assert_eq!(config.logging().severity(), "debug");
assert!(matches!(config.mailing().transport(), MailTransport::File));
assert_eq!(config.mailing().from(), "from@address.cz");
assert_eq!(config.mailing().path(), &Some("Maildir".to_string()));
assert_eq!(config.mailing().server(), &None);
}
#[test]
fn sample_session_key_is_long_enough() {
// actix_web::cookie::Key::from panics on keys shorter than 64 bytes
assert!(sample().session().key().len() >= 64);
}
#[test]
fn database_port_defaults_to_5432() {
let database: Database = toml::from_str(
"user = 'u'\npassword = 'p'\ndb_name = 'db'\nhost = 'db.example.com'").unwrap();
assert_eq!(database.con_string(), "postgres://u:p@db.example.com:5432/db");
}
#[test]
fn database_custom_port() {
let database: Database = toml::from_str(
"user = 'u'\npassword = 'p'\ndb_name = 'db'\nhost = 'db.example.com'\nport = 6543").unwrap();
assert_eq!(database.con_string(), "postgres://u:p@db.example.com:6543/db");
}
#[test]
fn smtp_mailing_config() {
let mailing: Mailing = toml::from_str(
"transport = 'Smtp'\nfrom = 'a@b.cz'\nserver = 'smtp.b.cz'\nport = 587\ntls = true").unwrap();
assert!(matches!(mailing.transport(), MailTransport::Smtp));
assert_eq!(mailing.server(), &Some("smtp.b.cz".to_string()));
assert_eq!(mailing.port(), Some(587));
assert_eq!(mailing.tls(), Some(true));
assert_eq!(mailing.accept_all_certs(), None);
}
}
+47 -1
View File
@@ -101,4 +101,50 @@ impl From<std::io::Error> for AppError {
log_error(&value);
AppError::FileIOError(value.to_string())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::NaiveTime;
#[test]
fn error_messages() {
assert_eq!(AppError::HourParseError.to_string(), "Hour parse error");
assert_eq!(AppError::SlotParseError.to_string(), "Book slot parse error");
assert_eq!(AppError::ServerError("boom".to_string()).to_string(), "Server error: boom");
assert_eq!(AppError::FatalError("boom".to_string()).to_string(), "Fatal error: boom");
assert_eq!(AppError::MailAddrParseErr("boom".to_string()).to_string(), "Cannot parse email address: boom");
assert_eq!(AppError::MailSendError("boom".to_string()).to_string(), "Cannot send email: boom");
assert_eq!(AppError::FileIOError("boom".to_string()).to_string(), "IO error: boom");
}
#[test]
fn time_parse_error_becomes_hour_parse_error() {
let err = NaiveTime::parse_from_str("nonsense", "%H:%M").unwrap_err();
assert_eq!(AppError::from(err), AppError::HourParseError);
}
#[test]
fn server_fn_error_keeps_message() {
let err = AppError::from(ServerFnError::new("boom"));
assert!(matches!(err, AppError::ServerError(ref m) if m.contains("boom")));
}
#[cfg(feature = "ssr")]
#[test]
fn io_error_becomes_file_io_error() {
let err = std::io::Error::new(std::io::ErrorKind::NotFound, "missing");
assert!(matches!(AppError::from(err), AppError::FileIOError(ref m) if m.contains("missing")));
}
#[test]
fn app_error_converts_to_server_fn_error() {
let err: ServerFnError = AppError::HourParseError.into();
assert!(err.to_string().contains("Hour parse error"));
}
}