//! Compose and the Out box, driven through the router against a real database. //! //! Two assertions carry the design rather than the code. One is that the screen //! has no form: the draft saves per field, which is what gets past the //! one-submit gap. The other is that Cc and Bcc are always drawn, which is what //! Eudora's shape buys and is the reason nothing here needs a word for //! progressive disclosure. A later pass that "tidies" either would be undoing a //! decision. use std::sync::Arc; use chrono::{Duration, Utc}; use goingson_core::NewEmailAccount; use quasi_http::Serves as _; use quasi_router::{Outcome, Params, Request}; 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 account, so a message has somewhere to be from. fn account(state: &AppState) -> goingson_core::EmailAccountId { state .email_accounts .create( DESKTOP_USER_ID, NewEmailAccount { account_name: "Mine", email_address: "me@example.com", imap_server: "imap.example.com", imap_port: 993, smtp_server: "smtp.example.com", smtp_port: 587, username: "me@example.com", password: "", use_tls: true, archive_folder_name: None, }, ) .expect("the account is created") .id } fn post(state: &AppState, path: &str, params: Params) -> quasi_router::Response { router() .handle(state, Request::post(path).sending(params)) .expect("the route answers") } fn html(state: &AppState, path: &str) -> String { let response = router() .handle(state, Request::get(path).carrying(Params::new())) .expect("the route answers"); match &response.outcome { Outcome::Screen(screen) => quasi_webview::Webview::new().screen(screen), Outcome::Fragment { node, .. } => quasi_webview::Webview::new().fragment(node), other => panic!("expected content, got {other:?}"), } } /// Start a message and answer its id. fn started(state: &AppState) -> goingson_core::EmailId { let response = post(state, "/compose", Params::new()); let Outcome::Goto(action) = &response.outcome else { panic!("starting a message goes to it"); }; let path = format!("{:?}", action.destination); let id = path .rsplit('/') .next() .expect("an id on the end") .trim_end_matches("\")") .to_owned(); id.parse::().expect("a uuid").into() } #[tokio::test] async fn a_new_message_is_a_post_because_a_get_that_writes_repeats_on_reload() { let state = state().await; let id = started(&state); let draft = state .emails .get_by_id(id, DESKTOP_USER_ID) .expect("read") .expect("the draft exists"); assert!(draft.is_draft); assert!(!draft.is_queued(), "a new message is not in the Out box"); // And the screen it went to is an ordinary address: reloadable, linkable. assert!(html(&state, &format!("/compose/{id}")).contains("New message")); } #[tokio::test] async fn the_header_is_eudoras_and_cc_and_bcc_are_always_drawn() { // The whole reason this screen needs no word for progressive disclosure. let state = state().await; let id = started(&state); let markup = html(&state, &format!("/compose/{id}")); for label in ["From", "To", "Cc", "Bcc", "Subject", "Attached"] { assert!(markup.contains(label), "the header is missing {label}"); } // Attached is a header row and says so even when empty, rather than a bar // that appears. That is the divergence 3fb2526a named first. assert!(markup.contains("Nothing"), "{markup}"); } #[tokio::test] async fn there_is_no_form_because_the_draft_saves_as_it_is_written() { // quasicoherent 7da72faf: a form has one submit and compose wanted three // verbs. It does not, once the values are already on disk. let state = state().await; let id = started(&state); let markup = html(&state, &format!("/compose/{id}")); assert!(!markup.contains("Queue<"), "{markup}"); post(&state, &format!("/compose/{id}/unqueue"), Params::new()); assert!( state .emails .list_outbox(DESKTOP_USER_ID) .expect("read") .is_empty() ); } #[tokio::test] async fn discarding_throws_the_message_away() { let state = state().await; let id = started(&state); post(&state, &format!("/compose/{id}/discard"), Params::new()); assert!( state .emails .get_by_id(id, DESKTOP_USER_ID) .expect("read") .is_none_or(|email| !email.is_draft) ); } // --- Attachments ----------------------------------------------------------- /// A file on disk to attach. fn a_file(name: &str, contents: &str) -> std::path::PathBuf { let dir = std::env::temp_dir().join("goingson-compose-attach-tests"); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join(name); std::fs::write(&path, contents).unwrap(); path } #[tokio::test] async fn attaching_is_the_hosts_call_and_lands_a_row_against_the_draft() { let state = state().await; let id = started(&state); // The control carries no transport: `Action::by_host`, the same as the // imports. Picking a file is not describable. let markup = html(&state, &format!("/compose/{id}")); assert!(markup.contains("Attach a file"), "{markup}"); assert!( markup.contains(&format!("data-sends=\"/compose/{id}/attach\"")), "{markup}" ); assert!(!markup.contains("type=\"file\""), "{markup}"); let path = a_file("notes.txt", "hello"); post( &state, &format!("/compose/{id}/attach"), Params::new().with("file", path.to_string_lossy().into_owned()), ); let files = state .attachments .list_for_email(id, DESKTOP_USER_ID) .expect("read"); assert_eq!(files.len(), 1); assert_eq!(files[0].filename, "notes.txt"); // And the header row says so where it said "Nothing". let markup = html(&state, &format!("/compose/{id}")); assert!(markup.contains("notes.txt"), "{markup}"); assert!(!markup.contains("Nothing"), "{markup}"); } #[tokio::test] async fn a_queued_message_lists_its_files_and_offers_no_way_to_change_them() { // The drainer may be reading them. let state = state().await; let account = account(&state); let id = started(&state); for (field, value) in [ ("from", account.to_string()), ("to", "them@example.com".to_owned()), ] { post( &state, &format!("/compose/{id}/field"), Params::new().with("field", field).with(field, value), ); } post( &state, &format!("/compose/{id}/attach"), Params::new().with( "file", a_file("report.csv", "a,b").to_string_lossy().into_owned(), ), ); post(&state, &format!("/compose/{id}/queue"), Params::new()); let markup = html(&state, &format!("/compose/{id}")); assert!(markup.contains("report.csv"), "still listed: {markup}"); assert!(!markup.contains("Attach a file"), "{markup}"); assert!(!markup.contains("Remove"), "{markup}"); } #[tokio::test] async fn removing_a_file_takes_the_row_and_leaves_the_blob_to_the_collector() { let state = state().await; let id = started(&state); post( &state, &format!("/compose/{id}/attach"), Params::new().with( "file", a_file("gone.txt", "bye").to_string_lossy().into_owned(), ), ); let files = state .attachments .list_for_email(id, DESKTOP_USER_ID) .expect("read"); let hash = files[0].blob_hash.clone(); post( &state, &format!("/compose/{id}/detach/{}", files[0].id), Params::new(), ); assert!( state .attachments .list_for_email(id, DESKTOP_USER_ID) .expect("read") .is_empty() ); // The blob is `blob_gc`'s to reclaim, which is what makes removing a file a // row delete rather than a disk operation. assert!( crate::commands::attachment::blob_path(&state.data_dir, &hash).exists(), "the blob is left for the collector" ); } #[tokio::test] async fn the_drainer_sends_the_blob_rather_than_the_path_it_came_from() { // THE ONE THAT MATTERS for send-later. A queued message may go hours after // it was written, and the file it was written from can be gone by then. let state = state().await; let account = account(&state); let id = started(&state); for (field, value) in [ ("from", account.to_string()), ("to", "them@example.com".to_owned()), ] { post( &state, &format!("/compose/{id}/field"), Params::new().with("field", field).with(field, value), ); } let path = a_file("fleeting.txt", "here for now"); post( &state, &format!("/compose/{id}/attach"), Params::new().with("file", path.to_string_lossy().into_owned()), ); post(&state, &format!("/compose/{id}/queue"), Params::new()); // The file the person picked goes away, as it may. std::fs::remove_file(&path).expect("remove"); // What the message carries is still there, under its hash. let files = state .attachments .list_for_email(id, DESKTOP_USER_ID) .expect("read"); assert_eq!(files.len(), 1); let blob = crate::commands::attachment::blob_path(&state.data_dir, &files[0].blob_hash); assert!(blob.exists(), "the blob outlives the file"); assert_eq!(std::fs::read_to_string(&blob).unwrap(), "here for now"); } #[tokio::test] async fn compose_offers_itself_in_a_window_of_its_own() { // `3fb2526a`, the second-mount half. The verb is an ordinary described // control marked `elsewhere`, so what the markup must carry is the address // and no transport: an `href` or an `hx-get` would put the answer in the // window the control is already in, which is the one thing the mark says // not to do. let state = state().await; let id = started(&state); let markup = html(&state, &format!("/compose/{id}")); assert!( markup.contains(&format!(r#"data-mount="/compose/{id}""#)), "{markup}" ); // No transport on it. A read would ordinarily be an anchor with an `href` // and an `hx-get`, and either would open the screen in the window the // control is already in -- the one thing the mark says not to do. Read off // the element itself, since the screen is full of controls that rightly // carry both. let verb = markup .split("').expect("a tag ends")]; assert!(!verb.contains("href="), "{verb}"); assert!(!verb.contains("hx-get"), "{verb}"); // Not `data-sends`: `host.js` posts to one and opens a window on the other, // so conflating them is a message sent where a window was wanted. assert!(!verb.contains("data-sends"), "{verb}"); } #[tokio::test] async fn the_compose_window_serves_the_same_screen_as_the_main_window() { // The whole point of the second mount, and the thing that would rot // silently: one description, two windows. If this screen ever learns which // mount it is in, it is back to the two code paths `3fb2526a` measured -- // `compose-form.js` shared by two documents with two chrome arrangements. // // Asserted through the router rather than through the protocol, because // what differs between the mounts is the renderer and the frame, and the // description is what must not differ. let state = state().await; let id = started(&state); let main = html(&state, &format!("/compose/{id}")); let framed = { let response = router() .handle( &state, Request::get(format!("/compose/{id}")).carrying(Params::new()), ) .expect("the route answers"); let Outcome::Screen(screen) = &response.outcome else { panic!("compose is a screen"); }; // The compose window's renderer: the same screens, a frame that // reports. See `quasi::compose_protocol`. quasi_webview::Webview::new() .with_frame(quasi_router::Frame::new().reporting()) .screen(screen) }; // Everything the screen says is in both. for said in ["Subject", "Attached", "Queue", "Discard"] { assert!(main.contains(said), "main window lost {said}: {main}"); assert!( framed.contains(said), "compose window lost {said}: {framed}" ); } // And the frame adds a place to speak, which the main window has not. That // is the fifth divergence of `3fb2526a`'s table and the only one left. assert!( framed.contains(quasi_webview::frame::STATUS_ID), "the compose window has no status line: {framed}" ); assert!( !main.contains(quasi_webview::frame::STATUS_ID), "the main window grew a status line: {main}" ); }