diff --git a/CLAUDE.md b/CLAUDE.md index ed13ce2..d156e57 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/README.md b/README.md index a4dd38b..65e2ccf 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/src/backend/data.rs b/src/backend/data.rs index b6da0af..0285880 100644 --- a/src/backend/data.rs +++ b/src/backend/data.rs @@ -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(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_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![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); + } +} diff --git a/src/backend/mail.rs b/src/backend/mail.rs index 1872b02..04f59b8 100644 --- a/src/backend/mail.rs +++ b/src/backend/mail.rs @@ -259,4 +259,86 @@ impl ForValidation for UpdateMessage { fn entity(&self) -> &dyn Validate { &self.message } -} \ No newline at end of file +} + +#[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(_)))); + } +} diff --git a/src/backend/reservation.rs b/src/backend/reservation.rs index 2203485..690021b 100644 --- a/src/backend/reservation.rs +++ b/src/backend/reservation.rs @@ -535,4 +535,50 @@ pub async fn reservations_in_month(year: i32, month: u32) -> Result 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)); + } +} diff --git a/src/config.rs b/src/config.rs index aa9c0ed..1b26a53 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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); + } +} diff --git a/src/error.rs b/src/error.rs index 92bad26..8a401c4 100644 --- a/src/error.rs +++ b/src/error.rs @@ -101,4 +101,50 @@ impl From for AppError { log_error(&value); AppError::FileIOError(value.to_string()) } -} \ No newline at end of file +} + +#[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")); + } +}