use std::collections::HashMap; use std::io::{Read, Write}; use onspring::{ BatchDeleteRecordsRequest, BatchGetRecordsRequest, DataFormat, QueryRecordsRequest, SaveRecordRequest, }; use serde::Deserialize; use crate::cli::{DataFormatArg, RecordsCommand}; use crate::client::OnspringRunner; use crate::error::CliResult; use crate::output::{ CollectionOutput, PagedOutput, RecordOutput, SaveRecordResponseOutput, SuccessResponse, write_json, }; use crate::validation::{parse_body, paging_request, validate_positive_i32}; impl From for DataFormat { fn from(value: DataFormatArg) -> Self { match value { DataFormatArg::Raw => DataFormat::Raw, DataFormatArg::Formatted => DataFormat::Formatted, } } } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SaveRecordInput { pub app_id: i32, pub record_id: Option, pub fields: HashMap, } impl From for SaveRecordRequest { fn from(value: SaveRecordInput) -> Self { Self { app_id: value.app_id, record_id: value.record_id, fields: value.fields, } } } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct BatchGetRecordsInput { pub app_id: i32, pub record_ids: Vec, pub field_ids: Option>, pub data_format: Option, } impl From for BatchGetRecordsRequest { fn from(value: BatchGetRecordsInput) -> Self { Self { app_id: value.app_id, record_ids: value.record_ids, field_ids: value.field_ids, data_format: value.data_format, } } } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct QueryRecordsInput { pub app_id: i32, pub filter: String, pub field_ids: Option>, pub data_format: Option, } impl From for QueryRecordsRequest { fn from(value: QueryRecordsInput) -> Self { Self { app_id: value.app_id, filter: value.filter, field_ids: value.field_ids, data_format: value.data_format, } } } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct BatchDeleteRecordsInput { pub app_id: i32, pub record_ids: Vec, } impl From for BatchDeleteRecordsRequest { fn from(value: BatchDeleteRecordsInput) -> Self { Self { app_id: value.app_id, record_ids: value.record_ids, } } } pub async fn handle( command: &RecordsCommand, client: &C, writer: &mut W, pretty: bool, ) -> CliResult<()> { handle_with_reader(command, client, writer, std::io::stdin(), pretty).await } pub async fn handle_with_reader( command: &RecordsCommand, client: &C, writer: &mut W, reader: R, pretty: bool, ) -> CliResult<()> { match command { RecordsCommand::List { app_id, paging, field_ids, data_format, } => { validate_positive_i32(*app_id, "app_id")?; let paging = paging_request(paging)?; let field_ids_slice = if field_ids.is_empty() { None } else { Some(field_ids.as_slice()) }; let data_format = data_format.map(Into::into); let response = client .list_records(*app_id, paging, field_ids_slice, data_format) .await?; let output: PagedOutput = response.into(); write_json(writer, &output, pretty)?; } RecordsCommand::Get { app_id, record_id, field_ids, data_format, } => { validate_positive_i32(*app_id, "app_id")?; validate_positive_i32(*record_id, "record_id")?; let field_ids_slice = if field_ids.is_empty() { None } else { Some(field_ids.as_slice()) }; let data_format = data_format.map(Into::into); let response = client .get_record(*app_id, *record_id, field_ids_slice, data_format) .await?; let output: RecordOutput = response.into(); write_json(writer, &output, pretty)?; } RecordsCommand::Save { body } => { let input: SaveRecordInput = parse_body(body, reader)?; let request: SaveRecordRequest = input.into(); let response = client.save_record(request).await?; let output: SaveRecordResponseOutput = response.into(); write_json(writer, &output, pretty)?; } RecordsCommand::Delete { app_id, record_id } => { validate_positive_i32(*app_id, "app_id")?; validate_positive_i32(*record_id, "record_id")?; client.delete_record(*app_id, *record_id).await?; write_json(writer, &SuccessResponse { ok: true }, pretty)?; } RecordsCommand::BatchGet { body } => { let input: BatchGetRecordsInput = parse_body(body, reader)?; let request: BatchGetRecordsRequest = input.into(); let response = client.batch_get_records(request).await?; let output: CollectionOutput = response.into(); write_json(writer, &output, pretty)?; } RecordsCommand::Query { body, paging } => { let input: QueryRecordsInput = parse_body(body, reader)?; let request: QueryRecordsRequest = input.into(); let paging = paging_request(paging)?; let response = client.query_records(request, paging).await?; let output: PagedOutput = response.into(); write_json(writer, &output, pretty)?; } RecordsCommand::BatchDelete { body } => { let input: BatchDeleteRecordsInput = parse_body(body, reader)?; let request: BatchDeleteRecordsRequest = input.into(); client.batch_delete_records(request).await?; write_json(writer, &SuccessResponse { ok: true }, pretty)?; } } Ok(()) } #[cfg(test)] mod tests { use super::*; use onspring::{ CollectionResponse, OnspringError, PagedResponse, Record, RecordFieldValue, SaveRecordResponse, ValueType, }; use serde_json::json; use crate::cli::{BodySource, PagingArgs}; use crate::client::testing::MockClient; use crate::error::CliError; fn test_record(app_id: i32, record_id: i32) -> Record { Record { app_id, record_id, field_data: Some(vec![RecordFieldValue { value_type: ValueType::String, field_id: 100, value: json!("Test Value"), }]), } } // --- List tests --- #[tokio::test] async fn handle_when_records_list_succeeds_it_should_write_paged_output() { let mock_client = MockClient { list_records_result: Ok(PagedResponse { page_number: Some(1), page_size: Some(50), total_pages: Some(1), total_records: Some(1), items: Some(vec![test_record(1, 10)]), }), ..Default::default() }; let command = RecordsCommand::List { app_id: 1, paging: PagingArgs::default(), field_ids: vec![], data_format: None, }; let mut buffer = Vec::new(); let result = handle(&command, &mock_client, &mut buffer, false).await; assert_eq!(result, Ok(())); let written = String::from_utf8(buffer).unwrap(); assert_eq!( written.trim(), r#"{"pageNumber":1,"pageSize":50,"totalPages":1,"totalRecords":1,"items":[{"appId":1,"recordId":10,"fieldData":[{"type":"String","fieldId":100,"value":"Test Value"}]}]}"# ); assert_eq!(*mock_client.list_records_app_id.lock().unwrap(), Some(1)); assert!( mock_client .list_records_paging .lock() .unwrap() .as_ref() .unwrap() .is_none() ); assert!( mock_client .list_records_field_ids .lock() .unwrap() .as_ref() .unwrap() .is_none() ); assert!( mock_client .list_records_data_format .lock() .unwrap() .as_ref() .unwrap() .is_none() ); } #[tokio::test] async fn handle_when_records_list_with_args_it_should_pass_all_args() { let mock_client = MockClient { list_records_result: Ok(PagedResponse { page_number: Some(2), page_size: Some(10), total_pages: Some(5), total_records: Some(50), items: Some(vec![]), }), ..Default::default() }; let command = RecordsCommand::List { app_id: 1, paging: PagingArgs { page_number: Some(2), page_size: Some(10), }, field_ids: vec![100, 200], data_format: Some(DataFormatArg::Formatted), }; let mut buffer = Vec::new(); let result = handle(&command, &mock_client, &mut buffer, false).await; assert_eq!(result, Ok(())); assert_eq!(*mock_client.list_records_app_id.lock().unwrap(), Some(1)); let paging = mock_client .list_records_paging .lock() .unwrap() .as_ref() .unwrap() .clone() .unwrap(); assert_eq!(paging.page_number, 2); assert_eq!(paging.page_size, 10); assert_eq!( *mock_client.list_records_field_ids.lock().unwrap(), Some(Some(vec![100, 200])) ); assert_eq!( *mock_client.list_records_data_format.lock().unwrap(), Some(Some(DataFormat::Formatted)) ); } #[tokio::test] async fn handle_when_records_list_succeeds_pretty_it_should_write_pretty_json() { let mock_client = MockClient { list_records_result: Ok(PagedResponse { page_number: Some(1), page_size: Some(50), total_pages: Some(1), total_records: Some(1), items: Some(vec![test_record(1, 10)]), }), ..Default::default() }; let command = RecordsCommand::List { app_id: 1, paging: PagingArgs::default(), field_ids: vec![], data_format: None, }; let mut buffer = Vec::new(); let result = handle(&command, &mock_client, &mut buffer, true).await; assert_eq!(result, Ok(())); let written = String::from_utf8(buffer).unwrap(); let expected = concat!( "{\n", " \"pageNumber\": 1,\n", " \"pageSize\": 50,\n", " \"totalPages\": 1,\n", " \"totalRecords\": 1,\n", " \"items\": [\n", " {\n", " \"appId\": 1,\n", " \"recordId\": 10,\n", " \"fieldData\": [\n", " {\n", " \"type\": \"String\",\n", " \"fieldId\": 100,\n", " \"value\": \"Test Value\"\n", " }\n", " ]\n", " }\n", " ]\n", "}" ); assert_eq!(written.trim(), expected); } #[tokio::test] async fn handle_when_records_list_fails_it_should_return_mapped_cli_error() { let mock_client = MockClient { list_records_result: Err(OnspringError::Api { status_code: 401, message: "Unauthorized".to_string(), }), ..Default::default() }; let command = RecordsCommand::List { app_id: 1, paging: PagingArgs::default(), field_ids: vec![], data_format: None, }; let mut buffer = Vec::new(); let result = handle(&command, &mock_client, &mut buffer, false).await; assert_eq!( result, Err(CliError::runtime("API request failed (401): Unauthorized")) ); assert!(buffer.is_empty()); } #[tokio::test] async fn handle_when_records_list_has_invalid_app_id_it_should_return_usage_error() { let mock_client = MockClient::default(); let command = RecordsCommand::List { app_id: 0, paging: PagingArgs::default(), field_ids: vec![], data_format: None, }; let mut buffer = Vec::new(); let result = handle(&command, &mock_client, &mut buffer, false).await; assert_eq!( result, Err(CliError::usage("app_id must be greater than 0.")) ); assert!(buffer.is_empty()); assert!(mock_client.list_records_app_id.lock().unwrap().is_none()); } #[tokio::test] async fn handle_when_records_list_has_invalid_paging_it_should_return_usage_error() { let mock_client = MockClient::default(); let command = RecordsCommand::List { app_id: 1, paging: PagingArgs { page_number: Some(-1), page_size: None, }, field_ids: vec![], data_format: None, }; let mut buffer = Vec::new(); let result = handle(&command, &mock_client, &mut buffer, false).await; assert_eq!( result, Err(CliError::usage("page_number must be greater than 0.")) ); assert!(buffer.is_empty()); } // --- Get tests --- #[tokio::test] async fn handle_when_records_get_succeeds_it_should_write_record_output() { let mock_client = MockClient { get_record_result: Ok(test_record(1, 10)), ..Default::default() }; let command = RecordsCommand::Get { app_id: 1, record_id: 10, field_ids: vec![], data_format: None, }; let mut buffer = Vec::new(); let result = handle(&command, &mock_client, &mut buffer, false).await; assert_eq!(result, Ok(())); let written = String::from_utf8(buffer).unwrap(); assert_eq!( written.trim(), r#"{"appId":1,"recordId":10,"fieldData":[{"type":"String","fieldId":100,"value":"Test Value"}]}"# ); assert_eq!(*mock_client.get_record_app_id.lock().unwrap(), Some(1)); assert_eq!(*mock_client.get_record_record_id.lock().unwrap(), Some(10)); assert!( mock_client .get_record_field_ids .lock() .unwrap() .as_ref() .unwrap() .is_none() ); assert!( mock_client .get_record_data_format .lock() .unwrap() .as_ref() .unwrap() .is_none() ); } #[tokio::test] async fn handle_when_records_get_with_args_it_should_pass_all_args() { let mock_client = MockClient { get_record_result: Ok(test_record(1, 10)), ..Default::default() }; let command = RecordsCommand::Get { app_id: 1, record_id: 10, field_ids: vec![100], data_format: Some(DataFormatArg::Raw), }; let mut buffer = Vec::new(); let result = handle(&command, &mock_client, &mut buffer, false).await; assert_eq!(result, Ok(())); assert_eq!(*mock_client.get_record_app_id.lock().unwrap(), Some(1)); assert_eq!(*mock_client.get_record_record_id.lock().unwrap(), Some(10)); assert_eq!( *mock_client.get_record_field_ids.lock().unwrap(), Some(Some(vec![100])) ); assert_eq!( *mock_client.get_record_data_format.lock().unwrap(), Some(Some(DataFormat::Raw)) ); } #[tokio::test] async fn handle_when_records_get_succeeds_pretty_it_should_write_pretty_json() { let mock_client = MockClient { get_record_result: Ok(test_record(1, 10)), ..Default::default() }; let command = RecordsCommand::Get { app_id: 1, record_id: 10, field_ids: vec![], data_format: None, }; let mut buffer = Vec::new(); let result = handle(&command, &mock_client, &mut buffer, true).await; assert_eq!(result, Ok(())); let written = String::from_utf8(buffer).unwrap(); let expected = concat!( "{\n", " \"appId\": 1,\n", " \"recordId\": 10,\n", " \"fieldData\": [\n", " {\n", " \"type\": \"String\",\n", " \"fieldId\": 100,\n", " \"value\": \"Test Value\"\n", " }\n", " ]\n", "}" ); assert_eq!(written.trim(), expected); } #[tokio::test] async fn handle_when_records_get_fails_it_should_return_mapped_cli_error() { let mock_client = MockClient { get_record_result: Err(OnspringError::Api { status_code: 404, message: "Record not found".to_string(), }), ..Default::default() }; let command = RecordsCommand::Get { app_id: 1, record_id: 999, field_ids: vec![], data_format: None, }; let mut buffer = Vec::new(); let result = handle(&command, &mock_client, &mut buffer, false).await; assert_eq!( result, Err(CliError::runtime( "API request failed (404): Record not found" )) ); assert!(buffer.is_empty()); } #[tokio::test] async fn handle_when_records_get_with_invalid_app_id_it_should_return_usage_error() { let mock_client = MockClient::default(); let command = RecordsCommand::Get { app_id: -1, record_id: 10, field_ids: vec![], data_format: None, }; let mut buffer = Vec::new(); let result = handle(&command, &mock_client, &mut buffer, false).await; assert_eq!( result, Err(CliError::usage("app_id must be greater than 0.")) ); assert!(buffer.is_empty()); } #[tokio::test] async fn handle_when_records_get_with_invalid_record_id_it_should_return_usage_error() { let mock_client = MockClient::default(); let command = RecordsCommand::Get { app_id: 1, record_id: 0, field_ids: vec![], data_format: None, }; let mut buffer = Vec::new(); let result = handle(&command, &mock_client, &mut buffer, false).await; assert_eq!( result, Err(CliError::usage("record_id must be greater than 0.")) ); assert!(buffer.is_empty()); } // --- Save tests --- #[tokio::test] async fn handle_when_records_save_succeeds_it_should_write_save_response() { let mock_client = MockClient { save_record_result: Ok(SaveRecordResponse { id: 42, warnings: Some(vec!["Test warning".to_string()]), }), ..Default::default() }; let command = RecordsCommand::Save { body: BodySource { json: Some(r#"{"appId":1,"fields":{"100":"New Value"}}"#.to_string()), file: None, stdin: false, }, }; let mut buffer = Vec::new(); let result = handle(&command, &mock_client, &mut buffer, false).await; assert_eq!(result, Ok(())); let written = String::from_utf8(buffer).unwrap(); assert_eq!( written.trim(), r#"{"id":42,"warnings":["Test warning"]}"# ); let req = mock_client.save_record_request.lock().unwrap().clone().unwrap(); assert_eq!(req.app_id, 1); assert_eq!(req.fields.get("100"), Some(&json!("New Value"))); } #[tokio::test] async fn handle_when_records_save_succeeds_pretty_it_should_write_pretty_json() { let mock_client = MockClient { save_record_result: Ok(SaveRecordResponse { id: 42, warnings: None, }), ..Default::default() }; let command = RecordsCommand::Save { body: BodySource { json: Some(r#"{"appId":1,"fields":{}}"#.to_string()), file: None, stdin: false, }, }; let mut buffer = Vec::new(); let result = handle(&command, &mock_client, &mut buffer, true).await; assert_eq!(result, Ok(())); let written = String::from_utf8(buffer).unwrap(); let expected = concat!( "{\n", " \"id\": 42,\n", " \"warnings\": null\n", "}" ); assert_eq!(written.trim(), expected); } #[tokio::test] async fn handle_when_records_save_fails_it_should_return_mapped_cli_error() { let mock_client = MockClient { save_record_result: Err(OnspringError::Api { status_code: 400, message: "Invalid field value".to_string(), }), ..Default::default() }; let command = RecordsCommand::Save { body: BodySource { json: Some(r#"{"appId":1,"fields":{}}"#.to_string()), file: None, stdin: false, }, }; let mut buffer = Vec::new(); let result = handle(&command, &mock_client, &mut buffer, false).await; assert_eq!( result, Err(CliError::runtime( "API request failed (400): Invalid field value" )) ); } #[tokio::test] async fn handle_when_records_save_has_missing_body_it_should_return_usage_error() { let mock_client = MockClient::default(); let command = RecordsCommand::Save { body: BodySource::default(), }; let mut buffer = Vec::new(); let result = handle(&command, &mock_client, &mut buffer, false).await; assert_eq!( result, Err(CliError::usage( "A request body is required. Provide --json, --file, or --stdin." )) ); } #[tokio::test] async fn handle_when_records_save_reads_from_stdin() { let mock_client = MockClient { save_record_result: Ok(SaveRecordResponse { id: 77, warnings: None, }), ..Default::default() }; let command = RecordsCommand::Save { body: BodySource { json: None, file: None, stdin: true, }, }; let mut buffer = Vec::new(); let stdin_data = r#"{"appId":2,"fields":{"100":"From Stdin"}}"#; let result = handle_with_reader( &command, &mock_client, &mut buffer, stdin_data.as_bytes(), false, ) .await; assert_eq!(result, Ok(())); let written = String::from_utf8(buffer).unwrap(); assert_eq!(written.trim(), r#"{"id":77,"warnings":null}"#); let req = mock_client.save_record_request.lock().unwrap().clone().unwrap(); assert_eq!(req.app_id, 2); } // --- Delete tests --- #[tokio::test] async fn handle_when_records_delete_succeeds_it_should_write_success_response() { let mock_client = MockClient { delete_record_result: Ok(()), ..Default::default() }; let command = RecordsCommand::Delete { app_id: 1, record_id: 10, }; let mut buffer = Vec::new(); let result = handle(&command, &mock_client, &mut buffer, false).await; assert_eq!(result, Ok(())); let written = String::from_utf8(buffer).unwrap(); assert_eq!(written.trim(), r#"{"ok":true}"#); assert_eq!(*mock_client.delete_record_app_id.lock().unwrap(), Some(1)); assert_eq!(*mock_client.delete_record_record_id.lock().unwrap(), Some(10)); } #[tokio::test] async fn handle_when_records_delete_succeeds_pretty_it_should_write_pretty_json() { let mock_client = MockClient { delete_record_result: Ok(()), ..Default::default() }; let command = RecordsCommand::Delete { app_id: 1, record_id: 10, }; let mut buffer = Vec::new(); let result = handle(&command, &mock_client, &mut buffer, true).await; assert_eq!(result, Ok(())); let written = String::from_utf8(buffer).unwrap(); let expected = concat!("{\n", " \"ok\": true\n", "}"); assert_eq!(written.trim(), expected); } #[tokio::test] async fn handle_when_records_delete_fails_it_should_return_mapped_cli_error() { let mock_client = MockClient { delete_record_result: Err(OnspringError::Api { status_code: 404, message: "Record not found".to_string(), }), ..Default::default() }; let command = RecordsCommand::Delete { app_id: 1, record_id: 999, }; let mut buffer = Vec::new(); let result = handle(&command, &mock_client, &mut buffer, false).await; assert_eq!( result, Err(CliError::runtime( "API request failed (404): Record not found" )) ); } #[tokio::test] async fn handle_when_records_delete_with_invalid_app_id_it_should_return_usage_error() { let mock_client = MockClient::default(); let command = RecordsCommand::Delete { app_id: 0, record_id: 10, }; let mut buffer = Vec::new(); let result = handle(&command, &mock_client, &mut buffer, false).await; assert_eq!( result, Err(CliError::usage("app_id must be greater than 0.")) ); } #[tokio::test] async fn handle_when_records_delete_with_invalid_record_id_it_should_return_usage_error() { let mock_client = MockClient::default(); let command = RecordsCommand::Delete { app_id: 1, record_id: -5, }; let mut buffer = Vec::new(); let result = handle(&command, &mock_client, &mut buffer, false).await; assert_eq!( result, Err(CliError::usage("record_id must be greater than 0.")) ); } // --- BatchGet tests --- #[tokio::test] async fn handle_when_records_batch_get_succeeds_it_should_write_collection_output() { let mock_client = MockClient { batch_get_records_result: Ok(CollectionResponse { count: Some(1), items: Some(vec![test_record(1, 10)]), }), ..Default::default() }; let command = RecordsCommand::BatchGet { body: BodySource { json: Some(r#"{"appId":1,"recordIds":[10]}"#.to_string()), file: None, stdin: false, }, }; let mut buffer = Vec::new(); let result = handle(&command, &mock_client, &mut buffer, false).await; assert_eq!(result, Ok(())); let written = String::from_utf8(buffer).unwrap(); assert_eq!( written.trim(), r#"{"count":1,"items":[{"appId":1,"recordId":10,"fieldData":[{"type":"String","fieldId":100,"value":"Test Value"}]}]}"# ); let req = mock_client .batch_get_records_request .lock() .unwrap() .clone() .unwrap(); assert_eq!(req.app_id, 1); assert_eq!(req.record_ids, vec![10]); } #[tokio::test] async fn handle_when_records_batch_get_succeeds_pretty_it_should_write_pretty_json() { let mock_client = MockClient { batch_get_records_result: Ok(CollectionResponse { count: Some(1), items: Some(vec![test_record(1, 10)]), }), ..Default::default() }; let command = RecordsCommand::BatchGet { body: BodySource { json: Some(r#"{"appId":1,"recordIds":[10]}"#.to_string()), file: None, stdin: false, }, }; let mut buffer = Vec::new(); let result = handle(&command, &mock_client, &mut buffer, true).await; assert_eq!(result, Ok(())); let written = String::from_utf8(buffer).unwrap(); let val: serde_json::Value = serde_json::from_str(&written).unwrap(); assert_eq!(val["count"], 1); assert!(written.contains(" \"count\": 1")); } #[tokio::test] async fn handle_when_records_batch_get_fails_it_should_return_mapped_cli_error() { let mock_client = MockClient { batch_get_records_result: Err(OnspringError::Api { status_code: 500, message: "Internal Server Error".to_string(), }), ..Default::default() }; let command = RecordsCommand::BatchGet { body: BodySource { json: Some(r#"{"appId":1,"recordIds":[10]}"#.to_string()), file: None, stdin: false, }, }; let mut buffer = Vec::new(); let result = handle(&command, &mock_client, &mut buffer, false).await; assert_eq!( result, Err(CliError::runtime( "API request failed (500): Internal Server Error" )) ); } // --- Query tests --- #[tokio::test] async fn handle_when_records_query_succeeds_it_should_write_paged_output() { let mock_client = MockClient { query_records_result: Ok(PagedResponse { page_number: Some(1), page_size: Some(50), total_pages: Some(1), total_records: Some(1), items: Some(vec![test_record(1, 10)]), }), ..Default::default() }; let command = RecordsCommand::Query { body: BodySource { json: Some(r#"{"appId":1,"filter":"100 eq 'Test'"}"#.to_string()), file: None, stdin: false, }, paging: PagingArgs::default(), }; let mut buffer = Vec::new(); let result = handle(&command, &mock_client, &mut buffer, false).await; assert_eq!(result, Ok(())); let written = String::from_utf8(buffer).unwrap(); assert_eq!( written.trim(), r#"{"pageNumber":1,"pageSize":50,"totalPages":1,"totalRecords":1,"items":[{"appId":1,"recordId":10,"fieldData":[{"type":"String","fieldId":100,"value":"Test Value"}]}]}"# ); let req = mock_client .query_records_request .lock() .unwrap() .clone() .unwrap(); assert_eq!(req.app_id, 1); assert_eq!(req.filter, "100 eq 'Test'"); } #[tokio::test] async fn handle_when_records_query_with_paging_it_should_pass_paging_request() { let mock_client = MockClient { query_records_result: Ok(PagedResponse { page_number: Some(3), page_size: Some(25), total_pages: Some(4), total_records: Some(100), items: Some(vec![]), }), ..Default::default() }; let command = RecordsCommand::Query { body: BodySource { json: Some(r#"{"appId":1,"filter":"100 eq 'Test'"}"#.to_string()), file: None, stdin: false, }, paging: PagingArgs { page_number: Some(3), page_size: Some(25), }, }; let mut buffer = Vec::new(); let result = handle(&command, &mock_client, &mut buffer, false).await; assert_eq!(result, Ok(())); let paging = mock_client .query_records_paging .lock() .unwrap() .as_ref() .unwrap() .clone() .unwrap(); assert_eq!(paging.page_number, 3); assert_eq!(paging.page_size, 25); } #[tokio::test] async fn handle_when_records_query_fails_it_should_return_mapped_cli_error() { let mock_client = MockClient { query_records_result: Err(OnspringError::Api { status_code: 400, message: "Invalid filter expression".to_string(), }), ..Default::default() }; let command = RecordsCommand::Query { body: BodySource { json: Some(r#"{"appId":1,"filter":"invalid"}"#.to_string()), file: None, stdin: false, }, paging: PagingArgs::default(), }; let mut buffer = Vec::new(); let result = handle(&command, &mock_client, &mut buffer, false).await; assert_eq!( result, Err(CliError::runtime( "API request failed (400): Invalid filter expression" )) ); } #[tokio::test] async fn handle_when_records_query_has_invalid_paging_it_should_return_usage_error() { let mock_client = MockClient::default(); let command = RecordsCommand::Query { body: BodySource { json: Some(r#"{"appId":1,"filter":"100 eq 'Test'"}"#.to_string()), file: None, stdin: false, }, paging: PagingArgs { page_number: Some(0), page_size: None, }, }; let mut buffer = Vec::new(); let result = handle(&command, &mock_client, &mut buffer, false).await; assert_eq!( result, Err(CliError::usage("page_number must be greater than 0.")) ); } // --- BatchDelete tests --- #[tokio::test] async fn handle_when_records_batch_delete_succeeds_it_should_write_success_response() { let mock_client = MockClient { batch_delete_records_result: Ok(()), ..Default::default() }; let command = RecordsCommand::BatchDelete { body: BodySource { json: Some(r#"{"appId":1,"recordIds":[10,20]}"#.to_string()), file: None, stdin: false, }, }; let mut buffer = Vec::new(); let result = handle(&command, &mock_client, &mut buffer, false).await; assert_eq!(result, Ok(())); let written = String::from_utf8(buffer).unwrap(); assert_eq!(written.trim(), r#"{"ok":true}"#); let req = mock_client .batch_delete_records_request .lock() .unwrap() .clone() .unwrap(); assert_eq!(req.app_id, 1); assert_eq!(req.record_ids, vec![10, 20]); } #[tokio::test] async fn handle_when_records_batch_delete_succeeds_pretty_it_should_write_pretty_json() { let mock_client = MockClient { batch_delete_records_result: Ok(()), ..Default::default() }; let command = RecordsCommand::BatchDelete { body: BodySource { json: Some(r#"{"appId":1,"recordIds":[10,20]}"#.to_string()), file: None, stdin: false, }, }; let mut buffer = Vec::new(); let result = handle(&command, &mock_client, &mut buffer, true).await; assert_eq!(result, Ok(())); let written = String::from_utf8(buffer).unwrap(); let expected = concat!("{\n", " \"ok\": true\n", "}"); assert_eq!(written.trim(), expected); } #[tokio::test] async fn handle_when_records_batch_delete_fails_it_should_return_mapped_cli_error() { let mock_client = MockClient { batch_delete_records_result: Err(OnspringError::Api { status_code: 500, message: "Failed to delete records".to_string(), }), ..Default::default() }; let command = RecordsCommand::BatchDelete { body: BodySource { json: Some(r#"{"appId":1,"recordIds":[10,20]}"#.to_string()), file: None, stdin: false, }, }; let mut buffer = Vec::new(); let result = handle(&command, &mock_client, &mut buffer, false).await; assert_eq!( result, Err(CliError::runtime( "API request failed (500): Failed to delete records" )) ); } }