Skip to content
Merged
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
36 changes: 31 additions & 5 deletions src/tasks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
//! # }
//! ```

use crate::error::CloudError;
use crate::types::{Link, ProcessorResponse};
use crate::{CloudClient, Result};
use serde::{Deserialize, Serialize};
Expand All @@ -63,6 +64,16 @@ use serde::{Deserialize, Serialize};
// Models
// ============================================================================

/// Wrapper response for `GET /tasks` per the OpenAPI spec (`TasksStateUpdate`).
///
/// Kept private because [`TasksHandler::get_all_tasks`] unwraps to the inner
/// `Vec<TaskStateUpdate>` for caller ergonomics.
#[derive(Debug, Clone, Deserialize)]
struct TasksStateUpdate {
#[serde(default)]
tasks: Vec<TaskStateUpdate>,
}

/// Task state update
///
/// Represents the state and result of an asynchronous task
Expand Down Expand Up @@ -124,17 +135,32 @@ impl TasksHandler {
/// Get tasks
/// Gets a list of all currently running tasks for this account.
///
/// The API returns an array when tasks exist but an empty object `{}` when
/// there are no tasks, so we handle both cases.
/// The OpenAPI spec defines the response as `TasksStateUpdate { tasks: [...] }`
/// (a wrapper object). In practice the API also returns:
/// - an empty object `{}` when there are no tasks,
/// - and historically a bare JSON array.
///
/// All three shapes deserialize cleanly to `Vec<TaskStateUpdate>`. Any other
/// shape surfaces as [`CloudError::JsonError`] rather than silently returning
/// an empty list, so a future schema change is loud instead of invisible.
///
/// GET /tasks
pub async fn get_all_tasks(&self) -> Result<Vec<TaskStateUpdate>> {
let value: serde_json::Value = self.client.get_raw("/tasks").await?;
match value {
serde_json::Value::Array(arr) => {
Ok(serde_json::from_value(serde_json::Value::Array(arr))?)
// Canonical spec shape: {"tasks": [...]}
serde_json::Value::Object(ref obj) if obj.contains_key("tasks") => {
let wrapped: TasksStateUpdate = serde_json::from_value(value)?;
Ok(wrapped.tasks)
}
_ => Ok(Vec::new()),
// No tasks: API returns `{}` (or null) instead of the wrapper.
serde_json::Value::Object(obj) if obj.is_empty() => Ok(Vec::new()),
serde_json::Value::Null => Ok(Vec::new()),
// Legacy bare-array shape, still tolerated.
serde_json::Value::Array(_) => Ok(serde_json::from_value(value)?),
other => Err(CloudError::JsonError(format!(
"GET /tasks: expected {{\"tasks\": [...]}}, {{}}, null, or a bare array; got {other}"
))),
}
}

Expand Down
10 changes: 8 additions & 2 deletions src/testing/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -273,13 +273,19 @@ impl MockCloudServer {

/// Mock the tasks list endpoint (GET /tasks)
///
/// Returns a direct array since `get_all_tasks()` returns `Result<Vec<TaskStateUpdate>>`.
/// Wraps the supplied tasks in the canonical `{"tasks": [...]}` shape that
/// the real Redis Cloud API returns (per the OpenAPI `TasksStateUpdate`
/// schema). Callers continue to pass `Vec<Value>`; the wrapper is added on
/// the wire so `get_all_tasks()` exercises the same code path it would
/// against production.
pub async fn mock_tasks_list(&self, tasks: Vec<Value>) {
Mock::given(method("GET"))
.and(path("/tasks"))
.and(header("x-api-key", "test-key"))
.and(header("x-api-secret-key", "test-secret"))
.respond_with(ResponseTemplate::new(200).set_body_json(tasks))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"tasks": tasks,
})))
.mount(&self.server)
.await;
}
Expand Down
100 changes: 85 additions & 15 deletions tests/tasks_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,19 @@ use serde_json::json;
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};

// Helper: build a Cloud client wired to the given mock server URI.
fn test_client(uri: String) -> CloudClient {
CloudClient::builder()
.api_key("test-key".to_string())
.api_secret("test-secret".to_string())
.base_url(uri)
.build()
.unwrap()
}

