201 lines
6.3 KiB
Rust
201 lines
6.3 KiB
Rust
use hermes_grocy_mcp::{
|
|
catalog,
|
|
model::{AccessMode, Page, RecordId},
|
|
tools::validation::{Conversion, RecordSelector, convert_quantity, resolve_record},
|
|
};
|
|
use rust_decimal::Decimal;
|
|
use serde_json::json;
|
|
#[test]
|
|
fn household_catalog_and_validation() {
|
|
assert!(catalog::operations(AccessMode::Full).len() > 40);
|
|
assert!(
|
|
catalog::operations(AccessMode::ReadOnly)
|
|
.iter()
|
|
.all(|o| !o.mutates)
|
|
);
|
|
for entity in ["api_keys", "sessions", "permission_hierarchy", "users"] {
|
|
assert!(
|
|
catalog::validate("records_list", json!({"entity":entity}), AccessMode::Full).is_err()
|
|
);
|
|
}
|
|
assert!(
|
|
catalog::validate(
|
|
"records_update",
|
|
json!({"entity":"stock_log","id":1,"fields":{"amount":2}}),
|
|
AccessMode::Full
|
|
)
|
|
.is_err()
|
|
);
|
|
assert!(
|
|
catalog::validate(
|
|
"records_create",
|
|
json!({"entity":"products","fields":{"name":"Sample","id":4}}),
|
|
AccessMode::Full
|
|
)
|
|
.is_err()
|
|
);
|
|
assert!(catalog::validate("records_create",json!({"entity":"userfields","fields":{"entity":"users","name":"unsafe","caption":"Unsafe","type":"text"}}),AccessMode::Full).is_err());
|
|
assert!(catalog::validate("records_create",json!({"entity":"quantity_unit_conversions","fields":{"from_qu_id":1,"to_qu_id":2,"factor":-1}}),AccessMode::Full).is_err());
|
|
for name in [
|
|
"recipe_fulfillment",
|
|
"recipe_consume",
|
|
"chore_details",
|
|
"battery_details",
|
|
] {
|
|
let key = if name.starts_with("recipe") {
|
|
"recipe_id"
|
|
} else if name.starts_with("chore") {
|
|
"chore_id"
|
|
} else {
|
|
"battery_id"
|
|
};
|
|
assert!(catalog::validate(name, json!({key:1}), AccessMode::Full).is_ok());
|
|
}
|
|
for name in ["stock_merge", "chore_merge"] {
|
|
let prefix = if name == "stock_merge" {
|
|
"product"
|
|
} else {
|
|
"chore"
|
|
};
|
|
assert!(
|
|
catalog::validate(
|
|
name,
|
|
json!({format!("{prefix}_id_to_keep"):1,format!("{prefix}_id_to_remove"):1}),
|
|
AccessMode::Full
|
|
)
|
|
.is_err()
|
|
);
|
|
}
|
|
let rows = vec![
|
|
json!({"id":"1","name":"Milk"}),
|
|
json!({"id":"2","name":"Milk"}),
|
|
];
|
|
assert_eq!(
|
|
resolve_record(&rows, &RecordSelector::ExactName("Milk".into()))
|
|
.unwrap_err()
|
|
.code,
|
|
"AMBIGUOUS_RECORD"
|
|
);
|
|
assert_eq!(
|
|
convert_quantity(
|
|
Decimal::from(500),
|
|
RecordId(1),
|
|
RecordId(2),
|
|
&[Conversion {
|
|
from: RecordId(1),
|
|
to: RecordId(2),
|
|
factor: "0.001".parse().unwrap()
|
|
}]
|
|
)
|
|
.unwrap()
|
|
.to_string(),
|
|
"0.500"
|
|
);
|
|
assert_eq!(
|
|
convert_quantity(Decimal::ONE, RecordId(1), RecordId(2), &[])
|
|
.unwrap_err()
|
|
.code,
|
|
"UNIT_CONVERSION_REQUIRED"
|
|
);
|
|
assert!(
|
|
catalog::validate(
|
|
"stock_add",
|
|
json!({"product_id":1,"amount":-1}),
|
|
AccessMode::Full
|
|
)
|
|
.is_err()
|
|
);
|
|
assert!(
|
|
catalog::validate(
|
|
"stock_add",
|
|
json!({"product_id":1,"amount":1,"best_before_date":"2026-02-30"}),
|
|
AccessMode::Full
|
|
)
|
|
.is_err()
|
|
);
|
|
assert!(
|
|
catalog::validate(
|
|
"stock_inventory",
|
|
json!({"product_id":1,"new_amount":0}),
|
|
AccessMode::Full
|
|
)
|
|
.is_ok()
|
|
);
|
|
assert_eq!(Page::default().limit, 100);
|
|
assert!(Page::new(0, 501).is_err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn stock_unit_change_requires_conversion() {
|
|
use axum::{Json, Router};
|
|
use hermes_grocy_mcp::{
|
|
client::{ApiBase, ApiClient},
|
|
model::{PrivateProfile, TransportPolicy},
|
|
tools::records,
|
|
};
|
|
use std::sync::{
|
|
Arc,
|
|
atomic::{AtomicBool, AtomicUsize, Ordering},
|
|
};
|
|
let conversion = Arc::new(AtomicBool::new(false));
|
|
let writes = Arc::new(AtomicUsize::new(0));
|
|
let conv = conversion.clone();
|
|
let written = writes.clone();
|
|
let app = Router::new().fallback(move |req: axum::extract::Request| {
|
|
let conv = conv.clone();
|
|
let written = written.clone();
|
|
async move {
|
|
if req.method() == "PUT" {
|
|
written.fetch_add(1, Ordering::SeqCst);
|
|
return Json(json!({}));
|
|
}
|
|
match req.uri().path() {
|
|
"/api/objects/products/7" => Json(
|
|
json!({"id":7,"qu_id_stock":if written.load(Ordering::SeqCst)>0{2}else{1}}),
|
|
),
|
|
"/api/objects/quantity_unit_conversions_resolved" => {
|
|
Json(if conv.load(Ordering::SeqCst) {
|
|
json!([{"product_id":7,"from_qu_id":1,"to_qu_id":2,"factor":"0.001"}])
|
|
} else {
|
|
json!([])
|
|
})
|
|
}
|
|
_ => Json(json!({"id":2})),
|
|
}
|
|
}
|
|
});
|
|
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(PrivateProfile {
|
|
api_base: ApiBase::parse(
|
|
&format!("http://127.0.0.1:{port}"),
|
|
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,
|
|
})
|
|
.unwrap();
|
|
let call = catalog::validate(
|
|
"records_update",
|
|
json!({"entity":"products","id":7,"fields":{"qu_id_stock":2}}),
|
|
AccessMode::Full,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
records::run(&client, &call).await.err().unwrap().code,
|
|
"UNIT_CONVERSION_REQUIRED"
|
|
);
|
|
assert_eq!(writes.load(Ordering::SeqCst), 0);
|
|
conversion.store(true, Ordering::SeqCst);
|
|
assert!(records::run(&client, &call).await.is_ok());
|
|
assert_eq!(writes.load(Ordering::SeqCst), 1);
|
|
server.abort();
|
|
}
|