Skip to main content

max / goingson

13.8 KB · 426 lines History Blame Raw
1 //! The contacts screen, driven through the router against a real database.
2 //!
3 //! Same property as the projects tests: no Tauri runtime and no window, because
4 //! a route is a function from state and params to a description. What is
5 //! asserted is the description, and the markup only where the markup is the
6 //! point.
7
8 use std::sync::Arc;
9
10 use goingson_core::{NewContact, NewContactEmail, NewSocialHandle};
11 use quasi_http::Serves as _;
12 use quasi_router::Outcome;
13 use quasi_router::{Params, Request, Response};
14
15 use super::super::router;
16 use crate::state::{AppState, DESKTOP_USER_ID};
17
18 /// State with the desktop user in place, which is who the handlers read as.
19 async fn state() -> Arc<AppState> {
20 let (state, _) = crate::test_utils::setup_test_state().await;
21 let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
22 state
23 .db
24 .conn()
25 .unwrap()
26 .execute(
27 "INSERT OR IGNORE INTO users (id, email, password_hash, display_name, created_at) \
28 VALUES (?, ?, ?, ?, ?)",
29 rusqlite::params![
30 DESKTOP_USER_ID.to_string(),
31 "desktop@localhost",
32 "x",
33 "Desktop User",
34 &now,
35 ],
36 )
37 .unwrap();
38 state
39 }
40
41 /// A contact carrying nothing but its name.
42 ///
43 /// `NewContact` has no `Default`, and giving it one here would be adding a trait
44 /// to the core crate for the convenience of one test module.
45 fn blank(name: &str) -> NewContact {
46 NewContact {
47 display_name: name.to_owned(),
48 nickname: None,
49 company: None,
50 title: None,
51 notes: String::new(),
52 tags: Vec::new(),
53 birthday: None,
54 timezone: None,
55 is_implicit: false,
56 }
57 }
58
59 fn add(state: &AppState, name: &str) -> goingson_core::Contact {
60 state.contacts.create(DESKTOP_USER_ID, blank(name)).unwrap()
61 }
62
63 fn get(state: &AppState, path: &str, params: Params) -> Response {
64 router()
65 .handle(state, Request::get(path).carrying(params))
66 .expect("the route answers")
67 }
68
69 fn post(state: &AppState, path: &str) -> Response {
70 router()
71 .handle(state, Request::post(path))
72 .expect("the route answers")
73 }
74
75 fn screen_html(response: Response) -> String {
76 let Outcome::Screen(screen) = (response).outcome else {
77 panic!("the route answers with a screen");
78 };
79 quasi_webview::Webview::new().screen(&screen)
80 }
81
82 fn fragment_html(response: Response) -> String {
83 let Outcome::Fragment { node, .. } = (response).outcome else {
84 panic!("the route answers with a fragment");
85 };
86 quasi_webview::Webview::new().fragment(&node)
87 }
88
89 #[tokio::test]
90 async fn an_empty_database_says_so_rather_than_rendering_nothing() {
91 let state = state().await;
92 let html = screen_html(get(&state, "/contacts", Params::new()));
93 assert!(html.contains("No contacts yet."));
94 }
95
96 #[tokio::test]
97 async fn an_empty_result_says_which_filter_emptied_it() {
98 // Three different sentences, because "nothing here" after a search means
99 // something different from "nothing here" on a fresh install, and the JS
100 // screen already distinguishes them.
101 let state = state().await;
102 add(&state, "Ada");
103
104 let searched = screen_html(get(&state, "/contacts", Params::new().with("q", "zzz")));
105 assert!(searched.contains("No contacts match that search."));
106
107 let tagged = screen_html(get(&state, "/contacts", Params::new().with("tag", "zzz")));
108 assert!(tagged.contains("No contacts carry that tag."));
109 }
110
111 #[tokio::test]
112 async fn a_blank_search_is_the_same_as_no_search() {
113 // An emptied search box sends the param with nothing in it. Treating that as
114 // a search for the empty string is how a screen goes blank when a user
115 // deletes what they typed.
116 let state = state().await;
117 add(&state, "Ada");
118
119 let html = screen_html(get(&state, "/contacts", Params::new().with("q", " ")));
120 assert!(html.contains("Ada"));
121 assert!(!html.contains("No contacts match"));
122 }
123
124 #[tokio::test]
125 async fn search_is_an_address_not_a_piece_of_module_state() {
126 let state = state().await;
127 add(&state, "Ada Lovelace");
128 add(&state, "Grace Hopper");
129
130 let html = fragment_html(get(
131 &state,
132 "/contacts/list",
133 Params::new().with("q", "Ada"),
134 ));
135 assert!(html.contains("Ada Lovelace"));
136 assert!(!html.contains("Grace Hopper"));
137 }
138
139 #[tokio::test]
140 async fn a_filter_swaps_the_grid_alone() {
141 let state = state().await;
142 add(&state, "Ada");
143
144 let response = get(&state, "/contacts/list", Params::new());
145 // Decision 7: the response names the region, so the whole document is not
146 // reflowed to change one pane.
147 assert_eq!(response.target(), Some("contacts-grid"));
148
149 let html = fragment_html(response);
150 assert!(html.starts_with("<ul"));
151 assert!(!html.contains("<html"));
152 assert!(html.contains("Ada"));
153 }
154
155 #[tokio::test]
156 async fn the_tag_filter_offers_only_tags_that_are_in_use() {
157 let state = state().await;
158 let contact = add(&state, "Ada");
159 state
160 .contacts
161 .tag_many(&[contact.id], DESKTOP_USER_ID, "friend")
162 .unwrap();
163
164 let html = screen_html(get(&state, "/contacts", Params::new()));
165 assert!(html.contains("friend"));
166
167 // And a tag nobody carries is not offered, which is the rule that keeps the
168 // band from growing a control per tag ever used.
169 assert!(!html.contains("colleague"));
170 }
171
172 #[tokio::test]
173 async fn a_latched_tag_offers_the_way_back_out_of_itself() {
174 let state = state().await;
175 let contact = add(&state, "Ada");
176 state
177 .contacts
178 .tag_many(&[contact.id], DESKTOP_USER_ID, "friend")
179 .unwrap();
180
181 let html = screen_html(get(
182 &state,
183 "/contacts",
184 Params::new().with("tag", "friend"),
185 ));
186 // Still on screen while it is the active filter, and its action drops the
187 // tag rather than re-applying it.
188 assert!(html.contains("friend"));
189 assert!(html.contains("hx-get=\"/contacts/list\""));
190 }
191
192 #[tokio::test]
193 async fn a_row_carries_its_tags_as_tokens_and_its_email_as_the_plain_fact() {
194 // Was `a_row_carries_the_email_and_the_tags_as_one_trailing_fact`, which
195 // asserted the workaround: both joined into `meta` as
196 // "ada@example.com · friend". makeover-layout 0.9.0 gave a row somewhere to
197 // put tokens, so the two facts are now the two different kinds of thing they
198 // always were.
199 let state = state().await;
200 let contact = add(&state, "Ada");
201 state
202 .contacts
203 .add_email(
204 contact.id,
205 DESKTOP_USER_ID,
206 NewContactEmail {
207 address: "ada@example.com".to_owned(),
208 label: "work".to_owned(),
209 is_primary: true,
210 },
211 )
212 .unwrap();
213 state
214 .contacts
215 .tag_many(&[contact.id], DESKTOP_USER_ID, "friend")
216 .unwrap();
217
218 let html = fragment_html(get(&state, "/contacts/list", Params::new()));
219
220 // The email is a plain trailing fact and stays one.
221 assert!(html.contains("class=\"row-meta\">ada@example.com</span>"));
222 // The tag is a token, in the token strip, and clicking it filters the grid.
223 assert!(html.contains("class=\"row-tokens\""));
224 assert!(html.contains("friend"));
225 assert!(html.contains("hx-get=\"/contacts/list?tag=friend\""));
226 // And no longer joined into one string.
227 assert!(!html.contains("ada@example.com · friend"));
228 }
229
230 #[tokio::test]
231 async fn a_contact_row_can_be_ticked_without_being_the_current_one() {
232 // The other closed gap. `Row::selected` used to mean "the detail pane is
233 // showing this", so a bulk checkbox had no way to be described. It is now
234 // the user's tick, and `current` is the app's pointer.
235 let state = state().await;
236 add(&state, "Ada");
237
238 let html = fragment_html(get(&state, "/contacts/list", Params::new()));
239 assert!(html.contains("type=\"checkbox\""));
240 // Selectable but not ticked, and not the current row either.
241 assert!(!html.contains(" checked"));
242 assert!(!html.contains("aria-current"));
243 }
244
245 #[tokio::test]
246 async fn a_nickname_joins_the_name_because_a_row_has_nowhere_else_to_put_it() {
247 let state = state().await;
248 state
249 .contacts
250 .create(
251 DESKTOP_USER_ID,
252 NewContact {
253 nickname: Some("Countess".to_owned()),
254 ..blank("Ada Lovelace")
255 },
256 )
257 .unwrap();
258
259 let html = fragment_html(get(&state, "/contacts/list", Params::new()));
260 assert!(html.contains("Ada Lovelace &quot;Countess&quot;"));
261 }
262
263 #[tokio::test]
264 async fn selecting_a_row_addresses_the_detail_pane() {
265 let state = state().await;
266 let contact = add(&state, "Ada");
267
268 let html = fragment_html(get(&state, "/contacts/list", Params::new()));
269 assert!(html.contains(&format!("hx-get=\"/contacts/{}\"", contact.id)));
270
271 let response = get(&state, &format!("/contacts/{}", contact.id), Params::new());
272 assert_eq!(response.target(), Some("contacts-detail"));
273 }
274
275 #[tokio::test]
276 async fn an_empty_sub_collection_says_so_rather_than_rendering_a_bare_heading() {
277 let state = state().await;
278 let contact = add(&state, "Ada");
279
280 let html = fragment_html(get(
281 &state,
282 &format!("/contacts/{}", contact.id),
283 Params::new(),
284 ));
285 assert!(html.contains("No email addresses"));
286 assert!(html.contains("No phone numbers"));
287 assert!(html.contains("No social handles"));
288 assert!(html.contains("No custom fields"));
289 }
290
291 #[tokio::test]
292 async fn a_social_handles_url_is_a_real_link_that_leaves_the_app() {
293 // Was `a_social_handles_url_is_text_because_a_row_cannot_carry_a_link`.
294 // Worth noting how that test would have survived this change: it asserted
295 // `!html.contains("<a href=\"...")`, and the renderer emits
296 // `<a class="act" href=...`, so it passed for the wrong reason. Asserting
297 // the absence of a literal string is only as good as the string.
298 let state = state().await;
299 let contact = add(&state, "Ada");
300 state
301 .contacts
302 .add_social_handle(
303 contact.id,
304 DESKTOP_USER_ID,
305 NewSocialHandle {
306 platform: "Mastodon".to_owned(),
307 handle: "@ada".to_owned(),
308 url: Some("https://example.com/@ada".to_owned()),
309 },
310 )
311 .unwrap();
312
313 let html = fragment_html(get(
314 &state,
315 &format!("/contacts/{}", contact.id),
316 Params::new(),
317 ));
318 assert!(html.contains("Mastodon: @ada"));
319
320 // An anchor, not a button, and not htmx's business: nothing swaps and no
321 // route is called.
322 assert!(html.contains("href=\"https://example.com/@ada\""));
323 assert!(html.contains("rel=\"noopener noreferrer\""));
324 assert!(!html.contains("hx-get=\"https://example.com/@ada\""));
325
326 // The removal beside it is still a route, so both kinds coexist in one row.
327 assert!(html.contains(&format!("hx-post=\"/contacts/{}/social/", contact.id)));
328 }
329
330 #[tokio::test]
331 async fn removing_an_email_answers_with_the_pane_it_happened_in() {
332 let state = state().await;
333 let contact = add(&state, "Ada");
334 let email = state
335 .contacts
336 .add_email(
337 contact.id,
338 DESKTOP_USER_ID,
339 NewContactEmail {
340 address: "ada@example.com".to_owned(),
341 label: String::new(),
342 is_primary: true,
343 },
344 )
345 .unwrap();
346
347 let before = fragment_html(get(
348 &state,
349 &format!("/contacts/{}", contact.id),
350 Params::new(),
351 ));
352 assert!(before.contains("ada@example.com"));
353
354 let response = post(
355 &state,
356 &format!("/contacts/{}/email/{}/delete", contact.id, email.id),
357 );
358 assert_eq!(response.target(), Some("contacts-detail"));
359
360 let after = fragment_html(response);
361 assert!(!after.contains("ada@example.com"));
362 assert!(after.contains("No email addresses"));
363 }
364
365 #[tokio::test]
366 async fn a_missing_contact_is_a_not_found_rather_than_a_panic() {
367 let state = state().await;
368 let error = router()
369 .handle(
370 &state,
371 Request::get(format!("/contacts/{}", uuid::Uuid::nil())),
372 )
373 .expect_err("no such contact");
374 assert_eq!(error.class.http_status(), 404);
375 }
376
377 #[tokio::test]
378 async fn an_id_that_is_not_a_uuid_is_a_not_found_rather_than_a_panic() {
379 let state = state().await;
380 let error = router()
381 .handle(&state, Request::get("/contacts/nonsense"))
382 .expect_err("not an id");
383 assert_eq!(error.class.http_status(), 404);
384 }
385
386 #[tokio::test]
387 async fn a_contact_name_cannot_become_markup() {
388 // The reason the description carries text and the renderer owns escaping.
389 // This is the whole point of the port: 57 `esc()` calls across the three
390 // contacts files exist to do by hand what this asserts is done by type.
391 let state = state().await;
392 add(&state, "<script>alert(1)</script>");
393
394 let html = screen_html(get(&state, "/contacts", Params::new()));
395 assert!(!html.contains("<script>alert"));
396 assert!(html.contains("&lt;script&gt;"));
397 }
398
399 #[tokio::test]
400 async fn a_social_url_cannot_become_markup_either() {
401 // `contacts-render.js` runs the URL through `safeUrl` and then `escAttr`
402 // before it reaches an href. Here it is text like everything else, so the
403 // only question is whether the renderer escapes it.
404 let state = state().await;
405 let contact = add(&state, "Ada");
406 state
407 .contacts
408 .add_social_handle(
409 contact.id,
410 DESKTOP_USER_ID,
411 NewSocialHandle {
412 platform: "X".to_owned(),
413 handle: "@ada".to_owned(),
414 url: Some("\"><script>alert(1)</script>".to_owned()),
415 },
416 )
417 .unwrap();
418
419 let html = fragment_html(get(
420 &state,
421 &format!("/contacts/{}", contact.id),
422 Params::new(),
423 ));
424 assert!(!html.contains("<script>alert"));
425 }
426