| 1 |
|
| 2 |
|
| 3 |
use std::sync::Arc; |
| 4 |
|
| 5 |
use goingson_core::{ |
| 6 |
MilestoneStatus, NewMilestone, NewProject, NewTask, ProjectId, ProjectStatus, ProjectType, |
| 7 |
}; |
| 8 |
use quasi_http::Serves as _; |
| 9 |
use quasi_router::Outcome; |
| 10 |
use quasi_router::{Params, Request, Response}; |
| 11 |
|
| 12 |
use crate::quasi::router; |
| 13 |
use crate::state::{AppState, DESKTOP_USER_ID}; |
| 14 |
|
| 15 |
async fn state() -> Arc<AppState> { |
| 16 |
let (state, _) = crate::test_utils::setup_test_state().await; |
| 17 |
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); |
| 18 |
state |
| 19 |
.db |
| 20 |
.conn() |
| 21 |
.unwrap() |
| 22 |
.execute( |
| 23 |
"INSERT OR IGNORE INTO users (id, email, password_hash, display_name, created_at) \ |
| 24 |
VALUES (?, ?, ?, ?, ?)", |
| 25 |
rusqlite::params![ |
| 26 |
DESKTOP_USER_ID.to_string(), |
| 27 |
"desktop@localhost", |
| 28 |
"x", |
| 29 |
"Desktop User", |
| 30 |
&now, |
| 31 |
], |
| 32 |
) |
| 33 |
.unwrap(); |
| 34 |
state |
| 35 |
} |
| 36 |
|
| 37 |
fn project(state: &AppState) -> ProjectId { |
| 38 |
state |
| 39 |
.projects |
| 40 |
.create( |
| 41 |
DESKTOP_USER_ID, |
| 42 |
NewProject { |
| 43 |
name: "Ported".to_owned(), |
| 44 |
description: String::new(), |
| 45 |
project_type: ProjectType::SideProject, |
| 46 |
status: ProjectStatus::Active, |
| 47 |
}, |
| 48 |
) |
| 49 |
.unwrap() |
| 50 |
.id |
| 51 |
} |
| 52 |
|
| 53 |
fn milestone(state: &AppState, project: ProjectId, name: &str) -> goingson_core::Milestone { |
| 54 |
state |
| 55 |
.milestones |
| 56 |
.create( |
| 57 |
DESKTOP_USER_ID, |
| 58 |
NewMilestone { |
| 59 |
project_id: project, |
| 60 |
name: name.to_owned(), |
| 61 |
description: String::new(), |
| 62 |
position: 0, |
| 63 |
target_date: None, |
| 64 |
}, |
| 65 |
) |
| 66 |
.unwrap() |
| 67 |
} |
| 68 |
|
| 69 |
fn html(response: Response) -> String { |
| 70 |
match response.outcome { |
| 71 |
Outcome::Screen(screen) => quasi_webview::Webview::new().screen(&screen), |
| 72 |
Outcome::Fragment { node, .. } => quasi_webview::Webview::new().fragment(&node), |
| 73 |
|
| 74 |
|
| 75 |
|
| 76 |
|
| 77 |
Outcome::Goto(action) => panic!("expected content, got a redirect to {action:?}"), |
| 78 |
|
| 79 |
|
| 80 |
|
| 81 |
Outcome::Over(_) => panic!("expected content, got a screen drawn over it"), |
| 82 |
Outcome::Anchored { .. } => { |
| 83 |
panic!("expected content, got a screen drawn at a point on it") |
| 84 |
} |
| 85 |
Outcome::Suggestions { field, .. } => { |
| 86 |
panic!("expected content, got a suggestion list for `{field}`") |
| 87 |
} |
| 88 |
Outcome::File { name, .. } => panic!("expected content, got the file `{name}`"), |
| 89 |
Outcome::Locate(_) => panic!("expected content, got a place on a map"), |
| 90 |
|
| 91 |
|
| 92 |
Outcome::Started { region, .. } => { |
| 93 |
panic!("expected content, got work started in `{region}`") |
| 94 |
} |
| 95 |
} |
| 96 |
} |
| 97 |
|
| 98 |
fn get(state: &AppState, path: &str, params: Params) -> Response { |
| 99 |
router() |
| 100 |
.handle(state, Request::get(path).carrying(params)) |
| 101 |
.expect("the route answers") |
| 102 |
} |
| 103 |
|
| 104 |
fn post(state: &AppState, path: &str, params: Params) -> Response { |
| 105 |
router() |
| 106 |
.handle(state, Request::post(path).sending(params)) |
| 107 |
.expect("the route answers") |
| 108 |
} |
| 109 |
|
| 110 |
|
| 111 |
|
| 112 |
|
| 113 |
fn viewing_post(state: &AppState, path: &str, carried: Params) -> Response { |
| 114 |
router() |
| 115 |
.handle(state, Request::post(path).carrying(carried)) |
| 116 |
.expect("the route answers") |
| 117 |
} |
| 118 |
|
| 119 |
fn dashboard(state: &AppState, project: ProjectId) -> String { |
| 120 |
html(get( |
| 121 |
state, |
| 122 |
&format!("/projects/{project}/dashboard"), |
| 123 |
Params::new(), |
| 124 |
)) |
| 125 |
} |
| 126 |
|
| 127 |
#[tokio::test] |
| 128 |
async fn an_empty_dashboard_says_so_in_every_column() { |
| 129 |
let state = state().await; |
| 130 |
let project = project(&state); |
| 131 |
let page = dashboard(&state, project); |
| 132 |
|
| 133 |
|
| 134 |
|
| 135 |
assert!(page.contains("No tasks linked yet.")); |
| 136 |
assert!(page.contains("No events linked yet.")); |
| 137 |
assert!(page.contains("No emails linked yet.")); |
| 138 |
assert!(page.contains("No attachments yet.")); |
| 139 |
assert!(page.contains("No milestones yet")); |
| 140 |
} |
| 141 |
|
| 142 |
#[tokio::test] |
| 143 |
async fn a_dashboard_of_nothing_is_still_four_named_regions() { |
| 144 |
let state = state().await; |
| 145 |
let project = project(&state); |
| 146 |
let page = dashboard(&state, project); |
| 147 |
|
| 148 |
for column in ["Tasks", "Events", "Emails", "Attachments"] { |
| 149 |
assert!(page.contains(column), "{column} is named"); |
| 150 |
} |
| 151 |
} |
| 152 |
|
| 153 |
#[tokio::test] |
| 154 |
async fn a_missing_project_is_a_not_found_rather_than_a_panic() { |
| 155 |
let state = state().await; |
| 156 |
let error = router() |
| 157 |
.handle( |
| 158 |
&state, |
| 159 |
Request::get(format!("/projects/{}/dashboard", uuid::Uuid::nil())), |
| 160 |
) |
| 161 |
.expect_err("no such project"); |
| 162 |
assert_eq!(error.class.http_status(), 404); |
| 163 |
} |
| 164 |
|
| 165 |
#[tokio::test] |
| 166 |
async fn the_dashboard_route_is_not_swallowed_by_the_detail_route() { |
| 167 |
|
| 168 |
|
| 169 |
|
| 170 |
let state = state().await; |
| 171 |
let project = project(&state); |
| 172 |
|
| 173 |
let detail = get(&state, &format!("/projects/{project}"), Params::new()); |
| 174 |
assert_eq!(detail.target(), Some("projects-detail")); |
| 175 |
|
| 176 |
let Outcome::Screen(_) = get( |
| 177 |
&state, |
| 178 |
&format!("/projects/{project}/dashboard"), |
| 179 |
Params::new(), |
| 180 |
) |
| 181 |
.outcome |
| 182 |
else { |
| 183 |
panic!("the dashboard answers with a screen"); |
| 184 |
}; |
| 185 |
} |
| 186 |
|
| 187 |
#[tokio::test] |
| 188 |
async fn all_tasks_complete_is_a_different_thing_from_no_tasks() { |
| 189 |
let state = state().await; |
| 190 |
let project = project(&state); |
| 191 |
let task = state |
| 192 |
.tasks |
| 193 |
.create( |
| 194 |
DESKTOP_USER_ID, |
| 195 |
NewTask::builder("Done") |
| 196 |
.title("Done") |
| 197 |
.project_id(project) |
| 198 |
.build(), |
| 199 |
) |
| 200 |
.unwrap(); |
| 201 |
state.tasks.complete(task.id, DESKTOP_USER_ID).unwrap(); |
| 202 |
|
| 203 |
let page = dashboard(&state, project); |
| 204 |
assert!(page.contains("All tasks complete.")); |
| 205 |
assert!(!page.contains("No tasks linked yet.")); |
| 206 |
} |
| 207 |
|
| 208 |
#[tokio::test] |
| 209 |
async fn a_linked_task_addresses_the_task_overview() { |
| 210 |
|
| 211 |
|
| 212 |
let state = state().await; |
| 213 |
let project = project(&state); |
| 214 |
let task = state |
| 215 |
.tasks |
| 216 |
.create( |
| 217 |
DESKTOP_USER_ID, |
| 218 |
NewTask::builder("Live") |
| 219 |
.title("Live") |
| 220 |
.project_id(project) |
| 221 |
.build(), |
| 222 |
) |
| 223 |
.unwrap(); |
| 224 |
|
| 225 |
let page = dashboard(&state, project); |
| 226 |
assert!(page.contains(&format!("hx-get=\"/tasks/{}\"", task.id))); |
| 227 |
|
| 228 |
|
| 229 |
let Outcome::Screen(_) = get(&state, &format!("/tasks/{}", task.id), Params::new()).outcome |
| 230 |
else { |
| 231 |
panic!("the overview answers"); |
| 232 |
}; |
| 233 |
} |
| 234 |
|
| 235 |
#[tokio::test] |
| 236 |
async fn a_milestone_draws_its_progress_as_a_bar_in_the_row() { |
| 237 |
|
| 238 |
|
| 239 |
|
| 240 |
let state = state().await; |
| 241 |
let project = project(&state); |
| 242 |
let target = milestone(&state, project, "Phase one"); |
| 243 |
|
| 244 |
let done = state |
| 245 |
.tasks |
| 246 |
.create( |
| 247 |
DESKTOP_USER_ID, |
| 248 |
NewTask::builder("Done") |
| 249 |
.title("Done") |
| 250 |
.project_id(project) |
| 251 |
.milestone_id(target.id) |
| 252 |
.build(), |
| 253 |
) |
| 254 |
.unwrap(); |
| 255 |
state.tasks.complete(done.id, DESKTOP_USER_ID).unwrap(); |
| 256 |
|
| 257 |
let page = dashboard(&state, project); |
| 258 |
assert!(page.contains("Phase one")); |
| 259 |
assert!(page.contains("row-proportion")); |
| 260 |
assert!(page.contains("progress-fill")); |
| 261 |
|
| 262 |
|
| 263 |
assert!(page.contains(r#"aria-label="1 of 1 tasks""#)); |
| 264 |
} |
| 265 |
|
| 266 |
#[tokio::test] |
| 267 |
async fn the_reorder_controls_are_disabled_at_the_ends_rather_than_hidden() { |
| 268 |
|
| 269 |
|
| 270 |
let state = state().await; |
| 271 |
let project = project(&state); |
| 272 |
milestone(&state, project, "First"); |
| 273 |
milestone(&state, project, "Second"); |
| 274 |
|
| 275 |
let page = dashboard(&state, project); |
| 276 |
assert!(page.contains("Move up")); |
| 277 |
assert!(page.contains("Move down")); |
| 278 |
assert!(page.contains("disabled")); |
| 279 |
} |
| 280 |
|
| 281 |
#[tokio::test] |
| 282 |
async fn moving_a_milestone_changes_the_order_it_comes_back_in() { |
| 283 |
let state = state().await; |
| 284 |
let project = project(&state); |
| 285 |
let first = milestone(&state, project, "First"); |
| 286 |
milestone(&state, project, "Second"); |
| 287 |
|
| 288 |
let before = dashboard(&state, project); |
| 289 |
assert!(before.find("First").unwrap() < before.find("Second").unwrap()); |
| 290 |
|
| 291 |
let page = html(post( |
| 292 |
&state, |
| 293 |
&format!("/projects/{project}/milestones/{}/move", first.id), |
| 294 |
Params::new().with("by", "1"), |
| 295 |
)); |
| 296 |
assert!(page.find("Second").unwrap() < page.find("First").unwrap()); |
| 297 |
} |
| 298 |
|
| 299 |
#[tokio::test] |
| 300 |
async fn moving_off_the_end_is_a_fresh_screen_rather_than_an_error() { |
| 301 |
|
| 302 |
|
| 303 |
let state = state().await; |
| 304 |
let project = project(&state); |
| 305 |
let first = milestone(&state, project, "First"); |
| 306 |
|
| 307 |
let page = html(post( |
| 308 |
&state, |
| 309 |
&format!("/projects/{project}/milestones/{}/move", first.id), |
| 310 |
Params::new().with("by", "-1"), |
| 311 |
)); |
| 312 |
assert!(page.contains("First")); |
| 313 |
} |
| 314 |
|
| 315 |
#[tokio::test] |
| 316 |
async fn a_move_that_is_not_one_step_is_refused() { |
| 317 |
let state = state().await; |
| 318 |
let project = project(&state); |
| 319 |
let first = milestone(&state, project, "First"); |
| 320 |
|
| 321 |
let error = router() |
| 322 |
.handle( |
| 323 |
&state, |
| 324 |
Request::post(format!("/projects/{project}/milestones/{}/move", first.id)) |
| 325 |
.sending(Params::new().with("by", "7")), |
| 326 |
) |
| 327 |
.expect_err("only one step at a time"); |
| 328 |
assert_eq!(error.class.http_status(), 404); |
| 329 |
} |
| 330 |
|
| 331 |
#[tokio::test] |
| 332 |
async fn deleting_a_milestone_takes_it_off_the_dashboard() { |
| 333 |
let state = state().await; |
| 334 |
let project = project(&state); |
| 335 |
let going = milestone(&state, project, "Going"); |
| 336 |
milestone(&state, project, "Staying"); |
| 337 |
|
| 338 |
let page = html(post( |
| 339 |
&state, |
| 340 |
&format!("/projects/{project}/milestones/{}/delete", going.id), |
| 341 |
Params::new(), |
| 342 |
)); |
| 343 |
assert!(!page.contains("Going")); |
| 344 |
assert!(page.contains("Staying")); |
| 345 |
} |
| 346 |
|
| 347 |
#[tokio::test] |
| 348 |
async fn the_completed_disclosure_is_an_address_not_module_state() { |
| 349 |
|
| 350 |
|
| 351 |
|
| 352 |
let state = state().await; |
| 353 |
let project = project(&state); |
| 354 |
milestone(&state, project, "Open one"); |
| 355 |
let done = milestone(&state, project, "Finished"); |
| 356 |
state |
| 357 |
.milestones |
| 358 |
.update( |
| 359 |
done.id, |
| 360 |
DESKTOP_USER_ID, |
| 361 |
"Finished", |
| 362 |
"", |
| 363 |
None, |
| 364 |
&MilestoneStatus::Completed, |
| 365 |
) |
| 366 |
.unwrap(); |
| 367 |
|
| 368 |
let collapsed = dashboard(&state, project); |
| 369 |
assert!(collapsed.contains("Show 1 completed")); |
| 370 |
assert!(!collapsed.contains("Complete<")); |
| 371 |
|
| 372 |
let expanded = html(get( |
| 373 |
&state, |
| 374 |
&format!("/projects/{project}/dashboard"), |
| 375 |
Params::new().with("completed", "1"), |
| 376 |
)); |
| 377 |
assert!(expanded.contains("Hide completed")); |
| 378 |
assert!(expanded.contains("Finished")); |
| 379 |
} |
| 380 |
|
| 381 |
#[tokio::test] |
| 382 |
async fn a_write_answers_under_the_disclosure_it_carried() { |
| 383 |
let state = state().await; |
| 384 |
let project = project(&state); |
| 385 |
let going = milestone(&state, project, "Going"); |
| 386 |
let done = milestone(&state, project, "Finished"); |
| 387 |
state |
| 388 |
.milestones |
| 389 |
.update( |
| 390 |
done.id, |
| 391 |
DESKTOP_USER_ID, |
| 392 |
"Finished", |
| 393 |
"", |
| 394 |
None, |
| 395 |
&MilestoneStatus::Completed, |
| 396 |
) |
| 397 |
.unwrap(); |
| 398 |
|
| 399 |
let page = html(viewing_post( |
| 400 |
&state, |
| 401 |
&format!("/projects/{project}/milestones/{}/delete", going.id), |
| 402 |
Params::new().with("completed", "1"), |
| 403 |
)); |
| 404 |
|
| 405 |
|
| 406 |
assert!(page.contains("Hide completed")); |
| 407 |
assert!(page.contains("Finished")); |
| 408 |
} |
| 409 |
|
| 410 |
#[tokio::test] |
| 411 |
async fn the_attachments_column_asks_the_host_to_pick_a_file() { |
| 412 |
|
| 413 |
|
| 414 |
|
| 415 |
|
| 416 |
|
| 417 |
|
| 418 |
|
| 419 |
let state = state().await; |
| 420 |
let project = project(&state); |
| 421 |
let page = dashboard(&state, project); |
| 422 |
|
| 423 |
assert!(page.contains("No attachments yet.")); |
| 424 |
assert!(!page.contains(r#"type="file""#), "{page}"); |
| 425 |
assert!(page.contains("data-sends="), "{page}"); |
| 426 |
assert!(page.contains("Attach a file")); |
| 427 |
} |
| 428 |
|
| 429 |
|
| 430 |
fn a_file(name: &str, contents: &str) -> std::path::PathBuf { |
| 431 |
let dir = std::env::temp_dir().join("goingson-quasi-attach-tests"); |
| 432 |
std::fs::create_dir_all(&dir).unwrap(); |
| 433 |
let path = dir.join(name); |
| 434 |
std::fs::write(&path, contents).unwrap(); |
| 435 |
path |
| 436 |
} |
| 437 |
|
| 438 |
fn attach(state: &AppState, project: ProjectId, path: &std::path::Path) -> Response { |
| 439 |
post( |
| 440 |
state, |
| 441 |
&format!("/projects/{project}/attachments"), |
| 442 |
Params::new().with("file", path.to_str().unwrap()), |
| 443 |
) |
| 444 |
} |
| 445 |
|
| 446 |
#[tokio::test] |
| 447 |
async fn attaching_a_picked_file_answers_with_the_column_it_landed_in() { |
| 448 |
let state = state().await; |
| 449 |
let project = project(&state); |
| 450 |
let response = attach(&state, project, &a_file("notes.txt", "hello")); |
| 451 |
|
| 452 |
|
| 453 |
|
| 454 |
assert_eq!(response.target(), Some("dashboard-attachments")); |
| 455 |
let page = html(response); |
| 456 |
assert!(page.contains("notes.txt")); |
| 457 |
assert!(page.contains("5 B")); |
| 458 |
|
| 459 |
|
| 460 |
assert!(page.contains("data-sends="), "{page}"); |
| 461 |
} |
| 462 |
|
| 463 |
#[tokio::test] |
| 464 |
async fn attaching_nothing_is_refused_on_the_field_it_came_from() { |
| 465 |
let state = state().await; |
| 466 |
let project = project(&state); |
| 467 |
let page = html(post( |
| 468 |
&state, |
| 469 |
&format!("/projects/{project}/attachments"), |
| 470 |
Params::new(), |
| 471 |
)); |
| 472 |
|
| 473 |
assert!(page.contains("Choose a file to attach.")); |
| 474 |
assert!(page.contains("No attachments yet.")); |
| 475 |
} |
| 476 |
|
| 477 |
#[tokio::test] |
| 478 |
async fn a_file_that_is_not_there_is_a_refusal_rather_than_a_failure() { |
| 479 |
|
| 480 |
|
| 481 |
let state = state().await; |
| 482 |
let project = project(&state); |
| 483 |
let page = html(attach( |
| 484 |
&state, |
| 485 |
project, |
| 486 |
std::path::Path::new("/nowhere/at/all.txt"), |
| 487 |
)); |
| 488 |
|
| 489 |
assert!(page.contains("File does not exist")); |
| 490 |
} |
| 491 |
|
| 492 |
#[tokio::test] |
| 493 |
async fn an_attachment_opens_as_a_file_address() { |
| 494 |
|
| 495 |
|
| 496 |
|
| 497 |
|
| 498 |
let state = state().await; |
| 499 |
let project = project(&state); |
| 500 |
attach(&state, project, &a_file("field notes.txt", "hello")); |
| 501 |
|
| 502 |
let attachment = state |
| 503 |
.attachments |
| 504 |
.list_for_project(project, DESKTOP_USER_ID) |
| 505 |
.unwrap() |
| 506 |
.pop() |
| 507 |
.expect("the attach landed"); |
| 508 |
|
| 509 |
let response = get( |
| 510 |
&state, |
| 511 |
&format!("/projects/{project}/attachments/{}/open", attachment.id), |
| 512 |
Params::new(), |
| 513 |
); |
| 514 |
let Outcome::Goto(action) = response.outcome else { |
| 515 |
panic!("opening hands the file over rather than answering with a screen"); |
| 516 |
}; |
| 517 |
let quasi_router::Destination::External(url) = action.destination else { |
| 518 |
panic!("a file lives outside anything this router answers"); |
| 519 |
}; |
| 520 |
assert!(url.starts_with("file:///")); |
| 521 |
assert!(url.ends_with("field%20notes.txt")); |
| 522 |
|
| 523 |
let path = url.replace("file://", "").replace("%20", " "); |
| 524 |
assert_eq!(std::fs::read_to_string(path).unwrap(), "hello"); |
| 525 |
} |
| 526 |
|
| 527 |
#[tokio::test] |
| 528 |
async fn opening_an_attachment_that_is_not_there_is_a_not_found() { |
| 529 |
let state = state().await; |
| 530 |
let project = project(&state); |
| 531 |
let error = router() |
| 532 |
.handle( |
| 533 |
&state, |
| 534 |
Request::get(format!( |
| 535 |
"/projects/{project}/attachments/{}/open", |
| 536 |
uuid::Uuid::nil() |
| 537 |
)), |
| 538 |
) |
| 539 |
.expect_err("no such attachment"); |
| 540 |
assert_eq!(error.class.http_status(), 404); |
| 541 |
} |
| 542 |
|
| 543 |
#[tokio::test] |
| 544 |
async fn a_project_name_cannot_become_markup() { |
| 545 |
let state = state().await; |
| 546 |
let project = state |
| 547 |
.projects |
| 548 |
.create( |
| 549 |
DESKTOP_USER_ID, |
| 550 |
NewProject { |
| 551 |
name: "<script>alert(1)</script>".to_owned(), |
| 552 |
description: String::new(), |
| 553 |
project_type: ProjectType::SideProject, |
| 554 |
status: ProjectStatus::Active, |
| 555 |
}, |
| 556 |
) |
| 557 |
.unwrap(); |
| 558 |
|
| 559 |
let page = dashboard(&state, project.id); |
| 560 |
assert!(!page.contains("<script>alert")); |
| 561 |
assert!(page.contains("<script>")); |
| 562 |
} |
| 563 |
|
| 564 |
#[tokio::test] |
| 565 |
async fn a_linked_task_says_whether_it_is_available() { |
| 566 |
|
| 567 |
|
| 568 |
|
| 569 |
let state = state().await; |
| 570 |
let project = project(&state); |
| 571 |
let blocker = state |
| 572 |
.tasks |
| 573 |
.create( |
| 574 |
DESKTOP_USER_ID, |
| 575 |
NewTask::builder("Do this first") |
| 576 |
.project_id(project) |
| 577 |
.build(), |
| 578 |
) |
| 579 |
.unwrap(); |
| 580 |
let blocked = state |
| 581 |
.tasks |
| 582 |
.create( |
| 583 |
DESKTOP_USER_ID, |
| 584 |
NewTask::builder("Then this").project_id(project).build(), |
| 585 |
) |
| 586 |
.unwrap(); |
| 587 |
state |
| 588 |
.tasks |
| 589 |
.add_dependency(DESKTOP_USER_ID, blocked.id, blocker.id) |
| 590 |
.unwrap(); |
| 591 |
|
| 592 |
let page = dashboard(&state, project); |
| 593 |
|
| 594 |
assert!(page.contains("Blocked"), "{page}"); |
| 595 |
assert!(page.contains("Unblocks 1"), "{page}"); |
| 596 |
} |
| 597 |
|
| 598 |
#[tokio::test] |
| 599 |
async fn the_add_form_asks_what_the_modal_asks() { |
| 600 |
|
| 601 |
|
| 602 |
|
| 603 |
let state = state().await; |
| 604 |
let project = project(&state); |
| 605 |
|
| 606 |
let form = html(get( |
| 607 |
&state, |
| 608 |
&format!("/projects/{project}/milestones/new"), |
| 609 |
Params::new(), |
| 610 |
)); |
| 611 |
assert!(form.contains("name=\"name\""), "{form}"); |
| 612 |
assert!(form.contains("name=\"description\""), "{form}"); |
| 613 |
assert!(form.contains("name=\"target_date\""), "{form}"); |
| 614 |
assert!(!form.contains("name=\"status\""), "{form}"); |
| 615 |
} |
| 616 |
|
| 617 |
#[tokio::test] |
| 618 |
async fn the_edit_form_is_the_add_form_plus_status() { |
| 619 |
let state = state().await; |
| 620 |
let project = project(&state); |
| 621 |
let existing = milestone(&state, project, "Shipping"); |
| 622 |
|
| 623 |
let form = html(get( |
| 624 |
&state, |
| 625 |
&format!("/projects/{project}/milestones/{}/edit", existing.id), |
| 626 |
Params::new(), |
| 627 |
)); |
| 628 |
assert!(form.contains("Shipping"), "{form}"); |
| 629 |
assert!(form.contains("name=\"status\""), "{form}"); |
| 630 |
} |
| 631 |
|
| 632 |
#[tokio::test] |
| 633 |
async fn add_and_edit_are_addresses_rather_than_overlays() { |
| 634 |
|
| 635 |
|
| 636 |
let state = state().await; |
| 637 |
let project = project(&state); |
| 638 |
let existing = milestone(&state, project, "Shipping"); |
| 639 |
|
| 640 |
let page = dashboard(&state, project); |
| 641 |
assert!( |
| 642 |
page.contains(&format!("/projects/{project}/milestones/new")), |
| 643 |
"{page}" |
| 644 |
); |
| 645 |
assert!( |
| 646 |
page.contains(&format!( |
| 647 |
"/projects/{project}/milestones/{}/edit", |
| 648 |
existing.id |
| 649 |
)), |
| 650 |
"{page}" |
| 651 |
); |
| 652 |
} |
| 653 |
|
| 654 |
#[tokio::test] |
| 655 |
async fn creating_a_milestone_puts_it_on_the_dashboard() { |
| 656 |
let state = state().await; |
| 657 |
let project = project(&state); |
| 658 |
|
| 659 |
let page = html(post( |
| 660 |
&state, |
| 661 |
&format!("/projects/{project}/milestones"), |
| 662 |
Params::new() |
| 663 |
.with("name", "Beta") |
| 664 |
.with("description", "Feature complete") |
| 665 |
.with("target_date", "2026-03-01"), |
| 666 |
)); |
| 667 |
assert!(page.contains("Beta"), "{page}"); |
| 668 |
|
| 669 |
let stored = state |
| 670 |
.milestones |
| 671 |
.list_by_project(project, DESKTOP_USER_ID) |
| 672 |
.unwrap(); |
| 673 |
assert_eq!(stored.len(), 1); |
| 674 |
assert_eq!( |
| 675 |
stored[0].target_date, |
| 676 |
Some(chrono::NaiveDate::from_ymd_opt(2026, 3, 1).unwrap()) |
| 677 |
); |
| 678 |
} |
| 679 |
|
| 680 |
#[tokio::test] |
| 681 |
async fn a_new_milestone_lands_last() { |
| 682 |
|
| 683 |
|
| 684 |
let state = state().await; |
| 685 |
let project = project(&state); |
| 686 |
milestone(&state, project, "First"); |
| 687 |
|
| 688 |
post( |
| 689 |
&state, |
| 690 |
&format!("/projects/{project}/milestones"), |
| 691 |
Params::new().with("name", "Second"), |
| 692 |
); |
| 693 |
|
| 694 |
let page = dashboard(&state, project); |
| 695 |
assert!(page.find("First").unwrap() < page.find("Second").unwrap()); |
| 696 |
} |
| 697 |
|
| 698 |
#[tokio::test] |
| 699 |
async fn a_milestone_with_no_name_is_refused_and_the_typing_survives() { |
| 700 |
let state = state().await; |
| 701 |
let project = project(&state); |
| 702 |
|
| 703 |
let page = html(post( |
| 704 |
&state, |
| 705 |
&format!("/projects/{project}/milestones"), |
| 706 |
Params::new() |
| 707 |
.with("name", "") |
| 708 |
.with("description", "Typed and nearly lost"), |
| 709 |
)); |
| 710 |
assert!(page.contains("A milestone needs a name."), "{page}"); |
| 711 |
assert!(page.contains("Typed and nearly lost"), "{page}"); |
| 712 |
assert!( |
| 713 |
state |
| 714 |
.milestones |
| 715 |
.list_by_project(project, DESKTOP_USER_ID) |
| 716 |
.unwrap() |
| 717 |
.is_empty() |
| 718 |
); |
| 719 |
} |
| 720 |
|
| 721 |
#[tokio::test] |
| 722 |
async fn a_date_nobody_can_parse_is_refused_rather_than_dropped() { |
| 723 |
|
| 724 |
|
| 725 |
|
| 726 |
let state = state().await; |
| 727 |
let project = project(&state); |
| 728 |
|
| 729 |
let page = html(post( |
| 730 |
&state, |
| 731 |
&format!("/projects/{project}/milestones"), |
| 732 |
Params::new() |
| 733 |
.with("name", "Beta") |
| 734 |
.with("target_date", "whenever"), |
| 735 |
)); |
| 736 |
assert!(page.contains("Date not recognized"), "{page}"); |
| 737 |
assert!( |
| 738 |
state |
| 739 |
.milestones |
| 740 |
.list_by_project(project, DESKTOP_USER_ID) |
| 741 |
.unwrap() |
| 742 |
.is_empty() |
| 743 |
); |
| 744 |
} |
| 745 |
|
| 746 |
#[tokio::test] |
| 747 |
async fn editing_a_milestone_saves_every_field() { |
| 748 |
let state = state().await; |
| 749 |
let project = project(&state); |
| 750 |
let existing = milestone(&state, project, "Old"); |
| 751 |
|
| 752 |
post( |
| 753 |
&state, |
| 754 |
&format!("/projects/{project}/milestones/{}", existing.id), |
| 755 |
Params::new() |
| 756 |
.with("name", "New") |
| 757 |
.with("description", "Rewritten") |
| 758 |
.with("target_date", "2026-04-02") |
| 759 |
.with("status", "Completed"), |
| 760 |
); |
| 761 |
|
| 762 |
let stored = state |
| 763 |
.milestones |
| 764 |
.get_by_id(existing.id, DESKTOP_USER_ID) |
| 765 |
.unwrap() |
| 766 |
.expect("still there"); |
| 767 |
assert_eq!(stored.name, "New"); |
| 768 |
assert_eq!(stored.description, "Rewritten"); |
| 769 |
assert_eq!( |
| 770 |
stored.target_date, |
| 771 |
Some(chrono::NaiveDate::from_ymd_opt(2026, 4, 2).unwrap()) |
| 772 |
); |
| 773 |
assert_eq!(stored.status, MilestoneStatus::Completed); |
| 774 |
} |
| 775 |
|
| 776 |
#[tokio::test] |
| 777 |
async fn a_milestone_belonging_to_another_project_is_not_found() { |
| 778 |
|
| 779 |
|
| 780 |
let state = state().await; |
| 781 |
let mine = project(&state); |
| 782 |
let theirs = state |
| 783 |
.projects |
| 784 |
.create( |
| 785 |
DESKTOP_USER_ID, |
| 786 |
NewProject { |
| 787 |
name: "Other".to_owned(), |
| 788 |
description: String::new(), |
| 789 |
project_type: ProjectType::SideProject, |
| 790 |
status: ProjectStatus::Active, |
| 791 |
}, |
| 792 |
) |
| 793 |
.unwrap() |
| 794 |
.id; |
| 795 |
let elsewhere = milestone(&state, theirs, "Not yours"); |
| 796 |
|
| 797 |
let error = router() |
| 798 |
.handle( |
| 799 |
&state, |
| 800 |
Request::get(format!("/projects/{mine}/milestones/{}/edit", elsewhere.id)), |
| 801 |
) |
| 802 |
.expect_err("the project in the address is checked"); |
| 803 |
assert_eq!(error.class.http_status(), 404); |
| 804 |
} |
| 805 |
|
| 806 |
#[tokio::test] |
| 807 |
async fn new_is_a_form_rather_than_a_milestone_called_new() { |
| 808 |
|
| 809 |
|
| 810 |
let state = state().await; |
| 811 |
let project = project(&state); |
| 812 |
|
| 813 |
let form = html(get( |
| 814 |
&state, |
| 815 |
&format!("/projects/{project}/milestones/new"), |
| 816 |
Params::new(), |
| 817 |
)); |
| 818 |
assert!(form.contains("Create milestone"), "{form}"); |
| 819 |
} |
| 820 |
|