Skip to main content

max / goingson

10.5 KB · 347 lines History Blame Raw
1 //! Contexts, driven through the router against a real database.
2
3 use std::sync::Arc;
4
5 use chrono::NaiveDate;
6 use goingson_core::models::{Context, ContextKind};
7 use quasi_http::Serves as _;
8 use quasi_router::Outcome;
9 use quasi_router::{Params, Request, Response};
10
11 use crate::quasi::router;
12 use crate::state::{AppState, DESKTOP_USER_ID};
13
14 async fn state() -> Arc<AppState> {
15 let (state, _) = crate::test_utils::setup_test_state().await;
16 let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
17 state
18 .db
19 .conn()
20 .unwrap()
21 .execute(
22 "INSERT OR IGNORE INTO users (id, email, password_hash, display_name, created_at) \
23 VALUES (?, ?, ?, ?, ?)",
24 rusqlite::params![
25 DESKTOP_USER_ID.to_string(),
26 "desktop@localhost",
27 "x",
28 "Desktop User",
29 &now,
30 ],
31 )
32 .unwrap();
33 state
34 }
35
36 fn day(date: &str) -> NaiveDate {
37 date.parse().expect("a date")
38 }
39
40 fn make(state: &AppState, label: &str, kind: ContextKind, starts: &str, ends: &str) -> Context {
41 state
42 .contexts
43 .create(DESKTOP_USER_ID, label, kind, day(starts), day(ends))
44 .unwrap()
45 }
46
47 fn html(response: Response) -> String {
48 match response.outcome {
49 Outcome::Screen(screen) => quasi_webview::Webview::new().screen(&screen),
50 Outcome::Fragment { node, .. } => quasi_webview::Webview::new().fragment(&node),
51 Outcome::Goto(action) => panic!("expected content, got a redirect to {action:?}"),
52 Outcome::Over(_) => panic!("expected content, got a screen drawn over it"),
53 Outcome::Anchored { .. } => {
54 panic!("expected content, got a screen drawn at a point on it")
55 }
56 Outcome::Suggestions { field, .. } => {
57 panic!("expected content, got a suggestion list for `{field}`")
58 }
59 Outcome::File { name, .. } => panic!("expected content, got the file `{name}`"),
60 Outcome::Locate(_) => panic!("expected content, got a place on a map"),
61 // `cb62a9dc`. Work that runs somewhere else and a region that says so:
62 // not content, and not a place either.
63 Outcome::Started { region, .. } => {
64 panic!("expected content, got work started in `{region}`")
65 }
66 }
67 }
68
69 fn get(state: &AppState, path: &str, params: Params) -> Response {
70 router()
71 .handle(state, Request::get(path).carrying(params))
72 .expect("the route answers")
73 }
74
75 fn post(state: &AppState, path: &str, params: Params) -> Response {
76 router()
77 .handle(state, Request::post(path).sending(params))
78 .expect("the route answers")
79 }
80
81 fn screen(state: &AppState) -> String {
82 html(get(state, "/contexts", Params::new()))
83 }
84
85 fn span(label: &str, kind: &str, starts: &str, ends: &str) -> Params {
86 let mut params = Params::new();
87 params.insert("label".to_owned(), label.to_owned());
88 params.insert("kind".to_owned(), kind.to_owned());
89 params.insert("starts_on".to_owned(), starts.to_owned());
90 params.insert("ends_on".to_owned(), ends.to_owned());
91 params
92 }
93
94 fn only(state: &AppState) -> Context {
95 let mut all = state.contexts.list_all(DESKTOP_USER_ID).unwrap();
96 assert_eq!(all.len(), 1, "expected exactly one context");
97 all.remove(0)
98 }
99
100 #[tokio::test]
101 async fn an_empty_list_says_so_and_offers_the_way_in() {
102 let state = state().await;
103 let page = screen(&state);
104 assert!(page.contains("No contexts yet."), "{page}");
105 assert!(page.contains("/contexts/new"), "{page}");
106 }
107
108 /// The whole point of the screen: a kind the checkboxes cannot say, with a name.
109 #[tokio::test]
110 async fn a_context_can_be_named_and_given_a_kind_the_checkboxes_cannot_say() {
111 let state = state().await;
112 post(
113 &state,
114 "/contexts",
115 span("Two weeks in Lisbon", "Trip", "2026-09-03", "2026-09-17"),
116 );
117
118 let recorded = only(&state);
119 assert_eq!(recorded.label, "Two weeks in Lisbon");
120 assert_eq!(recorded.kind, ContextKind::Trip);
121 assert_eq!(recorded.starts_on, day("2026-09-03"));
122 assert_eq!(recorded.ends_on, day("2026-09-17"));
123 }
124
125 /// Authored as a span, so the record is one decision and not fifteen.
126 #[tokio::test]
127 async fn a_span_is_one_record_however_many_days_it_covers() {
128 let state = state().await;
129 post(
130 &state,
131 "/contexts",
132 span("Leave", "Vacation", "2026-08-03", "2026-08-17"),
133 );
134 let recorded = only(&state);
135 assert_eq!(recorded.days(), 15, "both ends are inside");
136 assert!(
137 recorded.covers(day("2026-08-17")),
138 "and the last day is off"
139 );
140 }
141
142 #[tokio::test]
143 async fn the_list_shows_the_span_rather_than_the_label_alone() {
144 let state = state().await;
145 make(
146 &state,
147 "Leave",
148 ContextKind::Vacation,
149 "2026-08-03",
150 "2026-08-17",
151 );
152 let page = screen(&state);
153 assert!(page.contains("Leave"), "{page}");
154 assert!(page.contains("3 Aug 2026"), "{page}");
155 assert!(page.contains("17 Aug 2026"), "{page}");
156 assert!(page.contains("15 days"), "{page}");
157 }
158
159 /// The repository orders the ends on the way in, which is right for a migration
160 /// reading stored data and wrong for a form. A backwards span is reported.
161 #[tokio::test]
162 async fn a_backwards_span_is_refused_rather_than_silently_swapped() {
163 let state = state().await;
164 let page = html(post(
165 &state,
166 "/contexts",
167 span("Backwards", "Vacation", "2026-08-17", "2026-08-03"),
168 ));
169
170 assert!(
171 page.contains("The first day is after the last one."),
172 "{page}"
173 );
174 assert!(
175 state.contexts.list_all(DESKTOP_USER_ID).unwrap().is_empty(),
176 "nothing was written"
177 );
178 }
179
180 /// `1c4a66a4`'s repair, asked of this form: a refusal hands back what was typed.
181 #[tokio::test]
182 async fn a_refused_form_comes_back_carrying_what_was_typed() {
183 let state = state().await;
184 let page = html(post(
185 &state,
186 "/contexts",
187 span("", "Trip", "2026-09-03", "2026-09-17"),
188 ));
189
190 assert!(page.contains("A context needs a label."), "{page}");
191 // The dates the user did fill in survive the round trip.
192 assert!(page.contains("2026-09-03"), "{page}");
193 assert!(page.contains("2026-09-17"), "{page}");
194 }
195
196 #[tokio::test]
197 async fn every_complaint_arrives_at_once() {
198 let state = state().await;
199 let mut params = span("", "Vacation", "", "");
200 params.insert("kind".to_owned(), "Vacation".to_owned());
201 let page = html(post(&state, "/contexts", params));
202
203 assert!(page.contains("A context needs a label."), "{page}");
204 assert!(page.contains("Needs a first day."), "{page}");
205 assert!(page.contains("Needs a last day."), "{page}");
206 }
207
208 #[tokio::test]
209 async fn the_edit_form_arrives_filled() {
210 let state = state().await;
211 let existing = make(
212 &state,
213 "Leave",
214 ContextKind::Vacation,
215 "2026-08-03",
216 "2026-08-17",
217 );
218 let page = html(get(
219 &state,
220 &format!("/contexts/{}", existing.id),
221 Params::new(),
222 ));
223
224 assert!(page.contains("Leave"), "{page}");
225 assert!(page.contains("2026-08-03"), "{page}");
226 assert!(page.contains("2026-08-17"), "{page}");
227 }
228
229 #[tokio::test]
230 async fn a_context_can_be_relabelled_rekinded_and_respanned() {
231 let state = state().await;
232 let existing = make(
233 &state,
234 "Leave",
235 ContextKind::Vacation,
236 "2026-08-03",
237 "2026-08-17",
238 );
239 post(
240 &state,
241 &format!("/contexts/{}", existing.id),
242 span("Flu", "Illness", "2026-08-05", "2026-08-08"),
243 );
244
245 let updated = only(&state);
246 assert_eq!(updated.id, existing.id, "the same record, changed");
247 assert_eq!(updated.label, "Flu");
248 assert_eq!(updated.kind, ContextKind::Illness);
249 assert_eq!(updated.starts_on, day("2026-08-05"));
250 assert_eq!(updated.ends_on, day("2026-08-08"));
251 }
252
253 #[tokio::test]
254 async fn a_context_can_be_deleted() {
255 let state = state().await;
256 let existing = make(
257 &state,
258 "Leave",
259 ContextKind::Vacation,
260 "2026-08-03",
261 "2026-08-17",
262 );
263 post(
264 &state,
265 &format!("/contexts/{}/delete", existing.id),
266 Params::new(),
267 );
268 assert!(state.contexts.list_all(DESKTOP_USER_ID).unwrap().is_empty());
269 }
270
271 /// The reversal path `8ee5c4fe` made the condition of accepting a migration that
272 /// guesses: deleting the context puts its event back, and the screen says so
273 /// before the delete rather than after it.
274 #[tokio::test]
275 async fn a_migrated_context_says_where_it_came_from_and_puts_the_event_back() {
276 let state = state().await;
277 let event = state
278 .events
279 .create(
280 DESKTOP_USER_ID,
281 goingson_core::NewEvent::builder("Conference", chrono::Utc::now())
282 .user_id(DESKTOP_USER_ID)
283 .build(),
284 )
285 .unwrap();
286 let context = make(
287 &state,
288 "Conference",
289 ContextKind::Trip,
290 "2026-09-03",
291 "2026-09-05",
292 );
293
294 // The migration's own arrangement: the event is hidden rather than deleted,
295 // and the context points back at it.
296 let conn = state.db.conn().unwrap();
297 conn.execute(
298 "UPDATE contexts SET migrated_from_event_id = ?1 WHERE id = ?2",
299 rusqlite::params![event.id.to_string(), context.id.to_string()],
300 )
301 .unwrap();
302 conn.execute(
303 "UPDATE events SET converted_to_context_id = ?1 WHERE id = ?2",
304 rusqlite::params![context.id.to_string(), event.id.to_string()],
305 )
306 .unwrap();
307 drop(conn);
308
309 let pane = html(get(
310 &state,
311 &format!("/contexts/{}", context.id),
312 Params::new(),
313 ));
314 assert!(pane.contains("Converted from an event."), "{pane}");
315 assert!(
316 pane.contains("puts that event back on the timeline"),
317 "{pane}"
318 );
319
320 post(
321 &state,
322 &format!("/contexts/{}/delete", context.id),
323 Params::new(),
324 );
325
326 let hidden: Option<String> = state
327 .db
328 .conn()
329 .unwrap()
330 .query_row(
331 "SELECT converted_to_context_id FROM events WHERE id = ?1",
332 rusqlite::params![event.id.to_string()],
333 |row| row.get(0),
334 )
335 .unwrap();
336 assert!(hidden.is_none(), "the event is back on the timeline");
337 }
338
339 /// The day band is the way in, since a `Notice` carries no act and the banner
340 /// therefore cannot be one.
341 #[tokio::test]
342 async fn the_day_band_offers_the_way_to_the_contexts_screen() {
343 let state = state().await;
344 let page = html(get(&state, "/day", Params::new()));
345 assert!(page.contains("/contexts"), "{page}");
346 }
347