diff --git a/contract/contracts/hello-world/src/base/errors.rs b/contract/contracts/hello-world/src/base/errors.rs index 4de5713..b8716e1 100644 --- a/contract/contracts/hello-world/src/base/errors.rs +++ b/contract/contracts/hello-world/src/base/errors.rs @@ -72,4 +72,10 @@ pub enum Error { /// Triggered when a notification has already been delivered and cannot be recalled. NotificationDelivered = 30, InvalidLimit = 30, + /// Triggered when referencing a template ID that does not exist in the registry. + TemplateNotFound = 31, + /// Triggered when a template name exceeds the maximum allowed length. + TemplateNameTooLong = 32, + /// Triggered when a template content field is empty. + TemplateContentEmpty = 33, } diff --git a/contract/contracts/hello-world/src/base/events.rs b/contract/contracts/hello-world/src/base/events.rs index b4c5416..dc27e87 100644 --- a/contract/contracts/hello-world/src/base/events.rs +++ b/contract/contracts/hello-world/src/base/events.rs @@ -468,3 +468,44 @@ pub struct NotificationAcknowledged { pub priority: NotificationPriority, pub timestamp: u64, } + +// ============================================================================ +// Template Registry (Issue #352) +// ============================================================================ + +/// Emitted when a new notification template is registered on-chain. +/// +/// Off-chain indexers key off `template_id` to track registered templates. +/// The owner's address is published as an indexed topic for creator-based +/// filtering. +#[contractevent(data_format = "single-value")] +#[derive(Clone)] +pub struct TemplateRegistered { + /// Address that created and owns the template. + #[topic] + pub owner: Address, + #[topic] + pub category: NotificationCategory, + #[topic] + pub priority: NotificationPriority, + /// Unique identifier of the newly registered template. + pub template_id: BytesN<32>, +} + +/// Emitted when an existing notification template is updated by its owner. +/// +/// Off-chain indexers should use `template_id` to invalidate any cached +/// versions of the template and re-fetch the updated content. +#[contractevent(data_format = "single-value")] +#[derive(Clone)] +pub struct TemplateUpdated { + /// Address that owns (and updated) the template. + #[topic] + pub owner: Address, + #[topic] + pub category: NotificationCategory, + #[topic] + pub priority: NotificationPriority, + /// Unique identifier of the updated template. + pub template_id: BytesN<32>, +} diff --git a/contract/contracts/hello-world/src/base/types.rs b/contract/contracts/hello-world/src/base/types.rs index 21bd86d..07ad5ce 100644 --- a/contract/contracts/hello-world/src/base/types.rs +++ b/contract/contracts/hello-world/src/base/types.rs @@ -87,6 +87,32 @@ pub struct AuditRecord { pub timestamp: u64, } +// ============================================================================ +// Template Registry (Issue #352) +// ============================================================================ + +/// A reusable notification template stored on-chain. +/// +/// Senders can register a template once and reference its `id` in subsequent +/// notifications instead of resending identical payload data, reducing +/// transaction size and gas cost. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct NotificationTemplate { + /// Unique identifier for this template. + pub id: BytesN<32>, + /// Address that created and owns the template. Only the owner may update it. + pub owner: Address, + /// Human-readable name for the template (max 100 characters). + pub name: String, + /// The reusable notification content/payload stored with this template. + pub content: String, + /// Ledger timestamp (seconds) when the template was first registered. + pub created_at: u64, + /// Ledger timestamp (seconds) when the template was last updated, if ever. + pub updated_at: Option, +} + /// Protocol-level configurable limits for notifications. /// Allows administrators to set boundaries on notification sizes, /// expiration periods, and batch operation sizes. diff --git a/contract/contracts/hello-world/src/lib.rs b/contract/contracts/hello-world/src/lib.rs index e450608..d2740e7 100644 --- a/contract/contracts/hello-world/src/lib.rs +++ b/contract/contracts/hello-world/src/lib.rs @@ -19,6 +19,7 @@ pub mod interfaces { mod autoshare_logic; mod preferences_logic; mod reputation_logic; +mod template_registry_logic; #[cfg(test)] pub mod mock_token; @@ -562,6 +563,55 @@ impl AutoShareContract { reputation_logic::get_reputation_tier(&env, &sender).unwrap_or(0) } + // ============================================================================ + // Template Registry (Issue #352) + // ============================================================================ + + /// Registers a new reusable notification template on-chain. + /// + /// `id` must be unique — reverts with `AlreadyExists` if it is already taken. + /// `creator` becomes the sole owner and is the only address that can update it. + /// Emits a `TemplateRegistered` event on success. + pub fn register_template( + env: Env, + id: BytesN<32>, + creator: Address, + name: String, + content: String, + ) { + template_registry_logic::register_template(env, id, creator, name, content).unwrap(); + } + + /// Updates the `name` and `content` of an existing template. + /// + /// Only the original owner (creator) may call this function. + /// Reverts with `Unauthorized` for any other caller. + /// Reverts with `TemplateNotFound` if `id` is not registered. + /// Emits a `TemplateUpdated` event on success. + pub fn update_template( + env: Env, + id: BytesN<32>, + caller: Address, + name: String, + content: String, + ) { + template_registry_logic::update_template(env, id, caller, name, content).unwrap(); + } + + /// Returns the full `NotificationTemplate` record for `id`. + /// + /// Reverts with `TemplateNotFound` if `id` is not registered. + pub fn get_template(env: Env, id: BytesN<32>) -> base::types::NotificationTemplate { + template_registry_logic::get_template(env, id).unwrap() + } + + /// Returns `true` if a template is registered under `id`, `false` otherwise. + /// + /// This is a pure view function and never reverts. + pub fn template_exists(env: Env, id: BytesN<32>) -> bool { + template_registry_logic::template_exists(env, id) + } + // ============================================================================ // Schema Version Tracking (Issue #309) // ============================================================================ @@ -659,4 +709,7 @@ mod tests { #[path = "../tests/access_log_test.rs"] mod access_log_test; + + #[path = "../tests/template_registry_test.rs"] + mod template_registry_test; } diff --git a/contract/contracts/hello-world/src/template_registry_logic.rs b/contract/contracts/hello-world/src/template_registry_logic.rs new file mode 100644 index 0000000..2d8d3b9 --- /dev/null +++ b/contract/contracts/hello-world/src/template_registry_logic.rs @@ -0,0 +1,217 @@ +/// Template Registry Logic — Issue #352 +/// +/// Provides register/update/query operations for reusable notification templates. +/// Templates are stored in persistent storage keyed by a caller-supplied +/// `BytesN<32>` ID. Only the original owner (creator) of a template may update +/// it. Attempting to reference a non-existent template ID reverts with a clear +/// error. +use crate::base::errors::Error; +use crate::base::events::{ + AuthorizationFailure, NotificationCategory, NotificationPriority, TemplateRegistered, + TemplateUpdated, +}; +use crate::base::types::NotificationTemplate; +use soroban_sdk::{contracttype, Address, BytesN, Env, String}; + +// ============================================================================ +// Limits +// ============================================================================ + +/// Maximum allowed byte-length for a template name. +const MAX_TEMPLATE_NAME_LEN: u32 = 100; + +// ============================================================================ +// Storage keys +// ============================================================================ + +/// Persistent-storage key variants used by the template registry. +/// +/// Keys are separate from `DataKey` in `autoshare_logic` to avoid the two +/// enums merging into a single namespace and to keep each module's storage +/// footprint explicit. Both enums live in persistent storage and are +/// distinguished by their variant discriminant at the serialisation layer. +#[contracttype] +pub enum TemplateKey { + /// Full `NotificationTemplate` record keyed by template ID. + Template(BytesN<32>), +} + +// ============================================================================ +// Public API +// ============================================================================ + +/// Register a new notification template on-chain. +/// +/// The caller becomes the owner of the template and is the only address +/// authorised to update it in the future. +/// +/// # Arguments +/// * `id` – caller-chosen unique identifier; reverts with `AlreadyExists` +/// if it is already taken. +/// * `creator` – address that will own the template (must authorise the call). +/// * `name` – human-readable label (max 100 bytes); reverts with +/// `TemplateNameTooLong` if exceeded. +/// * `content` – notification payload/body; must not be empty, reverts with +/// `TemplateContentEmpty` otherwise. +/// +/// # Errors +/// - `AlreadyExists` – a template with this `id` is already registered. +/// - `TemplateNameTooLong` – `name` exceeds `MAX_TEMPLATE_NAME_LEN` bytes. +/// - `TemplateContentEmpty` – `content` is an empty string. +/// +/// # Events +/// Emits [`TemplateRegistered`] on success. +pub fn register_template( + env: Env, + id: BytesN<32>, + creator: Address, + name: String, + content: String, +) -> Result<(), Error> { + creator.require_auth(); + + let key = TemplateKey::Template(id.clone()); + + // Prevent overwriting an existing template. + if env.storage().persistent().has(&key) { + return Err(Error::AlreadyExists); + } + + validate_name(&name)?; + validate_content(&content)?; + + let template = NotificationTemplate { + id: id.clone(), + owner: creator.clone(), + name, + content, + created_at: env.ledger().timestamp(), + updated_at: None, + }; + + env.storage().persistent().set(&key, &template); + + TemplateRegistered { + owner: creator, + category: NotificationCategory::Notification, + priority: NotificationPriority::Medium, + template_id: id, + } + .publish(&env); + + Ok(()) +} + +/// Update the `name` and/or `content` of an existing template. +/// +/// Only the original owner of the template is permitted to call this function. +/// Any other caller reverts with `Unauthorized`. +/// +/// # Arguments +/// * `id` – identifier of the template to update. +/// * `caller` – must match the template's stored `owner`; must authorise the call. +/// * `name` – replacement name (max 100 bytes). +/// * `content`– replacement content; must not be empty. +/// +/// # Errors +/// - `TemplateNotFound` – no template is registered under `id`. +/// - `Unauthorized` – `caller` is not the template owner. +/// - `TemplateNameTooLong` – replacement `name` exceeds `MAX_TEMPLATE_NAME_LEN`. +/// - `TemplateContentEmpty` – replacement `content` is empty. +/// +/// # Events +/// Emits [`TemplateUpdated`] on success. +pub fn update_template( + env: Env, + id: BytesN<32>, + caller: Address, + name: String, + content: String, +) -> Result<(), Error> { + caller.require_auth(); + + let key = TemplateKey::Template(id.clone()); + + let mut template: NotificationTemplate = env + .storage() + .persistent() + .get(&key) + .ok_or(Error::TemplateNotFound)?; + + // Only the original owner may update. + if template.owner != caller { + publish_authorization_failure(&env, &caller, "update_template"); + return Err(Error::Unauthorized); + } + + validate_name(&name)?; + validate_content(&content)?; + + template.name = name; + template.content = content; + template.updated_at = Some(env.ledger().timestamp()); + + env.storage().persistent().set(&key, &template); + + TemplateUpdated { + owner: caller, + category: NotificationCategory::Notification, + priority: NotificationPriority::Medium, + template_id: id, + } + .publish(&env); + + Ok(()) +} + +/// Return the full record for a registered template. +/// +/// # Errors +/// - `TemplateNotFound` – no template is registered under `id`. +pub fn get_template(env: Env, id: BytesN<32>) -> Result { + let key = TemplateKey::Template(id); + env.storage() + .persistent() + .get(&key) + .ok_or(Error::TemplateNotFound) +} + +/// Return `true` if a template with the given `id` exists, `false` otherwise. +/// +/// This is a pure view function and never reverts. +pub fn template_exists(env: Env, id: BytesN<32>) -> bool { + let key = TemplateKey::Template(id); + env.storage().persistent().has(&key) +} + +// ============================================================================ +// Validation helpers +// ============================================================================ + +fn validate_name(name: &String) -> Result<(), Error> { + if name.len() > MAX_TEMPLATE_NAME_LEN { + return Err(Error::TemplateNameTooLong); + } + Ok(()) +} + +fn validate_content(content: &String) -> Result<(), Error> { + if content.len() == 0 { + return Err(Error::TemplateContentEmpty); + } + Ok(()) +} + +// ============================================================================ +// Auth helpers +// ============================================================================ + +fn publish_authorization_failure(env: &Env, caller: &Address, action: &str) { + AuthorizationFailure { + caller: caller.clone(), + category: NotificationCategory::Admin, + priority: NotificationPriority::Critical, + action: String::from_str(env, action), + } + .publish(env); +} diff --git a/contract/contracts/hello-world/src/tests/template_registry_test.rs b/contract/contracts/hello-world/src/tests/template_registry_test.rs new file mode 100644 index 0000000..49fe768 --- /dev/null +++ b/contract/contracts/hello-world/src/tests/template_registry_test.rs @@ -0,0 +1,403 @@ +/// Template Registry Tests — Issue #352 +/// +/// Covers: +/// - Successful template registration +/// - Duplicate and invalid registration attempts +/// - Update permission checks (only owner can update) +/// - Retrieval / existence checks for valid vs. invalid template IDs +/// - Event emission for both registration and update +#[cfg(test)] +mod template_registry_tests { + use crate::base::events::{NotificationCategory, NotificationPriority}; + use crate::base::errors::Error; + use crate::test_utils::setup_test_env; + use crate::{AutoShareContract, AutoShareContractClient}; + use soroban_sdk::{ + testutils::{Address as _, Events}, + Address, BytesN, Env, String, Symbol, TryFromVal, Val, Vec, + }; + + // ============================================================================ + // Helpers + // ============================================================================ + + /// Constructs a 32-byte ID from a single seed byte. + fn make_id(env: &Env, seed: u8) -> BytesN<32> { + let mut bytes = [0u8; 32]; + bytes[0] = seed; + BytesN::from_array(env, &bytes) + } + + /// Returns the topics of the most recently emitted event whose first topic + /// matches `event_name` (as produced by `#[contractevent]`). + fn topics_of(env: &Env, event_name: &str) -> Option> { + let target = Symbol::new(env, event_name); + let mut found: Option> = None; + for (_addr, topics, _data) in env.events().all().iter() { + if topics.is_empty() { + continue; + } + if let Ok(name) = Symbol::try_from_val(env, &topics.get(0).unwrap()) { + if name == target { + found = Some(topics); + } + } + } + found + } + + /// Extracts the `NotificationCategory` from the second-to-last topic of the + /// most recently emitted event named `event_name`. + fn category_of(env: &Env, event_name: &str) -> Option { + let topics = topics_of(env, event_name)?; + let n = topics.len(); + if n < 2 { + return None; + } + NotificationCategory::try_from_val(env, &topics.get(n - 2)?).ok() + } + + /// Extracts the `NotificationPriority` from the last topic of the most + /// recently emitted event named `event_name`. + fn priority_of(env: &Env, event_name: &str) -> Option { + let topics = topics_of(env, event_name)?; + NotificationPriority::try_from_val(env, &topics.last()?).ok() + } + + // ============================================================================ + // register_template — success path + // ============================================================================ + + #[test] + fn test_register_template_succeeds() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + + let owner = test_env.users.get(0).unwrap(); + let id = make_id(&test_env.env, 1); + let name = String::from_str(&test_env.env, "Payment Reminder"); + let content = String::from_str(&test_env.env, "Your payment of {amount} is due."); + + // Should not panic. + client.register_template(&id, &owner, &name, &content); + } + + #[test] + fn test_registered_template_can_be_retrieved() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + + let owner = test_env.users.get(0).unwrap(); + let id = make_id(&test_env.env, 2); + let name = String::from_str(&test_env.env, "Welcome"); + let content = String::from_str(&test_env.env, "Welcome, {user}!"); + + client.register_template(&id, &owner, &name, &content); + + let tmpl = client.get_template(&id); + assert_eq!(tmpl.id, id); + assert_eq!(tmpl.owner, owner); + assert_eq!(tmpl.name, name); + assert_eq!(tmpl.content, content); + assert!(tmpl.updated_at.is_none()); + } + + // ============================================================================ + // template_exists (existence check) + // ============================================================================ + + #[test] + fn test_template_exists_returns_true_for_registered_id() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + + let owner = test_env.users.get(0).unwrap(); + let id = make_id(&test_env.env, 3); + let name = String::from_str(&test_env.env, "Alert"); + let content = String::from_str(&test_env.env, "System alert: {message}"); + + client.register_template(&id, &owner, &name, &content); + + assert!(client.template_exists(&id)); + } + + #[test] + fn test_template_exists_returns_false_for_unknown_id() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + + let unknown_id = make_id(&test_env.env, 99); + assert!(!client.template_exists(&unknown_id)); + } + + // ============================================================================ + // get_template — error on missing ID + // ============================================================================ + + #[test] + #[should_panic] + fn test_get_template_panics_for_nonexistent_id() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + + let unknown_id = make_id(&test_env.env, 99); + // `get_template` calls `.unwrap()` in lib.rs so it panics on Error::TemplateNotFound. + let _ = client.get_template(&unknown_id); + } + + // ============================================================================ + // register_template — duplicate registration + // ============================================================================ + + #[test] + #[should_panic] + fn test_duplicate_registration_panics() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + + let owner = test_env.users.get(0).unwrap(); + let id = make_id(&test_env.env, 4); + let name = String::from_str(&test_env.env, "Original"); + let content = String::from_str(&test_env.env, "Original content."); + + client.register_template(&id, &owner, &name, &content); + // Second registration with the same ID must panic with AlreadyExists. + client.register_template( + &id, + &owner, + &String::from_str(&test_env.env, "Duplicate"), + &String::from_str(&test_env.env, "Duplicate content."), + ); + } + + // ============================================================================ + // register_template — validation guards + // ============================================================================ + + #[test] + #[should_panic] + fn test_register_with_empty_content_panics() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + + let owner = test_env.users.get(0).unwrap(); + let id = make_id(&test_env.env, 5); + let name = String::from_str(&test_env.env, "Empty Content"); + let empty = String::from_str(&test_env.env, ""); + + client.register_template(&id, &owner, &name, &empty); + } + + #[test] + #[should_panic] + fn test_register_with_name_too_long_panics() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + + let owner = test_env.users.get(0).unwrap(); + let id = make_id(&test_env.env, 6); + // 101-character name — exceeds the 100-byte limit. + let long_name = String::from_str( + &test_env.env, + "aaaaaaaaaabbbbbbbbbbccccccccccddddddddddeeeeeeeeeeaaaaaaaaaabbbbbbbbbbccccccccccdddddddddde1", + ); + let content = String::from_str(&test_env.env, "Some content."); + + client.register_template(&id, &owner, &long_name, &content); + } + + // ============================================================================ + // update_template — success path + // ============================================================================ + + #[test] + fn test_owner_can_update_template() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + + let owner = test_env.users.get(0).unwrap(); + let id = make_id(&test_env.env, 7); + let name = String::from_str(&test_env.env, "Original Name"); + let content = String::from_str(&test_env.env, "Original content."); + + client.register_template(&id, &owner, &name, &content); + + let new_name = String::from_str(&test_env.env, "Updated Name"); + let new_content = String::from_str(&test_env.env, "Updated content."); + client.update_template(&id, &owner, &new_name, &new_content); + + let tmpl = client.get_template(&id); + assert_eq!(tmpl.name, new_name); + assert_eq!(tmpl.content, new_content); + assert_eq!(tmpl.owner, owner); // owner unchanged + assert!(tmpl.updated_at.is_some()); + } + + // ============================================================================ + // update_template — ownership enforcement + // ============================================================================ + + #[test] + #[should_panic] + fn test_non_owner_cannot_update_template() { + // This test must NOT use env.mock_all_auths() for the unauthorized call. + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(AutoShareContract, ()); + let client = AutoShareContractClient::new(&env, &contract_id); + + // Initialize admin so the contract is usable. + let admin = Address::generate(&env); + client.initialize_admin(&admin); + + let owner = Address::generate(&env); + let attacker = Address::generate(&env); + let id = make_id(&env, 8); + let name = String::from_str(&env, "Owned Template"); + let content = String::from_str(&env, "Sensitive content."); + + client.register_template(&id, &owner, &name, &content); + + // Attacker tries to update owner's template — must panic (Unauthorized). + client.update_template( + &id, + &attacker, + &String::from_str(&env, "Hijacked"), + &String::from_str(&env, "Hijacked content."), + ); + } + + // ============================================================================ + // update_template — errors on non-existent template + // ============================================================================ + + #[test] + #[should_panic] + fn test_update_nonexistent_template_panics() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + + let caller = test_env.users.get(0).unwrap(); + let unknown_id = make_id(&test_env.env, 99); + + client.update_template( + &unknown_id, + &caller, + &String::from_str(&test_env.env, "Name"), + &String::from_str(&test_env.env, "Content."), + ); + } + + // ============================================================================ + // Event emission — TemplateRegistered + // ============================================================================ + + #[test] + fn test_register_emits_template_registered_event() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + + let owner = test_env.users.get(0).unwrap(); + let id = make_id(&test_env.env, 10); + client.register_template( + &id, + &owner, + &String::from_str(&test_env.env, "Evt Template"), + &String::from_str(&test_env.env, "Event content."), + ); + + // The event must have been emitted. + let topics = topics_of(&test_env.env, "template_registered"); + assert!( + topics.is_some(), + "TemplateRegistered event was not emitted" + ); + + // Category and priority must match the spec. + assert_eq!( + category_of(&test_env.env, "template_registered"), + Some(NotificationCategory::Notification) + ); + assert_eq!( + priority_of(&test_env.env, "template_registered"), + Some(NotificationPriority::Medium) + ); + } + + // ============================================================================ + // Event emission — TemplateUpdated + // ============================================================================ + + #[test] + fn test_update_emits_template_updated_event() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + + let owner = test_env.users.get(0).unwrap(); + let id = make_id(&test_env.env, 11); + client.register_template( + &id, + &owner, + &String::from_str(&test_env.env, "Before"), + &String::from_str(&test_env.env, "Before content."), + ); + + client.update_template( + &id, + &owner, + &String::from_str(&test_env.env, "After"), + &String::from_str(&test_env.env, "After content."), + ); + + // The update event must have been emitted. + let topics = topics_of(&test_env.env, "template_updated"); + assert!(topics.is_some(), "TemplateUpdated event was not emitted"); + + // Category and priority must match the spec. + assert_eq!( + category_of(&test_env.env, "template_updated"), + Some(NotificationCategory::Notification) + ); + assert_eq!( + priority_of(&test_env.env, "template_updated"), + Some(NotificationPriority::Medium) + ); + } + + // ============================================================================ + // Multiple templates are independent + // ============================================================================ + + #[test] + fn test_multiple_templates_are_independent() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + + let owner1 = test_env.users.get(0).unwrap(); + let owner2 = test_env.users.get(1).unwrap(); + + let id1 = make_id(&test_env.env, 20); + let id2 = make_id(&test_env.env, 21); + + client.register_template( + &id1, + &owner1, + &String::from_str(&test_env.env, "Template 1"), + &String::from_str(&test_env.env, "Content 1"), + ); + client.register_template( + &id2, + &owner2, + &String::from_str(&test_env.env, "Template 2"), + &String::from_str(&test_env.env, "Content 2"), + ); + + let t1 = client.get_template(&id1); + let t2 = client.get_template(&id2); + + assert_eq!(t1.owner, owner1); + assert_eq!(t2.owner, owner2); + assert_ne!(t1.id, t2.id); + } +}