//! The mail list and the thread, driven through the router against a real //! database. //! //! Same standard as the screens before it: no Tauri runtime and no window, the //! description asserted, and the markup only where the markup is the point. //! Every workaround this port had to take is asserted here rather than left to //! be noticed, so closing a finding is a test that has to change. use std::sync::Arc; use chrono::{Duration, Utc}; use goingson_core::{BodyFormat, Email, EmailId, NewEmailWithTracking}; use quasi_http::Serves as _; use quasi_router::screen::{Act, Row, Slot}; use quasi_router::{Action, Node, Outcome, Params, Request, Response, Screen}; use super::super::router; use crate::state::{AppState, DESKTOP_USER_ID}; /// State with the desktop user in place, which is who the handlers read as. async fn state() -> Arc { let (state, _) = crate::test_utils::setup_test_state().await; let now = 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 } /// A message with everything off, which each test then says something about. fn message(subject: &str) -> NewEmailWithTracking { NewEmailWithTracking { project_id: None, from_address: "ada@example.com".to_owned(), to_address: "desktop@localhost".to_owned(), subject: subject.to_owned(), body: "Body.".to_owned(), body_format: BodyFormat::Plain, html_body: None, is_read: false, is_archived: false, received_at: Some(Utc::now()), message_id: None, in_reply_to: None, thread_id: None, email_account_id: None, is_outgoing: false, imap_uid: None, source_folder: None, attachment_meta: None, body_truncated: false, jmap_id: None, } } fn add(state: &AppState, email: NewEmailWithTracking) -> Email { state .emails .create_with_tracking(DESKTOP_USER_ID, email) .unwrap() } 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") } /// A write made from a view: what the control sent, and where it was sent from. fn viewing_post(state: &AppState, path: &str, payload: Params, carried: Params) -> Response { router() .handle( state, Request::post(path).sending(payload).carrying(carried), ) .expect("the route answers") } 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), // Deliberately not a wildcard, for the reason the task tests give: a // redirect has no body, and a fallback returning empty markup would read // as a screen that rendered nothing. Outcome::Goto(action) => panic!("expected content, got a redirect to {action:?}"), // Same reasoning one step along: an overlay is a screen, but it is not // the screen this route was asked for. Rendering it here would let a // route that answered with the command palette pass as the page. 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}`") } } } /// The whole screen under a view. fn screen(state: &AppState, params: Params) -> String { html(get(state, "/emails", params)) } /// The list's rows, as the description holds them rather than as markup. fn rows(response: Response) -> Vec { match response.outcome { Outcome::Fragment { node: Node::Table { rows, .. }, .. } => rows, other => panic!("expected a list fragment, got {other:?}"), } } /// What a list fragment says is left, and how to ask for it. fn remainder(response: Response) -> Option { match response.outcome { Outcome::Fragment { node: Node::Table { more, .. }, .. } => more, other => panic!("expected a list fragment, got {other:?}"), } } /// What a response said on the way, which is not part of its content. fn notice(response: &Response) -> String { response .notice .as_ref() .map(|message| message.text.clone()) .unwrap_or_default() } /// The band, which is where the filters and the bulk bar live. fn band(page: &Screen) -> &Slot { page.slots .iter() .find(|slot| slot.id == "emails-band") .expect("the screen has a band") } /// The controls the band offers, in the order it offers them. fn acts(page: &Screen) -> Vec<&Act> { band(page) .body .iter() .filter_map(|ranked| match &ranked.node { Node::Act(act) => Some(act), _ => None, }) .collect() } /// What those controls are called. fn labels(page: &Screen) -> Vec { acts(page).iter().map(|act| act.label.clone()).collect() } /// Every route a filter control leads to: the two selects and the chip. fn filter_actions(page: &Screen) -> Vec { band(page) .body .iter() .filter_map(|ranked| match &ranked.node { Node::Field(field) => field.writes.clone(), Node::Token(tag) => tag.action.clone(), _ => None, }) .collect() } /// The rows the screen's list pane is holding. fn listed_rows(page: &Screen) -> Vec { page.slots .iter() .find(|slot| slot.id == super::LIST) .and_then(|slot| slot.body.get(0)) .map_or_else(Vec::new, |ranked| match &ranked.node { Node::Table { rows, .. } => rows.clone(), other => panic!("expected a list, got {other:?}"), }) } fn read(state: &AppState, id: EmailId) -> bool { state .emails .get_by_id(id, DESKTOP_USER_ID) .unwrap() .expect("the email is there") .is_read } #[tokio::test] async fn the_list_carries_each_thread_and_what_it_is() { let state = state().await; add(&state, message("Lunch on Thursday")); let page = screen(&state, Params::new()); assert!(page.contains("Lunch on Thursday")); assert!(page.contains("ada@example.com")); // The thread is unread, which is the row's own fact carried as a token // rather than as a class on the markup. assert!(page.contains("Unread")); } #[tokio::test] async fn every_row_offers_a_selection_and_says_what_its_tick_contributes() { // The first finding, inverted. It was written to fail loudly once the // vocabulary landed, and quasi 0.37.0 landed it: the screen names the set, // the row says what its tick contributes, and the bar acts on the whole of // it. A row that is tickable and contributes nothing is the dead affordance // `Row::ticking` exists to end, so both halves are asserted. let state = state().await; let one = add(&state, message("One")); let two = add(&state, message("Two")); let listed = rows(get(&state, "/emails/list", Params::new())); assert_eq!(listed.len(), 2); assert!( listed.iter().all(|row| row.selected == Some(false)), "a described row is tickable and arrives unticked", ); let values: Vec = listed .iter() .map(|row| row.value.clone().expect("the tick contributes a value")) .collect(); assert!(values.contains(&one.id.to_string())); assert!(values.contains(&two.id.to_string())); } #[tokio::test] async fn the_screen_names_the_set_and_five_controls_sit_over_it() { // The other half of the description: a set with no name is a tick with // nowhere to go, and `Act::over` is what makes a control read as a commit // control rather than as a second row action. let state = state().await; add(&state, message("One")); let Outcome::Screen(page) = get(&state, "/emails", Params::new()).outcome else { panic!("expected a screen"); }; assert_eq!(page.selection.as_deref(), Some(super::SELECTION)); let over: Vec<&str> = acts(&page) .iter() .filter(|act| act.over.as_deref() == Some(super::SELECTION)) .map(|act| act.label.as_str()) .collect(); assert_eq!(over, ["Mark read", "Archive", "Snooze", "Delete"]); // The fifth is the shipped bar's fifth, and it is an address rather than a // verb over the set: see `View::ticked`. assert!(labels(&page).iter().any(|label| label == "Select all")); assert!( !labels(&page).iter().any(|label| label == "Clear selection"), "nothing arrives ticked, so there is nothing to clear", ); } #[tokio::test] async fn the_snooze_verb_asks_for_a_time_before_it_fires() { // `Act::asks`, and the reason it exists: `bulk-actions.js` raises a whole // modal to collect this one value. The options are the same ones the open // thread offers, so the count is whatever the local clock allows rather // than a number written here. let state = state().await; add(&state, message("One")); let Outcome::Screen(page) = get(&state, "/emails", Params::new()).outcome else { panic!("expected a screen"); }; let snooze = acts(&page) .into_iter() .find(|act| act.label == "Snooze") .expect("the bar offers Snooze"); assert_eq!(snooze.asks.len(), 1); assert_eq!(snooze.asks[0].name, "until"); assert!( !snooze.asks[0].options.is_empty(), "the question is a pick from the clock's own options", ); } #[tokio::test] async fn select_all_is_an_address_and_a_filter_change_drops_it() { // The charter rule `emails.js` enforces by hand in each of its four filter // handlers, holding here because the ticks travel on the address and the // filter controls drop them. let state = state().await; add(&state, message("One")); let ticked = rows(get( &state, "/emails/list", Params::new().with("ticked", "all"), )); assert!( ticked.iter().all(|row| row.selected == Some(true)), "select-all is answered by the server, so the rows arrive ticked", ); let Outcome::Screen(page) = get(&state, "/emails", Params::new().with("ticked", "all")).outcome else { panic!("expected a screen"); }; assert!( labels(&page).iter().any(|label| label == "Clear selection"), "with everything ticked there is something to clear", ); // Every filter control leads to a view with nothing ticked. for action in filter_actions(&page) { assert_eq!( action.carried.get("ticked"), None, "a filter change cannot carry select-all: {action:?}", ); } } #[tokio::test] async fn a_bulk_write_reads_the_ticks_and_answers_with_them_cleared() { let state = state().await; let one = add(&state, message("One")); let two = add(&state, message("Two")); let untouched = add(&state, message("Three")); let answer = viewing_post( &state, "/emails/list/read", Params::new() .with(Node::TICKED, one.id.to_string()) .with(Node::TICKED, two.id.to_string()), Params::new().with("ticked", "all"), ); assert!(read(&state, one.id)); assert!(read(&state, two.id)); assert!(!read(&state, untouched.id), "an unticked row is untouched"); assert_eq!(notice(&answer), "2 emails marked read."); let Outcome::Screen(page) = answer.outcome else { panic!("expected a screen"); }; assert!( listed_rows(&page) .iter() .all(|row| row.selected == Some(false)), "the answer clears the ticks, select-all included", ); } #[tokio::test] async fn archive_delete_and_snooze_each_run_over_the_set() { let state = state().await; let archived = add(&state, message("Archive me")); let deleted = add(&state, message("Delete me")); let snoozed = add(&state, message("Snooze me")); let answer = post( &state, "/emails/list/archive", Params::new().with(Node::TICKED, archived.id.to_string()), ); assert_eq!(notice(&answer), "1 email archived."); assert!( state .emails .get_by_id(archived.id, DESKTOP_USER_ID) .unwrap() .unwrap() .is_archived ); let answer = post( &state, "/emails/list/delete", Params::new().with(Node::TICKED, deleted.id.to_string()), ); assert_eq!(notice(&answer), "1 email deleted."); assert!( state .emails .get_by_id(deleted.id, DESKTOP_USER_ID) .unwrap() .is_none() ); let until = Utc::now() + Duration::hours(3); let answer = post( &state, "/emails/list/snooze", Params::new() .with(Node::TICKED, snoozed.id.to_string()) .with("until", until.to_rfc3339()), ); assert!( notice(&answer).starts_with("1 email snoozed until "), "the toast counts and says when: {}", notice(&answer), ); assert!( state .emails .get_by_id(snoozed.id, DESKTOP_USER_ID) .unwrap() .unwrap() .is_snoozed() ); } #[tokio::test] async fn a_bulk_write_over_nothing_answers_the_screen_rather_than_erroring() { // Every renderer draws a control over an empty selection as disabled, so // this is the hand-typed request rather than the ordinary path. It still // gets the list back: see `chosen`. let state = state().await; add(&state, message("One")); let answer = post(&state, "/emails/list/delete", Params::new()); assert_eq!(notice(&answer), "0 emails deleted."); let Outcome::Screen(page) = answer.outcome else { panic!("expected a screen"); }; assert_eq!(listed_rows(&page).len(), 1); } #[tokio::test] async fn a_bulk_snooze_refuses_a_time_that_has_passed() { // `set_snooze`'s floor, applied to the set: the repository would take it, // `is_snoozed` would read false immediately, and the toast would claim // forty messages were hidden when none of them were. let state = state().await; let email = add(&state, message("One")); let answer = router().handle( &state, Request::post("/emails/list/snooze").sending( Params::new() .with(Node::TICKED, email.id.to_string()) .with("until", (Utc::now() - Duration::hours(1)).to_rfc3339()), ), ); assert!(answer.is_err(), "a past time is refused, not stored"); assert!( !state .emails .get_by_id(email.id, DESKTOP_USER_ID) .unwrap() .unwrap() .is_snoozed() ); } #[tokio::test] async fn a_ticked_id_that_is_not_an_id_is_dropped_rather_than_failing_the_press() { // The task list's rule, and the reason for it: failing the whole press over // one malformed value would lose the other thirty-nine, and the count in // the toast is what the user actually gets. let state = state().await; let email = add(&state, message("One")); let answer = post( &state, "/emails/list/read", Params::new() .with(Node::TICKED, "not-a-uuid") .with(Node::TICKED, email.id.to_string()), ); assert_eq!(notice(&answer), "1 email marked read."); assert!(read(&state, email.id)); } #[tokio::test] async fn the_remainder_is_a_real_count_and_a_way_to_ask_for_more() { // The second finding, which is a confirmation: the remainder was expected to // be unknown most of the time, and `list_threaded` returns the total in the // same call, so the number is honest here. It is derived now rather than // stored, off the window and the total this screen already had. let state = state().await; for index in 0..=super::PAGE { let mut mail = message(&format!("Message {index}")); // Distinct receipt times, so "the newest 200" is a stable set rather // than whatever the tie-break happens to do. mail.received_at = Some(Utc::now() - Duration::minutes(index)); add(&state, mail); } let rest = remainder(get(&state, "/emails/list", Params::new())) .expect("201 messages do not fit in one page"); assert_eq!(rest.as_layout().remaining(), Some(1)); // And asking for more is an address, so the wider list is reachable // directly rather than by having scrolled to it. let wider = rows(get( &state, "/emails/list", Params::new().with("shown", (super::PAGE + super::PAGE).to_string()), )); assert_eq!(i64::try_from(wider.len()).unwrap(), super::PAGE + 1); assert!( remainder(get( &state, "/emails/list", Params::new().with("shown", (super::PAGE + super::PAGE).to_string()), )) .is_none() ); } #[tokio::test] async fn a_filter_is_an_address_and_every_control_carries_it() { let state = state().await; let mut filed = message("Filed away"); filed.source_folder = Some("Archive".to_owned()); add(&state, filed); add(&state, message("In the inbox")); let listed = rows(get( &state, "/emails/list", Params::new().with("folder", "Archive"), )); assert_eq!(listed.len(), 1); assert_eq!(listed[0].primary(), "Filed away"); // Opening it stays in the folder, and so does everything the row offers. // A control that dropped the filter is a view you fall out of by using it. let opening = listed[0].activate.as_ref().expect("a row opens its thread"); assert_eq!(opening.carried.get("folder"), Some("Archive")); for act in listed[0].acts() { assert_eq!( act.action.carried.get("folder"), Some("Archive"), "{} drops the folder it was offered under", act.label, ); } } #[tokio::test] async fn a_default_is_never_written_into_an_address() { // Two addresses for one view is the thing `View::carry` exists to prevent. let state = state().await; add(&state, message("Anything")); let listed = rows(get(&state, "/emails/list", Params::new())); let opening = listed[0].activate.as_ref().unwrap(); assert_eq!(opening.params.get("folder"), None); assert_eq!(opening.params.get("label"), None); assert_eq!(opening.params.get("archived"), None); assert_eq!(opening.params.get("shown"), None); } #[tokio::test] async fn opening_a_thread_marks_it_read_and_answers_with_the_whole_screen() { // The reader is an address rather than a modal, which is the third finding. // Opening writes, because `emails-reader.js:open` writes, and the row behind // it has just lost its badge — so the answer cannot be the pane alone. let state = state().await; let mail = add(&state, message("Read me")); assert!(!read(&state, mail.id)); let response = get(&state, &format!("/emails/{}", mail.id), Params::new()); assert!( matches!(response.outcome, Outcome::Screen(_)), "a read that changes the list answers with the screen", ); assert!(read(&state, mail.id)); let page = html(response); assert!(page.contains("Read me")); assert!(!page.contains("Nothing selected")); } #[tokio::test] async fn the_body_is_carried_as_markdown_source() { // The fourth finding, half one. `Email::body` is pter's markdown whenever it // came from HTML, and the shipped reader escapes it and re-links bare URLs // by hand, so that markdown reaches the screen as literal syntax. // `Node::Rich` carries the source and the renderer does the pass. let state = state().await; let mut mail = message("Formatted"); mail.body = "**bold** and [a link](https://example.com)".to_owned(); mail.body_format = BodyFormat::Markdown; let mail = add(&state, mail); let page = html(get(&state, &format!("/emails/{}", mail.id), Params::new())); assert!(page.contains("bold")); assert!(page.contains(r#"href="https://example.com""#)); assert!(!page.contains("**bold**")); } #[tokio::test] async fn a_plain_body_keeps_its_punctuation() { // The fourth finding, half two, and the reason the port could not simply // keep `Node::rich`: a `text/plain` message that happens to contain // asterisks is not emphasis, and rendering it as markdown eats characters // the sender typed. `body_format` is what tells the two apart, so a plain // body goes through `Node::text` and survives verbatim. let state = state().await; let mut mail = message("Literal"); mail.body = "the *args and **kwargs conventions".to_owned(); let mail = add(&state, mail); let page = html(get(&state, &format!("/emails/{}", mail.id), Params::new())); assert!(page.contains("the *args and **kwargs conventions")); assert!(!page.contains("")); assert!(!page.contains("")); } #[tokio::test] async fn marking_unread_closes_the_thread() { let state = state().await; let mail = add(&state, message("Later")); get(&state, &format!("/emails/{}", mail.id), Params::new()); assert!(read(&state, mail.id)); let page = html(post( &state, &format!("/emails/{}/read", mail.id), Params::new().with("read", "false"), )); assert!(!read(&state, mail.id)); // Putting it back to unread and then showing it is the one combination the // user cannot have meant. assert!(page.contains("Nothing selected")); assert!(page.contains("Unread")); } #[tokio::test] async fn the_screen_offers_mark_all_read_the_way_the_filter_row_does() { let state = state().await; add(&state, message("One")); // Offered before it is needed and after it stops being, because the shipped // control is: the button in `.email-filter-row` is not gated on the count. assert!(screen(&state, Params::new()).contains("Mark all read")); post(&state, "/emails/read-all", Params::new()); assert!(screen(&state, Params::new()).contains("Mark all read")); } #[tokio::test] async fn mark_all_read_ignores_the_filter_it_was_sent_from() { let state = state().await; let inbox = add(&state, message("In the inbox")); let mut filed = message("Filed elsewhere"); filed.source_folder = Some("Archive".to_owned()); let filed = add(&state, filed); // Sent from a folder that holds one of the two. The other is still marked, // because the command takes a user and nothing else. let response = viewing_post( &state, "/emails/read-all", Params::new(), Params::new().with("folder", "Archive"), ); assert_eq!(notice(&response), "2 emails marked read"); assert!(read(&state, inbox.id)); assert!(read(&state, filed.id)); // The answer is the view it was sent from, not an unfiltered inbox. let page = html(response); assert!(page.contains("Filed elsewhere")); assert!(!page.contains("In the inbox")); } #[tokio::test] async fn mark_all_read_says_so_when_nothing_was_unread() { let state = state().await; add(&state, message("Seen already")); post(&state, "/emails/read-all", Params::new()); // The JS reports success identically both times, which tells a user who // clicked twice nothing. let response = post(&state, "/emails/read-all", Params::new()); assert_eq!(notice(&response), "Nothing was unread"); } #[tokio::test] async fn archiving_takes_it_out_of_the_view_it_was_in() { let state = state().await; let mail = add(&state, message("Done with")); let response = post( &state, &format!("/emails/{}/archive", mail.id), Params::new().with("archived", "true"), ); assert_eq!(notice(&response), "Email archived"); let page = html(response); assert!(page.contains("Nothing selected")); // Gone from the inbox view entirely: with no mail left and no account // configured, what stands there is the account stand-in rather than a list. let inbox = html(get(&state, "/emails/list", Params::new())); assert!(!inbox.contains("Done with")); // Unless the view is one that holds archived mail, where it stays put and // the thread stays open. let held = rows(get( &state, "/emails/list", Params::new().with("archived", "1"), )); assert_eq!(held.len(), 1); // The write's own parameter is `on` and the view's is `archived`, which is // the sixth finding: written the obvious way these are one name, and // unarchiving from a view that holds archived mail would silently leave it. let still_open = html(post( &state, &format!("/emails/{}/archive", mail.id), Params::new().with("on", "false").with("archived", "1"), )); assert!(still_open.contains("Done with")); } #[tokio::test] async fn labels_round_trip_through_the_comma_separated_box() { let state = state().await; let mail = add(&state, message("Tag me")); let response = post( &state, &format!("/emails/{}/labels", mail.id), Params::new().with("labels", " work , , follow-up "), ); assert_eq!(notice(&response), "Labels updated"); let page = html(response); assert!(page.contains("work")); assert!(page.contains("follow-up")); let stored = state .emails .get_by_id(mail.id, DESKTOP_USER_ID) .unwrap() .unwrap(); // Trimmed, and the empty entry between the commas is dropped rather than // stored as a label with no name. `saveLabels` does the same two things. assert_eq!( stored.labels, vec!["work".to_owned(), "follow-up".to_owned()] ); } #[tokio::test] async fn moving_it_to_the_folder_being_looked_at_keeps_it_there() { let state = state().await; let mut mail = message("Moving"); mail.source_folder = Some("INBOX".to_owned()); let mail = add(&state, mail); // Out of the folder being looked at: it leaves, and the pane closes with it. // // Both values are called `folder`, and that is the point. The destination // is the payload and the view is the address, so the same name says two // things without either reaching the other. This test used to prove the // workaround — the destination was `to` — and now proves the fix: one name // for both used to move the message and the screen at once. let left = viewing_post( &state, &format!("/emails/{}/folder", mail.id), Params::new().with("folder", "Archive"), Params::new().with("folder", "INBOX"), ); assert_eq!(notice(&left), "Moved to Archive"); assert!(html(left).contains("Nothing selected")); // And into it: it stays, and so does the thread. let stayed = html(viewing_post( &state, &format!("/emails/{}/folder", mail.id), Params::new().with("folder", "Archive"), Params::new().with("folder", "Archive"), )); assert!(stayed.contains("Moving")); } #[tokio::test] async fn a_snooze_in_the_past_is_refused_rather_than_stored() { let state = state().await; let mail = add(&state, message("Not yet")); let refused = router().handle( &state, Request::post(format!("/emails/{}/snooze", mail.id)) .sending(Params::new().with("until", (Utc::now() - Duration::hours(1)).to_rfc3339())), ); assert!( refused.is_err(), "storing it would report the message hidden when it is not", ); let accepted = post( &state, &format!("/emails/{}/snooze", mail.id), Params::new().with("until", (Utc::now() + Duration::hours(3)).to_rfc3339()), ); assert!(notice(&accepted).starts_with("Snoozed until")); let page = html(accepted); assert!(page.contains("Not yet")); } #[tokio::test] async fn deleting_something_that_is_not_there_is_a_404() { // The rule the projects delete set: a delete that reports done for something // it never saw is how two regions end up disagreeing about what exists. let state = state().await; let missing = router().handle( &state, Request::post(format!("/emails/{}/delete", EmailId::new())), ); assert!(missing.is_err()); } #[tokio::test] async fn converting_makes_a_task_and_leaves_the_mail_where_it_was() { let state = state().await; let mail = add(&state, message("Ship the port")); let response = post(&state, &format!("/emails/{}/task", mail.id), Params::new()); assert_eq!(notice(&response), "Task created from email"); let page = html(response); let tasks = state.tasks.list_all(DESKTOP_USER_ID).unwrap(); assert!( tasks .iter() .any(|task| task.title.contains("Ship the port")) ); // The mail is still in the list, and the thread it was converted from is // still open. assert!(page.contains("Ship the port")); } #[tokio::test] async fn a_thread_is_drawn_oldest_first_and_says_how_many_it_holds() { let state = state().await; let mut first = message("Re: the plan"); first.thread_id = Some("thread-1".to_owned()); first.body = "The first word.".to_owned(); first.received_at = Some(Utc::now() - Duration::hours(2)); let first = add(&state, first); let mut second = message("Re: the plan"); second.thread_id = Some("thread-1".to_owned()); second.body = "The last word.".to_owned(); second.received_at = Some(Utc::now()); add(&state, second); let page = html(get(&state, &format!("/emails/{}", first.id), Params::new())); let earlier = page.find("The first word.").expect("the first message"); let later = page.find("The last word.").expect("the second message"); assert!(earlier < later, "the thread reads oldest first"); let listed = rows(get(&state, "/emails/list", Params::new())); assert_eq!(listed.len(), 1, "a thread is one row"); assert!( listed[0] .tokens() .any(|tag| tag.label.contains("2 messages")), "the count says what it counts rather than showing a bare digit", ); } #[tokio::test] async fn an_empty_filtered_view_blames_the_filter_and_offers_the_way_out() { // The JS has no filter-specific empty state: it asks whether an account // exists and picks one of two, so a folder holding no mail tells the user to // set up an account they already have. let state = state().await; add(&state, message("In the inbox")); let page = html(get( &state, "/emails/list", Params::new().with("folder", "Nowhere"), )); assert!(page.contains("No mail matching this filter")); assert!(page.contains("Clear filters")); assert!(!page.contains("Set up an email account")); }