Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2babe19299 | ||
|
|
5dbe7c36b1 |
@@ -0,0 +1,54 @@
|
||||
---
|
||||
paths:
|
||||
- "src/backend/**"
|
||||
- "src/main.rs"
|
||||
- "src/error.rs"
|
||||
- "src/config.rs"
|
||||
---
|
||||
|
||||
# Backend rules
|
||||
|
||||
## Server functions
|
||||
|
||||
- One file per domain in `src/backend/`. Server-only helpers and their imports go in the `cfg_if! { if #[cfg(feature = "ssr")] { ... }}` block at the top; `#[server]` functions follow below it. Extra server-only imports inside a server fn body are written as local `use` statements.
|
||||
- Use plain `#[server]` for new functions (older ones spell out `#[server(Name, "/api", "Url", "name")]`; both end up under `/api`).
|
||||
- Return `Result<ApiResponse<T>, ServerFnError>`. Expected/business failures are `Ok(ApiResponse::Error("English message".to_string()))` — the client passes that string through `trl()`, so add it to the catalogue. `Err(ServerFnError)` is for real failures (DB down etc.).
|
||||
- A function used with `<DataForm>` / `<QuestionDialog>` must return `ApiResponse<()>` and take the form entity as a single struct argument; its name is the prefix of the form field names (`company: Company` ↔ `name="company[name]"`).
|
||||
- For `<DataForm>`, implement `ForValidation` for the generated struct (PascalCase of the fn name) right below the function:
|
||||
|
||||
```rust
|
||||
impl ForValidation for UpdateCompany {
|
||||
fn entity(&self) -> &dyn Validate { &self.company }
|
||||
}
|
||||
```
|
||||
|
||||
That only validates in the browser. Anything that matters must be checked again inside the server fn.
|
||||
|
||||
## Authorization
|
||||
|
||||
- Every server fn that reads or changes non-public data starts with `perm_check!(is_logged_in)` or `perm_check!(is_admin)`; use `user_check!(login)` when a non-admin may only touch their own record. The macros return early with 403 and `ApiResponse::Error`, so they only work in functions returning `Result<ApiResponse<_>, _>`.
|
||||
- The auth middleware protects only `/admin*` page requests. `/api/*` is not covered by it — a server fn without `perm_check!` is public. Only the public booking form endpoints should be.
|
||||
- Plain Actix handlers (like `appearance::upload`) must check the session themselves.
|
||||
- The logged-in `User` is stored in the cookie session under `"user"`; read it with `logged_in_user()`.
|
||||
|
||||
## Database access
|
||||
|
||||
- Get the pool with `get_pool().await?` (and the mailer with `get_mailing().await?`); they come from `AppData` in Actix app data.
|
||||
- Runtime-checked sqlx only: `query` / `query_as::<_, T>` with `$n` binds. No `query!` macros (there is no `DATABASE_URL`/offline data at build time).
|
||||
- Always bind values; never format user input into SQL.
|
||||
- Multi-statement writes go through a transaction (`pool.begin()`, helpers take `&mut Transaction<'_, Postgres>`), as in `create_reservation`.
|
||||
- The users table is `"user"` — always quoted.
|
||||
|
||||
## Data types (`data.rs`)
|
||||
|
||||
- Types crossing the server/client boundary live in `src/backend/data.rs` and must compile for WASM: sqlx derives only as `#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]` / `sqlx::Type`.
|
||||
- `id` fields are private with an `id()` getter; other fields are mostly `pub`.
|
||||
- Validation uses `validator` 0.16 derive attributes; every `message = "..."` is an English translation key.
|
||||
- Rust enums mapped to Postgres enums (`SlotType`, `ReservationState`, `MessageType`) must keep variant names identical to the SQL enum labels.
|
||||
|
||||
## Errors, logging, mail
|
||||
|
||||
- Server-side helpers return `AppError`; add a `From` impl in `src/error.rs` for new error sources (ssr-only ones behind `#[cfg(feature = "ssr")]`). The `From` impls already log the error.
|
||||
- Log with the `log` macros (`info!`, `warn!`, `error!`), not `println!`.
|
||||
- Send mail through `Mailer::send_mail` (queued to a background sender thread), never directly from a request. Templates are `message` rows keyed by `MessageType` with `#date#` and `#summary#` placeholders.
|
||||
- A new `[section]`/key in `config.toml` needs the struct in `src/config.rs` and an entry in `config_sample.toml`.
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
paths:
|
||||
- "migrations/**"
|
||||
- "src/backend/data.rs"
|
||||
---
|
||||
|
||||
# Database rules
|
||||
|
||||
- PostgreSQL. Schema changes are plain SQL files in `migrations/`, named `NN_short_name.sql` with the next two-digit number. They are embedded with `sqlx::migrate!()` and applied automatically at server start.
|
||||
- Never edit or rename a migration that has been committed — sqlx stores a checksum per version and existing installations will refuse to start. Fix things with a new migration.
|
||||
- Migrations must work on a database that already holds production data: add columns as nullable or with a `DEFAULT`, backfill in the same file if needed.
|
||||
- `migrate!()` reads the directory at compile time and there is no `build.rs`, so cargo may not notice a new `.sql` file. If a new migration doesn't run, touch `src/main.rs` and rebuild.
|
||||
- Default rows (admin user, company, message templates, appearance) are seeded in Rust at startup (`create_admin`, `check_*`), not in migrations. A new singleton table or `message_type` value needs the matching seeding code.
|
||||
- Keep the schema and `src/backend/data.rs` in sync in the same change: column names = struct field names (`SELECT *` + `FromRow`), Postgres enum labels = Rust variant names. A new enum value needs `ALTER TYPE ... ADD VALUE` plus the Rust variant, its `Display` arm and, for `message_type`, the list in `check_messages`.
|
||||
- Naming: singular snake_case tables (`reservation_sum`, `closing_time`), `id SERIAL PRIMARY KEY`, `"user"` always quoted, money as `NUMERIC(9, 2)` ↔ `rust_decimal::Decimal`, dates/times as `DATE`/`TIME` ↔ `chrono::NaiveDate`/`NaiveTime`.
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
paths:
|
||||
- "src/pages/**"
|
||||
- "src/components/**"
|
||||
- "src/locales/**"
|
||||
- "src/app.rs"
|
||||
- "src/validator.rs"
|
||||
- "style/**"
|
||||
---
|
||||
|
||||
# Frontend rules
|
||||
|
||||
## Components
|
||||
|
||||
- Leptos 0.6 API only. Components are `#[component]` functions returning `impl IntoView`, one page or dialog per file in `src/pages/`; reusable building blocks go to `src/components/`. Register new files in the respective `mod.rs` (`pub mod` only if used from `app.rs`).
|
||||
- This code runs on the server (SSR) and in the browser (hydration). Browser-only APIs (`window()`, `web_sys`) must not run during SSR — keep them in event handlers/effects or behind `#[cfg(not(feature = "ssr"))]`. Markup must render the same on both sides or hydration breaks.
|
||||
- A new admin page needs a `<Route path="admin/..."/>` in `src/app.rs` wrapped in `<AdminPortal>`, and a menu entry in `src/components/admin_portal.rs`. Paths starting with `/admin` are login-protected by the middleware; data access is still guarded by `perm_check!` in the server fn.
|
||||
|
||||
## Data loading and forms
|
||||
|
||||
- Load data with `create_blocking_resource` / `create_resource` calling the server fn, rendered inside `<Transition fallback=...>` with `{trl("Loading...")}`. Use a dialog's `opener.visible()` as the resource source so lists reload after the dialog closes (see `src/pages/users.rs`).
|
||||
- Handle all three outcomes of a server call: `Err(e)`, `Ok(ApiResponse::Error(msg))`, `Ok(ApiResponse::Data(d))`.
|
||||
- Edit/create dialogs: `DialogOpener::new()` in the parent, `create_server_action::<ServerFnStruct>()` in the dialog, wrapped in `<DataForm opener=.. action=.. title="..">`. It supplies the modal, Save/Close buttons, client validation and server error display. Confirmations use `<QuestionDialog>`.
|
||||
- Form field names follow the server fn argument: `name="company[name]"`. Pass the id as a hidden input. Fill inputs with `prop:value={move || signal.get().field}`.
|
||||
- `DataForm`/`ModalDialog` take `title` as an untranslated English key (`&'static str`); they translate it themselves.
|
||||
|
||||
## Translations
|
||||
|
||||
- Every user-visible string goes through `trl("English text")`; never hard-code Czech (or any text) in a view. The English text is the key and the fallback.
|
||||
- When you add or change a key, add the Czech translation to the `cs` map in `src/locales/catalogues.rs`. This includes validation `message`s and `ApiResponse::Error` strings from the backend.
|
||||
- `trl()` returns a closure — use it directly as `{trl("...")}` or attribute value; call it (`trl("...")()`) when a `String` is needed.
|
||||
- Dates shown to users go through `loc_date()`, weekday names through `show_day()`.
|
||||
|
||||
## Styling
|
||||
|
||||
- UI is the Bootstrap-based admin template in `assets/vendor/` plus Boxicons (`<i class="bx bx-...">`). Reuse its classes (`card`, `table`, `btn btn-primary`, `form-control`, `row`/`col`) before writing CSS.
|
||||
- Custom styles go to `style/main.scss`. Never edit `assets/vendor/`.
|
||||
- The public page banner style is `assets/data/banner.css`, rewritten at runtime by the appearance upload (`/*bg-img*/` marker) — keep that marker / the `background-image: url('...')` form intact.
|
||||
- Stylesheets and meta tags are declared in `src/components/header.rs`.
|
||||
@@ -0,0 +1,61 @@
|
||||
# Rezervovator
|
||||
|
||||
Booking system for sport/service facilities. Full-stack Rust: Leptos 0.6 (SSR + hydration) on Actix Web, PostgreSQL via sqlx. Crate name is `rezervator`, product name is "Rezervovator".
|
||||
|
||||
One crate, compiled twice by cargo-leptos:
|
||||
|
||||
- server binary — feature `ssr` (Actix, sqlx, lettre, charts-rs)
|
||||
- browser WASM — feature `hydrate`, target `wasm32-unknown-unknown`
|
||||
|
||||
Every change must compile for both. Server-only code and imports go behind `#[cfg(feature = "ssr")]` / `cfg_if!`.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
cargo leptos watch # dev server with reload (needs config.toml + running Postgres)
|
||||
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 and the tests after any change; a plain `cargo check` / `cargo test` (no features) checks neither side properly.
|
||||
|
||||
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
|
||||
|
||||
- `config.toml` (gitignored) is read from the working directory, or from `-c <path>`. Copy `config_sample.toml`. The bind address comes from `[network]` there, not from `LEPTOS_SITE_ADDR`.
|
||||
- Migrations in `migrations/` are embedded by `sqlx::migrate!()` and run on startup.
|
||||
- First start seeds an `admin` / `admin` user, a placeholder company, empty mail templates and an appearance row (`create_admin`, `check_company`, `check_messages`, `check_appearance` in `src/main.rs`).
|
||||
- With `transport = "File"` e-mails are written as `.eml` into `Maildir/` instead of being sent.
|
||||
- `assets/` is copied to `target/site` on build. `assets/data/` (banner.css, uploaded banner) is untracked but required: the page links `/data/<css_name>` and uploads are written to `target/site/data/` at runtime, relative to the working directory. A rebuild wipes `target/site`, so runtime uploads there are lost in dev.
|
||||
|
||||
## Layout
|
||||
|
||||
- `src/main.rs` — Actix setup: config, pool, migrations, seeding, session + auth middleware, `/api/*` server fns, `/upload`.
|
||||
- `src/app.rs` — `<App/>`, router, shared contexts (`MenuHelper`, `MenuOpener`, `DialogHelper`).
|
||||
- `src/backend/` — server functions per domain, plus `data.rs` (all DTOs/entities shared by server and client) and `auth_middleware.rs` (redirects unauthenticated `/admin*` page requests to `/login`).
|
||||
- `src/pages/` — page and dialog components; `src/components/` — reusable pieces (`DataForm`, `QuestionDialog`, `ModalDialog`, `ServerErr`, `ValidationErr`, `AdminPortal`).
|
||||
- `src/locales/` — `trl()` and the translation catalogue.
|
||||
- `src/error.rs` — `AppError`; `src/config.rs` — `config.toml` structs (ssr only); `src/validator.rs` — client-side form validation helper.
|
||||
- `assets/vendor/` — third-party admin template (Bootstrap based). Don't edit or reformat it; app styles go to `style/main.scss`.
|
||||
|
||||
Routes: `/` public booking form, `/login`, `/admin`, `/admin/settings`, `/admin/mail_settings`, `/admin/bookings`, `/admin/customers`, `/admin/appearance`.
|
||||
|
||||
## Domain model
|
||||
|
||||
`property` is a bookable thing with a price and slot length. A booking is one `reservation_sum` (date, customer, total price, state `New` → `Approved` / `Canceled`, public `uuid`) with one or more `reservation` rows (property + from/to time). `opening_hour` rows are per weekday, `closing_time` rows are date ranges when booking is closed. Customers are matched by e-mail. The public form is protected by a proof-of-work captcha (`leptos-captcha`).
|
||||
|
||||
## Conventions
|
||||
|
||||
- Code, comments, identifiers, log messages and commit messages are in English. UI strings are English keys translated at runtime (see `.claude/rules/frontend.md`).
|
||||
- Commit messages are always written in English, even when the conversation is in Czech: short past-tense sentences ending with a period, e.g. "Added setting for closing days."
|
||||
- The project has no rustfmt config and the code is not rustfmt-formatted. Don't run `cargo fmt` over existing files; match the surrounding style and keep diffs limited to what you changed.
|
||||
- Leptos is 0.6 — use the 0.6 API (`create_signal`, `create_resource`, `create_server_action`, `#[component]` returning `impl IntoView`), not 0.7/0.8 (`signal()`, `Resource::new`, …).
|
||||
- `wasm-bindgen` is pinned to `=0.2.100`; it must match the wasm-bindgen version of the installed cargo-leptos. Don't bump it on its own.
|
||||
- Version lives in `Cargo.toml` and is shown in the page footer.
|
||||
|
||||
Detailed rules are in `.claude/rules/`: `backend.md`, `frontend.md`, `database.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:
|
||||
|
||||
|
||||
@@ -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: "[email protected]".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: "[email protected]".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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,3 +260,85 @@ impl ForValidation for UpdateMessage {
|
||||
&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(
|
||||
"[email protected]".to_string(),
|
||||
"[email protected]".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(
|
||||
"[email protected]".to_string(),
|
||||
"[email protected]".to_string(),
|
||||
message("Booking", "Plain text"),
|
||||
&reservation());
|
||||
|
||||
assert_eq!(mail.text, "Plain text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_mail_sets_headers() {
|
||||
let mail = MailMessage::new(
|
||||
"[email protected]".to_string(),
|
||||
"[email protected]".to_string(),
|
||||
message("Booking", "Hello"),
|
||||
&reservation());
|
||||
|
||||
let built = String::from_utf8(mail.build_mail("[email protected]".to_string()).unwrap().formatted()).unwrap();
|
||||
|
||||
assert!(built.contains("From: [email protected]"));
|
||||
assert!(built.contains("Reply-To: [email protected]"));
|
||||
assert!(built.contains("To: [email protected]"));
|
||||
assert!(built.contains("Subject: Booking"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_mail_rejects_invalid_address() {
|
||||
let mail = MailMessage::new(
|
||||
"[email protected]".to_string(),
|
||||
"not an address".to_string(),
|
||||
message("Booking", "Hello"),
|
||||
&reservation());
|
||||
|
||||
assert!(matches!(mail.build_mail("[email protected]".to_string()), Err(AppError::MailAddrParseErr(_))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -536,3 +536,49 @@ pub async fn reservations_in_month(year: i32, month: u32) -> Result<ApiResponse<
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(), "[email protected]");
|
||||
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:[email protected]: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:[email protected]:6543/db");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smtp_mailing_config() {
|
||||
let mailing: Mailing = toml::from_str(
|
||||
"transport = 'Smtp'\nfrom = '[email protected]'\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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,3 +102,49 @@ impl From<std::io::Error> for AppError {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user