diff --git a/backend/src/services/mod.rs b/backend/src/services/mod.rs index 50b7492..d29b0fe 100644 --- a/backend/src/services/mod.rs +++ b/backend/src/services/mod.rs @@ -13,6 +13,7 @@ pub enum AppError { InternalError(anyhow::Error), JsonExtractError(JsonRejection), PathError(PathRejection), + Unprocessable(String), NotFound, } @@ -36,6 +37,11 @@ impl IntoResponse for AppError { }), ) .into_response(), + Self::Unprocessable(msg) => ( + StatusCode::UNPROCESSABLE_ENTITY, + Json(ErrorJson { error: msg }), + ) + .into_response(), Self::PathError(rej) => ( StatusCode::BAD_REQUEST, Json(ErrorJson { diff --git a/backend/src/services/tasks.rs b/backend/src/services/tasks.rs index ebf7f3e..3773f01 100644 --- a/backend/src/services/tasks.rs +++ b/backend/src/services/tasks.rs @@ -10,14 +10,17 @@ use serde::Deserialize; use sqlx::{Pool, Sqlite}; use uuid::Uuid; -use crate::models::TaskModel; +use crate::models::{TaskModel, TaskStatus}; use super::AppError; pub fn create_task_router() -> Router> { Router::new() - .route("/", post(create_task)) - .route("/{task_id}", get(get_task)) + .route("/", post(create_task).get(list_tasks)) + .route( + "/{task_id}", + get(get_task).put(update_task).delete(delete_task), + ) } #[derive(Deserialize)] @@ -37,6 +40,14 @@ pub async fn create_task( Ok((StatusCode::CREATED, Json(model))) } +pub async fn list_tasks( + State(pool): State>, +) -> Result<(StatusCode, Json>), AppError> { + let tasks = TaskModel::list_all(&pool).await?; + + Ok((StatusCode::OK, Json(tasks))) +} + pub async fn get_task( State(pool): State>, WithRejection(Path(task_id), _): WithRejection, AppError>, @@ -45,3 +56,51 @@ pub async fn get_task( Ok((StatusCode::OK, Json(model))) } + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct UpdateTaskRequest { + title: Option, + description: Option, + status: Option, +} + +pub async fn update_task( + State(pool): State>, + WithRejection(Path(task_id), _): WithRejection, AppError>, + WithRejection(Json(input), _): WithRejection, AppError>, +) -> Result<(StatusCode, Json), AppError> { + let mut model = TaskModel::get_by_id(&pool, task_id).await?; + + if let Some(title) = input.title { + if title.len() == 0 { + return Err(AppError::Unprocessable( + "Title must not be empty".to_string(), + )); + } + model.title = title; + } + + if let Some(description) = input.description { + model.description = Some(description); + } + + if let Some(status) = input.status { + model.status = status; + } + + let model = model.update(&pool).await?; + + Ok((StatusCode::OK, Json(model))) +} + +pub async fn delete_task( + State(pool): State>, + WithRejection(Path(task_id), _): WithRejection, AppError>, +) -> Result { + // Ensure that the task exists. + TaskModel::get_by_id(&pool, task_id).await?; + TaskModel::delete(&pool, task_id).await?; + + Ok(StatusCode::NO_CONTENT) +} diff --git a/backend/tests/api/delete_tasks.hurl b/backend/tests/api/delete_tasks.hurl new file mode 100644 index 0000000..cf8cc61 --- /dev/null +++ b/backend/tests/api/delete_tasks.hurl @@ -0,0 +1,92 @@ +# Task Delete API Tests + +# Setup: Create a task to delete +POST {{host}}/api/tasks +Content-Type: application/json +{ + "title": "Task to Delete", + "description": "This task will be deleted" +} + +HTTP 201 +[Captures] +delete_task_id: jsonpath "$.id" + +# Test: Delete task successfully +DELETE {{host}}/api/tasks/{{delete_task_id}} + +HTTP 204 + +# Test: Verify task is deleted (should return 404) +GET {{host}}/api/tasks/{{delete_task_id}} + +HTTP 404 +[Asserts] +jsonpath "$.error" exists + +# Setup: Create another task for additional tests +POST {{host}}/api/tasks +Content-Type: application/json +{ + "title": "Another Task to Delete" +} + +HTTP 201 +[Captures] +another_task_id: jsonpath "$.id" + +# Test: Verify task exists before deletion +GET {{host}}/api/tasks/{{another_task_id}} + +HTTP 200 +[Asserts] +jsonpath "$.title" == "Another Task to Delete" + +# Test: Delete the task +DELETE {{host}}/api/tasks/{{another_task_id}} + +HTTP 204 + +# Test: Confirm task no longer exists +GET {{host}}/api/tasks/{{another_task_id}} + +HTTP 404 +[Asserts] +jsonpath "$.error" exists + +# Test: Delete non-existent task +DELETE {{host}}/api/tasks/00000000-0000-0000-0000-000000000000 + +HTTP 404 +[Asserts] +jsonpath "$.error" exists + +# Test: Delete with invalid UUID format +DELETE {{host}}/api/tasks/invalid-uuid-format + +HTTP 400 +[Asserts] +jsonpath "$.error" exists + +# Test: Multiple deletions of same task (idempotency test) +POST {{host}}/api/tasks +Content-Type: application/json +{ + "title": "Task for Idempotency Test" +} + +HTTP 201 +[Captures] +idempotent_task_id: jsonpath "$.id" + +# First deletion should succeed +DELETE {{host}}/api/tasks/{{idempotent_task_id}} + +HTTP 204 + +# Second deletion should return 404 (task already gone) +DELETE {{host}}/api/tasks/{{idempotent_task_id}} + +HTTP 404 +[Asserts] +jsonpath "$.error" exists \ No newline at end of file diff --git a/backend/tests/api/list_tasks.hurl b/backend/tests/api/list_tasks.hurl new file mode 100644 index 0000000..f1f1449 --- /dev/null +++ b/backend/tests/api/list_tasks.hurl @@ -0,0 +1,143 @@ +# Task List API Tests + +# Test: Get task list (may or may not be empty) +GET {{host}}/api/tasks + +HTTP 200 +[Captures] +initial_count: jsonpath "$" count +[Asserts] +jsonpath "$" isCollection + +# Setup: Create multiple tasks with different properties +POST {{host}}/api/tasks +Content-Type: application/json +{ + "title": "Test Task Alpha", + "description": "Alpha task description" +} + +HTTP 201 +[Captures] +alpha_task_id: jsonpath "$.id" +alpha_created_at: jsonpath "$.created_at" + +POST {{host}}/api/tasks +Content-Type: application/json +{ + "title": "Test Task Beta" +} + +HTTP 201 +[Captures] +beta_task_id: jsonpath "$.id" +beta_created_at: jsonpath "$.created_at" + +POST {{host}}/api/tasks +Content-Type: application/json +{ + "title": "Test Task Gamma", + "description": "Gamma task with description" +} + +HTTP 201 +[Captures] +gamma_task_id: jsonpath "$.id" +gamma_created_at: jsonpath "$.created_at" + +# Test: List includes our newly created tasks +GET {{host}}/api/tasks + +HTTP 200 +[Asserts] +jsonpath "$" isCollection +jsonpath "$" count >= {{initial_count}} +jsonpath "$[*].id" includes "{{alpha_task_id}}" +jsonpath "$[*].id" includes "{{beta_task_id}}" +jsonpath "$[*].id" includes "{{gamma_task_id}}" +jsonpath "$[*].title" includes "Test Task Alpha" +jsonpath "$[*].title" includes "Test Task Beta" +jsonpath "$[*].title" includes "Test Task Gamma" + +# Test: Verify all tasks have required fields +GET {{host}}/api/tasks + +HTTP 200 +[Asserts] +jsonpath "$[?(@.id=='{{alpha_task_id}}')].title" exists +jsonpath "$[?(@.id=='{{alpha_task_id}}')].status" exists +jsonpath "$[?(@.id=='{{alpha_task_id}}')].created_at" exists +jsonpath "$[?(@.id=='{{alpha_task_id}}')].updated_at" exists +jsonpath "$[?(@.id=='{{beta_task_id}}')].title" exists +jsonpath "$[?(@.id=='{{beta_task_id}}')].status" exists +jsonpath "$[?(@.id=='{{beta_task_id}}')].created_at" exists +jsonpath "$[?(@.id=='{{beta_task_id}}')].updated_at" exists + +# Test: Verify our tasks have correct initial values +GET {{host}}/api/tasks + +HTTP 200 +[Asserts] +jsonpath "$[?(@.id=='{{alpha_task_id}}')].title" nth 0 == "Test Task Alpha" +jsonpath "$[?(@.id=='{{alpha_task_id}}')].description" nth 0 == "Alpha task description" +jsonpath "$[?(@.id=='{{alpha_task_id}}')].status" nth 0 == "todo" +jsonpath "$[?(@.id=='{{beta_task_id}}')].title" nth 0 == "Test Task Beta" +jsonpath "$[?(@.id=='{{beta_task_id}}')].description" nth 0 == null +jsonpath "$[?(@.id=='{{beta_task_id}}')].status" nth 0 == "todo" + +# Setup: Update tasks to test mixed statuses +PUT {{host}}/api/tasks/{{beta_task_id}} +Content-Type: application/json +{ + "status": "done" +} + +HTTP 200 + +PUT {{host}}/api/tasks/{{gamma_task_id}} +Content-Type: application/json +{ + "status": "backlog" +} + +HTTP 200 + +# Test: List shows updated statuses +GET {{host}}/api/tasks + +HTTP 200 +[Asserts] +jsonpath "$[?(@.id=='{{alpha_task_id}}')].status" nth 0 == "todo" +jsonpath "$[?(@.id=='{{beta_task_id}}')].status" nth 0 == "done" +jsonpath "$[?(@.id=='{{gamma_task_id}}')].status" nth 0 == "backlog" + +# Setup: Delete one task +DELETE {{host}}/api/tasks/{{alpha_task_id}} + +HTTP 204 + +# Test: Deleted task no longer appears in list +GET {{host}}/api/tasks + +HTTP 200 +[Asserts] +jsonpath "$[*].id" not includes "{{alpha_task_id}}" +jsonpath "$[*].id" includes "{{beta_task_id}}" +jsonpath "$[*].id" includes "{{gamma_task_id}}" + +# Cleanup: Delete remaining test tasks +DELETE {{host}}/api/tasks/{{beta_task_id}} + +HTTP 204 + +DELETE {{host}}/api/tasks/{{gamma_task_id}} + +HTTP 204 + +# Test: Our test tasks are gone +GET {{host}}/api/tasks + +HTTP 200 +[Asserts] +jsonpath "$[*].id" not includes "{{beta_task_id}}" +jsonpath "$[*].id" not includes "{{gamma_task_id}}" diff --git a/backend/tests/api/update_tasks.hurl b/backend/tests/api/update_tasks.hurl new file mode 100644 index 0000000..550f0cd --- /dev/null +++ b/backend/tests/api/update_tasks.hurl @@ -0,0 +1,154 @@ +# Task Update API Tests + +# Setup: Create a task to update +POST {{host}}/api/tasks +Content-Type: application/json +{ + "title": "Original Task", + "description": "Original description" +} + +HTTP 201 +[Captures] +task_id: jsonpath "$.id" +created_at: jsonpath "$.created_at" + +# Test: Update task title only +PUT {{host}}/api/tasks/{{task_id}} +Content-Type: application/json +{ + "title": "Updated Task Title" +} + +HTTP 200 +[Asserts] +jsonpath "$.id" == "{{task_id}}" +jsonpath "$.title" == "Updated Task Title" +jsonpath "$.description" == "Original description" +jsonpath "$.status" == "todo" +jsonpath "$.updated_at" != "{{created_at}}" + +# Test: Update description only +PUT {{host}}/api/tasks/{{task_id}} +Content-Type: application/json +{ + "description": "Updated description" +} + +HTTP 200 +[Asserts] +jsonpath "$.id" == "{{task_id}}" +jsonpath "$.title" == "Updated Task Title" +jsonpath "$.description" == "Updated description" +jsonpath "$.status" == "todo" + +# Test: Update status to done +PUT {{host}}/api/tasks/{{task_id}} +Content-Type: application/json +{ + "status": "done" +} + +HTTP 200 +[Asserts] +jsonpath "$.id" == "{{task_id}}" +jsonpath "$.status" == "done" +jsonpath "$.completed_at" exists + +# Test: Update status to backlog +PUT {{host}}/api/tasks/{{task_id}} +Content-Type: application/json +{ + "status": "backlog" +} + +HTTP 200 +[Asserts] +jsonpath "$.id" == "{{task_id}}" +jsonpath "$.status" == "backlog" +jsonpath "$.completed_at" == null + +# Test: Update multiple fields together +PUT {{host}}/api/tasks/{{task_id}} +Content-Type: application/json +{ + "title": "Completely Updated Task", + "description": "Completely updated description", + "status": "done" +} + +HTTP 200 +[Asserts] +jsonpath "$.id" == "{{task_id}}" +jsonpath "$.title" == "Completely Updated Task" +jsonpath "$.description" == "Completely updated description" +jsonpath "$.status" == "done" +jsonpath "$.completed_at" exists + +# Test: Clear description (set to null) +PUT {{host}}/api/tasks/{{task_id}} +Content-Type: application/json +{ + "description": "" +} + +HTTP 200 +[Asserts] +jsonpath "$.id" == "{{task_id}}" +jsonpath "$.description" == "" + +# Setup: Create another task for error tests +POST {{host}}/api/tasks +Content-Type: application/json +{ + "title": "Task for Error Tests" +} + +HTTP 201 +[Captures] +error_test_task_id: jsonpath "$.id" + +# Test: Update non-existent task +PUT {{host}}/api/tasks/00000000-0000-0000-0000-000000000000 +Content-Type: application/json +{ + "title": "This should fail" +} + +HTTP 404 +[Asserts] +jsonpath "$.error" exists + +# Test: Update with invalid UUID format +PUT {{host}}/api/tasks/invalid-uuid-format +Content-Type: application/json +{ + "title": "This should also fail" +} + +HTTP 400 +[Asserts] +jsonpath "$.error" exists + +# Test: Update with empty title +PUT {{host}}/api/tasks/{{error_test_task_id}} +Content-Type: application/json +{ + "title": "" +} + +HTTP 422 +[Asserts] +jsonpath "$.error" exists + +# Test: Update with invalid status +PUT {{host}}/api/tasks/{{error_test_task_id}} +Content-Type: application/json +{ + "status": "invalid_status" +} + +HTTP 422 +[Asserts] +jsonpath "$.error" exists +