Skip to main content

max / goingson

11.9 KB · 341 lines History Blame Raw
1 //! The problems inbox, driven through the router against a real database.
2 //!
3 //! Same property as its siblings: no Tauri runtime and no window, because a
4 //! route is a function from state and params to a description.
5
6 use std::sync::Arc;
7
8 use chrono::{Duration, Utc};
9 use goingson_core::{NewProblem, Problem, ProblemStatus};
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, DESKTOP_USER_ID};
16
17 /// State with the desktop user in place, which is who the handlers read as.
18 async fn state() -> Arc<AppState> {
19 let (state, _) = crate::test_utils::setup_test_state().await;
20 let now = Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
21 state
22 .db
23 .conn()
24 .unwrap()
25 .execute(
26 "INSERT OR IGNORE INTO users (id, email, password_hash, display_name, created_at) \
27 VALUES (?, ?, ?, ?, ?)",
28 rusqlite::params![
29 DESKTOP_USER_ID.to_string(),
30 "desktop@localhost",
31 "x",
32 "Desktop User",
33 &now,
34 ],
35 )
36 .unwrap();
37 state
38 }
39
40 /// A problem as an adapter would report it. `pain` and `scale` are the score's
41 /// two stored factors; age is the third and comes from `created_at`.
42 fn report(state: &AppState, source: &str, title: &str, pain: u8, weeks_old: i64) -> Problem {
43 let created = Utc::now() - Duration::weeks(weeks_old);
44 state
45 .problems
46 .ingest(
47 DESKTOP_USER_ID,
48 NewProblem {
49 source: source.to_owned(),
50 source_ref: format!("{source}-{title}"),
51 title: title.to_owned(),
52 body: String::new(),
53 pain,
54 scale: 3,
55 project_id: None,
56 tags: Vec::new(),
57 created_at: created,
58 updated_at: created,
59 resolved_upstream: false,
60 },
61 )
62 .unwrap()
63 }
64
65 fn get(state: &AppState, path: &str, params: Params) -> Response {
66 router()
67 .handle(state, Request::get(path).carrying(params))
68 .expect("the route answers")
69 }
70
71 fn post(state: &AppState, path: &str, params: Params) -> Response {
72 router()
73 .handle(state, Request::post(path).sending(params))
74 .expect("the route answers")
75 }
76
77 /// A write made from a filtered view: what the control sent, and the filter it
78 /// was sent under. Both may be called `status` — that is the whole point of the
79 /// split, and this screen is what proved it was needed.
80 fn viewing_post(state: &AppState, path: &str, payload: Params, carried: Params) -> Response {
81 router()
82 .handle(
83 state,
84 Request::post(path).sending(payload).carrying(carried),
85 )
86 .expect("the route answers")
87 }
88
89 fn screen_html(response: Response) -> String {
90 let Outcome::Screen(screen) = response.outcome else {
91 panic!("the route answers with a screen");
92 };
93 quasi_webview::Webview::new().screen(&screen)
94 }
95
96 fn fragment_html(response: Response) -> String {
97 let Outcome::Fragment { node, .. } = response.outcome else {
98 panic!("the route answers with a fragment");
99 };
100 quasi_webview::Webview::new().fragment(&node)
101 }
102
103 fn reread(state: &AppState, problem: &Problem) -> Problem {
104 state
105 .problems
106 .get_by_id(problem.id, DESKTOP_USER_ID)
107 .unwrap()
108 .expect("the problem is still there")
109 }
110
111 #[tokio::test]
112 async fn an_empty_inbox_says_what_would_fill_it() {
113 let state = state().await;
114 let html = screen_html(get(&state, "/problems", Params::new()));
115 assert!(html.contains("Nothing waiting for triage"), "got: {html}");
116 assert!(
117 html.contains("promoting one makes it a task"),
118 "got: {html}"
119 );
120 }
121
122 #[tokio::test]
123 async fn the_inbox_shows_the_untriaged_and_ranks_them_by_painhours() {
124 // The repository's order, not this screen's: the score moves with the clock,
125 // so it is computed on read and sorted after the fetch.
126 let state = state().await;
127 report(&state, "wam", "mild and new", 1, 1);
128 report(&state, "audit", "bad and old", 5, 40);
129
130 let html = screen_html(get(&state, "/problems", Params::new()));
131 let worse = html.find("bad and old").expect("the worse one is shown");
132 let milder = html.find("mild and new").expect("the milder one is shown");
133 assert!(worse < milder, "most urgent first: {html}");
134 }
135
136 #[tokio::test]
137 async fn a_row_says_out_loud_what_the_shipped_screen_hides_in_a_tooltip() {
138 // `rowHtml` puts the derivation in `title=`, which is absent on touch and on
139 // a keyboard. Here it is meta text.
140 let state = state().await;
141 report(&state, "wam", "it breaks", 4, 2);
142
143 let html = screen_html(get(&state, "/problems", Params::new()));
144 assert!(html.contains("pain 4 x scale 3"), "got: {html}");
145 assert!(html.contains("wam-it breaks"), "the source ref: {html}");
146 }
147
148 #[tokio::test]
149 async fn the_status_filter_is_in_the_address_rather_than_in_module_state() {
150 let state = state().await;
151 let open = report(&state, "wam", "still open", 3, 1);
152 let other = report(&state, "wam", "dealt with", 3, 1);
153 state
154 .problems
155 .set_status(other.id, DESKTOP_USER_ID, ProblemStatus::Dismissed)
156 .unwrap();
157
158 let inbox = screen_html(get(&state, "/problems", Params::new()));
159 assert!(inbox.contains("still open"), "got: {inbox}");
160 assert!(
161 !inbox.contains("dealt with"),
162 "Open is the default: {inbox}"
163 );
164
165 let dismissed = fragment_html(get(
166 &state,
167 "/problems/list",
168 Params::new().with("status", "Dismissed"),
169 ));
170 assert!(dismissed.contains("dealt with"), "got: {dismissed}");
171 assert!(!dismissed.contains("still open"), "got: {dismissed}");
172
173 let everything = fragment_html(get(
174 &state,
175 "/problems/list",
176 Params::new().with("status", "all"),
177 ));
178 assert!(everything.contains("still open"), "got: {everything}");
179 assert!(everything.contains("dealt with"), "got: {everything}");
180 let _ = open;
181 }
182
183 #[tokio::test]
184 async fn an_unknown_status_word_is_refused_rather_than_read_as_open() {
185 // Falling back would answer with a different list than the one asked for,
186 // which is `list_problems`'s rule and the reason it has one.
187 let state = state().await;
188 let refused = router().handle(
189 &state,
190 Request::get("/problems/list").carrying(Params::new().with("status", "bogus")),
191 );
192 assert!(refused.is_err(), "an invented state is not a filter");
193 }
194
195 #[tokio::test]
196 async fn the_source_filter_offers_a_source_that_the_current_view_has_none_of() {
197 // The workaround `problems.js` needs, not needed: the option list comes from
198 // the whole table rather than from the rows on screen.
199 let state = state().await;
200 let audited = report(&state, "audit", "found by reading", 3, 1);
201 state
202 .problems
203 .set_status(audited.id, DESKTOP_USER_ID, ProblemStatus::Dismissed)
204 .unwrap();
205 report(&state, "wam", "reported by a user", 3, 1);
206
207 // The Open list holds nothing from `audit`, and the chip is still there.
208 let html = screen_html(get(&state, "/problems", Params::new()));
209 assert!(html.contains("audit"), "got: {html}");
210 }
211
212 #[tokio::test]
213 async fn promoting_makes_a_task_and_leaves_the_backlink_on_the_row() {
214 let state = state().await;
215 let problem = report(&state, "audit", "the thing is wrong", 4, 3);
216
217 let html = fragment_html(viewing_post(
218 &state,
219 &format!("/problems/{}/promote", problem.id),
220 Params::new(),
221 Params::new().with("status", "all"),
222 ));
223
224 let promoted = reread(&state, &problem);
225 assert_eq!(promoted.status, ProblemStatus::Promoted);
226 let task_id = promoted.promoted_task_id.expect("the backlink is written");
227
228 let task = state
229 .tasks
230 .get_by_id(task_id, DESKTOP_USER_ID)
231 .unwrap()
232 .expect("the task exists");
233 assert!(
234 task.title.contains("the thing is wrong"),
235 "the description defaults to the problem's own text: {task:?}"
236 );
237
238 // The row now offers the way to the task it made, as an address rather than
239 // as a view switch.
240 assert!(html.contains(&format!("/tasks/{task_id}")), "got: {html}");
241 }
242
243 #[tokio::test]
244 async fn promoting_twice_reports_the_task_it_already_made() {
245 // The shared `promote` is idempotent, and the screen must not turn that into
246 // two tasks by pressing twice.
247 let state = state().await;
248 let problem = report(&state, "audit", "double pressed", 4, 3);
249 let path = format!("/problems/{}/promote", problem.id);
250
251 let all = || Params::new().with("status", "all");
252 viewing_post(&state, &path, Params::new(), all());
253 let first = reread(&state, &problem).promoted_task_id.unwrap();
254 viewing_post(&state, &path, Params::new(), all());
255 let second = reread(&state, &problem).promoted_task_id.unwrap();
256
257 assert_eq!(first, second, "one problem, one task");
258 }
259
260 #[tokio::test]
261 async fn the_target_state_is_named_so_two_windows_cannot_race() {
262 // The same decision the monthly review's goals landed on. Two presses from
263 // two stale windows name the same target, so the second is a no-op rather
264 // than a step around a cycle nobody can see.
265 //
266 // The target is called `status`, which is also this screen's filter. It
267 // could not be, for one afternoon: the write was renamed `to` because the
268 // two shared a namespace. They no longer do.
269 let state = state().await;
270 let problem = report(&state, "wam", "seen it", 2, 1);
271 let path = format!("/problems/{}/status", problem.id);
272
273 for _ in 0..2 {
274 post(&state, &path, Params::new().with("status", "Dismissed"));
275 }
276 assert_eq!(reread(&state, &problem).status, ProblemStatus::Dismissed);
277
278 post(&state, &path, Params::new().with("status", "Open"));
279 let reopened = reread(&state, &problem);
280 assert_eq!(reopened.status, ProblemStatus::Open);
281 assert!(
282 reopened.promoted_task_id.is_none(),
283 "reopening clears the backlink"
284 );
285 }
286
287 #[tokio::test]
288 async fn a_triage_decision_answers_with_the_list_it_happened_in() {
289 // Dismissing from the Open view removes the row, which is the point: the
290 // answer is the list re-read, not the row patched in place.
291 let state = state().await;
292 let problem = report(&state, "wam", "goes away", 2, 1);
293 report(&state, "wam", "stays put", 2, 1);
294
295 let html = fragment_html(post(
296 &state,
297 &format!("/problems/{}/status", problem.id),
298 Params::new().with("status", "Dismissed"),
299 ));
300 assert!(!html.contains("goes away"), "got: {html}");
301 assert!(html.contains("stays put"), "got: {html}");
302 }
303
304 #[tokio::test]
305 async fn a_stale_problem_says_so_where_the_shipped_screen_drops_the_fact() {
306 // The finding on `row_for`: `ProblemResponse.stale` is computed, serialised,
307 // and never read by `problems.js`. A source that stops reporting a problem
308 // leaves it in place, marked, because a brief outage must not erase triage
309 // history.
310 let state = state().await;
311 let old = report(&state, "wam", "no longer reported", 3, 4);
312 report(&state, "wam", "still reported", 3, 4);
313
314 // A later pull that saw only the second one moves the source's last-pull
315 // instant past the first one's `last_seen_at`.
316 state
317 .db
318 .conn()
319 .unwrap()
320 .execute(
321 "UPDATE problems SET last_seen_at = datetime('now', '-1 day') WHERE id = ?",
322 rusqlite::params![old.id.to_string()],
323 )
324 .unwrap();
325
326 let html = screen_html(get(&state, "/problems", Params::new()));
327 assert!(html.contains("Stale"), "got: {html}");
328 }
329
330 #[tokio::test]
331 async fn a_problem_that_does_not_exist_is_a_404_rather_than_a_crash() {
332 let state = state().await;
333 let missing = uuid::Uuid::new_v4();
334 let answer = router().handle(
335 &state,
336 Request::post(format!("/problems/{missing}/status"))
337 .sending(Params::new().with("status", "Open")),
338 );
339 assert!(answer.is_err());
340 }
341