feat: implement files commands
This commit is contained in:
1 parent
56b5abc1d1
commit
213ed0b834
8 files changed
+1447
-62
No files matched your search
Generated
+1
@@ -797,6 +797,7 @@ dependencies = [
|
|||||||
name = "onx"
|
name = "onx"
|
||||||
version = "0.0.0"
|
version = "0.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ license = "MIT"
|
|||||||
repository = "https://github.com/StevanFreeborn/onx"
|
repository = "https://github.com/StevanFreeborn/onx"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
bytes = "1.12.1"
|
||||||
chrono = { version = "0.4.45", features = ["serde"] }
|
chrono = { version = "0.4.45", features = ["serde"] }
|
||||||
clap = { version = "4.6.6", features = ["derive", "env"] }
|
clap = { version = "4.6.6", features = ["derive", "env"] }
|
||||||
mime_guess = "2.0.5"
|
mime_guess = "2.0.5"
|
||||||
|
|||||||
+255
-36
@@ -51,6 +51,12 @@ pub enum Command {
|
|||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: RecordsCommand,
|
command: RecordsCommand,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
#[command(about = "Perform operations against the files in the instance")]
|
||||||
|
Files {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: FilesCommand,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Subcommand)]
|
#[derive(Debug, PartialEq, Subcommand)]
|
||||||
@@ -60,19 +66,17 @@ pub enum AppsCommand {
|
|||||||
|
|
||||||
#[command(about = "Get information about an app")]
|
#[command(about = "Get information about an app")]
|
||||||
Get {
|
Get {
|
||||||
#[arg(long = "app-id")]
|
#[arg(long = "app")]
|
||||||
#[arg(short = 'i')]
|
#[arg(short = 'a')]
|
||||||
#[arg(alias = "app")]
|
|
||||||
#[arg(help = "App id to get")]
|
#[arg(help = "App id to get")]
|
||||||
app_id: i32,
|
app_id: i32,
|
||||||
},
|
},
|
||||||
|
|
||||||
#[command(about = "Get information for a batch of apps")]
|
#[command(about = "Get information for a batch of apps")]
|
||||||
BatchGet {
|
BatchGet {
|
||||||
#[arg(long = "ids")]
|
#[arg(long = "apps")]
|
||||||
#[arg(value_delimiter = ',')]
|
#[arg(value_delimiter = ',')]
|
||||||
#[arg(short = 'i')]
|
#[arg(short = 'a')]
|
||||||
#[arg(alias = "apps")]
|
|
||||||
#[arg(help = "Comma-separated list of app ids to get")]
|
#[arg(help = "Comma-separated list of app ids to get")]
|
||||||
ids: Vec<i32>,
|
ids: Vec<i32>,
|
||||||
},
|
},
|
||||||
@@ -82,9 +86,8 @@ pub enum AppsCommand {
|
|||||||
pub enum FieldsCommand {
|
pub enum FieldsCommand {
|
||||||
#[command(about = "Get information for a list of fields")]
|
#[command(about = "Get information for a list of fields")]
|
||||||
List {
|
List {
|
||||||
#[arg(long = "app-id")]
|
#[arg(long = "app")]
|
||||||
#[arg(short = 'i')]
|
#[arg(short = 'a')]
|
||||||
#[arg(alias = "app")]
|
|
||||||
#[arg(help = "App id whose fields will be retrieved")]
|
#[arg(help = "App id whose fields will be retrieved")]
|
||||||
app_id: i32,
|
app_id: i32,
|
||||||
|
|
||||||
@@ -94,19 +97,17 @@ pub enum FieldsCommand {
|
|||||||
|
|
||||||
#[command(about = "Get information about a field")]
|
#[command(about = "Get information about a field")]
|
||||||
Get {
|
Get {
|
||||||
#[arg(long = "field-id")]
|
#[arg(long = "field")]
|
||||||
#[arg(short = 'i')]
|
#[arg(short = 'f')]
|
||||||
#[arg(alias = "field")]
|
|
||||||
#[arg(help = "Field id to get")]
|
#[arg(help = "Field id to get")]
|
||||||
field_id: i32,
|
field_id: i32,
|
||||||
},
|
},
|
||||||
|
|
||||||
#[command(about = "Get information for a batch of fields")]
|
#[command(about = "Get information for a batch of fields")]
|
||||||
BatchGet {
|
BatchGet {
|
||||||
#[arg(long = "ids")]
|
#[arg(long = "fields")]
|
||||||
#[arg(value_delimiter = ',')]
|
#[arg(value_delimiter = ',')]
|
||||||
#[arg(short = 'i')]
|
#[arg(short = 'f')]
|
||||||
#[arg(alias = "fields")]
|
|
||||||
#[arg(help = "Comma-separated list of field ids to get")]
|
#[arg(help = "Comma-separated list of field ids to get")]
|
||||||
ids: Vec<i32>,
|
ids: Vec<i32>,
|
||||||
},
|
},
|
||||||
@@ -200,6 +201,107 @@ pub enum RecordsCommand {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Subcommand)]
|
||||||
|
pub enum FilesCommand {
|
||||||
|
#[command(about = "Get metadata info for a file")]
|
||||||
|
Info {
|
||||||
|
#[arg(long = "record")]
|
||||||
|
#[arg(short = 'r')]
|
||||||
|
#[arg(help = "Record id for record where the file is held")]
|
||||||
|
record_id: i32,
|
||||||
|
|
||||||
|
#[arg(long = "field")]
|
||||||
|
#[arg(short = 'f')]
|
||||||
|
#[arg(help = "Field id for field where the file is held")]
|
||||||
|
field_id: i32,
|
||||||
|
|
||||||
|
#[arg(long = "file")]
|
||||||
|
#[arg(short = 'l')]
|
||||||
|
#[arg(help = "The id of the file")]
|
||||||
|
file_id: i32,
|
||||||
|
},
|
||||||
|
|
||||||
|
#[command(about = "Get the content for a file")]
|
||||||
|
Get {
|
||||||
|
#[arg(long = "record")]
|
||||||
|
#[arg(short = 'r')]
|
||||||
|
#[arg(help = "Record id for record where the file is held")]
|
||||||
|
record_id: i32,
|
||||||
|
|
||||||
|
#[arg(long = "field")]
|
||||||
|
#[arg(short = 'f')]
|
||||||
|
#[arg(help = "Field id for field where the file is held")]
|
||||||
|
field_id: i32,
|
||||||
|
|
||||||
|
#[arg(long = "file")]
|
||||||
|
#[arg(short = 'l')]
|
||||||
|
#[arg(help = "The id of the file")]
|
||||||
|
file_id: i32,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
#[arg(short = 'o')]
|
||||||
|
#[arg(help = "The output path for the file")]
|
||||||
|
output: Option<PathBuf>,
|
||||||
|
},
|
||||||
|
|
||||||
|
Upload {
|
||||||
|
#[arg(long = "record")]
|
||||||
|
#[arg(short = 'r')]
|
||||||
|
#[arg(help = "Record id for record where the file should be uploaded")]
|
||||||
|
record_id: i32,
|
||||||
|
|
||||||
|
#[arg(long = "field")]
|
||||||
|
#[arg(short = 'f')]
|
||||||
|
#[arg(help = "Field id for field where the file should be uploaded")]
|
||||||
|
field_id: i32,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
#[arg(short = 'i')]
|
||||||
|
#[arg(help = "The input path of the file to be uploaded")]
|
||||||
|
input: Option<PathBuf>,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
#[arg(short = 's')]
|
||||||
|
#[arg(help = "Indicates whether file data should be read in from standard in")]
|
||||||
|
stdin: bool,
|
||||||
|
|
||||||
|
#[arg(long = "name")]
|
||||||
|
#[arg(short = 'n')]
|
||||||
|
#[arg(help = "The name of the file to be uploaded")]
|
||||||
|
file_name: Option<String>,
|
||||||
|
|
||||||
|
#[arg(long = "type")]
|
||||||
|
#[arg(short = 't')]
|
||||||
|
#[arg(help = "The content type of the file to be uploaded")]
|
||||||
|
content_type: Option<String>,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
#[arg(help = "Notes that should be included in the file upload")]
|
||||||
|
notes: Option<String>,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
#[arg(help = "Modified date that should be included in the file upload as RFC3339 timestamp")]
|
||||||
|
modified_date: Option<String>,
|
||||||
|
},
|
||||||
|
|
||||||
|
Delete {
|
||||||
|
#[arg(long = "record")]
|
||||||
|
#[arg(short = 'r')]
|
||||||
|
#[arg(help = "Record id for record where the file is held")]
|
||||||
|
record_id: i32,
|
||||||
|
|
||||||
|
#[arg(long = "field")]
|
||||||
|
#[arg(short = 'f')]
|
||||||
|
#[arg(help = "Field id for field where the file is held")]
|
||||||
|
field_id: i32,
|
||||||
|
|
||||||
|
#[arg(long = "file")]
|
||||||
|
#[arg(short = 'l')]
|
||||||
|
#[arg(help = "The id of the file")]
|
||||||
|
file_id: i32,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Clone, Args, Default)]
|
#[derive(Debug, PartialEq, Clone, Args, Default)]
|
||||||
pub struct PagingArgs {
|
pub struct PagingArgs {
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
@@ -316,7 +418,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_cli_from_when_called_with_apps_get_it_should_return_cli() {
|
fn parse_cli_from_when_called_with_apps_get_it_should_return_cli() {
|
||||||
let result = parse_cli_from(["test", "apps", "get", "--app-id", "123"]);
|
let result = parse_cli_from(["test", "apps", "get", "--app", "123"]);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
result,
|
result,
|
||||||
@@ -333,7 +435,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_cli_from_when_called_with_apps_get_short_flag_it_should_return_cli() {
|
fn parse_cli_from_when_called_with_apps_get_short_flag_it_should_return_cli() {
|
||||||
let result = parse_cli_from(["test", "apps", "get", "-i", "123"]);
|
let result = parse_cli_from(["test", "apps", "get", "-a", "123"]);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
result,
|
result,
|
||||||
@@ -359,7 +461,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_cli_from_when_called_with_apps_batch_get_it_should_return_cli() {
|
fn parse_cli_from_when_called_with_apps_batch_get_it_should_return_cli() {
|
||||||
let result = parse_cli_from(["test", "apps", "batch-get", "--ids", "1,2,3"]);
|
let result = parse_cli_from(["test", "apps", "batch-get", "--apps", "1,2,3"]);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
result,
|
result,
|
||||||
@@ -395,7 +497,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn parse_cli_from_when_called_with_apps_batch_get_invalid_id_format_it_should_return_usage_error()
|
fn parse_cli_from_when_called_with_apps_batch_get_invalid_id_format_it_should_return_usage_error()
|
||||||
{
|
{
|
||||||
let result = parse_cli_from(["test", "apps", "batch-get", "--ids", "invalid"]);
|
let result = parse_cli_from(["test", "apps", "batch-get", "--apps", "invalid"]);
|
||||||
|
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
let err = result.unwrap_err();
|
let err = result.unwrap_err();
|
||||||
@@ -448,7 +550,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_cli_from_when_called_with_fields_list_it_should_return_cli() {
|
fn parse_cli_from_when_called_with_fields_list_it_should_return_cli() {
|
||||||
let result = parse_cli_from(["test", "fields", "list", "--app-id", "10"]);
|
let result = parse_cli_from(["test", "fields", "list", "--app", "10"]);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
result,
|
result,
|
||||||
@@ -472,7 +574,7 @@ mod tests {
|
|||||||
"test",
|
"test",
|
||||||
"fields",
|
"fields",
|
||||||
"list",
|
"list",
|
||||||
"-i",
|
"-a",
|
||||||
"10",
|
"10",
|
||||||
"--page-number",
|
"--page-number",
|
||||||
"2",
|
"2",
|
||||||
@@ -510,7 +612,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_cli_from_when_called_with_fields_get_it_should_return_cli() {
|
fn parse_cli_from_when_called_with_fields_get_it_should_return_cli() {
|
||||||
let result = parse_cli_from(["test", "fields", "get", "--field-id", "123"]);
|
let result = parse_cli_from(["test", "fields", "get", "--field", "123"]);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
result,
|
result,
|
||||||
@@ -527,7 +629,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_cli_from_when_called_with_fields_get_short_flag_it_should_return_cli() {
|
fn parse_cli_from_when_called_with_fields_get_short_flag_it_should_return_cli() {
|
||||||
let result = parse_cli_from(["test", "fields", "get", "-i", "123"]);
|
let result = parse_cli_from(["test", "fields", "get", "-f", "123"]);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
result,
|
result,
|
||||||
@@ -553,7 +655,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_cli_from_when_called_with_fields_batch_get_it_should_return_cli() {
|
fn parse_cli_from_when_called_with_fields_batch_get_it_should_return_cli() {
|
||||||
let result = parse_cli_from(["test", "fields", "batch-get", "--ids", "1,2,3"]);
|
let result = parse_cli_from(["test", "fields", "batch-get", "--fields", "1,2,3"]);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
result,
|
result,
|
||||||
@@ -589,7 +691,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn parse_cli_from_when_called_with_fields_batch_get_invalid_id_format_it_should_return_usage_error()
|
fn parse_cli_from_when_called_with_fields_batch_get_invalid_id_format_it_should_return_usage_error()
|
||||||
{
|
{
|
||||||
let result = parse_cli_from(["test", "fields", "batch-get", "--ids", "invalid"]);
|
let result = parse_cli_from(["test", "fields", "batch-get", "--fields", "invalid"]);
|
||||||
|
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
let err = result.unwrap_err();
|
let err = result.unwrap_err();
|
||||||
@@ -680,7 +782,17 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn parse_cli_from_when_called_with_records_get_it_should_return_cli() {
|
fn parse_cli_from_when_called_with_records_get_it_should_return_cli() {
|
||||||
let result = parse_cli_from([
|
let result = parse_cli_from([
|
||||||
"test", "records", "get", "-a", "1", "-r", "10", "-f", "100", "-d", "formatted",
|
"test",
|
||||||
|
"records",
|
||||||
|
"get",
|
||||||
|
"-a",
|
||||||
|
"1",
|
||||||
|
"-r",
|
||||||
|
"10",
|
||||||
|
"-f",
|
||||||
|
"100",
|
||||||
|
"-d",
|
||||||
|
"formatted",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -703,9 +815,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_cli_from_when_called_with_records_save_json_it_should_return_cli() {
|
fn parse_cli_from_when_called_with_records_save_json_it_should_return_cli() {
|
||||||
let result = parse_cli_from([
|
let result = parse_cli_from(["test", "records", "save", "--json", r#"{"appId":1}"#]);
|
||||||
"test", "records", "save", "--json", r#"{"appId":1}"#,
|
|
||||||
]);
|
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
result,
|
result,
|
||||||
@@ -748,9 +858,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_cli_from_when_called_with_records_batch_get_it_should_return_cli() {
|
fn parse_cli_from_when_called_with_records_batch_get_it_should_return_cli() {
|
||||||
let result = parse_cli_from([
|
let result = parse_cli_from(["test", "records", "batch-get", "-f", "input.json"]);
|
||||||
"test", "records", "batch-get", "-f", "input.json",
|
|
||||||
]);
|
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
result,
|
result,
|
||||||
@@ -773,9 +881,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_cli_from_when_called_with_records_query_it_should_return_cli() {
|
fn parse_cli_from_when_called_with_records_query_it_should_return_cli() {
|
||||||
let result = parse_cli_from([
|
let result = parse_cli_from(["test", "records", "query", "--stdin", "-n", "1", "-s", "50"]);
|
||||||
"test", "records", "query", "--stdin", "-n", "1", "-s", "50",
|
|
||||||
]);
|
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
result,
|
result,
|
||||||
@@ -803,7 +909,11 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn parse_cli_from_when_called_with_records_batch_delete_it_should_return_cli() {
|
fn parse_cli_from_when_called_with_records_batch_delete_it_should_return_cli() {
|
||||||
let result = parse_cli_from([
|
let result = parse_cli_from([
|
||||||
"test", "records", "batch-delete", "-j", r#"{"appId":1,"recordIds":[10]}"#,
|
"test",
|
||||||
|
"records",
|
||||||
|
"batch-delete",
|
||||||
|
"-j",
|
||||||
|
r#"{"appId":1,"recordIds":[10]}"#,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -833,4 +943,113 @@ mod tests {
|
|||||||
let err = result.unwrap_err();
|
let err = result.unwrap_err();
|
||||||
assert_eq!(err.code, 2);
|
assert_eq!(err.code, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_cli_from_when_called_with_files_info_it_should_return_cli() {
|
||||||
|
let result = parse_cli_from([
|
||||||
|
"test", "files", "info", "--record", "1", "--field", "2", "--file", "3",
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
Ok(Cli {
|
||||||
|
api_key: None,
|
||||||
|
base_url: None,
|
||||||
|
pretty: false,
|
||||||
|
command: Command::Files {
|
||||||
|
command: FilesCommand::Info {
|
||||||
|
record_id: 1,
|
||||||
|
field_id: 2,
|
||||||
|
file_id: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_cli_from_when_called_with_files_get_it_should_return_cli() {
|
||||||
|
let result = parse_cli_from([
|
||||||
|
"test", "files", "get", "-r", "1", "-f", "2", "-l", "3", "-o", "out.bin",
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
Ok(Cli {
|
||||||
|
api_key: None,
|
||||||
|
base_url: None,
|
||||||
|
pretty: false,
|
||||||
|
command: Command::Files {
|
||||||
|
command: FilesCommand::Get {
|
||||||
|
record_id: 1,
|
||||||
|
field_id: 2,
|
||||||
|
file_id: 3,
|
||||||
|
output: Some(std::path::PathBuf::from("out.bin")),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_cli_from_when_called_with_files_upload_it_should_return_cli() {
|
||||||
|
let result = parse_cli_from([
|
||||||
|
"test", "files", "upload", "-r", "1", "-f", "2", "-i", "doc.pdf", "-n", "renamed.pdf",
|
||||||
|
"-t", "application/pdf", "--notes", "test notes", "--modified-date",
|
||||||
|
"2026-01-01T00:00:00Z",
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
Ok(Cli {
|
||||||
|
api_key: None,
|
||||||
|
base_url: None,
|
||||||
|
pretty: false,
|
||||||
|
command: Command::Files {
|
||||||
|
command: FilesCommand::Upload {
|
||||||
|
record_id: 1,
|
||||||
|
field_id: 2,
|
||||||
|
input: Some(std::path::PathBuf::from("doc.pdf")),
|
||||||
|
stdin: false,
|
||||||
|
file_name: Some("renamed.pdf".to_string()),
|
||||||
|
content_type: Some("application/pdf".to_string()),
|
||||||
|
notes: Some("test notes".to_string()),
|
||||||
|
modified_date: Some("2026-01-01T00:00:00Z".to_string()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_cli_from_when_called_with_files_delete_it_should_return_cli() {
|
||||||
|
let result = parse_cli_from([
|
||||||
|
"test", "files", "delete", "--record", "10", "--field", "20", "--file", "30",
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
Ok(Cli {
|
||||||
|
api_key: None,
|
||||||
|
base_url: None,
|
||||||
|
pretty: false,
|
||||||
|
command: Command::Files {
|
||||||
|
command: FilesCommand::Delete {
|
||||||
|
record_id: 10,
|
||||||
|
field_id: 20,
|
||||||
|
file_id: 30,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_cli_from_when_called_with_files_without_subcommand_it_should_return_usage_error() {
|
||||||
|
let result = parse_cli_from(["test", "files"]);
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
let err = result.unwrap_err();
|
||||||
|
assert_eq!(err.code, 2);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+171
-3
@@ -1,7 +1,8 @@
|
|||||||
use onspring::{
|
use onspring::{
|
||||||
App, BatchDeleteRecordsRequest, BatchGetRecordsRequest, CollectionResponse, DataFormat, Field,
|
App, BatchDeleteRecordsRequest, BatchGetRecordsRequest, CollectionResponse,
|
||||||
OnspringClient, OnspringError, PagedResponse, PagingRequest, QueryRecordsRequest, Record,
|
CreatedWithIdResponse, DataFormat, Field, FileInfo, FileResponse, OnspringClient, OnspringError,
|
||||||
SaveRecordRequest, SaveRecordResponse,
|
PagedResponse, PagingRequest, QueryRecordsRequest, Record, SaveFileRequest, SaveRecordRequest,
|
||||||
|
SaveRecordResponse,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub trait OnspringRunner {
|
pub trait OnspringRunner {
|
||||||
@@ -80,6 +81,32 @@ pub trait OnspringRunner {
|
|||||||
&self,
|
&self,
|
||||||
request: BatchDeleteRecordsRequest,
|
request: BatchDeleteRecordsRequest,
|
||||||
) -> impl std::future::Future<Output = Result<(), OnspringError>> + Send;
|
) -> impl std::future::Future<Output = Result<(), OnspringError>> + Send;
|
||||||
|
|
||||||
|
fn get_file_info(
|
||||||
|
&self,
|
||||||
|
record_id: i32,
|
||||||
|
field_id: i32,
|
||||||
|
file_id: i32,
|
||||||
|
) -> impl std::future::Future<Output = Result<FileInfo, OnspringError>> + Send;
|
||||||
|
|
||||||
|
fn get_file(
|
||||||
|
&self,
|
||||||
|
record_id: i32,
|
||||||
|
field_id: i32,
|
||||||
|
file_id: i32,
|
||||||
|
) -> impl std::future::Future<Output = Result<FileResponse, OnspringError>> + Send;
|
||||||
|
|
||||||
|
fn upload_file(
|
||||||
|
&self,
|
||||||
|
request: SaveFileRequest,
|
||||||
|
) -> impl std::future::Future<Output = Result<CreatedWithIdResponse, OnspringError>> + Send;
|
||||||
|
|
||||||
|
fn delete_file(
|
||||||
|
&self,
|
||||||
|
record_id: i32,
|
||||||
|
field_id: i32,
|
||||||
|
file_id: i32,
|
||||||
|
) -> impl std::future::Future<Output = Result<(), OnspringError>> + Send;
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OnspringRunner for OnspringClient {
|
impl OnspringRunner for OnspringClient {
|
||||||
@@ -170,6 +197,40 @@ impl OnspringRunner for OnspringClient {
|
|||||||
) -> Result<(), OnspringError> {
|
) -> Result<(), OnspringError> {
|
||||||
self.batch_delete_records(request).await
|
self.batch_delete_records(request).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_file_info(
|
||||||
|
&self,
|
||||||
|
record_id: i32,
|
||||||
|
field_id: i32,
|
||||||
|
file_id: i32,
|
||||||
|
) -> Result<FileInfo, OnspringError> {
|
||||||
|
self.get_file_info(record_id, field_id, file_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_file(
|
||||||
|
&self,
|
||||||
|
record_id: i32,
|
||||||
|
field_id: i32,
|
||||||
|
file_id: i32,
|
||||||
|
) -> Result<FileResponse, OnspringError> {
|
||||||
|
self.get_file(record_id, field_id, file_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn upload_file(
|
||||||
|
&self,
|
||||||
|
request: SaveFileRequest,
|
||||||
|
) -> Result<CreatedWithIdResponse, OnspringError> {
|
||||||
|
self.upload_file(request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_file(
|
||||||
|
&self,
|
||||||
|
record_id: i32,
|
||||||
|
field_id: i32,
|
||||||
|
file_id: i32,
|
||||||
|
) -> Result<(), OnspringError> {
|
||||||
|
self.delete_file(record_id, field_id, file_id).await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -219,6 +280,25 @@ pub mod testing {
|
|||||||
pub query_records_request: Mutex<Option<QueryRecordsRequest>>,
|
pub query_records_request: Mutex<Option<QueryRecordsRequest>>,
|
||||||
pub query_records_paging: Mutex<Option<Option<PagingRequest>>>,
|
pub query_records_paging: Mutex<Option<Option<PagingRequest>>>,
|
||||||
pub batch_delete_records_request: Mutex<Option<BatchDeleteRecordsRequest>>,
|
pub batch_delete_records_request: Mutex<Option<BatchDeleteRecordsRequest>>,
|
||||||
|
|
||||||
|
pub get_file_info_result: Result<FileInfo, OnspringError>,
|
||||||
|
pub get_file_result: Result<FileResponse, OnspringError>,
|
||||||
|
pub upload_file_result: Result<CreatedWithIdResponse, OnspringError>,
|
||||||
|
pub delete_file_result: Result<(), OnspringError>,
|
||||||
|
|
||||||
|
pub get_file_info_record_id: Mutex<Option<i32>>,
|
||||||
|
pub get_file_info_field_id: Mutex<Option<i32>>,
|
||||||
|
pub get_file_info_file_id: Mutex<Option<i32>>,
|
||||||
|
|
||||||
|
pub get_file_record_id: Mutex<Option<i32>>,
|
||||||
|
pub get_file_field_id: Mutex<Option<i32>>,
|
||||||
|
pub get_file_file_id: Mutex<Option<i32>>,
|
||||||
|
|
||||||
|
pub upload_file_request: Mutex<Option<SaveFileRequest>>,
|
||||||
|
|
||||||
|
pub delete_file_record_id: Mutex<Option<i32>>,
|
||||||
|
pub delete_file_field_id: Mutex<Option<i32>>,
|
||||||
|
pub delete_file_file_id: Mutex<Option<i32>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for MockClient {
|
impl Default for MockClient {
|
||||||
@@ -322,6 +402,38 @@ pub mod testing {
|
|||||||
query_records_request: Mutex::new(None),
|
query_records_request: Mutex::new(None),
|
||||||
query_records_paging: Mutex::new(None),
|
query_records_paging: Mutex::new(None),
|
||||||
batch_delete_records_request: Mutex::new(None),
|
batch_delete_records_request: Mutex::new(None),
|
||||||
|
|
||||||
|
get_file_info_result: Ok(FileInfo {
|
||||||
|
file_type: None,
|
||||||
|
content_type: None,
|
||||||
|
name: None,
|
||||||
|
created_date: None,
|
||||||
|
modified_date: None,
|
||||||
|
owner: None,
|
||||||
|
notes: None,
|
||||||
|
file_href: None,
|
||||||
|
}),
|
||||||
|
get_file_result: Ok(FileResponse {
|
||||||
|
content_type: None,
|
||||||
|
file_name: None,
|
||||||
|
data: bytes::Bytes::new(),
|
||||||
|
}),
|
||||||
|
upload_file_result: Ok(CreatedWithIdResponse { id: 1 }),
|
||||||
|
delete_file_result: Ok(()),
|
||||||
|
|
||||||
|
get_file_info_record_id: Mutex::new(None),
|
||||||
|
get_file_info_field_id: Mutex::new(None),
|
||||||
|
get_file_info_file_id: Mutex::new(None),
|
||||||
|
|
||||||
|
get_file_record_id: Mutex::new(None),
|
||||||
|
get_file_field_id: Mutex::new(None),
|
||||||
|
get_file_file_id: Mutex::new(None),
|
||||||
|
|
||||||
|
upload_file_request: Mutex::new(None),
|
||||||
|
|
||||||
|
delete_file_record_id: Mutex::new(None),
|
||||||
|
delete_file_field_id: Mutex::new(None),
|
||||||
|
delete_file_file_id: Mutex::new(None),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -496,5 +608,61 @@ pub mod testing {
|
|||||||
Err(err) => Err(clone_onspring_error(err)),
|
Err(err) => Err(clone_onspring_error(err)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_file_info(
|
||||||
|
&self,
|
||||||
|
record_id: i32,
|
||||||
|
field_id: i32,
|
||||||
|
file_id: i32,
|
||||||
|
) -> Result<FileInfo, OnspringError> {
|
||||||
|
*self.get_file_info_record_id.lock().unwrap() = Some(record_id);
|
||||||
|
*self.get_file_info_field_id.lock().unwrap() = Some(field_id);
|
||||||
|
*self.get_file_info_file_id.lock().unwrap() = Some(file_id);
|
||||||
|
match &self.get_file_info_result {
|
||||||
|
Ok(res) => Ok(res.clone()),
|
||||||
|
Err(err) => Err(clone_onspring_error(err)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_file(
|
||||||
|
&self,
|
||||||
|
record_id: i32,
|
||||||
|
field_id: i32,
|
||||||
|
file_id: i32,
|
||||||
|
) -> Result<FileResponse, OnspringError> {
|
||||||
|
*self.get_file_record_id.lock().unwrap() = Some(record_id);
|
||||||
|
*self.get_file_field_id.lock().unwrap() = Some(field_id);
|
||||||
|
*self.get_file_file_id.lock().unwrap() = Some(file_id);
|
||||||
|
match &self.get_file_result {
|
||||||
|
Ok(res) => Ok(res.clone()),
|
||||||
|
Err(err) => Err(clone_onspring_error(err)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn upload_file(
|
||||||
|
&self,
|
||||||
|
request: SaveFileRequest,
|
||||||
|
) -> Result<CreatedWithIdResponse, OnspringError> {
|
||||||
|
*self.upload_file_request.lock().unwrap() = Some(request);
|
||||||
|
match &self.upload_file_result {
|
||||||
|
Ok(res) => Ok(res.clone()),
|
||||||
|
Err(err) => Err(clone_onspring_error(err)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_file(
|
||||||
|
&self,
|
||||||
|
record_id: i32,
|
||||||
|
field_id: i32,
|
||||||
|
file_id: i32,
|
||||||
|
) -> Result<(), OnspringError> {
|
||||||
|
*self.delete_file_record_id.lock().unwrap() = Some(record_id);
|
||||||
|
*self.delete_file_field_id.lock().unwrap() = Some(field_id);
|
||||||
|
*self.delete_file_file_id.lock().unwrap() = Some(file_id);
|
||||||
|
match &self.delete_file_result {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(err) => Err(clone_onspring_error(err)),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,965 @@
|
|||||||
|
use std::io::{Read, Write};
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use onspring::SaveFileRequest;
|
||||||
|
|
||||||
|
use crate::cli::FilesCommand;
|
||||||
|
use crate::client::OnspringRunner;
|
||||||
|
use crate::error::{CliError, CliResult};
|
||||||
|
use crate::output::{CreatedWithIdResponseOutput, FileInfoOutput, SuccessResponse, write_json};
|
||||||
|
use crate::validation::validate_positive_i32;
|
||||||
|
|
||||||
|
pub async fn handle<C: OnspringRunner, W: Write>(
|
||||||
|
command: &FilesCommand,
|
||||||
|
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<C: OnspringRunner, W: Write, R: Read>(
|
||||||
|
command: &FilesCommand,
|
||||||
|
client: &C,
|
||||||
|
writer: &mut W,
|
||||||
|
mut reader: R,
|
||||||
|
pretty: bool,
|
||||||
|
) -> CliResult<()> {
|
||||||
|
match command {
|
||||||
|
FilesCommand::Info {
|
||||||
|
record_id,
|
||||||
|
field_id,
|
||||||
|
file_id,
|
||||||
|
} => {
|
||||||
|
validate_positive_i32(*record_id, "record_id")?;
|
||||||
|
validate_positive_i32(*field_id, "field_id")?;
|
||||||
|
validate_positive_i32(*file_id, "file_id")?;
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.get_file_info(*record_id, *field_id, *file_id)
|
||||||
|
.await?;
|
||||||
|
let output: FileInfoOutput = response.into();
|
||||||
|
write_json(writer, &output, pretty)?;
|
||||||
|
}
|
||||||
|
FilesCommand::Get {
|
||||||
|
record_id,
|
||||||
|
field_id,
|
||||||
|
file_id,
|
||||||
|
output,
|
||||||
|
} => {
|
||||||
|
validate_positive_i32(*record_id, "record_id")?;
|
||||||
|
validate_positive_i32(*field_id, "field_id")?;
|
||||||
|
validate_positive_i32(*file_id, "file_id")?;
|
||||||
|
|
||||||
|
let response = client.get_file(*record_id, *field_id, *file_id).await?;
|
||||||
|
|
||||||
|
if let Some(output_path) = output {
|
||||||
|
std::fs::write(output_path, &response.data).map_err(|e| {
|
||||||
|
CliError::runtime(format!(
|
||||||
|
"Failed to write file to '{}': {e}",
|
||||||
|
output_path.display()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
} else {
|
||||||
|
writer
|
||||||
|
.write_all(&response.data)
|
||||||
|
.map_err(|_| CliError::runtime("Failed to write output"))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FilesCommand::Upload {
|
||||||
|
record_id,
|
||||||
|
field_id,
|
||||||
|
input,
|
||||||
|
stdin,
|
||||||
|
file_name,
|
||||||
|
content_type,
|
||||||
|
notes,
|
||||||
|
modified_date,
|
||||||
|
} => {
|
||||||
|
validate_positive_i32(*record_id, "record_id")?;
|
||||||
|
validate_positive_i32(*field_id, "field_id")?;
|
||||||
|
|
||||||
|
if input.is_none() && !*stdin {
|
||||||
|
return Err(CliError::usage(
|
||||||
|
"A file source is required. Provide --input or --stdin.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if input.is_some() && *stdin {
|
||||||
|
return Err(CliError::usage(
|
||||||
|
"Only one file source may be specified (--input or --stdin).",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let (file_bytes, resolved_file_name) = if let Some(path) = input {
|
||||||
|
let bytes = std::fs::read(path).map_err(|e| {
|
||||||
|
CliError::usage(format!("Failed to read file '{}': {e}", path.display()))
|
||||||
|
})?;
|
||||||
|
let default_name = path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.map(String::from);
|
||||||
|
let name = file_name.clone().or(default_name).ok_or_else(|| {
|
||||||
|
CliError::usage("A file name is required. Provide --name.")
|
||||||
|
})?;
|
||||||
|
(bytes, name)
|
||||||
|
} else {
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
reader
|
||||||
|
.read_to_end(&mut buffer)
|
||||||
|
.map_err(|e| CliError::runtime(format!("Failed to read from stdin: {e}")))?;
|
||||||
|
let name = file_name
|
||||||
|
.clone()
|
||||||
|
.ok_or_else(|| CliError::usage("A file name is required when reading from stdin. Provide --name."))?;
|
||||||
|
(buffer, name)
|
||||||
|
};
|
||||||
|
|
||||||
|
let resolved_content_type = match content_type {
|
||||||
|
Some(ct) => ct.clone(),
|
||||||
|
None => mime_guess::from_path(&resolved_file_name)
|
||||||
|
.first_or_octet_stream()
|
||||||
|
.to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let parsed_modified_date = match modified_date {
|
||||||
|
Some(date_str) => Some(DateTime::parse_from_rfc3339(date_str).map_err(|e| {
|
||||||
|
CliError::usage(format!(
|
||||||
|
"Invalid modified_date '{date_str}'. Expected RFC3339 format: {e}"
|
||||||
|
))
|
||||||
|
})?.with_timezone(&Utc)),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let request = SaveFileRequest {
|
||||||
|
record_id: *record_id,
|
||||||
|
field_id: *field_id,
|
||||||
|
notes: notes.clone(),
|
||||||
|
modified_date: parsed_modified_date,
|
||||||
|
file_name: resolved_file_name,
|
||||||
|
file_data: file_bytes,
|
||||||
|
content_type: resolved_content_type,
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = client.upload_file(request).await?;
|
||||||
|
let output: CreatedWithIdResponseOutput = response.into();
|
||||||
|
write_json(writer, &output, pretty)?;
|
||||||
|
}
|
||||||
|
FilesCommand::Delete {
|
||||||
|
record_id,
|
||||||
|
field_id,
|
||||||
|
file_id,
|
||||||
|
} => {
|
||||||
|
validate_positive_i32(*record_id, "record_id")?;
|
||||||
|
validate_positive_i32(*field_id, "field_id")?;
|
||||||
|
validate_positive_i32(*file_id, "file_id")?;
|
||||||
|
|
||||||
|
client.delete_file(*record_id, *field_id, *file_id).await?;
|
||||||
|
write_json(writer, &SuccessResponse { ok: true }, pretty)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use bytes::Bytes;
|
||||||
|
use chrono::TimeZone;
|
||||||
|
use onspring::{CreatedWithIdResponse, FileInfo, FileResponse, OnspringError};
|
||||||
|
|
||||||
|
use crate::client::testing::MockClient;
|
||||||
|
use crate::error::CliError;
|
||||||
|
|
||||||
|
// --- Info tests ---
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_files_info_succeeds_it_should_write_file_info_output() {
|
||||||
|
let created = Utc.with_ymd_and_hms(2026, 1, 15, 10, 0, 0).unwrap();
|
||||||
|
let modified = Utc.with_ymd_and_hms(2026, 1, 16, 12, 0, 0).unwrap();
|
||||||
|
|
||||||
|
let mock_client = MockClient {
|
||||||
|
get_file_info_result: Ok(FileInfo {
|
||||||
|
file_type: Some("Attachment".to_string()),
|
||||||
|
content_type: Some("application/pdf".to_string()),
|
||||||
|
name: Some("test.pdf".to_string()),
|
||||||
|
created_date: Some(created),
|
||||||
|
modified_date: Some(modified),
|
||||||
|
owner: Some("User 1".to_string()),
|
||||||
|
notes: Some("Some notes".to_string()),
|
||||||
|
file_href: Some("https://api.onspring.com/files/3/file".to_string()),
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let command = FilesCommand::Info {
|
||||||
|
record_id: 1,
|
||||||
|
field_id: 2,
|
||||||
|
file_id: 3,
|
||||||
|
};
|
||||||
|
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#"{"type":"Attachment","contentType":"application/pdf","name":"test.pdf","createdDate":"2026-01-15T10:00:00Z","modifiedDate":"2026-01-16T12:00:00Z","owner":"User 1","notes":"Some notes","fileHref":"https://api.onspring.com/files/3/file"}"#
|
||||||
|
);
|
||||||
|
assert_eq!(*mock_client.get_file_info_record_id.lock().unwrap(), Some(1));
|
||||||
|
assert_eq!(*mock_client.get_file_info_field_id.lock().unwrap(), Some(2));
|
||||||
|
assert_eq!(*mock_client.get_file_info_file_id.lock().unwrap(), Some(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_files_info_succeeds_pretty_it_should_write_pretty_json() {
|
||||||
|
let mock_client = MockClient {
|
||||||
|
get_file_info_result: Ok(FileInfo {
|
||||||
|
file_type: Some("Attachment".to_string()),
|
||||||
|
content_type: Some("text/plain".to_string()),
|
||||||
|
name: Some("note.txt".to_string()),
|
||||||
|
created_date: None,
|
||||||
|
modified_date: None,
|
||||||
|
owner: None,
|
||||||
|
notes: None,
|
||||||
|
file_href: None,
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let command = FilesCommand::Info {
|
||||||
|
record_id: 1,
|
||||||
|
field_id: 2,
|
||||||
|
file_id: 3,
|
||||||
|
};
|
||||||
|
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",
|
||||||
|
" \"type\": \"Attachment\",\n",
|
||||||
|
" \"contentType\": \"text/plain\",\n",
|
||||||
|
" \"name\": \"note.txt\",\n",
|
||||||
|
" \"createdDate\": null,\n",
|
||||||
|
" \"modifiedDate\": null,\n",
|
||||||
|
" \"owner\": null,\n",
|
||||||
|
" \"notes\": null,\n",
|
||||||
|
" \"fileHref\": null\n",
|
||||||
|
"}"
|
||||||
|
);
|
||||||
|
assert_eq!(written.trim(), expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_files_info_with_invalid_record_id_it_should_return_usage_error() {
|
||||||
|
let mock_client = MockClient::default();
|
||||||
|
|
||||||
|
let command = FilesCommand::Info {
|
||||||
|
record_id: 0,
|
||||||
|
field_id: 2,
|
||||||
|
file_id: 3,
|
||||||
|
};
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_files_info_with_invalid_field_id_it_should_return_usage_error() {
|
||||||
|
let mock_client = MockClient::default();
|
||||||
|
|
||||||
|
let command = FilesCommand::Info {
|
||||||
|
record_id: 1,
|
||||||
|
field_id: -1,
|
||||||
|
file_id: 3,
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
let result = handle(&command, &mock_client, &mut buffer, false).await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
Err(CliError::usage("field_id must be greater than 0."))
|
||||||
|
);
|
||||||
|
assert!(buffer.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_files_info_with_invalid_file_id_it_should_return_usage_error() {
|
||||||
|
let mock_client = MockClient::default();
|
||||||
|
|
||||||
|
let command = FilesCommand::Info {
|
||||||
|
record_id: 1,
|
||||||
|
field_id: 2,
|
||||||
|
file_id: 0,
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
let result = handle(&command, &mock_client, &mut buffer, false).await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
Err(CliError::usage("file_id must be greater than 0."))
|
||||||
|
);
|
||||||
|
assert!(buffer.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_files_info_fails_it_should_return_mapped_cli_error() {
|
||||||
|
let mock_client = MockClient {
|
||||||
|
get_file_info_result: Err(OnspringError::Api {
|
||||||
|
status_code: 404,
|
||||||
|
message: "File not found".to_string(),
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let command = FilesCommand::Info {
|
||||||
|
record_id: 1,
|
||||||
|
field_id: 2,
|
||||||
|
file_id: 3,
|
||||||
|
};
|
||||||
|
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): File not found"))
|
||||||
|
);
|
||||||
|
assert!(buffer.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Get tests ---
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_files_get_without_output_flag_it_should_write_bytes_to_writer() {
|
||||||
|
let file_bytes = b"Hello, World!";
|
||||||
|
let mock_client = MockClient {
|
||||||
|
get_file_result: Ok(FileResponse {
|
||||||
|
content_type: Some("text/plain".to_string()),
|
||||||
|
file_name: Some("hello.txt".to_string()),
|
||||||
|
data: Bytes::from_static(file_bytes),
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let command = FilesCommand::Get {
|
||||||
|
record_id: 10,
|
||||||
|
field_id: 20,
|
||||||
|
file_id: 30,
|
||||||
|
output: None,
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
let result = handle(&command, &mock_client, &mut buffer, false).await;
|
||||||
|
|
||||||
|
assert_eq!(result, Ok(()));
|
||||||
|
assert_eq!(buffer, file_bytes);
|
||||||
|
assert_eq!(*mock_client.get_file_record_id.lock().unwrap(), Some(10));
|
||||||
|
assert_eq!(*mock_client.get_file_field_id.lock().unwrap(), Some(20));
|
||||||
|
assert_eq!(*mock_client.get_file_file_id.lock().unwrap(), Some(30));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_files_get_with_output_flag_it_should_write_to_file() {
|
||||||
|
let temp_dir = tempfile::tempdir().unwrap();
|
||||||
|
let file_path = temp_dir.path().join("downloaded.bin");
|
||||||
|
let file_bytes = vec![1, 2, 3, 4, 5];
|
||||||
|
|
||||||
|
let mock_client = MockClient {
|
||||||
|
get_file_result: Ok(FileResponse {
|
||||||
|
content_type: Some("application/octet-stream".to_string()),
|
||||||
|
file_name: Some("data.bin".to_string()),
|
||||||
|
data: Bytes::from(file_bytes.clone()),
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let command = FilesCommand::Get {
|
||||||
|
record_id: 1,
|
||||||
|
field_id: 2,
|
||||||
|
file_id: 3,
|
||||||
|
output: Some(file_path.clone()),
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
let result = handle(&command, &mock_client, &mut buffer, false).await;
|
||||||
|
|
||||||
|
assert_eq!(result, Ok(()));
|
||||||
|
assert!(buffer.is_empty());
|
||||||
|
let read_back = std::fs::read(&file_path).unwrap();
|
||||||
|
assert_eq!(read_back, file_bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_files_get_with_invalid_output_path_it_should_return_runtime_error() {
|
||||||
|
let mock_client = MockClient {
|
||||||
|
get_file_result: Ok(FileResponse {
|
||||||
|
content_type: None,
|
||||||
|
file_name: None,
|
||||||
|
data: Bytes::from_static(b"data"),
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let command = FilesCommand::Get {
|
||||||
|
record_id: 1,
|
||||||
|
field_id: 2,
|
||||||
|
file_id: 3,
|
||||||
|
output: Some(PathBuf::from("/nonexistent_dir_12345/sub/file.txt")),
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
let result = handle(&command, &mock_client, &mut buffer, false).await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
let err = result.unwrap_err();
|
||||||
|
assert_eq!(err.code, 1);
|
||||||
|
assert!(err.message.contains("Failed to write file to"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_files_get_with_invalid_ids_it_should_return_usage_error() {
|
||||||
|
let mock_client = MockClient::default();
|
||||||
|
|
||||||
|
let command = FilesCommand::Get {
|
||||||
|
record_id: 0,
|
||||||
|
field_id: 1,
|
||||||
|
file_id: 1,
|
||||||
|
output: None,
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
assert_eq!(
|
||||||
|
handle(&command, &mock_client, &mut buffer, false).await,
|
||||||
|
Err(CliError::usage("record_id must be greater than 0."))
|
||||||
|
);
|
||||||
|
|
||||||
|
let command = FilesCommand::Get {
|
||||||
|
record_id: 1,
|
||||||
|
field_id: 0,
|
||||||
|
file_id: 1,
|
||||||
|
output: None,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
handle(&command, &mock_client, &mut buffer, false).await,
|
||||||
|
Err(CliError::usage("field_id must be greater than 0."))
|
||||||
|
);
|
||||||
|
|
||||||
|
let command = FilesCommand::Get {
|
||||||
|
record_id: 1,
|
||||||
|
field_id: 1,
|
||||||
|
file_id: 0,
|
||||||
|
output: None,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
handle(&command, &mock_client, &mut buffer, false).await,
|
||||||
|
Err(CliError::usage("file_id must be greater than 0."))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_files_get_fails_it_should_return_mapped_cli_error() {
|
||||||
|
let mock_client = MockClient {
|
||||||
|
get_file_result: Err(OnspringError::Api {
|
||||||
|
status_code: 500,
|
||||||
|
message: "Internal error".to_string(),
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let command = FilesCommand::Get {
|
||||||
|
record_id: 1,
|
||||||
|
field_id: 2,
|
||||||
|
file_id: 3,
|
||||||
|
output: 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 (500): Internal error"
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Upload tests ---
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_files_upload_from_input_file_succeeds() {
|
||||||
|
let temp_file = tempfile::NamedTempFile::new().unwrap();
|
||||||
|
std::fs::write(temp_file.path(), b"Upload content").unwrap();
|
||||||
|
|
||||||
|
let mock_client = MockClient {
|
||||||
|
upload_file_result: Ok(CreatedWithIdResponse { id: 99 }),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let command = FilesCommand::Upload {
|
||||||
|
record_id: 1,
|
||||||
|
field_id: 2,
|
||||||
|
input: Some(temp_file.path().to_path_buf()),
|
||||||
|
stdin: false,
|
||||||
|
file_name: Some("custom_name.txt".to_string()),
|
||||||
|
content_type: Some("text/custom".to_string()),
|
||||||
|
notes: Some("Upload note".to_string()),
|
||||||
|
modified_date: Some("2026-03-01T12:00:00Z".to_string()),
|
||||||
|
};
|
||||||
|
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":99}"#);
|
||||||
|
|
||||||
|
let req = mock_client.upload_file_request.lock().unwrap().clone().unwrap();
|
||||||
|
assert_eq!(req.record_id, 1);
|
||||||
|
assert_eq!(req.field_id, 2);
|
||||||
|
assert_eq!(req.file_name, "custom_name.txt");
|
||||||
|
assert_eq!(req.content_type, "text/custom");
|
||||||
|
assert_eq!(req.file_data, b"Upload content");
|
||||||
|
assert_eq!(req.notes, Some("Upload note".to_string()));
|
||||||
|
assert_eq!(
|
||||||
|
req.modified_date,
|
||||||
|
Some(Utc.with_ymd_and_hms(2026, 3, 1, 12, 0, 0).unwrap())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_files_upload_succeeds_pretty_it_should_write_pretty_json() {
|
||||||
|
let temp_file = tempfile::NamedTempFile::new().unwrap();
|
||||||
|
std::fs::write(temp_file.path(), b"content").unwrap();
|
||||||
|
|
||||||
|
let mock_client = MockClient {
|
||||||
|
upload_file_result: Ok(CreatedWithIdResponse { id: 101 }),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let command = FilesCommand::Upload {
|
||||||
|
record_id: 1,
|
||||||
|
field_id: 2,
|
||||||
|
input: Some(temp_file.path().to_path_buf()),
|
||||||
|
stdin: false,
|
||||||
|
file_name: Some("data.txt".to_string()),
|
||||||
|
content_type: None,
|
||||||
|
notes: None,
|
||||||
|
modified_date: 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", " \"id\": 101\n", "}");
|
||||||
|
assert_eq!(written.trim(), expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_files_upload_infers_file_name_and_content_type_from_input() {
|
||||||
|
let temp_dir = tempfile::tempdir().unwrap();
|
||||||
|
let file_path = temp_dir.path().join("report.csv");
|
||||||
|
std::fs::write(&file_path, b"a,b,c\n1,2,3").unwrap();
|
||||||
|
|
||||||
|
let mock_client = MockClient {
|
||||||
|
upload_file_result: Ok(CreatedWithIdResponse { id: 50 }),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let command = FilesCommand::Upload {
|
||||||
|
record_id: 1,
|
||||||
|
field_id: 2,
|
||||||
|
input: Some(file_path),
|
||||||
|
stdin: false,
|
||||||
|
file_name: None,
|
||||||
|
content_type: None,
|
||||||
|
notes: None,
|
||||||
|
modified_date: None,
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
let result = handle(&command, &mock_client, &mut buffer, false).await;
|
||||||
|
|
||||||
|
assert_eq!(result, Ok(()));
|
||||||
|
|
||||||
|
let req = mock_client.upload_file_request.lock().unwrap().clone().unwrap();
|
||||||
|
assert_eq!(req.file_name, "report.csv");
|
||||||
|
assert_eq!(req.content_type, "text/csv");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_files_upload_reads_from_stdin() {
|
||||||
|
let mock_client = MockClient {
|
||||||
|
upload_file_result: Ok(CreatedWithIdResponse { id: 75 }),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let command = FilesCommand::Upload {
|
||||||
|
record_id: 5,
|
||||||
|
field_id: 10,
|
||||||
|
input: None,
|
||||||
|
stdin: true,
|
||||||
|
file_name: Some("data.json".to_string()),
|
||||||
|
content_type: None,
|
||||||
|
notes: None,
|
||||||
|
modified_date: None,
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
let stdin_bytes = b"{\"hello\":\"world\"}";
|
||||||
|
|
||||||
|
let result = handle_with_reader(
|
||||||
|
&command,
|
||||||
|
&mock_client,
|
||||||
|
&mut buffer,
|
||||||
|
&stdin_bytes[..],
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(result, Ok(()));
|
||||||
|
|
||||||
|
let req = mock_client.upload_file_request.lock().unwrap().clone().unwrap();
|
||||||
|
assert_eq!(req.record_id, 5);
|
||||||
|
assert_eq!(req.field_id, 10);
|
||||||
|
assert_eq!(req.file_name, "data.json");
|
||||||
|
assert_eq!(req.content_type, "application/json");
|
||||||
|
assert_eq!(req.file_data, stdin_bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_files_upload_without_source_it_should_return_usage_error() {
|
||||||
|
let mock_client = MockClient::default();
|
||||||
|
|
||||||
|
let command = FilesCommand::Upload {
|
||||||
|
record_id: 1,
|
||||||
|
field_id: 2,
|
||||||
|
input: None,
|
||||||
|
stdin: false,
|
||||||
|
file_name: Some("file.txt".to_string()),
|
||||||
|
content_type: None,
|
||||||
|
notes: None,
|
||||||
|
modified_date: None,
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
let result = handle(&command, &mock_client, &mut buffer, false).await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
Err(CliError::usage(
|
||||||
|
"A file source is required. Provide --input or --stdin."
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_files_upload_with_both_sources_it_should_return_usage_error() {
|
||||||
|
let mock_client = MockClient::default();
|
||||||
|
|
||||||
|
let command = FilesCommand::Upload {
|
||||||
|
record_id: 1,
|
||||||
|
field_id: 2,
|
||||||
|
input: Some(PathBuf::from("test.txt")),
|
||||||
|
stdin: true,
|
||||||
|
file_name: Some("file.txt".to_string()),
|
||||||
|
content_type: None,
|
||||||
|
notes: None,
|
||||||
|
modified_date: None,
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
let result = handle(&command, &mock_client, &mut buffer, false).await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
Err(CliError::usage(
|
||||||
|
"Only one file source may be specified (--input or --stdin)."
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_files_upload_input_file_not_found_it_should_return_usage_error() {
|
||||||
|
let mock_client = MockClient::default();
|
||||||
|
|
||||||
|
let command = FilesCommand::Upload {
|
||||||
|
record_id: 1,
|
||||||
|
field_id: 2,
|
||||||
|
input: Some(PathBuf::from("nonexistent_file_12345.txt")),
|
||||||
|
stdin: false,
|
||||||
|
file_name: None,
|
||||||
|
content_type: None,
|
||||||
|
notes: None,
|
||||||
|
modified_date: None,
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
let result = handle(&command, &mock_client, &mut buffer, false).await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
let err = result.unwrap_err();
|
||||||
|
assert_eq!(err.code, 2);
|
||||||
|
assert!(err.message.contains("Failed to read file"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_files_upload_stdin_missing_name_it_should_return_usage_error() {
|
||||||
|
let mock_client = MockClient::default();
|
||||||
|
|
||||||
|
let command = FilesCommand::Upload {
|
||||||
|
record_id: 1,
|
||||||
|
field_id: 2,
|
||||||
|
input: None,
|
||||||
|
stdin: true,
|
||||||
|
file_name: None,
|
||||||
|
content_type: None,
|
||||||
|
notes: None,
|
||||||
|
modified_date: None,
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
let result = handle_with_reader(
|
||||||
|
&command,
|
||||||
|
&mock_client,
|
||||||
|
&mut buffer,
|
||||||
|
std::io::empty(),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
Err(CliError::usage(
|
||||||
|
"A file name is required when reading from stdin. Provide --name."
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_files_upload_invalid_modified_date_it_should_return_usage_error() {
|
||||||
|
let temp_file = tempfile::NamedTempFile::new().unwrap();
|
||||||
|
|
||||||
|
let mock_client = MockClient::default();
|
||||||
|
|
||||||
|
let command = FilesCommand::Upload {
|
||||||
|
record_id: 1,
|
||||||
|
field_id: 2,
|
||||||
|
input: Some(temp_file.path().to_path_buf()),
|
||||||
|
stdin: false,
|
||||||
|
file_name: None,
|
||||||
|
content_type: None,
|
||||||
|
notes: None,
|
||||||
|
modified_date: Some("not-a-date".to_string()),
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
let result = handle(&command, &mock_client, &mut buffer, false).await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
let err = result.unwrap_err();
|
||||||
|
assert_eq!(err.code, 2);
|
||||||
|
assert!(err.message.contains("Invalid modified_date"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_files_upload_with_invalid_ids_it_should_return_usage_error() {
|
||||||
|
let mock_client = MockClient::default();
|
||||||
|
|
||||||
|
let command = FilesCommand::Upload {
|
||||||
|
record_id: 0,
|
||||||
|
field_id: 2,
|
||||||
|
input: Some(PathBuf::from("test.txt")),
|
||||||
|
stdin: false,
|
||||||
|
file_name: None,
|
||||||
|
content_type: None,
|
||||||
|
notes: None,
|
||||||
|
modified_date: None,
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
assert_eq!(
|
||||||
|
handle(&command, &mock_client, &mut buffer, false).await,
|
||||||
|
Err(CliError::usage("record_id must be greater than 0."))
|
||||||
|
);
|
||||||
|
|
||||||
|
let command = FilesCommand::Upload {
|
||||||
|
record_id: 1,
|
||||||
|
field_id: 0,
|
||||||
|
input: Some(PathBuf::from("test.txt")),
|
||||||
|
stdin: false,
|
||||||
|
file_name: None,
|
||||||
|
content_type: None,
|
||||||
|
notes: None,
|
||||||
|
modified_date: None,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
handle(&command, &mock_client, &mut buffer, false).await,
|
||||||
|
Err(CliError::usage("field_id must be greater than 0."))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_files_upload_fails_it_should_return_mapped_cli_error() {
|
||||||
|
let temp_file = tempfile::NamedTempFile::new().unwrap();
|
||||||
|
|
||||||
|
let mock_client = MockClient {
|
||||||
|
upload_file_result: Err(OnspringError::Api {
|
||||||
|
status_code: 400,
|
||||||
|
message: "Invalid file type".to_string(),
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let command = FilesCommand::Upload {
|
||||||
|
record_id: 1,
|
||||||
|
field_id: 2,
|
||||||
|
input: Some(temp_file.path().to_path_buf()),
|
||||||
|
stdin: false,
|
||||||
|
file_name: None,
|
||||||
|
content_type: None,
|
||||||
|
notes: None,
|
||||||
|
modified_date: 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 (400): Invalid file type"
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Delete tests ---
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_files_delete_succeeds_it_should_write_success_response() {
|
||||||
|
let mock_client = MockClient {
|
||||||
|
delete_file_result: Ok(()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let command = FilesCommand::Delete {
|
||||||
|
record_id: 1,
|
||||||
|
field_id: 2,
|
||||||
|
file_id: 3,
|
||||||
|
};
|
||||||
|
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_file_record_id.lock().unwrap(), Some(1));
|
||||||
|
assert_eq!(*mock_client.delete_file_field_id.lock().unwrap(), Some(2));
|
||||||
|
assert_eq!(*mock_client.delete_file_file_id.lock().unwrap(), Some(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_files_delete_succeeds_pretty_it_should_write_pretty_json() {
|
||||||
|
let mock_client = MockClient {
|
||||||
|
delete_file_result: Ok(()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let command = FilesCommand::Delete {
|
||||||
|
record_id: 1,
|
||||||
|
field_id: 2,
|
||||||
|
file_id: 3,
|
||||||
|
};
|
||||||
|
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_files_delete_with_invalid_ids_it_should_return_usage_error() {
|
||||||
|
let mock_client = MockClient::default();
|
||||||
|
|
||||||
|
let command = FilesCommand::Delete {
|
||||||
|
record_id: 0,
|
||||||
|
field_id: 2,
|
||||||
|
file_id: 3,
|
||||||
|
};
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
assert_eq!(
|
||||||
|
handle(&command, &mock_client, &mut buffer, false).await,
|
||||||
|
Err(CliError::usage("record_id must be greater than 0."))
|
||||||
|
);
|
||||||
|
|
||||||
|
let command = FilesCommand::Delete {
|
||||||
|
record_id: 1,
|
||||||
|
field_id: 0,
|
||||||
|
file_id: 3,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
handle(&command, &mock_client, &mut buffer, false).await,
|
||||||
|
Err(CliError::usage("field_id must be greater than 0."))
|
||||||
|
);
|
||||||
|
|
||||||
|
let command = FilesCommand::Delete {
|
||||||
|
record_id: 1,
|
||||||
|
field_id: 2,
|
||||||
|
file_id: -1,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
handle(&command, &mock_client, &mut buffer, false).await,
|
||||||
|
Err(CliError::usage("file_id must be greater than 0."))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_when_files_delete_fails_it_should_return_mapped_cli_error() {
|
||||||
|
let mock_client = MockClient {
|
||||||
|
delete_file_result: Err(OnspringError::Api {
|
||||||
|
status_code: 404,
|
||||||
|
message: "File not found".to_string(),
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let command = FilesCommand::Delete {
|
||||||
|
record_id: 1,
|
||||||
|
field_id: 2,
|
||||||
|
file_id: 3,
|
||||||
|
};
|
||||||
|
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): File not found"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+4
-2
@@ -2,6 +2,7 @@ pub mod apps;
|
|||||||
pub mod fields;
|
pub mod fields;
|
||||||
pub mod ping;
|
pub mod ping;
|
||||||
pub mod records;
|
pub mod records;
|
||||||
|
pub mod files;
|
||||||
|
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ pub async fn handle<C: OnspringRunner, W: Write>(
|
|||||||
Command::Ping => ping::handle(client, writer, cli.pretty).await,
|
Command::Ping => ping::handle(client, writer, cli.pretty).await,
|
||||||
Command::Apps { command } => apps::handle(command, client, writer, cli.pretty).await,
|
Command::Apps { command } => apps::handle(command, client, writer, cli.pretty).await,
|
||||||
Command::Fields { command } => fields::handle(command, client, writer, cli.pretty).await,
|
Command::Fields { command } => fields::handle(command, client, writer, cli.pretty).await,
|
||||||
Command::Records { command } => records::handle(command, client, writer, cli.pretty).await
|
Command::Records { command } => records::handle(command, client, writer, cli.pretty).await,
|
||||||
}
|
Command::Files { command } => files::handle(command, client, writer, cli.pretty).await,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+45
-2
@@ -1,8 +1,10 @@
|
|||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
use onspring::{
|
use onspring::{
|
||||||
App, CollectionResponse, Field, FormulaOutputType, ListFieldValue, Multiplicity, PagedResponse,
|
App, CollectionResponse, CreatedWithIdResponse, Field, FileInfo, FormulaOutputType,
|
||||||
Record, RecordFieldValue, SaveRecordResponse, ValueType,
|
ListFieldValue, Multiplicity, PagedResponse, Record, RecordFieldValue, SaveRecordResponse,
|
||||||
|
ValueType,
|
||||||
};
|
};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -208,6 +210,47 @@ impl From<SaveRecordResponse> for SaveRecordResponseOutput {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct FileInfoOutput {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub file_type: Option<String>,
|
||||||
|
pub content_type: Option<String>,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub created_date: Option<DateTime<Utc>>,
|
||||||
|
pub modified_date: Option<DateTime<Utc>>,
|
||||||
|
pub owner: Option<String>,
|
||||||
|
pub notes: Option<String>,
|
||||||
|
pub file_href: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<FileInfo> for FileInfoOutput {
|
||||||
|
fn from(value: FileInfo) -> Self {
|
||||||
|
Self {
|
||||||
|
file_type: value.file_type,
|
||||||
|
content_type: value.content_type,
|
||||||
|
name: value.name,
|
||||||
|
created_date: value.created_date,
|
||||||
|
modified_date: value.modified_date,
|
||||||
|
owner: value.owner,
|
||||||
|
notes: value.notes,
|
||||||
|
file_href: value.file_href,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CreatedWithIdResponseOutput {
|
||||||
|
pub id: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<CreatedWithIdResponse> for CreatedWithIdResponseOutput {
|
||||||
|
fn from(value: CreatedWithIdResponse) -> Self {
|
||||||
|
Self { id: value.id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn render_json<T: Serialize>(value: &T, pretty: bool) -> CliResult<String> {
|
pub fn render_json<T: Serialize>(value: &T, pretty: bool) -> CliResult<String> {
|
||||||
if pretty {
|
if pretty {
|
||||||
serde_json::to_string_pretty(value).map_err(|_| CliError::runtime("Failed to serialize output"))
|
serde_json::to_string_pretty(value).map_err(|_| CliError::runtime("Failed to serialize output"))
|
||||||
|
|||||||
+5
-19
@@ -58,11 +58,7 @@ pub fn validate_ids(ids: &[i32], name: &str, max_len: Option<usize>) -> CliResul
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn read_body<R: Read>(source: &BodySource, mut reader: R) -> CliResult<String> {
|
pub fn read_body<R: Read>(source: &BodySource, mut reader: R) -> CliResult<String> {
|
||||||
let count = [
|
let count = [source.json.is_some(), source.file.is_some(), source.stdin]
|
||||||
source.json.is_some(),
|
|
||||||
source.file.is_some(),
|
|
||||||
source.stdin,
|
|
||||||
]
|
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|&b| b)
|
.filter(|&b| b)
|
||||||
.count();
|
.count();
|
||||||
@@ -84,13 +80,8 @@ pub fn read_body<R: Read>(source: &BodySource, mut reader: R) -> CliResult<Strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(ref path) = source.file {
|
if let Some(ref path) = source.file {
|
||||||
return std::fs::read_to_string(path).map_err(|e| {
|
return std::fs::read_to_string(path)
|
||||||
CliError::usage(format!(
|
.map_err(|e| CliError::usage(format!("Failed to read file '{}': {}", path.display(), e)));
|
||||||
"Failed to read file '{}': {}",
|
|
||||||
path.display(),
|
|
||||||
e
|
|
||||||
))
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if source.stdin {
|
if source.stdin {
|
||||||
@@ -104,10 +95,7 @@ pub fn read_body<R: Read>(source: &BodySource, mut reader: R) -> CliResult<Strin
|
|||||||
unreachable!()
|
unreachable!()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse_body<T: DeserializeOwned, R: Read>(
|
pub fn parse_body<T: DeserializeOwned, R: Read>(source: &BodySource, reader: R) -> CliResult<T> {
|
||||||
source: &BodySource,
|
|
||||||
reader: R,
|
|
||||||
) -> CliResult<T> {
|
|
||||||
let content = read_body(source, reader)?;
|
let content = read_body(source, reader)?;
|
||||||
serde_json::from_str(&content)
|
serde_json::from_str(&content)
|
||||||
.map_err(|e| CliError::usage(format!("Invalid JSON request body: {e}")))
|
.map_err(|e| CliError::usage(format!("Invalid JSON request body: {e}")))
|
||||||
@@ -261,9 +249,7 @@ mod tests {
|
|||||||
let ids = vec![1; 101];
|
let ids = vec![1; 101];
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
validate_ids(&ids, "ids", Some(100)),
|
validate_ids(&ids, "ids", Some(100)),
|
||||||
Err(CliError::usage(
|
Err(CliError::usage("ids cannot contain more than 100 values."))
|
||||||
"ids cannot contain more than 100 values."
|
|
||||||
))
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user