From 4e1e9d0d68c3837cc98fceabe435c6c06a6cb5c1 Mon Sep 17 00:00:00 2001 From: gyorgybalazsi Date: Thu, 10 Sep 2026 12:04:59 +0200 Subject: [PATCH 1/3] Test the Holding parser token::holding::Holding had no test. cbtc-lib carried a copy of the struct and also had none, so the parser that reads every utility-registry holding was untested on both sides. cbtc-lib has now deleted its copy, which leaves this the only one. The tests cover the four parsed fields, the six error messages and the lock check. Two mutations confirm they fail when the parser breaks: reading the instrument id from the top level, and treating a null lock as a lock. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 6 ++ crates/token/src/holding.rs | 152 ++++++++++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+) 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..0f6a10e 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(); + + assert!( + error.starts_with("Invalid 'amount' field"), + "unexpected error: {error}" + ); + } + + #[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_holding_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))); + } +} From 3cb32645f64451d133aa16c3f7dae20efbaf3d87 Mon Sep 17 00:00:00 2001 From: gyorgybalazsi Date: Thu, 10 Sep 2026 12:14:18 +0200 Subject: [PATCH 2/3] Name the lock test without the doubled word The three sibling tests all read "a_holding_with_...", so the lock test now matches them. Co-Authored-By: Claude Opus 5 (1M context) --- crates/token/src/holding.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/token/src/holding.rs b/crates/token/src/holding.rs index 0f6a10e..108c615 100644 --- a/crates/token/src/holding.rs +++ b/crates/token/src/holding.rs @@ -193,7 +193,7 @@ mod tests { } #[test] - fn a_holding_holding_a_lock_is_locked() { + fn a_holding_with_a_lock_is_locked() { let mut argument = payload(); argument["lock"] = json!({ "holders": ["alice::1220ab"] }); From 9aa964358f75d13d5cf1b9fbc2e35c621a1d944a Mon Sep 17 00:00:00 2001 From: gyorgybalazsi Date: Thu, 10 Sep 2026 14:40:33 +0200 Subject: [PATCH 3/3] Assert the whole amount-parse error message The test checked a prefix, while the PR body claimed exact assertions. It now compares the full string, and derives the parser's own detail from DamlDecimal::parse rather than pinning wording this crate does not own. Rewording the format string fails the test. Co-Authored-By: Claude Opus 5 (1M context) --- crates/token/src/holding.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/token/src/holding.rs b/crates/token/src/holding.rs index 108c615..73c0a58 100644 --- a/crates/token/src/holding.rs +++ b/crates/token/src/holding.rs @@ -146,10 +146,10 @@ mod tests { let error = Holding::from_active_contract(&contract(Some(argument))).unwrap_err(); - assert!( - error.starts_with("Invalid 'amount' field"), - "unexpected error: {error}" - ); + // 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]