Skip to main content

max / goingson

42.1 KB · 1185 lines History Blame Raw
1 //! Settings, driven through the router against a real database.
2 //!
3 //! Same standard as the screens before it: no Tauri runtime and no window, the
4 //! description asserted, and the markup only where the markup is the point.
5 //! Every workaround this port had to take is asserted here rather than left to
6 //! be noticed, so closing a finding is a test that has to change.
7
8 use std::sync::Arc;
9
10 use quasi_http::Serves as _;
11 use quasi_router::Outcome;
12 use quasi_router::{Params, Request, Response};
13
14 use super::super::router;
15 use crate::state::AppState;
16
17 async fn state() -> Arc<AppState> {
18 let (state, _) = crate::test_utils::setup_test_state().await;
19 state
20 }
21
22 fn get(state: &AppState, path: &str) -> Response {
23 router()
24 .handle(state, Request::get(path))
25 .expect("the route answers")
26 }
27
28 fn post(state: &AppState, path: &str, params: Params) -> Response {
29 router()
30 .handle(state, Request::post(path).sending(params))
31 .expect("the route answers")
32 }
33
34 fn html(response: Response) -> String {
35 match response.outcome {
36 Outcome::Screen(screen) => quasi_webview::Webview::new().screen(&screen),
37 Outcome::Fragment { node, .. } => quasi_webview::Webview::new().fragment(&node),
38 Outcome::Goto(action) => panic!("expected content, got a redirect to {action:?}"),
39 // Same reasoning one step along: an overlay is a screen, but it is not
40 // the screen this route was asked for. Rendering it here would let a
41 // route that answered with the command palette pass as the page.
42 Outcome::Over(_) => panic!("expected content, got a screen drawn over it"),
43 Outcome::Anchored { .. } => {
44 panic!("expected content, got a screen drawn at a point on it")
45 }
46 Outcome::Suggestions { field, .. } => {
47 panic!("expected content, got a suggestion list for `{field}`")
48 }
49 Outcome::File { name, .. } => panic!("expected content, got the file `{name}`"),
50 Outcome::Locate(_) => panic!("expected content, got a place on a map"),
51 // `cb62a9dc`. Work that runs somewhere else and a region that says so:
52 // not content, and not a place either.
53 Outcome::Started { region, .. } => {
54 panic!("expected content, got work started in `{region}`")
55 }
56 }
57 }
58
59 /// What the settings key holds right now, read the way the command does.
60 fn stored(state: &AppState, key: &str) -> Option<String> {
61 crate::commands::all_config(state)
62 .unwrap()
63 .get(key)
64 .cloned()
65 }
66
67 #[tokio::test]
68 async fn settings_opens_on_appearance_the_way_the_js_does() {
69 let state = state().await;
70 let page = html(get(&state, "/settings"));
71
72 assert!(page.contains("Appearance"));
73 assert!(page.contains("Theme"));
74 // And the same screen is reachable under its own address.
75 assert_eq!(page, html(get(&state, "/settings/appearance")));
76 }
77
78 #[tokio::test]
79 async fn the_sidebar_offers_the_described_sections_and_points_at_the_open_one() {
80 let state = state().await;
81 let page = html(get(&state, "/settings/planning"));
82
83 assert!(page.contains("Appearance"));
84 assert!(page.contains("Notifications"));
85 assert!(page.contains("Planning &amp; Review"));
86 // The app's own pointer, not the user's tick.
87 assert!(page.contains("aria-current"));
88 // About joined them on 2026-08-22, once `AppState` held the two host facts
89 // it needed.
90 assert!(page.contains("About"));
91
92 // Sync joined them on 2026-08-22, once somebody measured `commands/sync.rs`
93 // per command rather than per file: its reads are local.
94 assert!(page.contains("Sync"));
95
96 // Sharing joined them on 2026-08-24, and it is the only one of the five
97 // whose stated reason was right: its reads were remote, so there was no
98 // local state to draw a section from at all. synckit 0.9.0 writes the group
99 // directory down, so the reads are local now. quasicoherent `82273265`.
100 assert!(page.contains("Sharing"));
101
102 // Which leaves none of the eight absent, and the header's single-cause
103 // explanation wrong about four of its five.
104 assert_eq!(super::SECTIONS.len(), 8);
105 }
106
107 #[tokio::test]
108 async fn import_and_export_is_a_sidebar_row_that_leaves_for_the_screen_it_lives_on() {
109 // The section is described in full and lives at `/data`, so the row goes
110 // there rather than swapping this screen's pane. See `Section::at`.
111 let state = state().await;
112 let page = html(get(&state, "/settings/planning"));
113
114 assert!(page.contains("Import &amp; Export"), "{page}");
115 assert!(page.contains("/data"), "{page}");
116 }
117
118 #[tokio::test]
119 async fn the_row_that_leaves_has_no_section_of_its_own_to_open() {
120 // Without this, `/settings/data` falls through `screen`'s match and draws
121 // Appearance under the Import & Export heading.
122 let state = state().await;
123 let error = router()
124 .handle(&state, Request::get("/settings/data"))
125 .expect_err("data is a screen, not a section here");
126 assert_eq!(error.class.http_status(), 404);
127
128 // And it never reads as the open one, on any section.
129 let page = html(get(&state, "/settings/planning"));
130 let at = page.find("Import &amp; Export").expect("the row is drawn");
131 let row = &page[page[..at].rfind('<').unwrap_or(0)..at];
132 assert!(!row.contains("aria-current"), "{row}");
133 }
134
135 #[tokio::test]
136 async fn a_section_that_is_not_described_is_a_not_found() {
137 // Every section in `SECTIONS` is described now, so the case wants a name
138 // that never will be rather than the last one to be built. It held
139 // `/settings/sharing` until 2026-08-24.
140 let state = state().await;
141 let error = router()
142 .handle(&state, Request::get("/settings/nonesuch"))
143 .expect_err("an unknown section is not a section");
144 assert_eq!(error.class.http_status(), 404);
145 }
146
147 #[tokio::test]
148 async fn a_control_writes_as_soon_as_it_changes_with_no_form_around_it() {
149 // What finding 14612ed8 was closed for, and this screen is its first
150 // consumer: Node::Field plus Field::writes, which is a bare control with a
151 // route on it. A Node::Form here would describe a submit that does not
152 // exist.
153 let state = state().await;
154 let page = html(get(&state, "/settings/notifications"));
155
156 assert!(page.contains("Event indicator lead time"));
157 assert!(page.contains("/settings/config/event_lead_minutes"));
158 // No submit, because there is no form.
159 assert!(!page.contains("type=\"submit\""));
160 }
161
162 #[tokio::test]
163 async fn every_control_on_the_screen_writes_through_the_one_route() {
164 let state = state().await;
165
166 for (key, value, section) in [
167 ("event_lead_minutes", "30", "notifications"),
168 ("work_start_hour", "7", "planning"),
169 ("work_end_hour", "19", "planning"),
170 ("plan_nudges", "disabled", "planning"),
171 ("review_nudges", "disabled", "planning"),
172 ] {
173 let page = html(post(
174 &state,
175 &format!("/settings/config/{key}"),
176 Params::new().with("value", value),
177 ));
178 assert_eq!(stored(&state, key).as_deref(), Some(value));
179 // And the write answers with the section the setting lives in, re-read,
180 // which the handler works out from the key rather than being told.
181 assert!(
182 page.contains(&format!("/settings/{section}")),
183 "{key} should answer in {section}"
184 );
185 }
186 }
187
188 #[tokio::test]
189 async fn the_notifications_section_is_generated_from_the_registry() {
190 // Adoption's proof: the controls come from `crate::notifs::NOTIFS` rather
191 // than from a list in the screen, so a kind added there appears here with
192 // nothing edited in `settings.rs`.
193 let state = state().await;
194 let page = html(get(&state, "/settings/notifications"));
195
196 for kind in crate::notifs::NOTIFS.kinds() {
197 assert!(page.contains(kind.title), "{} is missing", kind.id);
198 assert!(
199 page.contains(&quasi_notifs::config::enabled_key(kind.id)),
200 "{} does not carry its generated key",
201 kind.id
202 );
203 }
204 // The whole pane posts to one route, and every control names its own key.
205 assert!(page.contains("/settings/notifications"));
206 }
207
208 #[tokio::test]
209 async fn a_kind_that_ships_on_is_drawn_on_before_anyone_has_chosen() {
210 // The migration fact: adopting the framework must not silently stop a
211 // notification that fires today. A checkbox is on by presence.
212 let state = state().await;
213 let page = html(get(&state, "/settings/notifications"));
214
215 let checkbox = page
216 .split("<input")
217 .find(|chunk| chunk.contains("snooze-expiry.enabled"))
218 .expect("the snooze-expiry toggle is drawn");
219 assert!(checkbox.contains("checked"), "{checkbox}");
220 }
221
222 #[tokio::test]
223 async fn turning_a_kind_off_stores_false_and_leaves_the_others_alone() {
224 let state = state().await;
225
226 // A checkbox that is off sends nothing, which is how HTML submits one. So
227 // a post carrying only the other two turns this one off.
228 let page = html(post(
229 &state,
230 "/settings/notifications",
231 Params::new()
232 .with("response-overdue.enabled", "true")
233 .with("event-reminder.enabled", "true"),
234 ));
235
236 assert_eq!(
237 stored(&state, "snooze-expiry.enabled").as_deref(),
238 Some("false")
239 );
240 assert_eq!(
241 stored(&state, "response-overdue.enabled").as_deref(),
242 Some("true")
243 );
244 assert_eq!(
245 stored(&state, "event-reminder.enabled").as_deref(),
246 Some("true")
247 );
248 // Answered with the section, re-read, like every other write on this screen.
249 assert!(page.contains("Snoozed items resurface"));
250
251 // And the pane now draws it off.
252 let page = html(get(&state, "/settings/notifications"));
253 let checkbox = page
254 .split("<input")
255 .find(|chunk| chunk.contains("snooze-expiry.enabled"))
256 .expect("the toggle is still drawn");
257 assert!(!checkbox.contains("checked"), "{checkbox}");
258 }
259
260 #[tokio::test]
261 async fn the_events_tab_lead_time_is_not_one_of_the_notification_kinds() {
262 // The trap `07830eb5` named: `event_lead_minutes` colours a dot on a tab
263 // and is not a delivery setting. It keeps its own key, its own route, and
264 // its own heading; the generated keys are dotted and it is not.
265 let state = state().await;
266 let page = html(get(&state, "/settings/notifications"));
267
268 assert!(page.contains("/settings/config/event_lead_minutes"));
269 assert!(!page.contains("event-reminder.lead"));
270 assert!(page.contains("Events tab"));
271 assert!(
272 page.contains("This is the indicator, not a notification."),
273 "the pane owes the distinction in words"
274 );
275 }
276
277 #[tokio::test]
278 async fn a_key_the_spec_does_not_declare_is_refused_by_the_same_check_the_command_uses() {
279 let state = state().await;
280 let error = router()
281 .handle(
282 &state,
283 Request::post("/settings/config/not_a_setting")
284 .sending(Params::new().with("value", "x")),
285 )
286 .expect_err("the spec's closed set holds");
287 assert_eq!(error.class.http_status(), 500);
288 assert!(stored(&state, "not_a_setting").is_none());
289 }
290
291 #[tokio::test]
292 async fn a_setting_nobody_has_touched_shows_its_default_rather_than_nothing() {
293 let state = state().await;
294 let page = html(get(&state, "/settings/planning"));
295
296 // 9am to 5pm, which is what the JS falls back to at each of its call sites.
297 assert!(page.contains(r#"value="9" selected"#) || page.contains(r#"selected value="9""#));
298 assert!(page.contains("5:00 PM"));
299 // And the written value wins over the default once there is one.
300 post(
301 &state,
302 "/settings/config/work_start_hour",
303 Params::new().with("value", "6"),
304 );
305 let page = html(get(&state, "/settings/planning"));
306 assert!(page.contains(r#"value="6" selected"#) || page.contains(r#"selected value="6""#));
307 }
308
309 #[tokio::test]
310 async fn the_theme_list_comes_from_the_state_and_keeps_its_grouping() {
311 // The first finding, closed. This asserted the loss: the shipped JS grouped
312 // the themes in four optgroups, `Choice` is a value and a label, so the
313 // variant went into the label and the structure was gone - and the old
314 // assertion was `!page.contains("optgroup")`.
315 //
316 // makeover-layout 0.38.0's `Field::theme` is what gave it back. The
317 // grouping is a described fact now rather than markup this app writes, and
318 // the contrast tier arrives with it, which this screen never had at all.
319 //
320 // It is still the assertion that the host fact reached the handler: the
321 // search path is built from an AppHandle the handler never sees, and
322 // AppState holds the result.
323 let state = state().await;
324 let page = html(get(&state, "/settings/appearance"));
325
326 assert!(page.contains("Follow System"), "{page}");
327 assert!(page.contains("optgroup"), "{page}");
328 assert!(page.contains(r#"<optgroup label="Light""#), "{page}");
329 assert!(page.contains(r#"<optgroup label="Dark""#), "{page}");
330 // The variant is no longer bracketed onto the name; the badge is what sits
331 // there now, and it is the fact the flat list could not carry.
332 assert!(!page.contains("(Dark)"), "{page}");
333 assert!(page.contains("data-contrast="), "{page}");
334 }
335
336 #[tokio::test]
337 async fn the_theme_picker_orders_each_group_by_measured_contrast() {
338 // The sort no app can do for itself: the tier comes off the resolved
339 // colours, so a screen holding only names cannot produce this order. It is
340 // `makeover::theme_options`' and this asserts it survived the description.
341 let state = state().await;
342 let page = html(get(&state, "/settings/appearance"));
343
344 let light = page
345 .find(r#"<optgroup label="Light""#)
346 .expect("light group");
347 let dark = page.find(r#"<optgroup label="Dark""#).expect("dark group");
348 assert!(light < dark, "light comes first");
349
350 // Within the light group, AA before OK before low.
351 let group = &page[light..dark];
352 let tier = |mark: &str| group.find(mark);
353 if let (Some(aa), Some(low)) = (
354 tier(r#"data-contrast="high""#),
355 tier(r#"data-contrast="low""#),
356 ) {
357 assert!(aa < low, "the most legible theme leads its group: {group}");
358 }
359 }
360
361 #[tokio::test]
362 async fn the_theme_writes_through_the_same_route_as_everything_else() {
363 let state = state().await;
364 let page = html(post(
365 &state,
366 "/settings/config/theme",
367 Params::new().with("value", "catppuccin-mocha"),
368 ));
369 assert_eq!(stored(&state, "theme").as_deref(), Some("catppuccin-mocha"));
370 assert!(page.contains("Appearance"));
371 }
372
373 #[tokio::test]
374 async fn work_hours_are_two_labelled_controls_rather_than_one_row_reading_to() {
375 // The third finding, asserted as written. The JS draws one labelled row -
376 // a select, the word "to", a select. The description has one label per
377 // field, so it says which is which and loses the pairing. Expected to be
378 // refused as furniture; the test exists so the loss is visible.
379 let state = state().await;
380 let page = html(get(&state, "/settings/planning"));
381
382 assert!(page.contains("Work day starts"));
383 assert!(page.contains("Work day ends"));
384 assert!(!page.contains(">to<"));
385 }
386
387 #[tokio::test]
388 async fn a_theme_name_cannot_become_markup() {
389 // Theme names come off disk, which is a place the app does not control:
390 // importing a theme writes whatever the file says into the custom
391 // directory, and the name inside it is never seen by anything that
392 // validates. The renderer is what makes that safe, and this is the only
393 // untrusted string on the screen.
394 let dir = tempfile::tempdir().unwrap();
395 std::fs::write(
396 dir.path().join("hostile.toml"),
397 "[meta]\nname = \"<script>alert(1)</script>\"\n",
398 )
399 .unwrap();
400
401 let (mut state, _) = crate::test_utils::setup_test_state().await;
402 Arc::get_mut(&mut state).expect("sole owner").theme_dirs =
403 vec![(dir.path().to_path_buf(), true)];
404
405 let page = html(get(&state, "/settings/appearance"));
406
407 assert!(page.contains("&lt;script&gt;"));
408 assert!(!page.contains("<script>alert"));
409 }
410
411 /// Put a group in the directory the way the sync loop does. That the loop
412 /// writes it is synckit's test; what is under test here is a section reading it.
413 fn known_group(state: &AppState, id: u128, name: &str, is_admin: bool) {
414 let mut conn = state.db.conn().unwrap();
415 synckit_client::store::directory::ensure_tables(&conn).unwrap();
416 synckit_client::store::directory::add_group(
417 &mut conn,
418 &synckit_client::store::directory::KnownGroup {
419 id: synckit_client::GroupId::new(uuid::Uuid::from_u128(id)),
420 name: name.to_owned(),
421 gck_version: 1,
422 is_admin,
423 },
424 )
425 .unwrap();
426 }
427
428 fn sharing(state: &AppState) -> String {
429 html(get(state, "/settings/sharing"))
430 }
431
432 #[tokio::test]
433 async fn sharing_is_a_section_in_the_sidebar() {
434 let state = state().await;
435 let page = html(get(&state, "/settings"));
436 assert!(page.contains("Sharing"), "{page}");
437 assert!(page.contains("/settings/sharing"), "{page}");
438 }
439
440 /// A device that has not synced since the user joined a group knows of none,
441 /// which is a statement about what has reached it rather than about the user.
442 #[tokio::test]
443 async fn a_device_that_has_not_synced_says_what_it_does_not_know() {
444 let state = state().await;
445 let pane = sharing(&state);
446 assert!(pane.contains("knows of no groups"), "{pane}");
447 }
448
449 #[tokio::test]
450 async fn the_groups_this_device_knows_are_listed() {
451 let state = state().await;
452 known_group(&state, 1, "The Firm", false);
453 known_group(&state, 2, "Book club", true);
454
455 let pane = sharing(&state);
456 assert!(pane.contains("The Firm"), "{pane}");
457 assert!(pane.contains("Book club"), "{pane}");
458 }
459
460 /// Administering a group and belonging to one are different facts, and only one
461 /// of them is about permission. The badge comes from `is_admin`, never from the
462 /// member list being empty.
463 #[tokio::test]
464 async fn administering_a_group_is_marked_and_merely_belonging_is_not() {
465 let theirs = state().await;
466 known_group(&theirs, 1, "Someone elses", false);
467 let pane = sharing(&theirs);
468 assert!(pane.contains("Someone elses"), "{pane}");
469 assert!(!pane.contains("You administer this"), "{pane}");
470
471 let mine = state().await;
472 known_group(&mine, 2, "Mine", true);
473 let pane = sharing(&mine);
474 assert!(pane.contains("You administer this"), "{pane}");
475 }
476
477 /// The list is a copy of the server's answer, and how old the copy is decides
478 /// how much to trust it.
479 #[tokio::test]
480 async fn the_section_says_how_old_its_group_list_is() {
481 let state = state().await;
482 known_group(&state, 1, "The Firm", false);
483 let pane = sharing(&state);
484 assert!(pane.contains("Group list last updated"), "{pane}");
485 }
486
487 /// Offered only where they can mean something. Without sync configured there is
488 /// no client to reach a server with, so a create form would be a control that
489 /// cannot act, which is the thing this section spends its design not doing.
490 #[tokio::test]
491 async fn the_admin_forms_are_withheld_when_there_is_no_sync_to_use_them() {
492 let state = state().await;
493 known_group(&state, 1, "The Firm", true);
494 let pane = sharing(&state);
495 assert!(!pane.contains("Make a group"), "{pane}");
496 }
497
498 /// A described handler cannot await, so the act is a local insert and the
499 /// drainer does the talking. What the user is told has to say so.
500 #[tokio::test]
501 async fn queueing_a_group_writes_a_row_and_says_it_is_not_immediate() {
502 let state = state().await;
503 let mut params = Params::new();
504 params.insert("name".to_owned(), "The Firm".to_owned());
505 let response = router()
506 .handle(
507 &state,
508 Request::post("/settings/sharing/groups").sending(params),
509 )
510 .expect("the route answers");
511
512 let queued = crate::group_queue::pending(&state).unwrap();
513 assert_eq!(queued.len(), 1);
514 assert_eq!(queued[0].kind, "create_group");
515 assert_eq!(queued[0].name.as_deref(), Some("The Firm"));
516 assert!(queued[0].done_at.is_none(), "nothing reached a server");
517
518 let said = format!("{:?}", response.notice);
519 assert!(said.contains("within a minute"), "{said}");
520 }
521
522 #[tokio::test]
523 async fn a_group_with_no_name_is_refused_rather_than_queued() {
524 let state = state().await;
525 let mut params = Params::new();
526 params.insert("name".to_owned(), " ".to_owned());
527 let error = router()
528 .handle(
529 &state,
530 Request::post("/settings/sharing/groups").sending(params),
531 )
532 .expect_err("a nameless group is refused");
533 assert_eq!(error.class, quasi_router::Class::Conflict);
534 assert!(crate::group_queue::pending(&state).unwrap().is_empty());
535 }
536
537 /// A key that is not a key can never be sealed to, and the person who pasted it
538 /// is on screen now. Refused here rather than queued to fail a minute later.
539 #[tokio::test]
540 async fn a_public_key_that_is_not_one_is_refused_before_it_is_queued() {
541 let state = state().await;
542 let mut params = Params::new();
543 params.insert(
544 "group_id".to_owned(),
545 "00000000-0000-0000-0000-000000000001".to_owned(),
546 );
547 params.insert("email".to_owned(), "them@localhost".to_owned());
548 params.insert("pubkey".to_owned(), "not a key".to_owned());
549
550 let error = router()
551 .handle(
552 &state,
553 Request::post("/settings/sharing/members").sending(params),
554 )
555 .expect_err("a bad key is refused");
556 assert_eq!(error.class, quasi_router::Class::Conflict);
557 assert!(crate::group_queue::pending(&state).unwrap().is_empty());
558 }
559
560 /// A control whose effect is a minute away has to be visible, or the section
561 /// looks like it lost the request.
562 #[tokio::test]
563 async fn the_queue_is_shown_with_what_each_row_will_do() {
564 let state = state().await;
565 crate::group_queue::enqueue(
566 &state,
567 &crate::group_queue::QueuedOp {
568 id: "q1".to_owned(),
569 kind: "create_group".to_owned(),
570 group_id: None,
571 name: Some("The Firm".to_owned()),
572 email: None,
573 pubkey: None,
574 member_user_id: None,
575 ..Default::default()
576 },
577 )
578 .unwrap();
579
580 let pane = sharing(&state);
581 assert!(pane.contains("Waiting to reach the server"), "{pane}");
582 assert!(pane.contains("Create the group The Firm"), "{pane}");
583 assert!(pane.contains("Waiting"), "{pane}");
584 }
585
586 /// The whole reason a queue beats a toast here: the server's own words, still on
587 /// screen, where the person who caused them will look.
588 #[tokio::test]
589 async fn a_failed_row_carries_the_reason_it_failed() {
590 let state = state().await;
591 crate::group_queue::enqueue(
592 &state,
593 &crate::group_queue::QueuedOp {
594 id: "q1".to_owned(),
595 kind: "add_member".to_owned(),
596 group_id: Some("00000000-0000-0000-0000-000000000001".to_owned()),
597 name: None,
598 email: Some("them@localhost".to_owned()),
599 pubkey: Some("k".to_owned()),
600 member_user_id: None,
601 ..Default::default()
602 },
603 )
604 .unwrap();
605 state
606 .db
607 .conn()
608 .unwrap()
609 .execute(
610 "UPDATE group_admin_queue SET attempts = 3, last_error = 'No such account.'",
611 [],
612 )
613 .unwrap();
614
615 let pane = sharing(&state);
616 assert!(pane.contains("Add them@localhost to a group"), "{pane}");
617 assert!(pane.contains("No such account."), "{pane}");
618 assert!(pane.contains("Tried 3 times"), "{pane}");
619 }
620
621 /// The way out of a row that will never succeed, which is why a failure is held
622 /// rather than deleted: the person who queued it decides, not the drainer.
623 #[tokio::test]
624 async fn a_queued_action_can_be_taken_back_out() {
625 let state = state().await;
626 crate::group_queue::enqueue(
627 &state,
628 &crate::group_queue::QueuedOp {
629 id: "q1".to_owned(),
630 kind: "create_group".to_owned(),
631 group_id: None,
632 name: Some("Mistake".to_owned()),
633 email: None,
634 pubkey: None,
635 member_user_id: None,
636 ..Default::default()
637 },
638 )
639 .unwrap();
640
641 router()
642 .handle(&state, Request::post("/settings/sharing/queue/q1/cancel"))
643 .expect("the route answers");
644 assert!(crate::group_queue::pending(&state).unwrap().is_empty());
645 }
646
647 /// A cancel that raced the drainer must not claim it undid anything.
648 #[tokio::test]
649 async fn cancelling_something_that_already_went_is_a_not_found() {
650 let state = state().await;
651 let error = router()
652 .handle(&state, Request::post("/settings/sharing/queue/gone/cancel"))
653 .expect_err("there is nothing to cancel");
654 assert_eq!(error.class, quasi_router::Class::NotFound);
655 }
656
657 /// Being added to a group takes no write from the person being added, so the
658 /// whole member side is describable. Without sync configured there is no key,
659 /// and that is said rather than left blank.
660 #[tokio::test]
661 async fn the_identity_key_says_why_it_is_missing_when_sync_is_not_set_up() {
662 let state = state().await;
663 let pane = sharing(&state);
664 assert!(pane.contains("Your identity key"), "{pane}");
665 assert!(pane.contains("no identity key"), "{pane}");
666 }
667
668 // The invitation flow. Every test below writes the directory the way the sync
669 // loop does and then reads the pane, on `known_group`'s reasoning: that the
670 // loop writes it is synckit's test, and what is under test here is a section
671 // reading it.
672
673 /// Put an invitation in the directory. `token` is the issued code, which only
674 /// the device that issued it ever holds.
675 fn known_invitation(
676 state: &AppState,
677 group: u128,
678 id: u128,
679 invitation_state: synckit_client::InvitationState,
680 fingerprint: Option<&str>,
681 token: Option<&str>,
682 expires_at: &str,
683 ) {
684 let mut conn = state.db.conn().unwrap();
685 synckit_client::store::directory::ensure_tables(&conn).unwrap();
686 let known = synckit_client::store::directory::KnownInvitation {
687 id: synckit_client::InvitationId::new(uuid::Uuid::from_u128(id)),
688 group_id: synckit_client::GroupId::new(uuid::Uuid::from_u128(group)),
689 state: invitation_state,
690 invitee_email: Some("them@localhost".to_owned()),
691 invitee_fingerprint: fingerprint.map(ToOwned::to_owned),
692 token: None,
693 expires_at: expires_at.to_owned(),
694 created_at: "2026-08-24T09:00:00.000Z".to_owned(),
695 };
696 synckit_client::store::directory::write_invitations(
697 &mut conn,
698 synckit_client::GroupId::new(uuid::Uuid::from_u128(group)),
699 std::slice::from_ref(&known),
700 )
701 .unwrap();
702 // The token is not part of the server's answer, so it goes in the way the
703 // drainer puts it there rather than through the refresh.
704 if let Some(token) = token {
705 conn.execute(
706 "UPDATE sync_invitations SET token = ?1 WHERE invitation_id = ?2",
707 rusqlite::params![token, uuid::Uuid::from_u128(id).to_string()],
708 )
709 .unwrap();
710 }
711 }
712
713 fn far_future() -> String {
714 "2099-01-01T00:00:00.000Z".to_owned()
715 }
716
717 fn long_past() -> String {
718 "2020-01-01T00:00:00.000Z".to_owned()
719 }
720
721 /// Only while pending. Once somebody has accepted, the code has done its whole
722 /// job and holding it on screen is exposure with no use.
723 #[tokio::test]
724 async fn a_pending_invitation_shows_its_code_and_an_accepted_one_does_not() {
725 let pending = state().await;
726 known_group(&pending, 1, "The Firm", true);
727 known_invitation(
728 &pending,
729 1,
730 11,
731 synckit_client::InvitationState::Pending,
732 None,
733 Some("CODE-ALPHA"),
734 &far_future(),
735 );
736
737 let pane = sharing(&pending);
738 assert!(pane.contains("CODE-ALPHA"), "{pane}");
739
740 let accepted = state().await;
741 known_group(&accepted, 1, "The Firm", true);
742 known_invitation(
743 &accepted,
744 1,
745 11,
746 synckit_client::InvitationState::Accepted,
747 Some("aa:bb:cc"),
748 Some("CODE-ALPHA"),
749 &far_future(),
750 );
751 let pane = sharing(&accepted);
752 assert!(!pane.contains("CODE-ALPHA"), "{pane}");
753 }
754
755 /// A deadline passes on its own between two refreshes, so the stored state says
756 /// `pending` for a code that stopped working. Reading `effective_state` is what
757 /// keeps a dead code from being offered as a live one, and it costs no request.
758 #[tokio::test]
759 async fn a_code_past_its_deadline_reads_as_expired_before_any_refresh() {
760 let state = state().await;
761 known_group(&state, 1, "The Firm", true);
762 known_invitation(
763 &state,
764 1,
765 11,
766 synckit_client::InvitationState::Pending,
767 None,
768 Some("CODE-STALE"),
769 &long_past(),
770 );
771
772 let pane = sharing(&state);
773 assert!(pane.contains("Expired"), "{pane}");
774 assert!(
775 !pane.contains("CODE-STALE"),
776 "the token goes with it: {pane}"
777 );
778 assert!(!pane.contains("Cancel it"), "and so does the act: {pane}");
779 }
780
781 /// The security-bearing step, and the only one where a person is waiting. It is
782 /// drawn above the group list rather than found by opening each group in turn.
783 #[tokio::test]
784 async fn an_accepted_invitation_waits_at_the_top_with_its_fingerprint() {
785 let state = state().await;
786 known_group(&state, 1, "The Firm", true);
787 known_invitation(
788 &state,
789 1,
790 11,
791 synckit_client::InvitationState::Accepted,
792 Some("aa:bb:cc:dd"),
793 None,
794 &far_future(),
795 );
796
797 let pane = sharing(&state);
798 assert!(pane.contains("Waiting on you to admit them"), "{pane}");
799 assert!(pane.contains("aa:bb:cc:dd"), "{pane}");
800 assert!(pane.contains("read it out over a channel"), "{pane}");
801
802 let top = pane.find("Waiting on you to admit them").unwrap();
803 let groups = pane.find("The Firm").unwrap();
804 assert!(top < groups, "above the group list: {pane}");
805 }
806
807 /// A key that yields no fingerprint must never be confirmable. The comparison is
808 /// the whole security of the step, and offering Confirm beside "unavailable"
809 /// invites approving something nobody read.
810 #[tokio::test]
811 async fn an_invitation_with_no_readable_fingerprint_cannot_be_confirmed() {
812 let state = state().await;
813 known_group(&state, 1, "The Firm", true);
814 known_invitation(
815 &state,
816 1,
817 11,
818 synckit_client::InvitationState::Accepted,
819 None,
820 None,
821 &far_future(),
822 );
823
824 let pane = sharing(&state);
825 assert!(pane.contains("Cannot be checked"), "{pane}");
826 assert!(!pane.contains("It matches, admit them"), "{pane}");
827 }
828
829 #[tokio::test]
830 async fn queueing_an_invite_records_the_group_and_the_expiry() {
831 let state = state().await;
832 let mut params = Params::new();
833 params.insert("group_id".to_owned(), uuid::Uuid::from_u128(1).to_string());
834 params.insert("expires_in_hours".to_owned(), "48".to_owned());
835
836 router()
837 .handle(
838 &state,
839 Request::post("/settings/sharing/invites").sending(params),
840 )
841 .expect("the route answers");
842
843 let queued = crate::group_queue::pending(&state).unwrap();
844 assert_eq!(queued.len(), 1);
845 assert_eq!(queued[0].kind, "create_invite");
846 assert_eq!(queued[0].expires_in_hours, Some(48));
847 assert!(
848 queued[0].invite_token.is_none(),
849 "the issued code is not on the row; it does not exist yet"
850 );
851 }
852
853 /// Blank is a real answer: the server has a default and saying nothing takes it.
854 #[tokio::test]
855 async fn an_invite_with_no_expiry_takes_the_servers_default() {
856 let state = state().await;
857 let mut params = Params::new();
858 params.insert("group_id".to_owned(), uuid::Uuid::from_u128(1).to_string());
859 params.insert("expires_in_hours".to_owned(), String::new());
860
861 router()
862 .handle(
863 &state,
864 Request::post("/settings/sharing/invites").sending(params),
865 )
866 .expect("the route answers");
867
868 assert_eq!(
869 crate::group_queue::pending(&state).unwrap()[0].expires_in_hours,
870 None
871 );
872 }
873
874 #[tokio::test]
875 async fn an_expiry_that_is_not_a_positive_whole_number_is_refused_now() {
876 let state = state().await;
877 for bad in ["nope", "0", "-3"] {
878 let mut params = Params::new();
879 params.insert("group_id".to_owned(), uuid::Uuid::from_u128(1).to_string());
880 params.insert("expires_in_hours".to_owned(), bad.to_owned());
881 let error = router()
882 .handle(
883 &state,
884 Request::post("/settings/sharing/invites").sending(params),
885 )
886 .expect_err("refused rather than queued to fail later");
887 assert_eq!(error.class, quasi_router::Class::Conflict, "{bad}");
888 }
889 assert!(crate::group_queue::pending(&state).unwrap().is_empty());
890 }
891
892 /// The fingerprint is the thing being authorized, so it is never copied onto the
893 /// row: the drainer re-reads the server's current answer. A key swapped between
894 /// the reading and the drain is refused by the same comparison rather than waved
895 /// through by a copy of it.
896 #[tokio::test]
897 async fn a_queued_confirm_carries_the_invitation_and_not_the_fingerprint() {
898 let state = state().await;
899 known_group(&state, 1, "The Firm", true);
900 known_invitation(
901 &state,
902 1,
903 11,
904 synckit_client::InvitationState::Accepted,
905 Some("aa:bb:cc:dd"),
906 None,
907 &far_future(),
908 );
909
910 router()
911 .handle(
912 &state,
913 Request::post(format!(
914 "/settings/sharing/invites/{}/{}/confirm",
915 uuid::Uuid::from_u128(1),
916 uuid::Uuid::from_u128(11)
917 )),
918 )
919 .expect("the route answers");
920
921 let queued = crate::group_queue::pending(&state).unwrap();
922 assert_eq!(queued[0].kind, "confirm_invite");
923 assert_eq!(
924 queued[0].invitation_id.as_deref(),
925 Some(uuid::Uuid::from_u128(11).to_string().as_str())
926 );
927 assert!(
928 !format!("{:?}", queued[0]).contains("aa:bb:cc:dd"),
929 "no fingerprint anywhere on the row: {:?}",
930 queued[0]
931 );
932 }
933
934 /// "Confirm an invitation" authorizes nothing legible, so the queued row names
935 /// the fingerprint. It is resolved from the directory as the row is drawn, which
936 /// is a display of the current answer rather than a second copy of it.
937 #[tokio::test]
938 async fn a_queued_confirm_names_the_fingerprint_it_is_authorizing() {
939 let state = state().await;
940 known_group(&state, 1, "The Firm", true);
941 known_invitation(
942 &state,
943 1,
944 11,
945 synckit_client::InvitationState::Accepted,
946 Some("aa:bb:cc:dd"),
947 None,
948 &far_future(),
949 );
950 crate::group_queue::enqueue(
951 &state,
952 &crate::group_queue::QueuedOp {
953 id: "q1".to_owned(),
954 kind: "confirm_invite".to_owned(),
955 group_id: Some(uuid::Uuid::from_u128(1).to_string()),
956 invitation_id: Some(uuid::Uuid::from_u128(11).to_string()),
957 ..Default::default()
958 },
959 )
960 .unwrap();
961
962 let pane = sharing(&state);
963 assert!(pane.contains("Admit the holder of aa:bb:cc:dd"), "{pane}");
964 }
965
966 /// The invitation has left `accepted` since it was queued, or the directory has
967 /// not caught up. Both are honest reasons not to name a fingerprint, and neither
968 /// is a reason to name a stale one.
969 #[tokio::test]
970 async fn a_queued_confirm_the_directory_cannot_place_says_so_rather_than_guessing() {
971 let state = state().await;
972 crate::group_queue::enqueue(
973 &state,
974 &crate::group_queue::QueuedOp {
975 id: "q1".to_owned(),
976 kind: "confirm_invite".to_owned(),
977 group_id: Some(uuid::Uuid::from_u128(1).to_string()),
978 invitation_id: Some(uuid::Uuid::from_u128(99).to_string()),
979 ..Default::default()
980 },
981 )
982 .unwrap();
983
984 let pane = sharing(&state);
985 assert!(pane.contains("once their fingerprint is checked"), "{pane}");
986 }
987
988 /// Reading a code and accepting it are two queued writes. Collapsing them would
989 /// delete the step where a person reads what they are joining.
990 #[tokio::test]
991 async fn pasting_a_code_queues_a_read_and_sends_nothing() {
992 let state = state().await;
993 let mut params = Params::new();
994 params.insert("token".to_owned(), " CODE-BETA ".to_owned());
995
996 router()
997 .handle(
998 &state,
999 Request::post("/settings/sharing/invites/preview").sending(params),
1000 )
1001 .expect("the route answers");
1002
1003 let queued = crate::group_queue::pending(&state).unwrap();
1004 assert_eq!(queued[0].kind, "preview_invite");
1005 assert_eq!(
1006 queued[0].invite_token.as_deref(),
1007 Some("CODE-BETA"),
1008 "normalised where the one accepted spelling lives"
1009 );
1010 }
1011
1012 /// One accepted spelling of a code, owned by `normalize_invite_token`, so a
1013 /// paste that works in the Tauri command works here.
1014 #[tokio::test]
1015 async fn a_pasted_url_is_normalised_to_the_bare_code() {
1016 let state = state().await;
1017 let mut params = Params::new();
1018 params.insert(
1019 "token".to_owned(),
1020 "https://makenot.work/invite/CODE-GAMMA?from=mail".to_owned(),
1021 );
1022
1023 router()
1024 .handle(
1025 &state,
1026 Request::post("/settings/sharing/invites/preview").sending(params),
1027 )
1028 .expect("the route answers");
1029
1030 assert_eq!(
1031 crate::group_queue::pending(&state).unwrap()[0]
1032 .invite_token
1033 .as_deref(),
1034 Some("CODE-GAMMA")
1035 );
1036 }
1037
1038 /// Terminal states are drawn without an Accept control, so reaching the handler
1039 /// with one is a stale screen rather than a misuse. Refused rather than queued:
1040 /// it can only fail, and failing here says so now instead of in a minute.
1041 ///
1042 /// What the *pane* says about a dead code -- which kind of dead, read off
1043 /// `state`, because "expired" and "cancelled" send a person to different places
1044 /// -- is not asserted here. The join section is withheld without sync
1045 /// configured, and a test state has none.
1046 #[tokio::test]
1047 async fn a_terminal_code_is_refused_now_rather_than_queued_to_fail() {
1048 let state = state().await;
1049 let mut conn = state.db.conn().unwrap();
1050 synckit_client::store::directory::ensure_tables(&conn).unwrap();
1051 synckit_client::store::directory::write_preview(
1052 &mut conn,
1053 &synckit_client::store::directory::KnownPreview {
1054 token: "CODE-DEAD".to_owned(),
1055 group_name: "The Firm".to_owned(),
1056 inviter_email: "boss@localhost".to_owned(),
1057 redeemable: false,
1058 state: synckit_client::InvitationState::Revoked,
1059 expires_at: far_future(),
1060 },
1061 )
1062 .unwrap();
1063 drop(conn);
1064
1065 let error = router()
1066 .handle(&state, Request::post("/settings/sharing/invites/accept"))
1067 .expect_err("a terminal code cannot be accepted");
1068 assert_eq!(error.class, quasi_router::Class::Conflict);
1069 }
1070
1071 /// Accepting reads the token from the stored preview rather than from the
1072 /// request: the control appears only because a preview is on screen, and
1073 /// re-sending the code through the form would let the two disagree.
1074 #[tokio::test]
1075 async fn accepting_with_nothing_previewed_is_a_not_found() {
1076 let state = state().await;
1077 let error = router()
1078 .handle(&state, Request::post("/settings/sharing/invites/accept"))
1079 .expect_err("there is no code to accept");
1080 assert_eq!(error.class, quasi_router::Class::NotFound);
1081 }
1082
1083 /// A preview was a read, so dropping the answer to it is this device's business
1084 /// alone and there is nothing to tell a server.
1085 #[tokio::test]
1086 async fn dismissing_a_preview_queues_nothing() {
1087 let state = state().await;
1088 let mut conn = state.db.conn().unwrap();
1089 synckit_client::store::directory::ensure_tables(&conn).unwrap();
1090 synckit_client::store::directory::write_preview(
1091 &mut conn,
1092 &synckit_client::store::directory::KnownPreview {
1093 token: "CODE-DELTA".to_owned(),
1094 group_name: "The Firm".to_owned(),
1095 inviter_email: "boss@localhost".to_owned(),
1096 redeemable: true,
1097 state: synckit_client::InvitationState::Pending,
1098 expires_at: far_future(),
1099 },
1100 )
1101 .unwrap();
1102 drop(conn);
1103
1104 router()
1105 .handle(&state, Request::post("/settings/sharing/invites/dismiss"))
1106 .expect("the route answers");
1107
1108 let conn = state.db.conn().unwrap();
1109 assert!(
1110 synckit_client::store::directory::preview(&conn)
1111 .unwrap()
1112 .is_none()
1113 );
1114 assert!(crate::group_queue::pending(&state).unwrap().is_empty());
1115 }
1116
1117 /// Accepting posts this device's identity key, and there is no key without sync
1118 /// set up, so the control could not act and is not drawn.
1119 #[tokio::test]
1120 async fn the_join_section_is_withheld_when_there_is_no_sync_to_use_it() {
1121 let state = state().await;
1122 let pane = sharing(&state);
1123 assert!(
1124 !pane.contains("Joining a group you were invited to"),
1125 "{pane}"
1126 );
1127 }
1128
1129 /// The section dates the list it is drawing. `pending_confirmations_refreshed_at`
1130 /// is the freshness of exactly the rows above it, so the sentence is about them
1131 /// and not about the group list, which refreshes on a different schedule.
1132 #[tokio::test]
1133 async fn the_confirmations_section_dates_the_list_it_is_drawing() {
1134 let state = state().await;
1135 known_group(&state, 1, "The Firm", true);
1136 known_invitation(
1137 &state,
1138 1,
1139 11,
1140 synckit_client::InvitationState::Accepted,
1141 Some("aa:bb:cc:dd"),
1142 None,
1143 &far_future(),
1144 );
1145
1146 let pane = sharing(&state);
1147 assert!(pane.contains("This list was last refreshed"), "{pane}");
1148 assert!(
1149 !pane.contains("The group directory was last refreshed"),
1150 "the group list is a different list on a different schedule: {pane}"
1151 );
1152 }
1153
1154 /// The case the per-list reader exists for: the group list refreshed on a cycle
1155 /// where this group's invitation fetch failed. The section must report the older
1156 /// number, because that is how old what it is drawing actually is.
1157 #[tokio::test]
1158 async fn the_confirmations_timestamp_is_the_invitation_lists_not_the_groups() {
1159 let state = state().await;
1160 known_group(&state, 1, "The Firm", true);
1161 known_invitation(
1162 &state,
1163 1,
1164 11,
1165 synckit_client::InvitationState::Accepted,
1166 Some("aa:bb:cc:dd"),
1167 None,
1168 &far_future(),
1169 );
1170 {
1171 let conn = state.db.conn().unwrap();
1172 conn.execute(
1173 "UPDATE sync_invitations SET refreshed_at = '2026-01-01T00:00:00.000Z'",
1174 [],
1175 )
1176 .unwrap();
1177 }
1178
1179 let pane = sharing(&state);
1180 assert!(
1181 pane.contains("This list was last refreshed 2026-01-01T00:00:00.000Z"),
1182 "{pane}"
1183 );
1184 }
1185