Skip to main content

max / goingson

31.5 KB · 925 lines History Blame Raw
1 //! The mail list and the thread, driven through the router against a real
2 //! database.
3 //!
4 //! Same standard as the screens before it: no Tauri runtime and no window, the
5 //! description asserted, and the markup only where the markup is the point.
6 //! Every workaround this port had to take is asserted here rather than left to
7 //! be noticed, so closing a finding is a test that has to change.
8
9 use std::sync::Arc;
10
11 use chrono::{Duration, Utc};
12 use goingson_core::{BodyFormat, Email, EmailId, NewEmailWithTracking};
13 use quasi_http::Serves as _;
14 use quasi_router::screen::{Act, Row, Slot};
15 use quasi_router::{Action, Node, Outcome, Params, Request, Response, Screen};
16
17 use super::super::router;
18 use crate::state::{AppState, DESKTOP_USER_ID};
19
20 /// State with the desktop user in place, which is who the handlers read as.
21 async fn state() -> Arc<AppState> {
22 let (state, _) = crate::test_utils::setup_test_state().await;
23 let now = Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
24 state
25 .db
26 .conn()
27 .unwrap()
28 .execute(
29 "INSERT OR IGNORE INTO users (id, email, password_hash, display_name, created_at) \
30 VALUES (?, ?, ?, ?, ?)",
31 rusqlite::params![
32 DESKTOP_USER_ID.to_string(),
33 "desktop@localhost",
34 "x",
35 "Desktop User",
36 &now,
37 ],
38 )
39 .unwrap();
40 state
41 }
42
43 /// A message with everything off, which each test then says something about.
44 fn message(subject: &str) -> NewEmailWithTracking {
45 NewEmailWithTracking {
46 project_id: None,
47 from_address: "ada@example.com".to_owned(),
48 to_address: "desktop@localhost".to_owned(),
49 subject: subject.to_owned(),
50 body: "Body.".to_owned(),
51 body_format: BodyFormat::Plain,
52 html_body: None,
53 is_read: false,
54 is_archived: false,
55 received_at: Some(Utc::now()),
56 message_id: None,
57 in_reply_to: None,
58 thread_id: None,
59 email_account_id: None,
60 is_outgoing: false,
61 imap_uid: None,
62 source_folder: None,
63 attachment_meta: None,
64 body_truncated: false,
65 jmap_id: None,
66 }
67 }
68
69 fn add(state: &AppState, email: NewEmailWithTracking) -> Email {
70 state
71 .emails
72 .create_with_tracking(DESKTOP_USER_ID, email)
73 .unwrap()
74 }
75
76 fn get(state: &AppState, path: &str, params: Params) -> Response {
77 router()
78 .handle(state, Request::get(path).carrying(params))
79 .expect("the route answers")
80 }
81
82 fn post(state: &AppState, path: &str, params: Params) -> Response {
83 router()
84 .handle(state, Request::post(path).sending(params))
85 .expect("the route answers")
86 }
87
88 /// A write made from a view: what the control sent, and where it was sent from.
89 fn viewing_post(state: &AppState, path: &str, payload: Params, carried: Params) -> Response {
90 router()
91 .handle(
92 state,
93 Request::post(path).sending(payload).carrying(carried),
94 )
95 .expect("the route answers")
96 }
97
98 fn html(response: Response) -> String {
99 match response.outcome {
100 Outcome::Screen(screen) => quasi_webview::Webview::new().screen(&screen),
101 Outcome::Fragment { node, .. } => quasi_webview::Webview::new().fragment(&node),
102 // Deliberately not a wildcard, for the reason the task tests give: a
103 // redirect has no body, and a fallback returning empty markup would read
104 // as a screen that rendered nothing.
105 Outcome::Goto(action) => panic!("expected content, got a redirect to {action:?}"),
106 // Same reasoning one step along: an overlay is a screen, but it is not
107 // the screen this route was asked for. Rendering it here would let a
108 // route that answered with the command palette pass as the page.
109 Outcome::Over(_) => panic!("expected content, got a screen drawn over it"),
110 Outcome::Anchored { .. } => {
111 panic!("expected content, got a screen drawn at a point on it")
112 }
113 Outcome::Suggestions { field, .. } => {
114 panic!("expected content, got a suggestion list for `{field}`")
115 }
116 Outcome::File { name, .. } => panic!("expected content, got the file `{name}`"),
117 Outcome::Locate(_) => panic!("expected content, got a place on a map"),
118 // `cb62a9dc`. Work that runs somewhere else and a region that says so:
119 // not content, and not a place either.
120 Outcome::Started { region, .. } => {
121 panic!("expected content, got work started in `{region}`")
122 }
123 }
124 }
125
126 /// The whole screen under a view.
127 fn screen(state: &AppState, params: Params) -> String {
128 html(get(state, "/emails", params))
129 }
130
131 /// The list's rows, as the description holds them rather than as markup.
132 fn rows(response: Response) -> Vec<Row> {
133 match response.outcome {
134 Outcome::Fragment {
135 node: Node::Table { rows, .. },
136 ..
137 } => rows,
138 other => panic!("expected a list fragment, got {other:?}"),
139 }
140 }
141
142 /// What a list fragment says is left, and how to ask for it.
143 fn remainder(response: Response) -> Option<quasi_router::screen::Rest> {
144 match response.outcome {
145 Outcome::Fragment {
146 node: Node::Table { more, .. },
147 ..
148 } => more,
149 other => panic!("expected a list fragment, got {other:?}"),
150 }
151 }
152
153 /// What a response said on the way, which is not part of its content.
154 fn notice(response: &Response) -> String {
155 response
156 .notice
157 .as_ref()
158 .map(|message| message.text.clone())
159 .unwrap_or_default()
160 }
161
162 /// The band, which is where the filters and the bulk bar live.
163 fn band(page: &Screen) -> &Slot {
164 page.slots
165 .iter()
166 .find(|slot| slot.id == "emails-band")
167 .expect("the screen has a band")
168 }
169
170 /// The controls the band offers, in the order it offers them.
171 fn acts(page: &Screen) -> Vec<&Act> {
172 band(page)
173 .body
174 .iter()
175 .filter_map(|ranked| match &ranked.node {
176 Node::Act(act) => Some(act),
177 _ => None,
178 })
179 .collect()
180 }
181
182 /// What those controls are called.
183 fn labels(page: &Screen) -> Vec<String> {
184 acts(page).iter().map(|act| act.label.clone()).collect()
185 }
186
187 /// Every route a filter control leads to: the two selects and the chip.
188 fn filter_actions(page: &Screen) -> Vec<Action> {
189 band(page)
190 .body
191 .iter()
192 .filter_map(|ranked| match &ranked.node {
193 Node::Field(field) => field.writes.clone(),
194 Node::Token(tag) => tag.action.clone(),
195 _ => None,
196 })
197 .collect()
198 }
199
200 /// The rows the screen's list pane is holding.
201 fn listed_rows(page: &Screen) -> Vec<Row> {
202 page.slots
203 .iter()
204 .find(|slot| slot.id == super::LIST)
205 .and_then(|slot| slot.body.get(0))
206 .map_or_else(Vec::new, |ranked| match &ranked.node {
207 Node::Table { rows, .. } => rows.clone(),
208 other => panic!("expected a list, got {other:?}"),
209 })
210 }
211
212 fn read(state: &AppState, id: EmailId) -> bool {
213 state
214 .emails
215 .get_by_id(id, DESKTOP_USER_ID)
216 .unwrap()
217 .expect("the email is there")
218 .is_read
219 }
220
221 #[tokio::test]
222 async fn the_list_carries_each_thread_and_what_it_is() {
223 let state = state().await;
224 add(&state, message("Lunch on Thursday"));
225
226 let page = screen(&state, Params::new());
227
228 assert!(page.contains("Lunch on Thursday"));
229 assert!(page.contains("ada@example.com"));
230 // The thread is unread, which is the row's own fact carried as a token
231 // rather than as a class on the markup.
232 assert!(page.contains("Unread"));
233 }
234
235 #[tokio::test]
236 async fn every_row_offers_a_selection_and_says_what_its_tick_contributes() {
237 // The first finding, inverted. It was written to fail loudly once the
238 // vocabulary landed, and quasi 0.37.0 landed it: the screen names the set,
239 // the row says what its tick contributes, and the bar acts on the whole of
240 // it. A row that is tickable and contributes nothing is the dead affordance
241 // `Row::ticking` exists to end, so both halves are asserted.
242 let state = state().await;
243 let one = add(&state, message("One"));
244 let two = add(&state, message("Two"));
245
246 let listed = rows(get(&state, "/emails/list", Params::new()));
247
248 assert_eq!(listed.len(), 2);
249 assert!(
250 listed.iter().all(|row| row.selected == Some(false)),
251 "a described row is tickable and arrives unticked",
252 );
253 let values: Vec<String> = listed
254 .iter()
255 .map(|row| row.value.clone().expect("the tick contributes a value"))
256 .collect();
257 assert!(values.contains(&one.id.to_string()));
258 assert!(values.contains(&two.id.to_string()));
259 }
260
261 #[tokio::test]
262 async fn the_screen_names_the_set_and_five_controls_sit_over_it() {
263 // The other half of the description: a set with no name is a tick with
264 // nowhere to go, and `Act::over` is what makes a control read as a commit
265 // control rather than as a second row action.
266 let state = state().await;
267 add(&state, message("One"));
268
269 let Outcome::Screen(page) = get(&state, "/emails", Params::new()).outcome else {
270 panic!("expected a screen");
271 };
272
273 assert_eq!(page.selection.as_deref(), Some(super::SELECTION));
274
275 let over: Vec<&str> = acts(&page)
276 .iter()
277 .filter(|act| act.over.as_deref() == Some(super::SELECTION))
278 .map(|act| act.label.as_str())
279 .collect();
280 assert_eq!(over, ["Mark read", "Archive", "Snooze", "Delete"]);
281
282 // The fifth is the shipped bar's fifth, and it is an address rather than a
283 // verb over the set: see `View::ticked`.
284 assert!(labels(&page).iter().any(|label| label == "Select all"));
285 assert!(
286 !labels(&page).iter().any(|label| label == "Clear selection"),
287 "nothing arrives ticked, so there is nothing to clear",
288 );
289 }
290
291 #[tokio::test]
292 async fn the_snooze_verb_asks_for_a_time_before_it_fires() {
293 // `Act::asks`, and the reason it exists: `bulk-actions.js` raises a whole
294 // modal to collect this one value. The options are the same ones the open
295 // thread offers, so the count is whatever the local clock allows rather
296 // than a number written here.
297 let state = state().await;
298 add(&state, message("One"));
299
300 let Outcome::Screen(page) = get(&state, "/emails", Params::new()).outcome else {
301 panic!("expected a screen");
302 };
303
304 let snooze = acts(&page)
305 .into_iter()
306 .find(|act| act.label == "Snooze")
307 .expect("the bar offers Snooze");
308 assert_eq!(snooze.asks.len(), 1);
309 assert_eq!(snooze.asks[0].name, "until");
310 assert!(
311 !snooze.asks[0].options.is_empty(),
312 "the question is a pick from the clock's own options",
313 );
314 }
315
316 #[tokio::test]
317 async fn select_all_is_an_address_and_a_filter_change_drops_it() {
318 // The charter rule `emails.js` enforces by hand in each of its four filter
319 // handlers, holding here because the ticks travel on the address and the
320 // filter controls drop them.
321 let state = state().await;
322 add(&state, message("One"));
323
324 let ticked = rows(get(
325 &state,
326 "/emails/list",
327 Params::new().with("ticked", "all"),
328 ));
329 assert!(
330 ticked.iter().all(|row| row.selected == Some(true)),
331 "select-all is answered by the server, so the rows arrive ticked",
332 );
333
334 let Outcome::Screen(page) = get(&state, "/emails", Params::new().with("ticked", "all")).outcome
335 else {
336 panic!("expected a screen");
337 };
338 assert!(
339 labels(&page).iter().any(|label| label == "Clear selection"),
340 "with everything ticked there is something to clear",
341 );
342 // Every filter control leads to a view with nothing ticked.
343 for action in filter_actions(&page) {
344 assert_eq!(
345 action.carried.get("ticked"),
346 None,
347 "a filter change cannot carry select-all: {action:?}",
348 );
349 }
350 }
351
352 #[tokio::test]
353 async fn a_bulk_write_reads_the_ticks_and_answers_with_them_cleared() {
354 let state = state().await;
355 let one = add(&state, message("One"));
356 let two = add(&state, message("Two"));
357 let untouched = add(&state, message("Three"));
358
359 let answer = viewing_post(
360 &state,
361 "/emails/list/read",
362 Params::new()
363 .with(Node::TICKED, one.id.to_string())
364 .with(Node::TICKED, two.id.to_string()),
365 Params::new().with("ticked", "all"),
366 );
367
368 assert!(read(&state, one.id));
369 assert!(read(&state, two.id));
370 assert!(!read(&state, untouched.id), "an unticked row is untouched");
371 assert_eq!(notice(&answer), "2 emails marked read.");
372
373 let Outcome::Screen(page) = answer.outcome else {
374 panic!("expected a screen");
375 };
376 assert!(
377 listed_rows(&page)
378 .iter()
379 .all(|row| row.selected == Some(false)),
380 "the answer clears the ticks, select-all included",
381 );
382 }
383
384 #[tokio::test]
385 async fn archive_delete_and_snooze_each_run_over_the_set() {
386 let state = state().await;
387 let archived = add(&state, message("Archive me"));
388 let deleted = add(&state, message("Delete me"));
389 let snoozed = add(&state, message("Snooze me"));
390
391 let answer = post(
392 &state,
393 "/emails/list/archive",
394 Params::new().with(Node::TICKED, archived.id.to_string()),
395 );
396 assert_eq!(notice(&answer), "1 email archived.");
397 assert!(
398 state
399 .emails
400 .get_by_id(archived.id, DESKTOP_USER_ID)
401 .unwrap()
402 .unwrap()
403 .is_archived
404 );
405
406 let answer = post(
407 &state,
408 "/emails/list/delete",
409 Params::new().with(Node::TICKED, deleted.id.to_string()),
410 );
411 assert_eq!(notice(&answer), "1 email deleted.");
412 assert!(
413 state
414 .emails
415 .get_by_id(deleted.id, DESKTOP_USER_ID)
416 .unwrap()
417 .is_none()
418 );
419
420 let until = Utc::now() + Duration::hours(3);
421 let answer = post(
422 &state,
423 "/emails/list/snooze",
424 Params::new()
425 .with(Node::TICKED, snoozed.id.to_string())
426 .with("until", until.to_rfc3339()),
427 );
428 assert!(
429 notice(&answer).starts_with("1 email snoozed until "),
430 "the toast counts and says when: {}",
431 notice(&answer),
432 );
433 assert!(
434 state
435 .emails
436 .get_by_id(snoozed.id, DESKTOP_USER_ID)
437 .unwrap()
438 .unwrap()
439 .is_snoozed()
440 );
441 }
442
443 #[tokio::test]
444 async fn a_bulk_write_over_nothing_answers_the_screen_rather_than_erroring() {
445 // Every renderer draws a control over an empty selection as disabled, so
446 // this is the hand-typed request rather than the ordinary path. It still
447 // gets the list back: see `chosen`.
448 let state = state().await;
449 add(&state, message("One"));
450
451 let answer = post(&state, "/emails/list/delete", Params::new());
452
453 assert_eq!(notice(&answer), "0 emails deleted.");
454 let Outcome::Screen(page) = answer.outcome else {
455 panic!("expected a screen");
456 };
457 assert_eq!(listed_rows(&page).len(), 1);
458 }
459
460 #[tokio::test]
461 async fn a_bulk_snooze_refuses_a_time_that_has_passed() {
462 // `set_snooze`'s floor, applied to the set: the repository would take it,
463 // `is_snoozed` would read false immediately, and the toast would claim
464 // forty messages were hidden when none of them were.
465 let state = state().await;
466 let email = add(&state, message("One"));
467
468 let answer = router().handle(
469 &state,
470 Request::post("/emails/list/snooze").sending(
471 Params::new()
472 .with(Node::TICKED, email.id.to_string())
473 .with("until", (Utc::now() - Duration::hours(1)).to_rfc3339()),
474 ),
475 );
476
477 assert!(answer.is_err(), "a past time is refused, not stored");
478 assert!(
479 !state
480 .emails
481 .get_by_id(email.id, DESKTOP_USER_ID)
482 .unwrap()
483 .unwrap()
484 .is_snoozed()
485 );
486 }
487
488 #[tokio::test]
489 async fn a_ticked_id_that_is_not_an_id_is_dropped_rather_than_failing_the_press() {
490 // The task list's rule, and the reason for it: failing the whole press over
491 // one malformed value would lose the other thirty-nine, and the count in
492 // the toast is what the user actually gets.
493 let state = state().await;
494 let email = add(&state, message("One"));
495
496 let answer = post(
497 &state,
498 "/emails/list/read",
499 Params::new()
500 .with(Node::TICKED, "not-a-uuid")
501 .with(Node::TICKED, email.id.to_string()),
502 );
503
504 assert_eq!(notice(&answer), "1 email marked read.");
505 assert!(read(&state, email.id));
506 }
507
508 #[tokio::test]
509 async fn the_remainder_is_a_real_count_and_a_way_to_ask_for_more() {
510 // The second finding, which is a confirmation: the remainder was expected to
511 // be unknown most of the time, and `list_threaded` returns the total in the
512 // same call, so the number is honest here. It is derived now rather than
513 // stored, off the window and the total this screen already had.
514 let state = state().await;
515 for index in 0..=super::PAGE {
516 let mut mail = message(&format!("Message {index}"));
517 // Distinct receipt times, so "the newest 200" is a stable set rather
518 // than whatever the tie-break happens to do.
519 mail.received_at = Some(Utc::now() - Duration::minutes(index));
520 add(&state, mail);
521 }
522
523 let rest = remainder(get(&state, "/emails/list", Params::new()))
524 .expect("201 messages do not fit in one page");
525 assert_eq!(rest.as_layout().remaining(), Some(1));
526
527 // And asking for more is an address, so the wider list is reachable
528 // directly rather than by having scrolled to it.
529 let wider = rows(get(
530 &state,
531 "/emails/list",
532 Params::new().with("shown", (super::PAGE + super::PAGE).to_string()),
533 ));
534 assert_eq!(i64::try_from(wider.len()).unwrap(), super::PAGE + 1);
535 assert!(
536 remainder(get(
537 &state,
538 "/emails/list",
539 Params::new().with("shown", (super::PAGE + super::PAGE).to_string()),
540 ))
541 .is_none()
542 );
543 }
544
545 #[tokio::test]
546 async fn a_filter_is_an_address_and_every_control_carries_it() {
547 let state = state().await;
548 let mut filed = message("Filed away");
549 filed.source_folder = Some("Archive".to_owned());
550 add(&state, filed);
551 add(&state, message("In the inbox"));
552
553 let listed = rows(get(
554 &state,
555 "/emails/list",
556 Params::new().with("folder", "Archive"),
557 ));
558
559 assert_eq!(listed.len(), 1);
560 assert_eq!(listed[0].primary(), "Filed away");
561
562 // Opening it stays in the folder, and so does everything the row offers.
563 // A control that dropped the filter is a view you fall out of by using it.
564 let opening = listed[0].activate.as_ref().expect("a row opens its thread");
565 assert_eq!(opening.carried.get("folder"), Some("Archive"));
566 for act in listed[0].acts() {
567 assert_eq!(
568 act.action.carried.get("folder"),
569 Some("Archive"),
570 "{} drops the folder it was offered under",
571 act.label,
572 );
573 }
574 }
575
576 #[tokio::test]
577 async fn a_default_is_never_written_into_an_address() {
578 // Two addresses for one view is the thing `View::carry` exists to prevent.
579 let state = state().await;
580 add(&state, message("Anything"));
581
582 let listed = rows(get(&state, "/emails/list", Params::new()));
583 let opening = listed[0].activate.as_ref().unwrap();
584
585 assert_eq!(opening.params.get("folder"), None);
586 assert_eq!(opening.params.get("label"), None);
587 assert_eq!(opening.params.get("archived"), None);
588 assert_eq!(opening.params.get("shown"), None);
589 }
590
591 #[tokio::test]
592 async fn opening_a_thread_marks_it_read_and_answers_with_the_whole_screen() {
593 // The reader is an address rather than a modal, which is the third finding.
594 // Opening writes, because `emails-reader.js:open` writes, and the row behind
595 // it has just lost its badge — so the answer cannot be the pane alone.
596 let state = state().await;
597 let mail = add(&state, message("Read me"));
598 assert!(!read(&state, mail.id));
599
600 let response = get(&state, &format!("/emails/{}", mail.id), Params::new());
601 assert!(
602 matches!(response.outcome, Outcome::Screen(_)),
603 "a read that changes the list answers with the screen",
604 );
605 assert!(read(&state, mail.id));
606
607 let page = html(response);
608 assert!(page.contains("Read me"));
609 assert!(!page.contains("Nothing selected"));
610 }
611
612 #[tokio::test]
613 async fn the_body_is_carried_as_markdown_source() {
614 // The fourth finding, half one. `Email::body` is pter's markdown whenever it
615 // came from HTML, and the shipped reader escapes it and re-links bare URLs
616 // by hand, so that markdown reaches the screen as literal syntax.
617 // `Node::Rich` carries the source and the renderer does the pass.
618 let state = state().await;
619 let mut mail = message("Formatted");
620 mail.body = "**bold** and [a link](https://example.com)".to_owned();
621 mail.body_format = BodyFormat::Markdown;
622 let mail = add(&state, mail);
623
624 let page = html(get(&state, &format!("/emails/{}", mail.id), Params::new()));
625
626 assert!(page.contains("<strong>bold</strong>"));
627 assert!(page.contains(r#"href="https://example.com""#));
628 assert!(!page.contains("**bold**"));
629 }
630
631 #[tokio::test]
632 async fn a_plain_body_keeps_its_punctuation() {
633 // The fourth finding, half two, and the reason the port could not simply
634 // keep `Node::rich`: a `text/plain` message that happens to contain
635 // asterisks is not emphasis, and rendering it as markdown eats characters
636 // the sender typed. `body_format` is what tells the two apart, so a plain
637 // body goes through `Node::text` and survives verbatim.
638 let state = state().await;
639 let mut mail = message("Literal");
640 mail.body = "the *args and **kwargs conventions".to_owned();
641 let mail = add(&state, mail);
642
643 let page = html(get(&state, &format!("/emails/{}", mail.id), Params::new()));
644
645 assert!(page.contains("the *args and **kwargs conventions"));
646 assert!(!page.contains("<strong>"));
647 assert!(!page.contains("<em>"));
648 }
649
650 #[tokio::test]
651 async fn marking_unread_closes_the_thread() {
652 let state = state().await;
653 let mail = add(&state, message("Later"));
654 get(&state, &format!("/emails/{}", mail.id), Params::new());
655 assert!(read(&state, mail.id));
656
657 let page = html(post(
658 &state,
659 &format!("/emails/{}/read", mail.id),
660 Params::new().with("read", "false"),
661 ));
662
663 assert!(!read(&state, mail.id));
664 // Putting it back to unread and then showing it is the one combination the
665 // user cannot have meant.
666 assert!(page.contains("Nothing selected"));
667 assert!(page.contains("Unread"));
668 }
669
670 #[tokio::test]
671 async fn the_screen_offers_mark_all_read_the_way_the_filter_row_does() {
672 let state = state().await;
673 add(&state, message("One"));
674
675 // Offered before it is needed and after it stops being, because the shipped
676 // control is: the button in `.email-filter-row` is not gated on the count.
677 assert!(screen(&state, Params::new()).contains("Mark all read"));
678 post(&state, "/emails/read-all", Params::new());
679 assert!(screen(&state, Params::new()).contains("Mark all read"));
680 }
681
682 #[tokio::test]
683 async fn mark_all_read_ignores_the_filter_it_was_sent_from() {
684 let state = state().await;
685 let inbox = add(&state, message("In the inbox"));
686 let mut filed = message("Filed elsewhere");
687 filed.source_folder = Some("Archive".to_owned());
688 let filed = add(&state, filed);
689
690 // Sent from a folder that holds one of the two. The other is still marked,
691 // because the command takes a user and nothing else.
692 let response = viewing_post(
693 &state,
694 "/emails/read-all",
695 Params::new(),
696 Params::new().with("folder", "Archive"),
697 );
698
699 assert_eq!(notice(&response), "2 emails marked read");
700 assert!(read(&state, inbox.id));
701 assert!(read(&state, filed.id));
702 // The answer is the view it was sent from, not an unfiltered inbox.
703 let page = html(response);
704 assert!(page.contains("Filed elsewhere"));
705 assert!(!page.contains("In the inbox"));
706 }
707
708 #[tokio::test]
709 async fn mark_all_read_says_so_when_nothing_was_unread() {
710 let state = state().await;
711 add(&state, message("Seen already"));
712 post(&state, "/emails/read-all", Params::new());
713
714 // The JS reports success identically both times, which tells a user who
715 // clicked twice nothing.
716 let response = post(&state, "/emails/read-all", Params::new());
717 assert_eq!(notice(&response), "Nothing was unread");
718 }
719
720 #[tokio::test]
721 async fn archiving_takes_it_out_of_the_view_it_was_in() {
722 let state = state().await;
723 let mail = add(&state, message("Done with"));
724
725 let response = post(
726 &state,
727 &format!("/emails/{}/archive", mail.id),
728 Params::new().with("archived", "true"),
729 );
730 assert_eq!(notice(&response), "Email archived");
731 let page = html(response);
732 assert!(page.contains("Nothing selected"));
733 // Gone from the inbox view entirely: with no mail left and no account
734 // configured, what stands there is the account stand-in rather than a list.
735 let inbox = html(get(&state, "/emails/list", Params::new()));
736 assert!(!inbox.contains("Done with"));
737
738 // Unless the view is one that holds archived mail, where it stays put and
739 // the thread stays open.
740 let held = rows(get(
741 &state,
742 "/emails/list",
743 Params::new().with("archived", "1"),
744 ));
745 assert_eq!(held.len(), 1);
746 // The write's own parameter is `on` and the view's is `archived`, which is
747 // the sixth finding: written the obvious way these are one name, and
748 // unarchiving from a view that holds archived mail would silently leave it.
749 let still_open = html(post(
750 &state,
751 &format!("/emails/{}/archive", mail.id),
752 Params::new().with("on", "false").with("archived", "1"),
753 ));
754 assert!(still_open.contains("Done with"));
755 }
756
757 #[tokio::test]
758 async fn labels_round_trip_through_the_comma_separated_box() {
759 let state = state().await;
760 let mail = add(&state, message("Tag me"));
761
762 let response = post(
763 &state,
764 &format!("/emails/{}/labels", mail.id),
765 Params::new().with("labels", " work , , follow-up "),
766 );
767 assert_eq!(notice(&response), "Labels updated");
768 let page = html(response);
769 assert!(page.contains("work"));
770 assert!(page.contains("follow-up"));
771
772 let stored = state
773 .emails
774 .get_by_id(mail.id, DESKTOP_USER_ID)
775 .unwrap()
776 .unwrap();
777 // Trimmed, and the empty entry between the commas is dropped rather than
778 // stored as a label with no name. `saveLabels` does the same two things.
779 assert_eq!(
780 stored.labels,
781 vec!["work".to_owned(), "follow-up".to_owned()]
782 );
783 }
784
785 #[tokio::test]
786 async fn moving_it_to_the_folder_being_looked_at_keeps_it_there() {
787 let state = state().await;
788 let mut mail = message("Moving");
789 mail.source_folder = Some("INBOX".to_owned());
790 let mail = add(&state, mail);
791
792 // Out of the folder being looked at: it leaves, and the pane closes with it.
793 //
794 // Both values are called `folder`, and that is the point. The destination
795 // is the payload and the view is the address, so the same name says two
796 // things without either reaching the other. This test used to prove the
797 // workaround — the destination was `to` — and now proves the fix: one name
798 // for both used to move the message and the screen at once.
799 let left = viewing_post(
800 &state,
801 &format!("/emails/{}/folder", mail.id),
802 Params::new().with("folder", "Archive"),
803 Params::new().with("folder", "INBOX"),
804 );
805 assert_eq!(notice(&left), "Moved to Archive");
806 assert!(html(left).contains("Nothing selected"));
807
808 // And into it: it stays, and so does the thread.
809 let stayed = html(viewing_post(
810 &state,
811 &format!("/emails/{}/folder", mail.id),
812 Params::new().with("folder", "Archive"),
813 Params::new().with("folder", "Archive"),
814 ));
815 assert!(stayed.contains("Moving"));
816 }
817
818 #[tokio::test]
819 async fn a_snooze_in_the_past_is_refused_rather_than_stored() {
820 let state = state().await;
821 let mail = add(&state, message("Not yet"));
822
823 let refused = router().handle(
824 &state,
825 Request::post(format!("/emails/{}/snooze", mail.id))
826 .sending(Params::new().with("until", (Utc::now() - Duration::hours(1)).to_rfc3339())),
827 );
828 assert!(
829 refused.is_err(),
830 "storing it would report the message hidden when it is not",
831 );
832
833 let accepted = post(
834 &state,
835 &format!("/emails/{}/snooze", mail.id),
836 Params::new().with("until", (Utc::now() + Duration::hours(3)).to_rfc3339()),
837 );
838 assert!(notice(&accepted).starts_with("Snoozed until"));
839 let page = html(accepted);
840 assert!(page.contains("Not yet"));
841 }
842
843 #[tokio::test]
844 async fn deleting_something_that_is_not_there_is_a_404() {
845 // The rule the projects delete set: a delete that reports done for something
846 // it never saw is how two regions end up disagreeing about what exists.
847 let state = state().await;
848
849 let missing = router().handle(
850 &state,
851 Request::post(format!("/emails/{}/delete", EmailId::new())),
852 );
853
854 assert!(missing.is_err());
855 }
856
857 #[tokio::test]
858 async fn converting_makes_a_task_and_leaves_the_mail_where_it_was() {
859 let state = state().await;
860 let mail = add(&state, message("Ship the port"));
861
862 let response = post(&state, &format!("/emails/{}/task", mail.id), Params::new());
863 assert_eq!(notice(&response), "Task created from email");
864 let page = html(response);
865 let tasks = state.tasks.list_all(DESKTOP_USER_ID).unwrap();
866 assert!(
867 tasks
868 .iter()
869 .any(|task| task.title.contains("Ship the port"))
870 );
871 // The mail is still in the list, and the thread it was converted from is
872 // still open.
873 assert!(page.contains("Ship the port"));
874 }
875
876 #[tokio::test]
877 async fn a_thread_is_drawn_oldest_first_and_says_how_many_it_holds() {
878 let state = state().await;
879 let mut first = message("Re: the plan");
880 first.thread_id = Some("thread-1".to_owned());
881 first.body = "The first word.".to_owned();
882 first.received_at = Some(Utc::now() - Duration::hours(2));
883 let first = add(&state, first);
884
885 let mut second = message("Re: the plan");
886 second.thread_id = Some("thread-1".to_owned());
887 second.body = "The last word.".to_owned();
888 second.received_at = Some(Utc::now());
889 add(&state, second);
890
891 let page = html(get(&state, &format!("/emails/{}", first.id), Params::new()));
892
893 let earlier = page.find("The first word.").expect("the first message");
894 let later = page.find("The last word.").expect("the second message");
895 assert!(earlier < later, "the thread reads oldest first");
896
897 let listed = rows(get(&state, "/emails/list", Params::new()));
898 assert_eq!(listed.len(), 1, "a thread is one row");
899 assert!(
900 listed[0]
901 .tokens()
902 .any(|tag| tag.label.contains("2 messages")),
903 "the count says what it counts rather than showing a bare digit",
904 );
905 }
906
907 #[tokio::test]
908 async fn an_empty_filtered_view_blames_the_filter_and_offers_the_way_out() {
909 // The JS has no filter-specific empty state: it asks whether an account
910 // exists and picks one of two, so a folder holding no mail tells the user to
911 // set up an account they already have.
912 let state = state().await;
913 add(&state, message("In the inbox"));
914
915 let page = html(get(
916 &state,
917 "/emails/list",
918 Params::new().with("folder", "Nowhere"),
919 ));
920
921 assert!(page.contains("No mail matching this filter"));
922 assert!(page.contains("Clear filters"));
923 assert!(!page.contains("Set up an email account"));
924 }
925