//! The problems inbox, driven through the router against a real database. //! //! Same property as its siblings: no Tauri runtime and no window, because a //! route is a function from state and params to a description. use std::sync::Arc; use chrono::{Duration, Utc}; use goingson_core::{NewProblem, Problem, ProblemStatus}; use quasi_http::Serves as _; use quasi_router::Outcome; use quasi_router::{Params, Request, Response}; use super::super::router; use crate::state::{AppState, DESKTOP_USER_ID}; /// State with the desktop user in place, which is who the handlers read as. async fn state() -> Arc { let (state, _) = crate::test_utils::setup_test_state().await; let now = Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); state .db .conn() .unwrap() .execute( "INSERT OR IGNORE INTO users (id, email, password_hash, display_name, created_at) \ VALUES (?, ?, ?, ?, ?)", rusqlite::params![ DESKTOP_USER_ID.to_string(), "desktop@localhost", "x", "Desktop User", &now, ], ) .unwrap(); state } /// A problem as an adapter would report it. `pain` and `scale` are the score's /// two stored factors; age is the third and comes from `created_at`. fn report(state: &AppState, source: &str, title: &str, pain: u8, weeks_old: i64) -> Problem { let created = Utc::now() - Duration::weeks(weeks_old); state .problems .ingest( DESKTOP_USER_ID, NewProblem { source: source.to_owned(), source_ref: format!("{source}-{title}"), title: title.to_owned(), body: String::new(), pain, scale: 3, project_id: None, tags: Vec::new(), created_at: created, updated_at: created, resolved_upstream: false, }, ) .unwrap() } fn get(state: &AppState, path: &str, params: Params) -> Response { router() .handle(state, Request::get(path).carrying(params)) .expect("the route answers") } fn post(state: &AppState, path: &str, params: Params) -> Response { router() .handle(state, Request::post(path).sending(params)) .expect("the route answers") } /// A write made from a filtered view: what the control sent, and the filter it /// was sent under. Both may be called `status` — that is the whole point of the /// split, and this screen is what proved it was needed. fn viewing_post(state: &AppState, path: &str, payload: Params, carried: Params) -> Response { router() .handle( state, Request::post(path).sending(payload).carrying(carried), ) .expect("the route answers") } fn screen_html(response: Response) -> String { let Outcome::Screen(screen) = response.outcome else { panic!("the route answers with a screen"); }; quasi_webview::Webview::new().screen(&screen) } fn fragment_html(response: Response) -> String { let Outcome::Fragment { node, .. } = response.outcome else { panic!("the route answers with a fragment"); }; quasi_webview::Webview::new().fragment(&node) } fn reread(state: &AppState, problem: &Problem) -> Problem { state .problems .get_by_id(problem.id, DESKTOP_USER_ID) .unwrap() .expect("the problem is still there") } #[tokio::test] async fn an_empty_inbox_says_what_would_fill_it() { let state = state().await; let html = screen_html(get(&state, "/problems", Params::new())); assert!(html.contains("Nothing waiting for triage"), "got: {html}"); assert!( html.contains("promoting one makes it a task"), "got: {html}" ); } #[tokio::test] async fn the_inbox_shows_the_untriaged_and_ranks_them_by_painhours() { // The repository's order, not this screen's: the score moves with the clock, // so it is computed on read and sorted after the fetch. let state = state().await; report(&state, "wam", "mild and new", 1, 1); report(&state, "audit", "bad and old", 5, 40); let html = screen_html(get(&state, "/problems", Params::new())); let worse = html.find("bad and old").expect("the worse one is shown"); let milder = html.find("mild and new").expect("the milder one is shown"); assert!(worse < milder, "most urgent first: {html}"); } #[tokio::test] async fn a_row_says_out_loud_what_the_shipped_screen_hides_in_a_tooltip() { // `rowHtml` puts the derivation in `title=`, which is absent on touch and on // a keyboard. Here it is meta text. let state = state().await; report(&state, "wam", "it breaks", 4, 2); let html = screen_html(get(&state, "/problems", Params::new())); assert!(html.contains("pain 4 x scale 3"), "got: {html}"); assert!(html.contains("wam-it breaks"), "the source ref: {html}"); } #[tokio::test] async fn the_status_filter_is_in_the_address_rather_than_in_module_state() { let state = state().await; let open = report(&state, "wam", "still open", 3, 1); let other = report(&state, "wam", "dealt with", 3, 1); state .problems .set_status(other.id, DESKTOP_USER_ID, ProblemStatus::Dismissed) .unwrap(); let inbox = screen_html(get(&state, "/problems", Params::new())); assert!(inbox.contains("still open"), "got: {inbox}"); assert!( !inbox.contains("dealt with"), "Open is the default: {inbox}" ); let dismissed = fragment_html(get( &state, "/problems/list", Params::new().with("status", "Dismissed"), )); assert!(dismissed.contains("dealt with"), "got: {dismissed}"); assert!(!dismissed.contains("still open"), "got: {dismissed}"); let everything = fragment_html(get( &state, "/problems/list", Params::new().with("status", "all"), )); assert!(everything.contains("still open"), "got: {everything}"); assert!(everything.contains("dealt with"), "got: {everything}"); let _ = open; } #[tokio::test] async fn an_unknown_status_word_is_refused_rather_than_read_as_open() { // Falling back would answer with a different list than the one asked for, // which is `list_problems`'s rule and the reason it has one. let state = state().await; let refused = router().handle( &state, Request::get("/problems/list").carrying(Params::new().with("status", "bogus")), ); assert!(refused.is_err(), "an invented state is not a filter"); } #[tokio::test] async fn the_source_filter_offers_a_source_that_the_current_view_has_none_of() { // The workaround `problems.js` needs, not needed: the option list comes from // the whole table rather than from the rows on screen. let state = state().await; let audited = report(&state, "audit", "found by reading", 3, 1); state .problems .set_status(audited.id, DESKTOP_USER_ID, ProblemStatus::Dismissed) .unwrap(); report(&state, "wam", "reported by a user", 3, 1); // The Open list holds nothing from `audit`, and the chip is still there. let html = screen_html(get(&state, "/problems", Params::new())); assert!(html.contains("audit"), "got: {html}"); } #[tokio::test] async fn promoting_makes_a_task_and_leaves_the_backlink_on_the_row() { let state = state().await; let problem = report(&state, "audit", "the thing is wrong", 4, 3); let html = fragment_html(viewing_post( &state, &format!("/problems/{}/promote", problem.id), Params::new(), Params::new().with("status", "all"), )); let promoted = reread(&state, &problem); assert_eq!(promoted.status, ProblemStatus::Promoted); let task_id = promoted.promoted_task_id.expect("the backlink is written"); let task = state .tasks .get_by_id(task_id, DESKTOP_USER_ID) .unwrap() .expect("the task exists"); assert!( task.title.contains("the thing is wrong"), "the description defaults to the problem's own text: {task:?}" ); // The row now offers the way to the task it made, as an address rather than // as a view switch. assert!(html.contains(&format!("/tasks/{task_id}")), "got: {html}"); } #[tokio::test] async fn promoting_twice_reports_the_task_it_already_made() { // The shared `promote` is idempotent, and the screen must not turn that into // two tasks by pressing twice. let state = state().await; let problem = report(&state, "audit", "double pressed", 4, 3); let path = format!("/problems/{}/promote", problem.id); let all = || Params::new().with("status", "all"); viewing_post(&state, &path, Params::new(), all()); let first = reread(&state, &problem).promoted_task_id.unwrap(); viewing_post(&state, &path, Params::new(), all()); let second = reread(&state, &problem).promoted_task_id.unwrap(); assert_eq!(first, second, "one problem, one task"); } #[tokio::test] async fn the_target_state_is_named_so_two_windows_cannot_race() { // The same decision the monthly review's goals landed on. Two presses from // two stale windows name the same target, so the second is a no-op rather // than a step around a cycle nobody can see. // // The target is called `status`, which is also this screen's filter. It // could not be, for one afternoon: the write was renamed `to` because the // two shared a namespace. They no longer do. let state = state().await; let problem = report(&state, "wam", "seen it", 2, 1); let path = format!("/problems/{}/status", problem.id); for _ in 0..2 { post(&state, &path, Params::new().with("status", "Dismissed")); } assert_eq!(reread(&state, &problem).status, ProblemStatus::Dismissed); post(&state, &path, Params::new().with("status", "Open")); let reopened = reread(&state, &problem); assert_eq!(reopened.status, ProblemStatus::Open); assert!( reopened.promoted_task_id.is_none(), "reopening clears the backlink" ); } #[tokio::test] async fn a_triage_decision_answers_with_the_list_it_happened_in() { // Dismissing from the Open view removes the row, which is the point: the // answer is the list re-read, not the row patched in place. let state = state().await; let problem = report(&state, "wam", "goes away", 2, 1); report(&state, "wam", "stays put", 2, 1); let html = fragment_html(post( &state, &format!("/problems/{}/status", problem.id), Params::new().with("status", "Dismissed"), )); assert!(!html.contains("goes away"), "got: {html}"); assert!(html.contains("stays put"), "got: {html}"); } #[tokio::test] async fn a_stale_problem_says_so_where_the_shipped_screen_drops_the_fact() { // The finding on `row_for`: `ProblemResponse.stale` is computed, serialised, // and never read by `problems.js`. A source that stops reporting a problem // leaves it in place, marked, because a brief outage must not erase triage // history. let state = state().await; let old = report(&state, "wam", "no longer reported", 3, 4); report(&state, "wam", "still reported", 3, 4); // A later pull that saw only the second one moves the source's last-pull // instant past the first one's `last_seen_at`. state .db .conn() .unwrap() .execute( "UPDATE problems SET last_seen_at = datetime('now', '-1 day') WHERE id = ?", rusqlite::params![old.id.to_string()], ) .unwrap(); let html = screen_html(get(&state, "/problems", Params::new())); assert!(html.contains("Stale"), "got: {html}"); } #[tokio::test] async fn a_problem_that_does_not_exist_is_a_404_rather_than_a_crash() { let state = state().await; let missing = uuid::Uuid::new_v4(); let answer = router().handle( &state, Request::post(format!("/problems/{missing}/status")) .sending(Params::new().with("status", "Open")), ); assert!(answer.is_err()); }