//! Contexts, driven through the router against a real database. use std::sync::Arc; use chrono::NaiveDate; use goingson_core::models::{Context, ContextKind}; 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 } fn day(date: &str) -> NaiveDate { date.parse().expect("a date") } fn make(state: &AppState, label: &str, kind: ContextKind, starts: &str, ends: &str) -> Context { state .contexts .create(DESKTOP_USER_ID, label, kind, day(starts), day(ends)) .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, "/contexts", Params::new())) } fn span(label: &str, kind: &str, starts: &str, ends: &str) -> Params { let mut params = Params::new(); params.insert("label".to_owned(), label.to_owned()); params.insert("kind".to_owned(), kind.to_owned()); params.insert("starts_on".to_owned(), starts.to_owned()); params.insert("ends_on".to_owned(), ends.to_owned()); params } fn only(state: &AppState) -> Context { let mut all = state.contexts.list_all(DESKTOP_USER_ID).unwrap(); assert_eq!(all.len(), 1, "expected exactly one context"); all.remove(0) } #[tokio::test] async fn an_empty_list_says_so_and_offers_the_way_in() { let state = state().await; let page = screen(&state); assert!(page.contains("No contexts yet."), "{page}"); assert!(page.contains("/contexts/new"), "{page}"); } /// The whole point of the screen: a kind the checkboxes cannot say, with a name. #[tokio::test] async fn a_context_can_be_named_and_given_a_kind_the_checkboxes_cannot_say() { let state = state().await; post( &state, "/contexts", span("Two weeks in Lisbon", "Trip", "2026-09-03", "2026-09-17"), ); let recorded = only(&state); assert_eq!(recorded.label, "Two weeks in Lisbon"); assert_eq!(recorded.kind, ContextKind::Trip); assert_eq!(recorded.starts_on, day("2026-09-03")); assert_eq!(recorded.ends_on, day("2026-09-17")); } /// Authored as a span, so the record is one decision and not fifteen. #[tokio::test] async fn a_span_is_one_record_however_many_days_it_covers() { let state = state().await; post( &state, "/contexts", span("Leave", "Vacation", "2026-08-03", "2026-08-17"), ); let recorded = only(&state); assert_eq!(recorded.days(), 15, "both ends are inside"); assert!( recorded.covers(day("2026-08-17")), "and the last day is off" ); } #[tokio::test] async fn the_list_shows_the_span_rather_than_the_label_alone() { let state = state().await; make( &state, "Leave", ContextKind::Vacation, "2026-08-03", "2026-08-17", ); let page = screen(&state); assert!(page.contains("Leave"), "{page}"); assert!(page.contains("3 Aug 2026"), "{page}"); assert!(page.contains("17 Aug 2026"), "{page}"); assert!(page.contains("15 days"), "{page}"); } /// The repository orders the ends on the way in, which is right for a migration /// reading stored data and wrong for a form. A backwards span is reported. #[tokio::test] async fn a_backwards_span_is_refused_rather_than_silently_swapped() { let state = state().await; let page = html(post( &state, "/contexts", span("Backwards", "Vacation", "2026-08-17", "2026-08-03"), )); assert!( page.contains("The first day is after the last one."), "{page}" ); assert!( state.contexts.list_all(DESKTOP_USER_ID).unwrap().is_empty(), "nothing was written" ); } /// `1c4a66a4`'s repair, asked of this form: a refusal hands back what was typed. #[tokio::test] async fn a_refused_form_comes_back_carrying_what_was_typed() { let state = state().await; let page = html(post( &state, "/contexts", span("", "Trip", "2026-09-03", "2026-09-17"), )); assert!(page.contains("A context needs a label."), "{page}"); // The dates the user did fill in survive the round trip. assert!(page.contains("2026-09-03"), "{page}"); assert!(page.contains("2026-09-17"), "{page}"); } #[tokio::test] async fn every_complaint_arrives_at_once() { let state = state().await; let mut params = span("", "Vacation", "", ""); params.insert("kind".to_owned(), "Vacation".to_owned()); let page = html(post(&state, "/contexts", params)); assert!(page.contains("A context needs a label."), "{page}"); assert!(page.contains("Needs a first day."), "{page}"); assert!(page.contains("Needs a last day."), "{page}"); } #[tokio::test] async fn the_edit_form_arrives_filled() { let state = state().await; let existing = make( &state, "Leave", ContextKind::Vacation, "2026-08-03", "2026-08-17", ); let page = html(get( &state, &format!("/contexts/{}", existing.id), Params::new(), )); assert!(page.contains("Leave"), "{page}"); assert!(page.contains("2026-08-03"), "{page}"); assert!(page.contains("2026-08-17"), "{page}"); } #[tokio::test] async fn a_context_can_be_relabelled_rekinded_and_respanned() { let state = state().await; let existing = make( &state, "Leave", ContextKind::Vacation, "2026-08-03", "2026-08-17", ); post( &state, &format!("/contexts/{}", existing.id), span("Flu", "Illness", "2026-08-05", "2026-08-08"), ); let updated = only(&state); assert_eq!(updated.id, existing.id, "the same record, changed"); assert_eq!(updated.label, "Flu"); assert_eq!(updated.kind, ContextKind::Illness); assert_eq!(updated.starts_on, day("2026-08-05")); assert_eq!(updated.ends_on, day("2026-08-08")); } #[tokio::test] async fn a_context_can_be_deleted() { let state = state().await; let existing = make( &state, "Leave", ContextKind::Vacation, "2026-08-03", "2026-08-17", ); post( &state, &format!("/contexts/{}/delete", existing.id), Params::new(), ); assert!(state.contexts.list_all(DESKTOP_USER_ID).unwrap().is_empty()); } /// The reversal path `8ee5c4fe` made the condition of accepting a migration that /// guesses: deleting the context puts its event back, and the screen says so /// before the delete rather than after it. #[tokio::test] async fn a_migrated_context_says_where_it_came_from_and_puts_the_event_back() { let state = state().await; let event = state .events .create( DESKTOP_USER_ID, goingson_core::NewEvent::builder("Conference", chrono::Utc::now()) .user_id(DESKTOP_USER_ID) .build(), ) .unwrap(); let context = make( &state, "Conference", ContextKind::Trip, "2026-09-03", "2026-09-05", ); // The migration's own arrangement: the event is hidden rather than deleted, // and the context points back at it. let conn = state.db.conn().unwrap(); conn.execute( "UPDATE contexts SET migrated_from_event_id = ?1 WHERE id = ?2", rusqlite::params![event.id.to_string(), context.id.to_string()], ) .unwrap(); conn.execute( "UPDATE events SET converted_to_context_id = ?1 WHERE id = ?2", rusqlite::params![context.id.to_string(), event.id.to_string()], ) .unwrap(); drop(conn); let pane = html(get( &state, &format!("/contexts/{}", context.id), Params::new(), )); assert!(pane.contains("Converted from an event."), "{pane}"); assert!( pane.contains("puts that event back on the timeline"), "{pane}" ); post( &state, &format!("/contexts/{}/delete", context.id), Params::new(), ); let hidden: Option = state .db .conn() .unwrap() .query_row( "SELECT converted_to_context_id FROM events WHERE id = ?1", rusqlite::params![event.id.to_string()], |row| row.get(0), ) .unwrap(); assert!(hidden.is_none(), "the event is back on the timeline"); } /// The day band is the way in, since a `Notice` carries no act and the banner /// therefore cannot be one. #[tokio::test] async fn the_day_band_offers_the_way_to_the_contexts_screen() { let state = state().await; let page = html(get(&state, "/day", Params::new())); assert!(page.contains("/contexts"), "{page}"); }