//! The calendar, driven through the router against a real database. use std::sync::Arc; use chrono::{Duration, Utc}; use goingson_core::{Event, EventId, NewEvent, Recurrence}; use quasi_http::Serves as _; use quasi_router::Outcome; use quasi_router::{Params, Request, Response}; use crate::quasi::router; use crate::state::{AppState, DESKTOP_USER_ID}; async fn state() -> Arc { let (state, _) = crate::test_utils::setup_test_state().await; let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); state .db .conn() .unwrap() .execute( "INSERT OR IGNORE INTO users (id, email, password_hash, display_name, created_at) \ VALUES (?, ?, ?, ?, ?)", rusqlite::params![ DESKTOP_USER_ID.to_string(), "desktop@localhost", "x", "Desktop User", &now, ], ) .unwrap(); state } /// An event at an offset from now, so "upcoming" and "past" are decidable /// without freezing the clock. fn event_at(state: &AppState, title: &str, hours_from_now: i64) -> Event { make( state, NewEvent::builder(title, Utc::now() + Duration::hours(hours_from_now)) .user_id(DESKTOP_USER_ID) .build(), ) } fn make(state: &AppState, new: NewEvent) -> Event { state.events.create(DESKTOP_USER_ID, new).unwrap() } fn html(response: Response) -> String { match response.outcome { Outcome::Screen(screen) => quasi_webview::Webview::new().screen(&screen), Outcome::Fragment { node, .. } => quasi_webview::Webview::new().fragment(&node), Outcome::Goto(action) => panic!("expected content, got a redirect to {action:?}"), Outcome::Over(_) => panic!("expected content, got a screen drawn over it"), Outcome::Anchored { .. } => { panic!("expected content, got a screen drawn at a point on it") } Outcome::Suggestions { field, .. } => { panic!("expected content, got a suggestion list for `{field}`") } Outcome::File { name, .. } => panic!("expected content, got the file `{name}`"), Outcome::Locate(_) => panic!("expected content, got a place on a map"), // `cb62a9dc`. Work that runs somewhere else and a region that says so: // not content, and not a place either. Outcome::Started { region, .. } => { panic!("expected content, got work started in `{region}`") } } } fn get(state: &AppState, path: &str, params: Params) -> Response { router() .handle(state, Request::get(path).carrying(params)) .expect("the route answers") } fn post(state: &AppState, path: &str, params: Params) -> Response { router() .handle(state, Request::post(path).sending(params)) .expect("the route answers") } fn screen(state: &AppState) -> String { html(get(state, "/events", Params::new())) } #[tokio::test] async fn an_empty_calendar_says_so() { let state = state().await; assert!(screen(&state).contains("No events scheduled.")); } #[tokio::test] async fn the_three_sections_split_the_way_the_js_splits_them() { let state = state().await; event_at(&state, "Ahead", 24); event_at(&state, "Behind", -24); make( &state, NewEvent::builder("Every week", Utc::now() + Duration::hours(48)) .user_id(DESKTOP_USER_ID) .recurrence(Recurrence::Weekly) .build(), ); let page = screen(&state); // Recurring first: the rules are the shortest list and what a reader scans // for, which is `events.js`'s own order. let recurring = page.find("Recurring").expect("a recurring section"); let upcoming = page.find("Upcoming").expect("an upcoming section"); let past = page.find("Past").expect("a past section"); assert!(recurring < upcoming, "{page}"); assert!(upcoming < past, "{page}"); assert!(page.contains("Every week"), "{page}"); assert!(page.contains("Ahead"), "{page}"); assert!(page.contains("Behind"), "{page}"); } #[tokio::test] async fn a_recurring_rule_leads_with_its_pattern_rather_than_a_date() { // The arbitrary date a weekly rule happens to start on says nothing about // the rule, which is why the JS swaps the same cell. let state = state().await; make( &state, NewEvent::builder("Standup", Utc::now() + Duration::hours(48)) .user_id(DESKTOP_USER_ID) .recurrence(Recurrence::Weekly) .build(), ); let page = screen(&state); // `RecurrenceRule::display()` is the human label, not the enum name. assert!(page.contains("Every week"), "{page}"); } #[tokio::test] async fn an_occurrence_is_not_filed_as_a_rule() { // `is_template` is a recurrence AND not being an expanded instance. An // event carrying only the first would put every occurrence in the rules // section, which is the bug the split exists to avoid. // The flag is set by recurrence expansion and is never persisted, so this // flips it on the value rather than storing one. let state = state().await; let mut stored = make( &state, NewEvent::builder("Standup", Utc::now() + Duration::hours(2)) .user_id(DESKTOP_USER_ID) .recurrence(Recurrence::Weekly) .build(), ); assert!(super::is_template(&stored), "the stored rule is a template"); stored.is_recurring_instance = true; assert!( !super::is_template(&stored), "an expanded occurrence is not" ); } #[tokio::test] async fn the_snoozed_filter_is_an_address_not_a_checkbox() { // `filter-events-snoozed` is DOM state in the JS, so the filtered view has // no address. Here it does. let state = state().await; let hidden = event_at(&state, "Snoozed away", 12); state .events .snooze(hidden.id, DESKTOP_USER_ID, Utc::now() + Duration::days(3)) .unwrap(); assert!(!screen(&state).contains("Snoozed away")); let shown = html(get(&state, "/events", Params::new().with("snoozed", "1"))); assert!(shown.contains("Snoozed away"), "{shown}"); } #[tokio::test] async fn a_shown_snoozed_event_says_that_is_what_it_is() { // Turning the filter on mixes snoozed rows in with the rest, so the row has // to carry the fact or the two are indistinguishable. let state = state().await; let event = event_at(&state, "Only once", 12); state .events .snooze(event.id, DESKTOP_USER_ID, Utc::now() + Duration::days(3)) .unwrap(); let page = html(get(&state, "/events", Params::new().with("snoozed", "1"))); assert_eq!(page.matches("Only once").count(), 1, "{page}"); assert!(page.contains("Snoozed"), "{page}"); } #[tokio::test] async fn the_repository_read_is_not_the_command_read() { // The port's one real correction. `events.js` calls `list_events`, which // excludes snoozed rows; `list_all` underneath it does not. Reading the // repository directly and calling it equivalent would put snoozed events on // the screen on every visit. let state = state().await; let event = event_at(&state, "Hidden", 12); state .events .snooze(event.id, DESKTOP_USER_ID, Utc::now() + Duration::days(3)) .unwrap(); let straight_from_the_repository = state.events.list_all(DESKTOP_USER_ID).unwrap(); assert!( straight_from_the_repository .iter() .any(|e| e.title == "Hidden"), "list_all still carries it, which is why the screen filters" ); assert!(!screen(&state).contains("Hidden")); } #[tokio::test] async fn selecting_an_event_addresses_the_detail_pane() { let state = state().await; let event = event_at(&state, "Dentist", 6); let page = screen(&state); assert!(page.contains(&format!("/events/{}", event.id)), "{page}"); let detail = html(get(&state, &format!("/events/{}", event.id), Params::new())); assert!(detail.contains("Dentist"), "{detail}"); } #[tokio::test] async fn deleting_an_event_takes_it_off_the_list() { let state = state().await; let going = event_at(&state, "Going", 6); event_at(&state, "Staying", 8); let page = html(post( &state, &format!("/events/{}/delete", going.id), Params::new(), )); assert!(!page.contains("Going"), "{page}"); assert!(page.contains("Staying"), "{page}"); } #[tokio::test] async fn a_delete_made_from_the_filtered_view_answers_with_the_filtered_view() { let state = state().await; let going = event_at(&state, "Going", 6); let snoozed = event_at(&state, "Snoozed away", 8); state .events .snooze(snoozed.id, DESKTOP_USER_ID, Utc::now() + Duration::days(3)) .unwrap(); let page = html(post( &state, &format!("/events/{}/delete", going.id), Params::new().with("snoozed", "1"), )); assert!(page.contains("Snoozed away"), "{page}"); } #[tokio::test] async fn deleting_a_recurring_rule_is_refused_rather_than_guessed() { // `confirmRecurringScope` asks "this occurrence or the series?" before the // write lands. Nothing describes a write that pauses for an answer, so the // route refuses rather than choosing for the user. let state = state().await; let rule = make( &state, NewEvent::builder("Standup", Utc::now() + Duration::hours(48)) .user_id(DESKTOP_USER_ID) .recurrence(Recurrence::Weekly) .build(), ); let error = router() .handle( &state, Request::post(format!("/events/{}/delete", rule.id)).sending(Params::new()), ) .expect_err("a rule needs the scope question"); assert_eq!(error.class.http_status(), 404); assert!( state .events .get_by_id(rule.id, DESKTOP_USER_ID) .unwrap() .is_some(), "the rule is still there" ); } #[tokio::test] async fn the_rule_says_why_it_offers_no_delete() { let state = state().await; let rule = make( &state, NewEvent::builder("Standup", Utc::now() + Duration::hours(48)) .user_id(DESKTOP_USER_ID) .recurrence(Recurrence::Weekly) .build(), ); let detail = html(get(&state, &format!("/events/{}", rule.id), Params::new())); assert!(detail.contains("scope question"), "{detail}"); assert!( !detail.contains(&format!("/events/{}/delete", rule.id)), "{detail}" ); } #[tokio::test] async fn list_is_a_route_rather_than_an_event_called_list() { // The literal segment is mounted above the capture. Read the other way, // `/events/list` is an event id that does not parse. let state = state().await; event_at(&state, "Dentist", 6); let fragment = html(get(&state, "/events/list", Params::new())); assert!(fragment.contains("Dentist"), "{fragment}"); } #[tokio::test] async fn an_event_that_is_not_there_is_a_not_found() { let state = state().await; let error = router() .handle( &state, Request::get(format!("/events/{}", EventId::from(uuid::Uuid::nil()))), ) .expect_err("no such event"); assert_eq!(error.class.http_status(), 404); } // The form. `8fdb814c`. /// A submission with the questions the form asks, filled the way the boxes are /// prefilled. One place, so a test about one answer says what it changed. fn form(title: &str, start: &str) -> Params { Params::new() .with("title", title) .with("description", "") .with("start_time", start) .with("end_time", "") .with("location", "") .with("recurrence", "None") .with("tz_kind", "relative") .with("timezone", "") .with("block_type", "") .with("contact_id", "") .with("project_id", "") } /// The same submission with one answer replaced. /// /// In front rather than appended: `Params::get` answers with the first value /// under a name, so a second `with` would leave the default standing. fn instead(name: &str, value: impl Into, params: Params) -> Params { let mut out = Params::new().with(name, value); out.absorb(params); out } /// A wall clock in the shape the boxes offer and the parser reads back. fn typed_at(hours_from_now: i64) -> String { (chrono::Local::now() + Duration::hours(hours_from_now)) .format("%Y-%m-%dT%H:%M") .to_string() } fn events(state: &AppState) -> Vec { state.events.list_all(DESKTOP_USER_ID).unwrap() } #[tokio::test] async fn the_form_asks_what_the_js_form_asked() { let state = state().await; let html = html(get(&state, "/events/new", Params::new())); for name in [ "is_all_day", "title", "description", "start_time", "end_time", "location", "recurrence", "tz_kind", "timezone", "block_type", "contact_id", "project_id", ] { assert!(html.contains(&format!("name=\"{name}\"")), "{name}: {html}"); } // The reminders are one question answered N times, so the name on the wire // is indexed and the blank the add control clones is already there. assert!(html.contains("data-repeat=\"reminder\""), "{html}"); assert!(html.contains("Add reminder"), "{html}"); // One form and one submit, whatever the questions inside it are. assert_eq!(html.matches("").expect("the end"), "{form}"); // And it is revealed locally: no route is called to bring it out. assert!(!html.contains("/events/timezone"), "{html}"); } #[tokio::test] async fn an_event_can_be_created_from_the_form() { let state = state().await; let answer = html(post(&state, "/events", form("Dentist", &typed_at(24)))); let made = events(&state); assert_eq!(made.len(), 1, "{made:?}"); assert_eq!(made[0].title, "Dentist"); // Answered with the screen the write happened on, re-read, so the list // shows what was just created. assert!(answer.contains("Dentist"), "{answer}"); } #[tokio::test] async fn an_event_can_be_edited_from_the_form() { let state = state().await; let event = event_at(&state, "Dentist", 24); // The form opens filled from the event. let opened = html(get( &state, &format!("/events/{}/edit", event.id), Params::new(), )); assert!(opened.contains("value=\"Dentist\""), "{opened}"); post( &state, &format!("/events/{}", event.id), instead( "location", "Rose Street", form("Dentist, moved", &typed_at(48)), ), ); let saved = state .events .get_by_id(event.id, DESKTOP_USER_ID) .unwrap() .expect("the event is still there"); assert_eq!(saved.title, "Dentist, moved"); assert_eq!(saved.location.as_deref(), Some("Rose Street")); } #[tokio::test] async fn the_detail_pane_offers_edit() { let state = state().await; let event = event_at(&state, "Dentist", 24); let detail = html(get(&state, &format!("/events/{}", event.id), Params::new())); assert!( detail.contains(&format!("/events/{}/edit", event.id)), "{detail}" ); } /// The second word. One question, three answers, one submit, and the values /// arrive as the `Vec` the column has always been. #[tokio::test] async fn reminders_are_one_question_answered_n_times() { let state = state().await; post( &state, "/events", form("Standup", &typed_at(24)) .with("reminder[0]", "300") .with("reminder[1]", "900") .with("reminder[2]", "3600"), ); let made = events(&state); assert_eq!(made[0].reminder_offsets_seconds, vec![300, 900, 3600]); // And the edit form offers one slot per answer, each under its own name. let opened = html(get( &state, &format!("/events/{}/edit", made[0].id), Params::new(), )); let standing = &opened[..opened.find("