Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 26 additions & 16 deletions backend/src/routes/tasks/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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.
Expand All @@ -168,7 +178,7 @@ pub async fn save_tasks(
};

// Optimistic concurrency check.
if payload.version < current.version {
if has_version_conflict(&payload, &current) {
return (
StatusCode::CONFLICT,
Json(serde_json::json!({
Expand Down
77 changes: 41 additions & 36 deletions backend/src/routes/tasks/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,49 +9,54 @@ 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"]);
}

#[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, &current));
assert!(!has_version_conflict(&current, &current));
}

#[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::<BoardData>("{broken").is_err());
}
49 changes: 49 additions & 0 deletions frontend/src/board_normalizer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,52 @@
}
(data, needs_save)
}

#[cfg(test)]
mod tests {
use super::*;

fn data(columns: indexmap::IndexMap<String, Column>) -> 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());

Check failure on line 95 in frontend/src/board_normalizer.rs

View workflow job for this annotation

GitHub Actions / build-and-test

no associated function or constant named `default` found for struct `types::BoardData` in the current scope
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);
}
}
Loading