diff --git a/src/catalog/data.json b/src/catalog/data.json index 044dffb..5f1a953 100644 --- a/src/catalog/data.json +++ b/src/catalog/data.json @@ -1137,7 +1137,9 @@ "maxLength": 16384 }, "price": { - "type": "number" + "type": "number", + "minimum": 0, + "allow_decimal_string": true }, "open": { "type": "boolean" @@ -1328,7 +1330,9 @@ "maxLength": 16384 }, "price": { - "type": "number" + "type": "number", + "minimum": 0, + "allow_decimal_string": true }, "location_id": { "type": "integer" @@ -1442,10 +1446,12 @@ "allow_decimal_string": true }, "location_id_from": { - "type": "integer" + "type": "integer", + "minimum": 1 }, "location_id_to": { - "type": "integer" + "type": "integer", + "minimum": 1 }, "stock_entry_id": { "type": "string", @@ -1454,7 +1460,9 @@ }, "required": [ "product_id", - "amount" + "amount", + "location_id_from", + "location_id_to" ], "path_params": { "product_id": "productId" @@ -1492,7 +1500,9 @@ "type": "integer" }, "price": { - "type": "number" + "type": "number", + "minimum": 0, + "allow_decimal_string": true }, "stock_label_type": { "type": "integer", @@ -1656,7 +1666,9 @@ "maxLength": 16384 }, "price": { - "type": "number" + "type": "number", + "minimum": 0, + "allow_decimal_string": true }, "location_id": { "type": "integer" @@ -1754,10 +1766,12 @@ "allow_decimal_string": true }, "location_id_from": { - "type": "integer" + "type": "integer", + "minimum": 1 }, "location_id_to": { - "type": "integer" + "type": "integer", + "minimum": 1 }, "stock_entry_id": { "type": "string", @@ -1766,7 +1780,9 @@ }, "required": [ "barcode", - "amount" + "amount", + "location_id_from", + "location_id_to" ], "path_params": { "barcode": "barcode" @@ -1801,7 +1817,9 @@ "type": "integer" }, "price": { - "type": "number" + "type": "number", + "minimum": 0, + "allow_decimal_string": true } }, "required": [ @@ -1886,10 +1904,13 @@ "method": "post", "properties": { "list_id": { - "type": "integer" + "type": "integer", + "minimum": 1 } }, - "required": [], + "required": [ + "list_id" + ], "path_params": {}, "body_keys": [ "list_id" @@ -1901,10 +1922,13 @@ "method": "post", "properties": { "list_id": { - "type": "integer" + "type": "integer", + "minimum": 1 } }, - "required": [], + "required": [ + "list_id" + ], "path_params": {}, "body_keys": [ "list_id" @@ -1916,10 +1940,13 @@ "method": "post", "properties": { "list_id": { - "type": "integer" + "type": "integer", + "minimum": 1 } }, - "required": [], + "required": [ + "list_id" + ], "path_params": {}, "body_keys": [ "list_id" @@ -1931,13 +1958,16 @@ "method": "post", "properties": { "list_id": { - "type": "integer" + "type": "integer", + "minimum": 1 }, "done_only": { "type": "boolean" } }, - "required": [], + "required": [ + "list_id" + ], "path_params": {}, "body_keys": [ "list_id", @@ -1950,23 +1980,29 @@ "method": "post", "properties": { "product_id": { - "type": "integer" + "type": "integer", + "minimum": 1 }, "qu_id": { "type": "integer" }, "list_id": { - "type": "integer" + "type": "integer", + "minimum": 1 }, "product_amount": { - "type": "number" + "type": "number", + "minimum": 0, + "allow_decimal_string": true }, "note": { "type": "string", "maxLength": 16384 } }, - "required": [], + "required": [ + "list_id" + ], "path_params": {}, "body_keys": [ "product_id", @@ -1982,16 +2018,22 @@ "method": "post", "properties": { "product_id": { - "type": "integer" + "type": "integer", + "minimum": 1 }, "list_id": { - "type": "integer" + "type": "integer", + "minimum": 1 }, "product_amount": { - "type": "number" + "type": "number", + "minimum": 0, + "allow_decimal_string": true } }, - "required": [], + "required": [ + "list_id" + ], "path_params": {}, "body_keys": [ "product_id", diff --git a/src/catalog/mod.rs b/src/catalog/mod.rs index ae429ed..60d04d8 100644 --- a/src/catalog/mod.rs +++ b/src/catalog/mod.rs @@ -179,13 +179,18 @@ pub fn operations(mode: AccessMode) -> Vec { body_keys, }); } + ops.extend(crate::tools::files::operations(mode)); ops } pub fn validate(name: &str, args: Value, mode: AccessMode) -> Result { if serde_json::to_vec(&args) .map_err(|_| AppError::input())? .len() - > crate::client::JSON_LIMIT + > if name == "file_upload" { + 12 * 1024 * 1024 + } else { + crate::client::JSON_LIMIT + } { return Err(AppError::input()); } @@ -239,6 +244,18 @@ pub fn validate(name: &str, args: Value, mode: AccessMode) -> Result ApiRequest { + let mut r = ApiRequest::get(call.op.route); + r.method = call.op.method; + for (key, param) in &call.op.path_params { + let value = &call.args[key]; + r.params.insert( + param.clone(), + value + .as_str() + .map(str::to_owned) + .unwrap_or_else(|| value.to_string()), + ); + } + if !call.op.body_keys.is_empty() { + let body = call + .op + .body_keys + .iter() + .filter_map(|k| call.args.get(k).map(|v| (k.clone(), v.clone()))) + .collect::>(); + r.body = RequestBody::Json(Value::Object(body)); + } + r +} +fn readback(route: &'static str, param: &str, id: &Value) -> ApiRequest { + let mut r = ApiRequest::get(route); + r.params.insert( + param.into(), + id.as_str() + .map(str::to_owned) + .unwrap_or_else(|| id.to_string()), + ); + r +} +pub async fn run(client: &ApiClient, call: &ValidatedCall) -> Result { + let mut r = build(call); + let target = r.target(); + let mut verify = None; + if call.op.mutates { + if let Some(id) = call + .args + .get("product_id") + .or_else(|| call.args.get("product_id_to_keep")) + { + let read = readback("/stock/products/{productId}", "productId", id); + let details = client.request(read).await?.json()?; + if let Some(unit) = call.args.get("quantity_unit_id") { + apply_conversion(client, call, &details, &mut r, unit).await?; + } + verify = Some(readback("/stock/products/{productId}", "productId", id)); + } else if let Some(barcode) = call.args.get("barcode") { + let read = readback("/stock/products/by-barcode/{barcode}", "barcode", barcode); + let details = client.request(read).await?.json()?; + if let Some(unit) = call.args.get("quantity_unit_id") { + apply_conversion(client, call, &details, &mut r, unit).await?; + } + verify = Some(readback( + "/stock/products/by-barcode/{barcode}", + "barcode", + barcode, + )); + } else if let Some(id) = call.args.get("entry_id") { + let details = client + .request(readback("/stock/entry/{entryId}", "entryId", id)) + .await? + .json()?; + if let Some(unit) = call.args.get("quantity_unit_id") { + let details = json!({"product":super::records::get_record(client,"products",&details["product_id"]).await?}); + apply_conversion(client, call, &details, &mut r, unit).await?; + } + verify = Some(readback("/stock/entry/{entryId}", "entryId", id)); + } else if let Some(id) = call.args.get("recipe_id") { + super::records::get_record(client, "recipes", id).await?; + verify = Some(readback("/recipes/{recipeId}/fulfillment", "recipeId", id)); + } else if let Some(id) = call + .args + .get("chore_id") + .or_else(|| call.args.get("chore_id_to_keep")) + { + super::records::get_record(client, "chores", id).await?; + verify = Some(readback("/chores/{choreId}", "choreId", id)); + } else if let Some(id) = call.args.get("battery_id") { + super::records::get_record(client, "batteries", id).await?; + verify = Some(readback("/batteries/{batteryId}", "batteryId", id)); + } else if let Some(id) = call.args.get("task_id") { + super::records::get_record(client, "tasks", id).await?; + verify = Some(readback("/objects/{entity}/{id}", "id", id)); + verify + .as_mut() + .unwrap() + .params + .insert("entity".into(), "tasks".into()); + } else if let Some(id) = call.args.get("transaction_id") { + client + .request(readback( + "/stock/transactions/{transactionId}", + "transactionId", + id, + )) + .await?; + verify = Some(readback( + "/stock/transactions/{transactionId}", + "transactionId", + id, + )); + } else if let Some(id) = call.args.get("booking_id") { + client + .request(readback("/stock/bookings/{bookingId}", "bookingId", id)) + .await?; + verify = Some(readback("/stock/bookings/{bookingId}", "bookingId", id)); + } else if let Some(id) = call.args.get("execution_id") { + let log = super::records::get_record(client, "chores_log", id).await?; + verify = Some(readback("/chores/{choreId}", "choreId", &log["chore_id"])); + } else if let Some(id) = call.args.get("charge_cycle_id") { + let log = super::records::get_record(client, "battery_charge_cycles", id).await?; + verify = Some(readback( + "/batteries/{batteryId}", + "batteryId", + &log["battery_id"], + )); + } else if call.op.name.starts_with("shopping_") { + if let Some(id) = call.args.get("list_id") { + super::records::get_record(client, "shopping_lists", id).await?; + } + let mut q = ApiRequest::get("/objects/{entity}"); + q.params.insert("entity".into(), "shopping_list".into()); + verify = Some(q); + } + for key in ["location_id", "location_id_from", "location_id_to"] { + if let Some(id) = call.args.get(key) { + super::records::get_record(client, "locations", id).await?; + } + } + if call.op.name.starts_with("stock_add") || call.op.name.starts_with("stock_inventory") { + if let RequestBody::Json(body) = &mut r.body { + body["stock_label_type"] = json!(1); + } + } + } + if call.op.mutates && call.op.name.starts_with("shopping_") { + let mut q = ApiRequest::get("/objects/{entity}"); + q.params.insert("entity".into(), "shopping_list".into()); + verify = Some(q); + } + if call.op.mutates && call.op.name == "chores_recalculate_assignments" { + verify = Some(ApiRequest::get("/chores")); + } + let receipt = client.request(r).await?.json()?; + if !call.op.mutates { + let data = if let Some(arr) = receipt.as_array() { + call.page.slice(arr.clone()) + } else if call.op.name == "stock_volatile" { + let mut grouped = serde_json::Map::new(); + for (k, v) in receipt.as_object().ok_or_else(AppError::input)? { + grouped.insert( + k.clone(), + if let Some(a) = v.as_array() { + call.page.slice(a.clone()) + } else { + v.clone() + }, + ); + } + Value::Object(grouped) + } else { + receipt + }; + return Ok(ToolOutput::data(data)); + } + if call.op.name == "stock_copy" { + if let Some(id) = receipt.get("created_object_id") { + verify = Some(readback("/stock/products/{productId}", "productId", id)); + } + } + if call.op.name == "recipe_copy" { + if let Some(id) = receipt.get("created_object_id") { + verify = Some(readback("/recipes/{recipeId}/fulfillment", "recipeId", id)); + } + } + let (data, verification) = if let Some(q) = verify { + match client.request(q).await.and_then(|r| r.json()) { + Ok(v) => (v, Verification::Confirmed), + Err(_) => (Value::Null, Verification::Incomplete), + } + } else { + (Value::Null, Verification::Unavailable) + }; + Ok(ToolOutput { + data, + write: Some(WriteReport { + status: WriteStatus::Completed, + verification, + target, + receipt, + }), + attachment: None, + }) +} +async fn apply_conversion( + client: &ApiClient, + call: &ValidatedCall, + details: &Value, + r: &mut ApiRequest, + unit: &Value, +) -> Result<()> { + let product = details.get("product").unwrap_or(details); + let id = super::validation::record_id(&product["id"])?; + let stock = super::validation::record_id(&product["qu_id_stock"])?; + let from = super::validation::record_id(unit)?; + let conversions = if from != stock { + super::validation::load_conversions(client, id).await? + } else { + vec![] + }; + let field = if call.args.get("new_amount").is_some() { + "new_amount" + } else { + "amount" + }; + let value = &call.args[field]; + let amount = value + .as_str() + .map(str::to_owned) + .unwrap_or_else(|| value.to_string()) + .parse::() + .map_err(|_| AppError::input())?; + let converted = super::validation::convert_quantity(amount, from, stock, &conversions)?; + if let RequestBody::Json(body) = &mut r.body { + body[field] = json!(converted.to_string()); + } + Ok(()) +} diff --git a/src/tools/exports.rs b/src/tools/exports.rs new file mode 100644 index 0000000..508f246 --- /dev/null +++ b/src/tools/exports.rs @@ -0,0 +1,46 @@ +use crate::{ + catalog::ValidatedCall, + client::{ApiClient, ApiRequest, ResponseBody, ResponseKind}, + error::{AppError, Result}, + model::ToolOutput, +}; +use serde_json::{Value, json}; +pub async fn run(client: &ApiClient, call: &ValidatedCall) -> Result { + let mut r = ApiRequest::get(call.op.route); + if call.op.name == "calendar_export" { + r.kind = ResponseKind::Text; + let response = client.request(r).await?; + let ResponseBody::Text(content) = response.body else { + return Err(AppError::input()); + }; + if !content.trim_start().starts_with("BEGIN:VCALENDAR") { + return Err(AppError::new( + "INVALID_RESPONSE", + "Expected iCalendar data from the API.", + )); + } + return Ok(ToolOutput::data( + json!({"mime":"text/calendar","content":content}), + )); + } + if call.op.name == "shopping_export" { + r.params.insert("entity".into(), "shopping_list".into()); + let rows = client.request(r).await?.json()?; + let id = call.args["list_id"].as_u64().ok_or_else(AppError::input)?; + let items = rows + .as_array() + .ok_or_else(AppError::input)? + .iter() + .filter(|r| { + super::validation::record_id(&r["shopping_list_id"]).is_ok_and(|n| n.0 == id) + }) + .cloned() + .collect::>(); + return Ok(ToolOutput::data( + json!({"shopping_list_id":id,"items":items}), + )); + } + let rows = client.request(r).await?.json()?; + let users=rows.as_array().ok_or_else(AppError::input)?.iter().map(|u|json!({"id":u["id"],"name":u.get("display_name").or_else(||u.get("username")).unwrap_or(&Value::Null)})).collect::>(); + Ok(ToolOutput::data(json!({"users":users}))) +} diff --git a/src/tools/files.rs b/src/tools/files.rs new file mode 100644 index 0000000..651d82f --- /dev/null +++ b/src/tools/files.rs @@ -0,0 +1,167 @@ +use crate::{ + catalog::OperationSpec, + client::{ + ApiClient, ApiRequest, BINARY_LIMIT, HttpMethod, RequestBody, ResponseBody, ResponseKind, + }, + error::{AppError, Result}, + model::{AccessMode, Attachment, ToolOutput, Verification, WriteReport, WriteStatus}, +}; +use base64::{Engine, engine::general_purpose::STANDARD}; +use serde_json::{Value, json}; +pub fn validate_filename(name: &str) -> Result<()> { + if name.is_empty() + || name.len() > 255 + || matches!(name, "." | "..") + || name.contains(['/', '\\', '%']) + || name.chars().any(char::is_control) + { + return Err(AppError::input()); + } + Ok(()) +} +pub fn operations(mode: AccessMode) -> Vec { + let mut ops = vec![]; + for (name, method, route) in [ + ("file_get", HttpMethod::Get, "/files/{group}/{fileName}"), + ("file_upload", HttpMethod::Put, "/files/{group}/{fileName}"), + ( + "file_delete", + HttpMethod::Delete, + "/files/{group}/{fileName}", + ), + ("calendar_export", HttpMethod::Get, "/calendar/ical"), + ("shopping_export", HttpMethod::Get, "/objects/{entity}"), + ("assignment_users", HttpMethod::Get, "/users"), + ] { + if method.mutates() && mode == AccessMode::ReadOnly { + continue; + } + let mut properties = json!({}); + let mut required = vec![]; + if name.starts_with("file_") { + properties = json!({"group":{"type":"string","enum":["equipmentmanuals","recipepictures","productpictures","userfiles"]},"filename":{"type":"string","maxLength":255}}); + required.extend(["group", "filename"]); + if name == "file_upload" { + properties["content_base64"] = json!({"type":"string","maxLength":((BINARY_LIMIT+2)/3)*4,"description":"Standard base64 of actual file bytes, at most 8 MiB decoded. No local path or external URL."}); + required.push("content_base64") + } + } + if name == "shopping_export" { + properties["list_id"] = json!({"type":"integer","minimum":1}); + required.push("list_id") + } + let description = match name { + "file_get" => { + "Read a known API file by group and filename. Returns real bytes as an image or embedded resource, at most 8 MiB. Does not fetch URLs or host files. Grocy has no orphan-file listing API." + } + "file_upload" => { + "Upload bounded base64 content as binary through the API, replacing this exact named household file. Returns a write receipt and readback verification." + } + "file_delete" => { + "Permanently delete the exact API file. Specify the group and filename; no host filesystem access." + } + "calendar_export" => { + "Export household dates as iCalendar data through the API; no public sharing link." + } + "shopping_export" => { + "Export actual shopping-list items for an explicit list ID as JSON; no printer action." + } + _ => { + "List assignment user IDs and display names only. No passwords, keys or account fields." + } + }; + ops.push(OperationSpec{name,route,method,schema:json!({"type":"object","properties":properties,"required":required,"additionalProperties":false}),mutates:method.mutates(),destructive:method.mutates(),description:description.into(),path_params:Default::default(),body_keys:vec![]}); + } + ops +} +fn file_request(args: &Value, method: HttpMethod) -> Result { + let filename = args["filename"].as_str().ok_or_else(AppError::input)?; + validate_filename(filename)?; + let mut r = ApiRequest::get("/files/{group}/{fileName}"); + r.params.insert( + "group".into(), + args["group"].as_str().ok_or_else(AppError::input)?.into(), + ); + r.params + .insert("fileName".into(), STANDARD.encode(filename)); + r.method = method; + r.kind = ResponseKind::Binary; + Ok(r) +} +pub async fn run(client: &ApiClient, call: &crate::catalog::ValidatedCall) -> Result { + let mut r = file_request(&call.args, call.op.method)?; + let target = json!({"group":call.args["group"],"filename":call.args["filename"]}); + let mut expected = None; + if call.op.method == HttpMethod::Put { + let encoded = call.args["content_base64"] + .as_str() + .ok_or_else(AppError::input)?; + if encoded.len() > ((BINARY_LIMIT + 2) / 3) * 4 { + return Err(AppError::input()); + } + let bytes = STANDARD.decode(encoded).map_err(|_| AppError::input())?; + if bytes.len() > BINARY_LIMIT { + return Err(AppError::input()); + } + expected = Some(bytes.clone()); + r.body = RequestBody::Binary(bytes); + } + let response = client.request(r).await?; + if call.op.method == HttpMethod::Get { + let ResponseBody::Binary(bytes) = response.body else { + return Err(AppError::input()); + }; + let mime = validated_mime(&bytes, &response.content_type); + return Ok(ToolOutput { + data: json!({"file":target,"bytes":bytes.len(),"mime":mime}), + write: None, + attachment: Some(Attachment { + bytes, + mime, + name: call.args["filename"].as_str().unwrap().into(), + }), + }); + } + let check = client + .request(file_request(&call.args, HttpMethod::Get)?) + .await; + let verification = match (call.op.method, check) { + (HttpMethod::Delete, Err(e)) if e.code == "NOT_FOUND" => Verification::Confirmed, + (HttpMethod::Put, Ok(r)) => match r.body { + ResponseBody::Binary(b) if Some(&b) == expected.as_ref() => Verification::Confirmed, + _ => Verification::Incomplete, + }, + _ => Verification::Incomplete, + }; + Ok(ToolOutput { + data: json!({"file":target,"bytes":expected.as_ref().map(Vec::len)}), + write: Some(WriteReport { + status: WriteStatus::Completed, + verification, + target, + receipt: json!({"http_status":response.status}), + }), + attachment: None, + }) +} +fn validated_mime(bytes: &[u8], reported: &str) -> String { + let image = if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { + Some("image/png") + } else if bytes.starts_with(&[255, 216, 255]) { + Some("image/jpeg") + } else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") { + Some("image/gif") + } else if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") { + Some("image/webp") + } else { + None + }; + if let Some(mime) = image { + return mime.into(); + } + if reported == "application/pdf" && bytes.starts_with(b"%PDF-") { + "application/pdf".into() + } else { + "application/octet-stream".into() + } +} diff --git a/src/tools/mod.rs b/src/tools/mod.rs index 77f1be0..974a55e 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -1,2 +1,17 @@ +mod actions; +pub mod exports; +pub mod files; pub mod records; pub mod validation; +use crate::{catalog::ValidatedCall, client::ApiClient, error::Result, model::ToolOutput}; +pub async fn execute(client: &ApiClient, call: ValidatedCall) -> Result { + if call.op.name.starts_with("records_") || call.op.name.starts_with("userfields_") { + records::run(client, &call).await + } else if call.op.name.starts_with("file_") { + files::run(client, &call).await + } else if ["calendar_export", "shopping_export", "assignment_users"].contains(&call.op.name) { + exports::run(client, &call).await + } else { + actions::run(client, &call).await + } +} diff --git a/tests/workflows.rs b/tests/workflows.rs new file mode 100644 index 0000000..3dc0d29 --- /dev/null +++ b/tests/workflows.rs @@ -0,0 +1,135 @@ +use axum::{Router, http::StatusCode, response::IntoResponse}; +use hermes_grocy_mcp::{ + catalog, + client::{ApiBase, ApiClient}, + model::{AccessMode, PrivateProfile, TransportPolicy, Verification, WriteStatus}, + tools, +}; +use serde_json::json; +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; +fn profile(url: &str) -> PrivateProfile { + PrivateProfile { + api_base: ApiBase::parse(url, TransportPolicy::LoopbackDevelopment).unwrap(), + api_key: secrecy::SecretString::from("synthetic-key"), + access: AccessMode::Full, + timezone: None, + defaults: json!(null), + ca_path: None, + transport: TransportPolicy::LoopbackDevelopment, + unsafe_storage: false, + } +} +#[tokio::test] +async fn workflow_results_and_files() { + let writes = Arc::new(AtomicUsize::new(0)); + let accepted = writes.clone(); + let uploaded = Arc::new(std::sync::Mutex::new(Vec::new())); + let captured = uploaded.clone(); + let app = Router::new().fallback(move |req: axum::extract::Request| { + let accepted = accepted.clone(); + let captured = captured.clone(); + async move { + let path = req.uri().path().to_string(); + let method = req.method().to_string(); + if path.starts_with("/api/files/") { + if method == "PUT" { + *captured.lock().unwrap() = + axum::body::to_bytes(req.into_body(), 8 * 1024 * 1024) + .await + .unwrap() + .to_vec(); + return StatusCode::NO_CONTENT.into_response(); + } + return ( + [("content-type", "application/pdf")], + b"%PDF-synthetic".to_vec(), + ) + .into_response(); + } + if path == "/api/calendar/ical" { + return ( + [("content-type", "text/calendar")], + "BEGIN:VCALENDAR\r\nEND:VCALENDAR", + ) + .into_response(); + } + if method == "POST" { + accepted.fetch_add(1, Ordering::SeqCst); + return ( + [("content-type", "application/json")], + "[{\"transaction_id\":\"synthetic-transaction\"}]", + ) + .into_response(); + } + if path == "/api/stock/products/7" { + if accepted.load(Ordering::SeqCst) > 0 { + return StatusCode::SERVICE_UNAVAILABLE.into_response(); + } + return ( + [("content-type", "application/json")], + "{\"product\":{\"id\":7,\"qu_id_stock\":1}}", + ) + .into_response(); + } + if path == "/api/stock/volatile" { + return axum::Json(json!({"due_products":[{"id":7}],"overdue_products":[]})) + .into_response(); + } + axum::Json(json!([])).into_response() + } + }); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let client = ApiClient::new(profile(&format!("http://127.0.0.1:{port}"))).unwrap(); + let call = |name, args| catalog::validate(name, args, AccessMode::Full).unwrap(); + let volatile = tools::execute(&client, call("stock_volatile", json!({}))) + .await + .unwrap(); + assert_eq!(volatile.data["due_products"]["items"][0]["id"], 7); + let written = tools::execute( + &client, + call("stock_add", json!({"product_id":7,"amount":1})), + ) + .await + .unwrap(); + let report = written.write.unwrap(); + assert_eq!(report.status, WriteStatus::Completed); + assert_eq!(report.verification, Verification::Incomplete); + assert_eq!(report.receipt[0]["transaction_id"], "synthetic-transaction"); + assert_eq!(writes.load(Ordering::SeqCst), 1); + let upload=tools::execute(&client,call("file_upload",json!({"group":"userfiles","filename":"sample.pdf","content_base64":"JVBERi1zeW50aGV0aWM="}))).await.unwrap(); + assert_eq!(*uploaded.lock().unwrap(), b"%PDF-synthetic"); + assert_eq!(upload.write.unwrap().status, WriteStatus::Completed); + let file = tools::execute( + &client, + call( + "file_get", + json!({"group":"userfiles","filename":"sample.pdf"}), + ), + ) + .await + .unwrap(); + assert_eq!(file.attachment.unwrap().bytes, b"%PDF-synthetic"); + assert!( + catalog::validate( + "file_get", + json!({"group":"userfiles","filename":"../unsafe"}), + AccessMode::Full + ) + .is_err() + ); + let ical = tools::execute(&client, call("calendar_export", json!({}))) + .await + .unwrap(); + assert!( + ical.data["content"] + .as_str() + .unwrap() + .starts_with("BEGIN:VCALENDAR") + ); + server.abort(); +}