diff --git a/CHANGELOG.md b/CHANGELOG.md index 625253e..6a25dd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Unit tests for `token::holding::Holding`, which had none. They cover the + four parsed fields, the six error messages and the lock check, including a + null lock. + ## [0.7.0] - 2026-09-09 ### Added diff --git a/crates/token/src/holding.rs b/crates/token/src/holding.rs index 9a5af7b..73c0a58 100644 --- a/crates/token/src/holding.rs +++ b/crates/token/src/holding.rs @@ -66,3 +66,155 @@ impl Holding { .is_some_and(|lock| !lock.is_null()) } } + +#[cfg(test)] +mod tests { + use super::*; + use canton_api_client::models::CreatedEvent; + use serde_json::json; + + /// An active contract carrying a concrete utility-registry Holding + /// payload. `Holding` reads `createArgument`, not an interface view. + fn contract(argument: Option) -> JsActiveContract { + JsActiveContract { + created_event: Box::new(CreatedEvent { + contract_id: "00cid".to_string(), + create_argument: argument, + ..Default::default() + }), + ..Default::default() + } + } + + fn payload() -> serde_json::Value { + json!({ + "amount": "1.25", + "instrument": { "admin": "admin::1220ef", "id": "CBTC" }, + "owner": "alice::1220ab", + }) + } + + #[test] + fn parses_every_field_of_a_holding() { + let holding = Holding::from_active_contract(&contract(Some(payload()))) + .expect("the fixture is a valid holding"); + + assert_eq!(holding.contract_id, "00cid"); + assert_eq!(holding.amount, DamlDecimal::parse("1.25").unwrap()); + assert_eq!(holding.instrument_id, "CBTC"); + assert_eq!(holding.owner, "alice::1220ab"); + } + + #[test] + fn reads_the_id_out_of_the_instrument_object() { + let mut argument = payload(); + argument["instrument"]["id"] = json!("OTHER"); + + let holding = Holding::from_active_contract(&contract(Some(argument))).unwrap(); + + assert_eq!(holding.instrument_id, "OTHER"); + } + + #[test] + fn rejects_a_contract_with_no_create_argument() { + let error = Holding::from_active_contract(&contract(None)).unwrap_err(); + + assert_eq!(error, "createArgument is not an object"); + } + + #[test] + fn rejects_a_create_argument_that_is_not_an_object() { + let error = Holding::from_active_contract(&contract(Some(json!("a string")))).unwrap_err(); + + assert_eq!(error, "createArgument is not an object"); + } + + #[test] + fn rejects_a_holding_with_no_amount() { + let mut argument = payload(); + argument.as_object_mut().unwrap().remove("amount"); + + let error = Holding::from_active_contract(&contract(Some(argument))).unwrap_err(); + + assert_eq!(error, "Missing 'amount' field"); + } + + #[test] + fn rejects_an_amount_that_is_not_a_decimal() { + let mut argument = payload(); + argument["amount"] = json!("not a number"); + + let error = Holding::from_active_contract(&contract(Some(argument))).unwrap_err(); + + // The detail comes from the decimal parser, so derive it rather than + // pinning wording this crate does not own. + let detail = DamlDecimal::parse("not a number").unwrap_err(); + assert_eq!(error, format!("Invalid 'amount' field: {detail}")); + } + + #[test] + fn rejects_an_amount_that_is_a_json_number() { + let mut argument = payload(); + argument["amount"] = json!(1.25); + + let error = Holding::from_active_contract(&contract(Some(argument))).unwrap_err(); + + assert_eq!(error, "Missing 'amount' field"); + } + + #[test] + fn rejects_a_holding_with_no_instrument() { + let mut argument = payload(); + argument.as_object_mut().unwrap().remove("instrument"); + + let error = Holding::from_active_contract(&contract(Some(argument))).unwrap_err(); + + assert_eq!(error, "Missing 'instrument' field"); + } + + #[test] + fn rejects_an_instrument_with_no_id() { + let mut argument = payload(); + argument["instrument"].as_object_mut().unwrap().remove("id"); + + let error = Holding::from_active_contract(&contract(Some(argument))).unwrap_err(); + + assert_eq!(error, "Missing 'instrument.id' field"); + } + + #[test] + fn rejects_a_holding_with_no_owner() { + let mut argument = payload(); + argument.as_object_mut().unwrap().remove("owner"); + + let error = Holding::from_active_contract(&contract(Some(argument))).unwrap_err(); + + assert_eq!(error, "Missing 'owner' field"); + } + + #[test] + fn a_holding_with_a_lock_is_locked() { + let mut argument = payload(); + argument["lock"] = json!({ "holders": ["alice::1220ab"] }); + + assert!(Holding::is_locked_in_contract(&contract(Some(argument)))); + } + + #[test] + fn a_holding_with_a_null_lock_is_not_locked() { + let mut argument = payload(); + argument["lock"] = json!(null); + + assert!(!Holding::is_locked_in_contract(&contract(Some(argument)))); + } + + #[test] + fn a_holding_with_no_lock_field_is_not_locked() { + assert!(!Holding::is_locked_in_contract(&contract(Some(payload())))); + } + + #[test] + fn a_contract_with_no_create_argument_is_not_locked() { + assert!(!Holding::is_locked_in_contract(&contract(None))); + } +}