#[tokio::test]
async fn test_get_all_tasks() {
async fn test_get_all_tasks_canonical_wrapper() {
// OpenAPI spec response shape: TasksStateUpdate { tasks: [TaskStateUpdate] }.
let mock_server = MockServer::start().await;

Mock::given(method("GET"))
Expand All @@ -16,7 +27,7 @@ async fn test_get_all_tasks() {
{
"taskId": "task-1",
"commandType": "CREATE_DATABASE",
"status": "completed",
"status": "processing-completed",
"description": "Created database successfully",
"timestamp": "2024-01-01T10:00:00Z",
"response": {
Expand All @@ -26,14 +37,14 @@ async fn test_get_all_tasks() {
{
"taskId": "task-2",
"commandType": "UPDATE_SUBSCRIPTION",
"status": "processing",
"status": "processing-in-progress",
"description": "Updating subscription",
"timestamp": "2024-01-01T11:00:00Z"
},
{
"taskId": "task-3",
"commandType": "DELETE_DATABASE",
"status": "failed",
"status": "processing-error",
"description": "Failed to delete database",
"timestamp": "2024-01-01T12:00:00Z",
"response": {
Expand All @@ -45,18 +56,77 @@ async fn test_get_all_tasks() {
.mount(&mock_server)
.await;

let client = CloudClient::builder()
.api_key("test-key".to_string())
.api_secret("test-secret".to_string())
.base_url(mock_server.uri())
.build()
.unwrap();
let handler = TasksHandler::new(test_client(mock_server.uri()));
let tasks = handler.get_all_tasks().await.unwrap();

assert_eq!(tasks.len(), 3);
assert_eq!(tasks[0].task_id, Some("task-1".to_string()));
assert_eq!(tasks[0].command_type, Some("CREATE_DATABASE".to_string()));
assert_eq!(tasks[1].status, Some("processing-in-progress".to_string()));
assert_eq!(tasks[2].task_id, Some("task-3".to_string()));
}

#[tokio::test]
async fn test_get_all_tasks_empty_object() {
// When the account has no tasks, the API returns `{}` rather than the wrapper.
let mock_server = MockServer::start().await;

Mock::given(method("GET"))
.and(path("/tasks"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({})))
.mount(&mock_server)
.await;

let handler = TasksHandler::new(test_client(mock_server.uri()));
let tasks = handler.get_all_tasks().await.unwrap();

let _handler = TasksHandler::new(client);
// Note: get_all_tasks currently returns () - the method seems not fully implemented
// For now, we skip the actual test since the endpoint doesn't return the expected response
// let result = handler.get_all_tasks().await;
// assert!(result.is_ok());
assert!(tasks.is_empty());
}

#[tokio::test]
async fn test_get_all_tasks_legacy_bare_array() {
// Older responses use a bare array. Still tolerated for backward compatibility.
let mock_server = MockServer::start().await;

Mock::given(method("GET"))
.and(path("/tasks"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([
{ "taskId": "legacy-1", "status": "processing-completed" }
])))
.mount(&mock_server)
.await;

let handler = TasksHandler::new(test_client(mock_server.uri()));
let tasks = handler.get_all_tasks().await.unwrap();

assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0].task_id, Some("legacy-1".to_string()));
}

#[tokio::test]
async fn test_get_all_tasks_unknown_shape_errors() {
// Anything outside the three known shapes should surface a JsonError —
// never an empty Vec that silently masks a schema regression.
let mock_server = MockServer::start().await;

Mock::given(method("GET"))
.and(path("/tasks"))
.respond_with(ResponseTemplate::new(200).set_body_string("\"unexpected string\""))
.mount(&mock_server)
.await;

let handler = TasksHandler::new(test_client(mock_server.uri()));
let err = handler.get_all_tasks().await.unwrap_err();

match err {
redis_cloud::CloudError::JsonError(msg) => {
assert!(
msg.contains("GET /tasks"),
"JsonError message should mention the endpoint: {msg}"
);
}
other => panic!("expected CloudError::JsonError, got: {other:?}"),
}
}

#[tokio::test]
Expand Down
Loading