From a6de60cddbc0e659ce4f020e6138cb9309a80fcb Mon Sep 17 00:00:00 2001 From: OpenCode Date: Sun, 26 Jul 2026 18:56:58 -0700 Subject: [PATCH] test: improve backend and board normalization coverage --- backend/src/routes/tasks/mod.rs | 42 ++++++++++------- backend/src/routes/tasks/tests.rs | 77 ++++++++++++++++--------------- frontend/src/board_normalizer.rs | 49 ++++++++++++++++++++ 3 files changed, 116 insertions(+), 52 deletions(-) diff --git a/backend/src/routes/tasks/mod.rs b/backend/src/routes/tasks/mod.rs index d7495e9..e14953d 100644 --- a/backend/src/routes/tasks/mod.rs +++ b/backend/src/routes/tasks/mod.rs @@ -86,6 +86,22 @@ fn atomic_write(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> { fs::rename(&tmp, path) } +fn has_version_conflict(payload: &BoardData, current: &BoardData) -> bool { + payload.version < current.version +} + +fn validate_board_data(data: &BoardData) -> Result<(), String> { + for (board_id, board) in &data.boards { + if board.columns.is_empty() { + return Err(format!("board '{board_id}' has no columns")); + } + if board.columns.values().any(|column| column.name.trim().is_empty()) { + return Err(format!("board '{board_id}' has an unnamed column")); + } + } + Ok(()) +} + pub async fn serve_health() -> impl IntoResponse { Json(serde_json::json!({ "status": "ok", @@ -130,21 +146,15 @@ pub async fn save_tasks( ) -> impl IntoResponse { let _lock = state.tasks_lock.lock().await; - // Validate structure: every board must have at least one column, every - // column must have a name. Malformed payloads (e.g. truncated JSON) - // already fail at Json extraction with 422 by axum's built-in handler, - // but we also defensively check invariants here. - for (board_id, board) in &payload.boards { - if board.columns.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ - "error": "validation_failed", - "detail": format!("board '{board_id}' has no columns"), - })), - ) - .into_response(); - } + if let Err(detail) = validate_board_data(&payload) { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "validation_failed", + "detail": detail, + })), + ) + .into_response(); } // Load current state to check version. @@ -168,7 +178,7 @@ pub async fn save_tasks( }; // Optimistic concurrency check. - if payload.version < current.version { + if has_version_conflict(&payload, ¤t) { return ( StatusCode::CONFLICT, Json(serde_json::json!({ diff --git a/backend/src/routes/tasks/tests.rs b/backend/src/routes/tasks/tests.rs index 4dfd36b..7e25041 100644 --- a/backend/src/routes/tasks/tests.rs +++ b/backend/src/routes/tasks/tests.rs @@ -9,29 +9,12 @@ fn default_board_data_has_zero_version() { #[test] fn board_data_roundtrip_preserves_version() { - let mut boards = indexmap::IndexMap::new(); let mut columns = indexmap::IndexMap::new(); - columns.insert( - "todo".to_string(), - Column { - name: "To Do".to_string(), - tasks: vec!["a".into(), "b".into()], - }, - ); - boards.insert( - "work".to_string(), - Board { - name: "Work".to_string(), - columns, - }, - ); - let data = BoardData { - version: 42, - boards, - active_board: "work".to_string(), - }; - let s = serde_json::to_string(&data).unwrap(); - let parsed: BoardData = serde_json::from_str(&s).unwrap(); + columns.insert("todo".to_string(), Column { name: "To Do".to_string(), tasks: vec!["a".into(), "b".into()] }); + let mut boards = indexmap::IndexMap::new(); + boards.insert("work".to_string(), Board { name: "Work".to_string(), columns }); + let data = BoardData { version: 42, boards, active_board: "work".to_string() }; + let parsed: BoardData = serde_json::from_str(&serde_json::to_string(&data).unwrap()).unwrap(); assert_eq!(parsed.version, 42); assert_eq!(parsed.active_board, "work"); assert_eq!(parsed.boards["work"].columns["todo"].tasks, vec!["a", "b"]); @@ -39,19 +22,41 @@ fn board_data_roundtrip_preserves_version() { #[test] fn board_data_deserializes_legacy_without_version() { - // Older files written before optimistic concurrency was added. - let legacy = r#"{ - "boards": { - "work": { - "name": "Work", - "columns": { - "todo": { "name": "To Do", "tasks": [] } - } - } - }, - "activeBoard": "work" - }"#; + let legacy = r#"{"boards":{"work":{"name":"Work","columns":{"todo":{"name":"To Do","tasks":[]}}}},"activeBoard":"work"}"#; let parsed: BoardData = serde_json::from_str(legacy).unwrap(); - assert_eq!(parsed.version, 0, "legacy files default version to 0"); - assert_eq!(parsed.active_board, "work"); + assert_eq!(parsed.version, 0); +} + +#[test] +fn validation_rejects_empty_boards_and_column_names() { + let mut boards = indexmap::IndexMap::new(); + boards.insert("empty".to_string(), Board { name: "Empty".to_string(), columns: indexmap::IndexMap::new() }); + assert!(validate_board_data(&BoardData { version: 0, boards, active_board: "empty".to_string() }).is_err()); + let mut columns = indexmap::IndexMap::new(); + columns.insert("todo".to_string(), Column { name: " ".to_string(), tasks: vec![] }); + let mut boards = indexmap::IndexMap::new(); + boards.insert("work".to_string(), Board { name: "Work".to_string(), columns }); + assert!(validate_board_data(&BoardData { version: 0, boards, active_board: "work".to_string() }).is_err()); +} + +#[test] +fn older_payload_has_version_conflict() { + let current = BoardData { version: 4, ..BoardData::default() }; + let old = BoardData { version: 3, ..BoardData::default() }; + assert!(has_version_conflict(&old, ¤t)); + assert!(!has_version_conflict(¤t, ¤t)); +} + +#[test] +fn atomic_write_replaces_complete_file() { + let path = std::env::temp_dir().join(format!("grid-tasks-{}.json", std::process::id())); + std::fs::write(&path, b"old").unwrap(); + atomic_write(&path, b"new").unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"new"); + let _ = std::fs::remove_file(path); +} + +#[test] +fn corrupt_storage_is_not_deserialized() { + assert!(serde_json::from_str::("{broken").is_err()); } diff --git a/frontend/src/board_normalizer.rs b/frontend/src/board_normalizer.rs index 3751c52..f87eeef 100644 --- a/frontend/src/board_normalizer.rs +++ b/frontend/src/board_normalizer.rs @@ -79,3 +79,52 @@ pub fn normalize_board_data(mut data: BoardData) -> (BoardData, bool) { } (data, needs_save) } + +#[cfg(test)] +mod tests { + use super::*; + + fn data(columns: indexmap::IndexMap) -> BoardData { + let mut boards = indexmap::IndexMap::new(); + boards.insert("work".to_string(), Board { name: "Work".to_string(), columns }); + BoardData { version: 3, boards, active_board: "work".to_string() } + } + + #[test] + fn empty_data_gets_default_board_and_needs_save() { + let (normalized, needs_save) = normalize_board_data(BoardData::default()); + assert!(needs_save); + assert_eq!(normalized.boards["work"].columns.len(), 3); + assert_eq!(normalized.active_board, "work"); + } + + #[test] + fn unknown_columns_are_moved_to_todo() { + let mut columns = indexmap::IndexMap::new(); + columns.insert("later".to_string(), Column { name: "Later".to_string(), tasks: vec!["a".to_string()] }); + let (normalized, needs_save) = normalize_board_data(data(columns)); + assert!(needs_save); + assert_eq!(normalized.boards["work"].columns["todo"].tasks, vec!["a"]); + } + + #[test] + fn normalization_is_idempotent_after_first_save() { + let mut columns = indexmap::IndexMap::new(); + columns.insert("todo".to_string(), Column { name: "Old".to_string(), tasks: vec!["a".to_string()] }); + let (normalized, first_save) = normalize_board_data(data(columns)); + let (again, second_save) = normalize_board_data(normalized.clone()); + assert!(first_save); + assert!(!second_save); + assert_eq!(again, normalized); + } + + #[test] + fn known_columns_do_not_need_save() { + let mut columns = indexmap::IndexMap::new(); + for id in ["todo", "doing", "done"] { + columns.insert(id.to_string(), Column { name: id.to_string(), tasks: vec![] }); + } + let (_, needs_save) = normalize_board_data(data(columns)); + assert!(!needs_save); + } +}