Skip to main content

max / goingson

31.6 KB · 875 lines History Blame Raw
1 //! The projects screen, driven through the router against a real database.
2 //!
3 //! No Tauri runtime and no window: a route is a function from state and params
4 //! to a description, which is the property that makes the router testable at
5 //! all. What is asserted here is the description, and the markup only where the
6 //! markup is the point.
7
8 use std::sync::Arc;
9
10 use goingson_core::{NewProject, ProjectStatus, ProjectType};
11 use quasi_http::Serves as _;
12 use quasi_router::Outcome;
13 use quasi_router::{Params, Request, Response};
14
15 use super::super::{protocol, 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 fn add(state: &AppState, name: &str, status: ProjectStatus) -> goingson_core::Project {
42 state
43 .projects
44 .create(
45 DESKTOP_USER_ID,
46 NewProject {
47 name: name.to_owned(),
48 description: String::new(),
49 project_type: ProjectType::SideProject,
50 status,
51 },
52 )
53 .unwrap()
54 }
55
56 fn answer(state: &AppState, path: &str, params: Params) -> Response {
57 router()
58 .handle(state, Request::get(path).carrying(params))
59 .expect("the route answers")
60 }
61
62 /// A write, which is the shape every action on this screen arrives in.
63 fn post(state: &AppState, path: &str, params: Params) -> Response {
64 router()
65 .handle(state, Request::post(path).sending(params))
66 .expect("the route answers")
67 }
68
69 /// A write made from a filtered view: what the control sent, and where it was
70 /// sent from. The two never merge, which is why a screen may filter on the same
71 /// name it writes.
72 fn viewing_post(state: &AppState, path: &str, carried: Params) -> Response {
73 router()
74 .handle(state, Request::post(path).carrying(carried))
75 .expect("the route answers")
76 }
77
78 /// The four the create form asks for.
79 ///
80 /// Every value spelled out at each call site rather than overridden on top of a
81 /// valid default: `Params::get` answers with the *first* value under a name, so
82 /// a `.with("status", ...)` after a valid one is silently ignored and the test
83 /// passes for the wrong reason.
84 fn a_project(name: &str, project_type: &str, status: &str) -> Params {
85 Params::new()
86 .with("name", name)
87 .with("description", "")
88 .with("project_type", project_type)
89 .with("status", status)
90 }
91
92 /// A valid submission.
93 fn valid(name: &str) -> Params {
94 a_project(name, "SideProject", "Active")
95 }
96
97 #[tokio::test]
98 async fn an_empty_database_says_so_as_a_stand_in_with_a_way_out() {
99 // `703f4cd2`. This asserted a bare line of text until 2026-08-09, because
100 // nothing named an empty state and the description had only `Node::text` to
101 // say it with. `Node::StandIn` is what changed.
102 let state = state().await;
103 let Outcome::Screen(screen) = answer(&state, "/projects", Params::new()).outcome else {
104 panic!("the index answers with a screen");
105 };
106 let html = quasi_webview::Webview::new().screen(&screen);
107
108 assert!(html.contains("No projects yet."));
109 assert!(html.contains(r#"data-state="empty""#));
110 // Not a fault: an empty database is the normal state of a new install.
111 assert!(!html.contains(r#"role="alert""#));
112 // The way out, which is the half `Node::text` could never carry.
113 assert!(html.contains("Create your first project"));
114 assert!(html.contains("hx-get=\"/projects/new"));
115 }
116
117 #[tokio::test]
118 async fn deleting_a_project_asks_first() {
119 // `524a63fe`. The JS confirms through `confirmDelete` and the described
120 // screen did not, so the description was worse than what it replaces on
121 // the one path where that matters most.
122 let state = state().await;
123 let project = add(&state, "Doomed", ProjectStatus::Active);
124
125 let Outcome::Fragment { node, .. } =
126 answer(&state, &format!("/projects/{}", project.id), Params::new()).outcome
127 else {
128 panic!("the detail pane answers with a fragment");
129 };
130 let html = quasi_webview::Webview::new().fragment(&node);
131
132 assert!(html.contains("Delete project"));
133 assert!(html.contains("hx-confirm=\"Are you sure you want to delete this project?"));
134 }
135
136 #[tokio::test]
137 async fn the_grid_lists_live_projects_and_holds_the_retired_ones_back() {
138 let state = state().await;
139 add(&state, "Live one", ProjectStatus::Active);
140 add(&state, "Paused", ProjectStatus::OnHold);
141 add(&state, "Finished", ProjectStatus::Completed);
142
143 let Outcome::Screen(screen) = answer(&state, "/projects", Params::new()).outcome else {
144 panic!("a screen");
145 };
146 let html = quasi_webview::Webview::new().screen(&screen);
147
148 assert!(html.contains("Live one"));
149 assert!(html.contains("Paused"));
150 assert!(!html.contains("Finished"));
151 // The control that reveals them names how many there are, which is what
152 // `projects.js` puts on its retired toggle.
153 assert!(html.contains("Show 1 completed or archived"));
154 }
155
156 #[tokio::test]
157 async fn the_retired_toggle_is_an_address_not_a_piece_of_module_state() {
158 let state = state().await;
159 add(&state, "Finished", ProjectStatus::Completed);
160
161 let shown = answer(&state, "/projects", Params::new().with("retired", "1"));
162 let Outcome::Screen(screen) = shown.outcome else {
163 panic!("a screen");
164 };
165 let html = quasi_webview::Webview::new().screen(&screen);
166 assert!(html.contains("Finished"));
167 assert!(html.contains("Hide completed and archived"));
168 }
169
170 #[tokio::test]
171 async fn a_filter_toggle_swaps_the_grid_alone() {
172 let state = state().await;
173 add(&state, "Live one", ProjectStatus::Active);
174
175 let response = answer(&state, "/projects/list", Params::new());
176 // Decision 7: the response names the region, so the whole document is not
177 // reflowed to change one pane.
178 assert_eq!(response.target(), Some("projects-grid"));
179
180 let Outcome::Fragment { node, .. } = (response).outcome else {
181 panic!("a fragment");
182 };
183 let html = quasi_webview::Webview::new().fragment(&node);
184 assert!(html.starts_with("<ul"));
185 assert!(!html.contains("<html"));
186 assert!(html.contains("Live one"));
187 }
188
189 #[tokio::test]
190 async fn the_shared_filter_appears_only_when_sharing_is_in_play() {
191 let state = state().await;
192 add(&state, "Mine", ProjectStatus::Active);
193
194 let Outcome::Screen(screen) = answer(&state, "/projects", Params::new()).outcome else {
195 panic!("a screen");
196 };
197 let html = quasi_webview::Webview::new().screen(&screen);
198 assert!(!html.contains("Shared only"));
199
200 // Asking for the filtered view surfaces the control even with nothing
201 // shared, so the way back is always on screen. Same rule as `projects.js`.
202 let Outcome::Screen(screen) =
203 answer(&state, "/projects", Params::new().with("shared", "1")).outcome
204 else {
205 panic!("a screen");
206 };
207 let html = quasi_webview::Webview::new().screen(&screen);
208 assert!(html.contains("Shared only"));
209 assert!(html.contains("No shared projects yet."));
210 }
211
212 #[tokio::test]
213 async fn a_row_carries_both_badges_as_tokens_and_keeps_the_status_tone() {
214 // Was `a_row_carries_both_badges_as_one_trailing_fact`, which asserted the
215 // workaround: "Side Project · On Hold" joined into `meta`, and
216 // `!html.contains("warning")` pinning down the loss. makeover-layout
217 // 0.9.0's `RowPart::Tokens` is what this screen's finding asked for, and
218 // this is the assertion inverted.
219 let state = state().await;
220 add(&state, "Mine", ProjectStatus::OnHold);
221
222 let Outcome::Fragment { node, .. } = answer(&state, "/projects/list", Params::new()).outcome
223 else {
224 panic!("a fragment");
225 };
226 let html = quasi_webview::Webview::new().fragment(&node);
227
228 assert!(html.contains("class=\"row-tokens\""));
229 assert!(html.contains("Side Project"));
230 assert!(html.contains("On Hold"));
231 // The thing the join could not keep. `utils.js:statusTone` maps OnHold to
232 // warning, and now so does the description.
233 // `data-tone`, not a `tone-warning` class. quasi@f287ac1 stopped emitting
234 // the class names, because makeover defines none of them: its whole
235 // vocabulary keys tone off the attribute, so a toned thing wearing a class
236 // rendered untoned.
237 assert!(html.contains("data-tone=\"warning\""));
238 assert!(!html.contains("Side Project · On Hold"));
239 }
240
241 #[tokio::test]
242 async fn only_the_status_badge_is_toned() {
243 // A type is not news and takes no tone, which is what keeps a row from
244 // being a row of colours. Archived is the same case on the status side:
245 // `statusTone` returns nothing for it deliberately.
246 let state = state().await;
247 add(&state, "Done", ProjectStatus::Archived);
248
249 let Outcome::Fragment { node, .. } =
250 answer(&state, "/projects/list", Params::new().with("retired", "1")).outcome
251 else {
252 panic!("a fragment");
253 };
254 let html = quasi_webview::Webview::new().fragment(&node);
255
256 assert!(html.contains("Archived"));
257 assert!(!html.contains("tone-"));
258 }
259
260 #[tokio::test]
261 async fn selecting_a_row_addresses_the_detail_pane() {
262 let state = state().await;
263 let project = add(&state, "Mine", ProjectStatus::Active);
264
265 let Outcome::Fragment { node, .. } = answer(&state, "/projects/list", Params::new()).outcome
266 else {
267 panic!("a fragment");
268 };
269 let html = quasi_webview::Webview::new().fragment(&node);
270 assert!(html.contains(&format!("hx-get=\"/projects/{}\"", project.id)));
271
272 let response = answer(&state, &format!("/projects/{}", project.id), Params::new());
273 assert_eq!(response.target(), Some("projects-detail"));
274 }
275
276 #[tokio::test]
277 async fn a_missing_project_is_a_not_found_rather_than_a_panic() {
278 let state = state().await;
279 let error = router()
280 .handle(
281 &state,
282 Request::get(format!("/projects/{}", uuid::Uuid::nil())),
283 )
284 .expect_err("no such project");
285 assert_eq!(error.class.http_status(), 404);
286 }
287
288 #[tokio::test]
289 async fn a_project_name_cannot_become_markup() {
290 // The reason the description carries text and the renderer owns escaping.
291 // A project name is user input and goes through the same door as everything
292 // else.
293 let state = state().await;
294 add(&state, "<script>alert(1)</script>", ProjectStatus::Active);
295
296 let Outcome::Screen(screen) = answer(&state, "/projects", Params::new()).outcome else {
297 panic!("a screen");
298 };
299 let html = quasi_webview::Webview::new().screen(&screen);
300 assert!(!html.contains("<script>alert"));
301 assert!(html.contains("&lt;script&gt;"));
302 }
303
304 #[tokio::test]
305 async fn the_two_described_writes_reach_a_handler() {
306 // The standard the contacts port set, and what this screen was short of:
307 // "New project" and "Delete project" were described controls calling routes
308 // that were never registered, so the screen said it could do two things it
309 // could not.
310 let state = state().await;
311 let project = add(&state, "Mine", ProjectStatus::Active);
312
313 let Outcome::Screen(screen) = answer(&state, "/projects", Params::new()).outcome else {
314 panic!("a screen");
315 };
316 let html = quasi_webview::Webview::new().screen(&screen);
317 assert!(html.contains("hx-get=\"/projects/new\""));
318
319 let Outcome::Fragment { node, .. } =
320 answer(&state, &format!("/projects/{}", project.id), Params::new()).outcome
321 else {
322 panic!("a fragment");
323 };
324 let html = quasi_webview::Webview::new().fragment(&node);
325 assert!(html.contains(&format!("hx-post=\"/projects/{}/delete\"", project.id)));
326
327 // Both answer rather than 404, which is the whole claim.
328 answer(&state, "/projects/new", Params::new());
329 post(
330 &state,
331 &format!("/projects/{}/delete", project.id),
332 Params::new(),
333 );
334 }
335
336 #[tokio::test]
337 async fn the_create_form_asks_what_the_modal_asks() {
338 let state = state().await;
339 let Outcome::Fragment { node, .. } = answer(&state, "/projects/new", Params::new()).outcome
340 else {
341 panic!("a fragment");
342 };
343 let html = quasi_webview::Webview::new().fragment(&node);
344
345 assert!(html.contains("hx-post=\"/projects\""));
346 for field in ["name", "description", "project_type", "status"] {
347 assert!(
348 html.contains(&format!("name=\"{field}\"")),
349 "{field} is asked"
350 );
351 }
352 // A project cannot be created finished, which is `projects.js` slicing its
353 // status list to the first two on create.
354 assert!(html.contains("value=\"OnHold\""));
355 assert!(!html.contains("value=\"Archived\""));
356 }
357
358 #[tokio::test]
359 async fn creating_a_project_puts_it_in_the_grid() {
360 let state = state().await;
361 let Outcome::Screen(screen) = post(&state, "/projects", valid("Made here")).outcome else {
362 panic!("a write answers with the whole screen");
363 };
364 let html = quasi_webview::Webview::new().screen(&screen);
365 assert!(html.contains("Made here"));
366 // The pane the form was in goes back to saying nothing is selected, which is
367 // the half a grid-only fragment would have left stale.
368 assert!(html.contains("Nothing selected"));
369 }
370
371 #[tokio::test]
372 async fn a_rejected_form_names_what_is_wrong_and_offers_back_what_was_typed() {
373 // The fourth finding, closed. It was asserted here in its broken form until
374 // 2026-08-09 — the too-long name was reported and then thrown away, and the
375 // user retyped it to shorten it. `Field::refilled` is what changed, and this
376 // test changing is what says the finding closed. `1c4a66a4`.
377 let state = state().await;
378 let long = "x".repeat(101);
379
380 let Outcome::Fragment { node, .. } = post(&state, "/projects", valid(&long)).outcome else {
381 panic!("a refusal answers with the form, not the screen");
382 };
383 let html = quasi_webview::Webview::new().fragment(&node);
384
385 assert!(html.contains("Maximum 100 characters"));
386 assert!(html.contains("aria-invalid=\"true\""));
387 // What the finding was about: the name comes back in the box it was typed
388 // in, so shortening it is an edit rather than a retype.
389 assert!(html.contains(&format!("value=\"{long}\"")));
390 // The select keeps what was chosen too, which is the same fact in the other
391 // control the form offers.
392 assert!(html.contains("value=\"SideProject\" selected"));
393
394 // And nothing was written.
395 assert!(state.projects.list_all(DESKTOP_USER_ID).unwrap().is_empty());
396 }
397
398 #[tokio::test]
399 async fn a_nameless_project_is_refused_rather_than_created_blank() {
400 let state = state().await;
401 let Outcome::Fragment { .. } = post(&state, "/projects", valid(" ")).outcome else {
402 panic!("a refusal");
403 };
404 assert!(state.projects.list_all(DESKTOP_USER_ID).unwrap().is_empty());
405 }
406
407 #[tokio::test]
408 async fn a_value_no_option_offered_is_refused_rather_than_defaulted() {
409 // `from_str_or_default` is what the rest of goingson reads enums with, and
410 // it would file this as an `Other` project and say nothing.
411 let state = state().await;
412 let params = a_project("Mine", "Sculpture", "Active");
413
414 let Outcome::Fragment { node, .. } = post(&state, "/projects", params).outcome else {
415 panic!("a refusal");
416 };
417 let html = quasi_webview::Webview::new().fragment(&node);
418 assert!(html.contains("Not one of the options offered."));
419 assert!(state.projects.list_all(DESKTOP_USER_ID).unwrap().is_empty());
420
421 // A status that parses but is not on offer is refused on the same grounds.
422 let params = a_project("Mine", "SideProject", "Archived");
423 let Outcome::Fragment { node, .. } = post(&state, "/projects", params).outcome else {
424 panic!("a refusal");
425 };
426 let html = quasi_webview::Webview::new().fragment(&node);
427 assert!(html.contains("Not a status a project starts in."));
428 assert!(state.projects.list_all(DESKTOP_USER_ID).unwrap().is_empty());
429 }
430
431 #[tokio::test]
432 async fn deleting_a_project_takes_it_out_of_the_grid_and_empties_the_pane() {
433 let state = state().await;
434 let project = add(&state, "Going", ProjectStatus::Active);
435 add(&state, "Staying", ProjectStatus::Active);
436
437 let Outcome::Screen(screen) = post(
438 &state,
439 &format!("/projects/{}/delete", project.id),
440 Params::new(),
441 )
442 .outcome
443 else {
444 panic!("a write answers with the whole screen");
445 };
446 let html = quasi_webview::Webview::new().screen(&screen);
447
448 assert!(!html.contains("Going"));
449 assert!(html.contains("Staying"));
450 // The reason a write does not answer with the grid alone: the pane would
451 // still be offering to delete something that is gone.
452 assert!(html.contains("Nothing selected"));
453 assert!(!html.contains(&format!("/projects/{}/delete", project.id)));
454 }
455
456 #[tokio::test]
457 async fn deleting_something_that_is_not_there_is_a_not_found() {
458 let state = state().await;
459 let error = router()
460 .handle(
461 &state,
462 Request::post(format!("/projects/{}/delete", uuid::Uuid::nil())),
463 )
464 .expect_err("no such project");
465 assert_eq!(error.class.http_status(), 404);
466 }
467
468 #[tokio::test]
469 async fn an_action_carries_the_filters_it_was_offered_under() {
470 // The filters are the only state this screen has, and they live in the
471 // address. A control that dropped them would be a filtered view you fall
472 // out of by using it.
473 let state = state().await;
474 let project = add(&state, "Finished", ProjectStatus::Completed);
475 let retired = Params::new().with("retired", "1");
476
477 let Outcome::Screen(screen) = answer(&state, "/projects", retired.clone()).outcome else {
478 panic!("a screen");
479 };
480 let html = quasi_webview::Webview::new().screen(&screen);
481 // On the address, because the filters are the view rather than something a
482 // control sends. A write's own values still travel in `hx-vals`, and that
483 // separation is what stops a screen reading its own payload back as its
484 // filters.
485 assert!(html.contains("hx-get=\"/projects/new?retired=1\""));
486 assert!(html.contains(&format!("hx-get=\"/projects/{}?retired=1\"", project.id)));
487
488 let Outcome::Fragment { node, .. } =
489 answer(&state, &format!("/projects/{}", project.id), retired).outcome
490 else {
491 panic!("a fragment");
492 };
493 let html = quasi_webview::Webview::new().fragment(&node);
494 assert!(html.contains(&format!(
495 "hx-post=\"/projects/{}/delete?retired=1\"",
496 project.id
497 )));
498 }
499
500 #[tokio::test]
501 async fn a_write_answers_under_the_filters_it_carried() {
502 let state = state().await;
503 add(&state, "Finished", ProjectStatus::Completed);
504 let doomed = add(&state, "Going", ProjectStatus::Completed);
505
506 let Outcome::Screen(screen) = viewing_post(
507 &state,
508 &format!("/projects/{}/delete", doomed.id),
509 Params::new().with("retired", "1"),
510 )
511 .outcome
512 else {
513 panic!("a screen");
514 };
515 let html = quasi_webview::Webview::new().screen(&screen);
516
517 // Still showing retired projects afterwards. Dropping the filter here is
518 // how a delete reads as having emptied the grid.
519 assert!(html.contains("Finished"));
520 assert!(html.contains("Hide completed and archived"));
521 }
522
523 #[tokio::test]
524 async fn the_protocol_serves_the_screen_from_its_own_scheme() {
525 let state = state().await;
526 add(&state, "Mine", ProjectStatus::Active);
527
528 let (served, late) = protocol();
529 assert_eq!(served.url().scheme(), "quasi");
530
531 // The state arrives after the builder has already taken the protocol, so
532 // the handle is the only way in. See `quasi::protocol`.
533 assert!(!late.is_set());
534 assert!(late.set(state));
535 }
536
537 #[tokio::test]
538 async fn a_card_says_what_its_description_says_rather_than_how_it_is_written() {
539 // The second finding's tail. A row part holds a string, which the
540 // 2026-08-08 ruling settles and this does not reopen; the question is which
541 // string, and it used to be the markdown source, so the row showed its
542 // syntax and spent its width on a URL it cannot follow.
543 let state = state().await;
544 state
545 .projects
546 .create(
547 DESKTOP_USER_ID,
548 NewProject {
549 name: "Atlas".to_owned(),
550 description: "**Ships Q3.** See [the brief](https://example.com/very/long/path)."
551 .to_owned(),
552 project_type: ProjectType::SideProject,
553 status: ProjectStatus::Active,
554 },
555 )
556 .unwrap();
557
558 let Outcome::Screen(screen) = answer(&state, "/projects", Params::new()).outcome else {
559 panic!("the index answers with a screen");
560 };
561 let page = quasi_webview::Webview::new().screen(&screen);
562
563 // The description layer says "this is markdown" and the webview draws it:
564 // emphasis kept, the anchor dropped because the row is already the target.
565 assert!(page.contains("<strong>Ships Q3.</strong>"));
566 assert!(page.contains("See the brief."));
567 assert!(!page.contains("**Ships Q3.**"));
568 assert!(!page.contains("example.com"));
569 }
570
571 #[tokio::test]
572 async fn a_cards_description_is_one_line() {
573 // A row is one line tall, so block structure is noise here even though it
574 // is exactly what a document wants.
575 let state = state().await;
576 state
577 .projects
578 .create(
579 DESKTOP_USER_ID,
580 NewProject {
581 name: "Borealis".to_owned(),
582 description: "# Goal\n\nShip it.\n\n- one\n- two".to_owned(),
583 project_type: ProjectType::SideProject,
584 status: ProjectStatus::Active,
585 },
586 )
587 .unwrap();
588
589 let Outcome::Screen(screen) = answer(&state, "/projects", Params::new()).outcome else {
590 panic!("the index answers with a screen");
591 };
592 let page = quasi_webview::Webview::new().screen(&screen);
593
594 let secondary = page
595 .split_once(r#"<span class="row-secondary">"#)
596 .expect("the card draws its description")
597 .1
598 .split_once("</span>")
599 .expect("the part closes")
600 .0;
601 for block in ["<h1", "<ul", "<li", "<p"] {
602 assert!(!secondary.contains(block), "no {block} in a row: {page}");
603 }
604 for word in ["Goal", "Ship it.", "one", "two"] {
605 assert!(page.contains(word), "{word} survives: {page}");
606 }
607 }
608
609 /// A project's scope, stamped the way `set_project_scope` stamps it.
610 ///
611 /// Written straight rather than through `share_project`: that command awaits the
612 /// SyncKit client to confirm membership, and what is under test here is the two
613 /// halves that read and clear the column, neither of which talks to a server.
614 fn share_into(state: &AppState, project: &goingson_core::Project, group: &str) {
615 crate::commands::group::set_project_scope(
616 &state.db,
617 DESKTOP_USER_ID,
618 &project.id.to_string(),
619 Some(group),
620 )
621 .unwrap();
622 }
623
624 fn scope_of(state: &AppState, project: &goingson_core::Project) -> Option<String> {
625 state
626 .db
627 .conn()
628 .unwrap()
629 .query_row(
630 "SELECT group_id FROM projects WHERE id = ?1",
631 rusqlite::params![project.id.to_string()],
632 |row| row.get(0),
633 )
634 .unwrap()
635 }
636
637 /// The filter shipped before the mark did, so a "Shared only" view was the only
638 /// way to tell which rows it would keep.
639 #[tokio::test]
640 async fn a_shared_project_says_so_in_the_list() {
641 let state = state().await;
642 let shared = add(&state, "Shared work", ProjectStatus::Active);
643 add(&state, "Mine alone", ProjectStatus::Active);
644 share_into(&state, &shared, "11111111-1111-1111-1111-111111111111");
645
646 let Outcome::Screen(screen) = answer(&state, "/projects", Params::new()).outcome else {
647 panic!("the index answers with a screen");
648 };
649 let page = quasi_webview::Webview::new().screen(&screen);
650 assert!(page.contains("Shared"), "{page}");
651 }
652
653 #[tokio::test]
654 async fn a_personal_project_carries_no_shared_badge_and_no_way_out() {
655 let state = state().await;
656 let personal = add(&state, "Mine alone", ProjectStatus::Active);
657
658 let Outcome::Fragment { node, .. } =
659 answer(&state, &format!("/projects/{}", personal.id), Params::new()).outcome
660 else {
661 panic!("the detail pane answers with a fragment");
662 };
663 let pane = quasi_webview::Webview::new().fragment(&node);
664 assert!(!pane.contains("Move back to personal"), "{pane}");
665 assert!(!pane.contains("Shared into a group"), "{pane}");
666 }
667
668 #[tokio::test]
669 async fn a_shared_project_offers_the_way_back_to_personal() {
670 let state = state().await;
671 let shared = add(&state, "Shared work", ProjectStatus::Active);
672 share_into(&state, &shared, "11111111-1111-1111-1111-111111111111");
673
674 let Outcome::Fragment { node, .. } =
675 answer(&state, &format!("/projects/{}", shared.id), Params::new()).outcome
676 else {
677 panic!("the detail pane answers with a fragment");
678 };
679 let pane = quasi_webview::Webview::new().fragment(&node);
680 assert!(pane.contains("Move back to personal"), "{pane}");
681 assert!(
682 pane.contains(&format!("/projects/{}/unshare", shared.id)),
683 "{pane}"
684 );
685 }
686
687 #[tokio::test]
688 async fn unsharing_clears_the_scope_across_the_subtree() {
689 let state = state().await;
690 let shared = add(&state, "Shared work", ProjectStatus::Active);
691 let group = "11111111-1111-1111-1111-111111111111";
692 share_into(&state, &shared, group);
693
694 let task = state
695 .tasks
696 .create(
697 DESKTOP_USER_ID,
698 goingson_core::NewTask::builder("Inside it")
699 .project_id(shared.id)
700 .build(),
701 )
702 .unwrap();
703 // The create path stamps a child from its parent, which is what makes the
704 // subtree worth re-checking after the clear.
705 assert_eq!(
706 scope_of_task(&state, &task).as_deref(),
707 Some(group),
708 "the child inherited the scope"
709 );
710
711 post(
712 &state,
713 &format!("/projects/{}/unshare", shared.id),
714 Params::new(),
715 );
716
717 assert!(
718 scope_of(&state, &shared).is_none(),
719 "the project is personal"
720 );
721 assert!(
722 scope_of_task(&state, &task).is_none(),
723 "and so is everything in it"
724 );
725 }
726
727 fn scope_of_task(state: &AppState, task: &goingson_core::Task) -> Option<String> {
728 state
729 .db
730 .conn()
731 .unwrap()
732 .query_row(
733 "SELECT group_id FROM tasks WHERE id = ?1",
734 rusqlite::params![task.id.to_string()],
735 |row| row.get(0),
736 )
737 .unwrap()
738 }
739
740 /// Put a group in the directory the way the sync loop does, so a described
741 /// handler can read it. That the loop writes this is synckit's test; what is
742 /// under test here is a screen reading it.
743 fn known_group(state: &AppState, id: u128, name: &str) {
744 let mut conn = state.db.conn().unwrap();
745 // The sync DDL runs when a `SyncStore` is built, which a test state does not
746 // do. The directory's own DDL is separable for exactly this reason, so what
747 // is under test is a screen reading the table rather than the table's
748 // absence, which `a_device_that_knows_no_groups_is_offered_no_picker` covers.
749 synckit_client::store::directory::ensure_tables(&conn).unwrap();
750 synckit_client::store::directory::add_group(
751 &mut conn,
752 &synckit_client::store::directory::KnownGroup {
753 id: synckit_client::GroupId::new(uuid::Uuid::from_u128(id)),
754 name: name.to_owned(),
755 gck_version: 1,
756 is_admin: true,
757 },
758 )
759 .unwrap();
760 }
761
762 fn pane(state: &AppState, project: &goingson_core::Project) -> String {
763 let Outcome::Fragment { node, .. } =
764 answer(state, &format!("/projects/{}", project.id), Params::new()).outcome
765 else {
766 panic!("the detail pane answers with a fragment");
767 };
768 quasi_webview::Webview::new().fragment(&node)
769 }
770
771 /// An empty directory means this device has not synced since groups existed,
772 /// not that the user has no groups, so the control is withheld rather than
773 /// drawn with nothing in it.
774 #[tokio::test]
775 async fn a_device_that_knows_no_groups_is_offered_no_picker() {
776 let state = state().await;
777 let personal = add(&state, "Mine alone", ProjectStatus::Active);
778 let shown = pane(&state, &personal);
779 assert!(!shown.contains("Share into a group"), "{shown}");
780 }
781
782 #[tokio::test]
783 async fn a_personal_project_offers_the_groups_this_device_knows() {
784 let state = state().await;
785 let personal = add(&state, "Mine alone", ProjectStatus::Active);
786 known_group(&state, 1, "The Firm");
787
788 let shown = pane(&state, &personal);
789 assert!(shown.contains("Share into a group"), "{shown}");
790 assert!(shown.contains("The Firm"), "{shown}");
791 assert!(
792 shown.contains(&format!("/projects/{}/share", personal.id)),
793 "{shown}"
794 );
795 }
796
797 /// One project is in one scope, so a shared project is offered the way out and
798 /// not a second way in.
799 #[tokio::test]
800 async fn a_shared_project_is_not_offered_the_picker_again() {
801 let state = state().await;
802 let shared = add(&state, "Shared work", ProjectStatus::Active);
803 known_group(&state, 1, "The Firm");
804 share_into(&state, &shared, "00000000-0000-0000-0000-000000000001");
805
806 let shown = pane(&state, &shared);
807 assert!(!shown.contains("Share into a group"), "{shown}");
808 assert!(shown.contains("Move back to personal"), "{shown}");
809 }
810
811 #[tokio::test]
812 async fn sharing_stamps_the_scope_across_the_subtree() {
813 let state = state().await;
814 let project = add(&state, "Mine alone", ProjectStatus::Active);
815 known_group(&state, 1, "The Firm");
816 let group = "00000000-0000-0000-0000-000000000001";
817
818 let task = state
819 .tasks
820 .create(
821 DESKTOP_USER_ID,
822 goingson_core::NewTask::builder("Inside it")
823 .project_id(project.id)
824 .build(),
825 )
826 .unwrap();
827
828 let mut params = Params::new();
829 params.insert("group_id".to_owned(), group.to_owned());
830 post(&state, &format!("/projects/{}/share", project.id), params);
831
832 assert_eq!(scope_of(&state, &project).as_deref(), Some(group));
833 assert_eq!(
834 scope_of_task(&state, &task).as_deref(),
835 Some(group),
836 "and everything in it went too"
837 );
838 }
839
840 /// The check `share_project` used to make against the server, made against the
841 /// directory. Stamping a scope the engine holds no key for routes the whole
842 /// subtree into a changelog that goes nowhere.
843 #[tokio::test]
844 async fn sharing_into_a_group_this_device_does_not_know_is_refused() {
845 let state = state().await;
846 let project = add(&state, "Mine alone", ProjectStatus::Active);
847 known_group(&state, 1, "The Firm");
848
849 let mut params = Params::new();
850 params.insert(
851 "group_id".to_owned(),
852 "00000000-0000-0000-0000-0000000000ff".to_owned(),
853 );
854 post(&state, &format!("/projects/{}/share", project.id), params);
855
856 assert!(scope_of(&state, &project).is_none(), "nothing was stamped");
857 }
858
859 /// A stale id stamps zero rows and would otherwise report success, which is
860 /// `share_project`'s own recorded lesson asked of the way back out.
861 #[tokio::test]
862 async fn unsharing_a_project_that_is_not_there_is_refused() {
863 let state = state().await;
864 let missing = uuid::Uuid::new_v4();
865 assert!(
866 router()
867 .handle(
868 &state,
869 Request::post(format!("/projects/{missing}/unshare")),
870 )
871 .is_err(),
872 "a stale id is refused rather than answered with success"
873 );
874 }
875