Skip to main content

dpp/document/v0/
mod.rs

1//! Documents.
2//!
3//! This module defines the `Document` struct and implements its functions.
4//!
5
6mod accessors;
7#[cfg(feature = "document-cbor-conversion")]
8pub(super) mod cbor_conversion;
9#[cfg(feature = "value-conversion")]
10mod platform_value_conversion;
11pub mod serialize;
12
13use chrono::DateTime;
14use std::collections::BTreeMap;
15use std::fmt;
16
17use platform_value::Value;
18
19use crate::document::document_methods::{
20    DocumentGetRawForContractV0, DocumentGetRawForDocumentTypeV0, DocumentHashV0Method,
21    DocumentIsEqualIgnoringTimestampsV0,
22};
23
24use crate::identity::TimestampMillis;
25use crate::prelude::Revision;
26use crate::prelude::{BlockHeight, CoreBlockHeight, Identifier};
27#[cfg(feature = "json-conversion")]
28use crate::serialization::json_safe_fields;
29
30/// Documents contain the data that goes into data contracts.
31#[cfg_attr(feature = "json-conversion", json_safe_fields)]
32#[derive(Clone, Debug, PartialEq, Default)]
33#[cfg_attr(
34    feature = "serde-conversion",
35    derive(serde::Serialize, serde::Deserialize)
36)]
37pub struct DocumentV0 {
38    /// The unique document ID.
39    #[cfg_attr(feature = "serde-conversion", serde(rename = "$id"))]
40    pub id: Identifier,
41    /// The ID of the document's owner.
42    #[cfg_attr(feature = "serde-conversion", serde(rename = "$ownerId"))]
43    pub owner_id: Identifier,
44    /// The document's properties (data).
45    #[cfg_attr(feature = "serde-conversion", serde(flatten))]
46    pub properties: BTreeMap<String, Value>,
47    /// The document revision, if the document is mutable.
48    #[cfg_attr(feature = "serde-conversion", serde(rename = "$revision", default))]
49    pub revision: Option<Revision>,
50    /// The time in milliseconds that the document was created, if it is set as required by the document type schema.
51    #[cfg_attr(feature = "serde-conversion", serde(rename = "$createdAt", default))]
52    pub created_at: Option<TimestampMillis>,
53    /// The time in milliseconds that the document was last updated, if it is set as required by the document type schema.
54    #[cfg_attr(feature = "serde-conversion", serde(rename = "$updatedAt", default))]
55    pub updated_at: Option<TimestampMillis>,
56    /// The time in milliseconds that the document was last transferred, if it is set as required by the document type schema.
57    #[cfg_attr(
58        feature = "serde-conversion",
59        serde(rename = "$transferredAt", default)
60    )]
61    pub transferred_at: Option<TimestampMillis>,
62    /// The block that the document was created, if it is set as required by the document type schema.
63    #[cfg_attr(
64        feature = "serde-conversion",
65        serde(rename = "$createdAtBlockHeight", default)
66    )]
67    pub created_at_block_height: Option<BlockHeight>,
68    /// The block that the document was last updated, if it is set as required by the document type schema.
69    #[cfg_attr(
70        feature = "serde-conversion",
71        serde(rename = "$updatedAtBlockHeight", default)
72    )]
73    pub updated_at_block_height: Option<BlockHeight>,
74    /// The block that the document was last transferred to a new identity, if it is set as required by the document type schema.
75    #[cfg_attr(
76        feature = "serde-conversion",
77        serde(rename = "$transferredAtBlockHeight", default)
78    )]
79    pub transferred_at_block_height: Option<BlockHeight>,
80    /// The core block that the document was created, if it is set as required by the document type schema.
81    #[cfg_attr(
82        feature = "serde-conversion",
83        serde(rename = "$createdAtCoreBlockHeight", default)
84    )]
85    pub created_at_core_block_height: Option<CoreBlockHeight>,
86    /// The core block that the document was last updated, if it is set as required by the document type schema.
87    #[cfg_attr(
88        feature = "serde-conversion",
89        serde(rename = "$updatedAtCoreBlockHeight", default)
90    )]
91    pub updated_at_core_block_height: Option<CoreBlockHeight>,
92    /// The core block that the document was last transferred to a new identity, if it is set as required by the document type schema.
93    #[cfg_attr(
94        feature = "serde-conversion",
95        serde(rename = "$transferredAtCoreBlockHeight", default)
96    )]
97    pub transferred_at_core_block_height: Option<CoreBlockHeight>,
98    /// The creator id.
99    #[cfg_attr(feature = "serde-conversion", serde(rename = "$creatorId", default))]
100    pub creator_id: Option<Identifier>,
101    /// The data contract version this document's bytes conform to — assigned
102    /// by Drive when document content is (re-)supplied (create/replace) and
103    /// preserved across server-side rewrites (transfer/purchase). Selects the
104    /// per-property byte layout when the document type carries `requiredSince`
105    /// annotations. `None` for documents serialized before format 3.
106    #[cfg_attr(
107        feature = "serde-conversion",
108        serde(
109            rename = "$contractVersion",
110            default,
111            skip_serializing_if = "Option::is_none"
112        )
113    )]
114    pub contract_version: Option<u32>,
115}
116
117impl DocumentGetRawForContractV0 for DocumentV0 {
118    //automatically done
119}
120
121impl DocumentIsEqualIgnoringTimestampsV0 for DocumentV0 {
122    //automatically done
123}
124
125impl DocumentGetRawForDocumentTypeV0 for DocumentV0 {
126    //automatically done
127}
128
129impl DocumentHashV0Method for DocumentV0 {
130    //automatically done
131}
132
133impl fmt::Display for DocumentV0 {
134    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
135        write!(f, "id:{} ", self.id)?;
136        write!(f, "owner_id:{} ", self.owner_id)?;
137        if let Some(created_at) = self.created_at {
138            let datetime = DateTime::from_timestamp_millis(created_at as i64).unwrap_or_default();
139            write!(f, "created_at:{} ", datetime.format("%Y-%m-%d %H:%M:%S"))?;
140        }
141        if let Some(updated_at) = self.updated_at {
142            let datetime = DateTime::from_timestamp_millis(updated_at as i64).unwrap_or_default();
143            write!(f, "updated_at:{} ", datetime.format("%Y-%m-%d %H:%M:%S"))?;
144        }
145        if let Some(transferred_at) = self.transferred_at {
146            let datetime =
147                DateTime::from_timestamp_millis(transferred_at as i64).unwrap_or_default();
148            write!(
149                f,
150                "transferred_at:{} ",
151                datetime.format("%Y-%m-%d %H:%M:%S")
152            )?;
153        }
154
155        if let Some(created_at_block_height) = self.created_at_block_height {
156            write!(f, "created_at_block_height:{} ", created_at_block_height)?;
157        }
158        if let Some(updated_at_block_height) = self.updated_at_block_height {
159            write!(f, "updated_at_block_height:{} ", updated_at_block_height)?;
160        }
161        if let Some(transferred_at_block_height) = self.transferred_at_block_height {
162            write!(
163                f,
164                "transferred_at_block_height:{} ",
165                transferred_at_block_height
166            )?;
167        }
168        if let Some(created_at_core_block_height) = self.created_at_core_block_height {
169            write!(
170                f,
171                "created_at_core_block_height:{} ",
172                created_at_core_block_height
173            )?;
174        }
175        if let Some(updated_at_core_block_height) = self.updated_at_core_block_height {
176            write!(
177                f,
178                "updated_at_core_block_height:{} ",
179                updated_at_core_block_height
180            )?;
181        }
182        if let Some(transferred_at_core_block_height) = self.transferred_at_core_block_height {
183            write!(
184                f,
185                "transferred_at_core_block_height:{} ",
186                transferred_at_core_block_height
187            )?;
188        }
189
190        if let Some(creator_id) = self.creator_id {
191            write!(f, "creator_id:{} ", creator_id)?;
192        }
193
194        if self.properties.is_empty() {
195            write!(f, "no properties")?;
196        } else {
197            for (key, value) in self.properties.iter() {
198                write!(f, "{}:{} ", key, value)?
199            }
200        }
201        Ok(())
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use crate::data_contract::accessors::v0::DataContractV0Getters;
209    use crate::document::{DocumentV0Getters, DocumentV0Setters};
210    use platform_value::Identifier;
211
212    fn minimal_doc() -> DocumentV0 {
213        DocumentV0 {
214            contract_version: None,
215            id: Identifier::new([1u8; 32]),
216            owner_id: Identifier::new([2u8; 32]),
217            properties: BTreeMap::new(),
218            revision: None,
219            created_at: None,
220            updated_at: None,
221            transferred_at: None,
222            created_at_block_height: None,
223            updated_at_block_height: None,
224            transferred_at_block_height: None,
225            created_at_core_block_height: None,
226            updated_at_core_block_height: None,
227            transferred_at_core_block_height: None,
228            creator_id: None,
229        }
230    }
231
232    // ================================================================
233    //  Display impl: exercise each optional-field branch
234    // ================================================================
235
236    #[test]
237    fn display_minimal_document_has_no_properties_marker() {
238        let doc = minimal_doc();
239        let s = format!("{}", doc);
240        assert!(s.contains("id:"), "should contain id");
241        assert!(s.contains("owner_id:"), "should contain owner_id");
242        assert!(
243            s.contains("no properties"),
244            "empty properties should render as 'no properties', got: {s}"
245        );
246    }
247
248    #[test]
249    fn display_with_properties_formats_key_value_pairs() {
250        let mut doc = minimal_doc();
251        doc.properties
252            .insert("name".to_string(), Value::Text("Bob".to_string()));
253        let s = format!("{}", doc);
254        assert!(!s.contains("no properties"));
255        assert!(s.contains("name:"), "should contain property key");
256    }
257
258    #[test]
259    fn display_formats_all_optional_timestamp_fields() {
260        let mut doc = minimal_doc();
261        // Set every optional field to exercise each branch of Display
262        doc.created_at = Some(1_700_000_000_000);
263        doc.updated_at = Some(1_700_000_100_000);
264        doc.transferred_at = Some(1_700_000_200_000);
265        doc.created_at_block_height = Some(10);
266        doc.updated_at_block_height = Some(20);
267        doc.transferred_at_block_height = Some(30);
268        doc.created_at_core_block_height = Some(1);
269        doc.updated_at_core_block_height = Some(2);
270        doc.transferred_at_core_block_height = Some(3);
271        doc.creator_id = Some(Identifier::new([9u8; 32]));
272
273        let s = format!("{}", doc);
274        // Each branch should emit its labeled prefix
275        assert!(s.contains("created_at:"), "missing created_at: {s}");
276        assert!(s.contains("updated_at:"), "missing updated_at: {s}");
277        assert!(s.contains("transferred_at:"), "missing transferred_at: {s}");
278        assert!(
279            s.contains("created_at_block_height:10"),
280            "missing created_at_block_height: {s}"
281        );
282        assert!(
283            s.contains("updated_at_block_height:20"),
284            "missing updated_at_block_height: {s}"
285        );
286        assert!(
287            s.contains("transferred_at_block_height:30"),
288            "missing transferred_at_block_height: {s}"
289        );
290        assert!(
291            s.contains("created_at_core_block_height:1"),
292            "missing created_at_core_block_height: {s}"
293        );
294        assert!(
295            s.contains("updated_at_core_block_height:2"),
296            "missing updated_at_core_block_height: {s}"
297        );
298        assert!(
299            s.contains("transferred_at_core_block_height:3"),
300            "missing transferred_at_core_block_height: {s}"
301        );
302        assert!(s.contains("creator_id:"), "missing creator_id: {s}");
303    }
304
305    #[test]
306    fn display_invalid_timestamp_uses_default_formatter() {
307        // Timestamps that overflow DateTime should use `.unwrap_or_default()`.
308        // This ensures the "unwrap_or_default()" branch of Display is hit.
309        let mut doc = minimal_doc();
310        // u64::MAX casts to -1i64, which IS inside chrono's range (1 ms before
311        // epoch). Use i64::MAX instead — it exceeds chrono's supported ms
312        // range (~262,000 years) so `from_timestamp_millis` returns None and
313        // the `.unwrap_or_default()` branch is actually exercised.
314        doc.created_at = Some(i64::MAX as u64);
315        let s = format!("{}", doc);
316        // Must not panic and must contain the created_at prefix
317        assert!(s.contains("created_at:"));
318    }
319
320    // ================================================================
321    //  bump_revision: saturating behavior and None pass-through
322    // ================================================================
323
324    #[test]
325    fn bump_revision_increments_when_some() {
326        let mut doc = minimal_doc();
327        doc.set_revision(Some(5));
328        doc.bump_revision();
329        assert_eq!(doc.revision(), Some(6));
330    }
331
332    #[test]
333    fn bump_revision_is_noop_when_none() {
334        let mut doc = minimal_doc();
335        assert_eq!(doc.revision(), None);
336        doc.bump_revision();
337        // None -> None; no panic, no change.
338        assert_eq!(doc.revision(), None);
339    }
340
341    #[test]
342    fn bump_revision_saturates_at_max() {
343        let mut doc = minimal_doc();
344        doc.set_revision(Some(Revision::MAX));
345        doc.bump_revision();
346        // saturating_add should cap at MAX, not wrap
347        assert_eq!(doc.revision(), Some(Revision::MAX));
348    }
349
350    // ================================================================
351    //  Default impl
352    // ================================================================
353
354    #[test]
355    fn default_document_has_zero_identifiers_and_none_fields() {
356        let doc = DocumentV0::default();
357        assert_eq!(doc.id, Identifier::new([0u8; 32]));
358        assert_eq!(doc.owner_id, Identifier::new([0u8; 32]));
359        assert!(doc.properties.is_empty());
360        assert_eq!(doc.revision, None);
361        assert_eq!(doc.created_at, None);
362        assert_eq!(doc.updated_at, None);
363        assert_eq!(doc.transferred_at, None);
364        assert_eq!(doc.creator_id, None);
365    }
366
367    // ================================================================
368    //  PartialEq semantics
369    // ================================================================
370
371    #[test]
372    fn documents_with_different_creator_id_are_not_equal() {
373        let a = minimal_doc();
374        let mut b = minimal_doc();
375        b.creator_id = Some(Identifier::new([7u8; 32]));
376        assert_ne!(a, b);
377    }
378
379    #[test]
380    fn documents_with_equal_fields_are_equal() {
381        let a = minimal_doc();
382        let b = minimal_doc();
383        assert_eq!(a, b);
384    }
385
386    #[test]
387    fn clone_produces_equal_document() {
388        let mut doc = minimal_doc();
389        doc.properties.insert("k".to_string(), Value::U64(42));
390        doc.revision = Some(3);
391        let cloned = doc.clone();
392        assert_eq!(doc, cloned);
393    }
394
395    // ================================================================
396    //  Display impl: properties ordering and mixed fields
397    // ================================================================
398
399    #[test]
400    fn display_writes_properties_in_btreemap_sorted_order() {
401        // BTreeMap iterates in sorted key order. Verify the Display impl
402        // (which delegates to self.properties.iter()) emits the keys in that
403        // order. This exercises the properties-iteration branch of Display
404        // with more than one property.
405        let mut doc = minimal_doc();
406        doc.properties
407            .insert("zebra".to_string(), Value::Text("z".into()));
408        doc.properties
409            .insert("apple".to_string(), Value::Text("a".into()));
410        doc.properties
411            .insert("mango".to_string(), Value::Text("m".into()));
412
413        let s = format!("{}", doc);
414        let apple_idx = s.find("apple:").expect("apple missing");
415        let mango_idx = s.find("mango:").expect("mango missing");
416        let zebra_idx = s.find("zebra:").expect("zebra missing");
417        assert!(
418            apple_idx < mango_idx && mango_idx < zebra_idx,
419            "properties should appear in sorted (BTreeMap) order: {s}"
420        );
421    }
422
423    #[test]
424    fn display_mixes_system_fields_and_user_properties() {
425        // Exercise Display with only some optional system fields set,
426        // plus a property. Different combo than prior tests so we hit
427        // the transition from "system optional Some arm" to "properties
428        // iteration arm".
429        let mut doc = minimal_doc();
430        doc.revision = Some(42);
431        doc.created_at_block_height = Some(7);
432        doc.properties
433            .insert("greeting".to_string(), Value::Text("hi".into()));
434
435        let s = format!("{}", doc);
436        assert!(s.contains("created_at_block_height:7"));
437        assert!(s.contains("greeting:"));
438        // revision is NOT rendered by Display (only system timestamps +
439        // properties are). Verify Display does not add spurious revision text.
440        assert!(!s.contains("revision"));
441    }
442
443    // ================================================================
444    //  Hash method: from the DocumentHashV0Method trait, which is the
445    //  empty impl on DocumentV0 that forwards to hash_v0. Exercises a
446    //  code path not covered by accessor-only tests.
447    // ================================================================
448
449    #[test]
450    fn hash_v0_produces_deterministic_output_for_identical_documents() {
451        use crate::document::document_methods::DocumentHashV0Method;
452        use crate::document::serialization_traits::DocumentPlatformConversionMethodsV0;
453        use crate::tests::json_document::json_document_to_contract;
454        use platform_version::version::PlatformVersion;
455
456        // hash_v0 is the default-method impl on DocumentV0 (via empty impl
457        // block). It requires a contract + document type to hash through.
458        let platform_version = PlatformVersion::first();
459        let contract = json_document_to_contract(
460            "../rs-drive/tests/supporting_files/contract/family/family-contract.json",
461            false,
462            platform_version,
463        )
464        .expect("expected to load family contract");
465        let doc_type = contract
466            .document_type_for_name("person")
467            .expect("expected person type");
468
469        // Build a document that can be serialized under this type.
470        use crate::data_contract::document_type::random_document::CreateRandomDocument;
471        let document = doc_type
472            .random_document(Some(7), platform_version)
473            .expect("random document");
474        let doc_v0 = match &document {
475            crate::document::Document::V0(d) => d.clone(),
476        };
477
478        // Determinism: hashing the same document twice must produce equal bytes.
479        let h1 = doc_v0
480            .hash_v0(&contract, doc_type, platform_version)
481            .expect("hash succeeds");
482        let h2 = doc_v0
483            .hash_v0(&contract, doc_type, platform_version)
484            .expect("hash succeeds");
485        assert_eq!(h1, h2);
486        // The double-SHA256 result is 32 bytes.
487        assert_eq!(h1.len(), 32);
488
489        // And sanity: the hash must differ from the plain serialized bytes
490        // — i.e. the impl actually hashes, it doesn't just forward serialize().
491        let serialized = doc_v0
492            .serialize(doc_type, &contract, platform_version)
493            .expect("serialize");
494        assert_ne!(h1, serialized);
495    }
496
497    #[test]
498    fn hash_v0_differs_between_different_documents() {
499        use crate::document::document_methods::DocumentHashV0Method;
500        use crate::tests::json_document::json_document_to_contract;
501        use platform_version::version::PlatformVersion;
502
503        let platform_version = PlatformVersion::first();
504        let contract = json_document_to_contract(
505            "../rs-drive/tests/supporting_files/contract/family/family-contract.json",
506            false,
507            platform_version,
508        )
509        .expect("family contract");
510        let doc_type = contract
511            .document_type_for_name("person")
512            .expect("person type");
513
514        use crate::data_contract::document_type::random_document::CreateRandomDocument;
515        let crate::document::Document::V0(doc_a) = doc_type
516            .random_document(Some(1), platform_version)
517            .expect("random a");
518        let crate::document::Document::V0(doc_b) = doc_type
519            .random_document(Some(2), platform_version)
520            .expect("random b");
521
522        let h_a = doc_a
523            .hash_v0(&contract, doc_type, platform_version)
524            .expect("hash a");
525        let h_b = doc_b
526            .hash_v0(&contract, doc_type, platform_version)
527            .expect("hash b");
528        assert_ne!(h_a, h_b);
529    }
530
531    // ================================================================
532    //  PartialEq: individually flip each field and assert inequality.
533    //  Exercises the derived PartialEq arm comparisons field-by-field.
534    // ================================================================
535
536    #[test]
537    fn not_equal_when_revision_differs() {
538        let a = minimal_doc();
539        let mut b = minimal_doc();
540        b.revision = Some(1);
541        assert_ne!(a, b);
542    }
543
544    #[test]
545    fn not_equal_when_each_timestamp_differs() {
546        let a = minimal_doc();
547
548        let mut b = minimal_doc();
549        b.created_at = Some(1);
550        assert_ne!(a, b);
551
552        let mut b = minimal_doc();
553        b.updated_at = Some(2);
554        assert_ne!(a, b);
555
556        let mut b = minimal_doc();
557        b.transferred_at = Some(3);
558        assert_ne!(a, b);
559
560        let mut b = minimal_doc();
561        b.created_at_block_height = Some(4);
562        assert_ne!(a, b);
563
564        let mut b = minimal_doc();
565        b.updated_at_block_height = Some(5);
566        assert_ne!(a, b);
567
568        let mut b = minimal_doc();
569        b.transferred_at_block_height = Some(6);
570        assert_ne!(a, b);
571
572        let mut b = minimal_doc();
573        b.created_at_core_block_height = Some(7);
574        assert_ne!(a, b);
575
576        let mut b = minimal_doc();
577        b.updated_at_core_block_height = Some(8);
578        assert_ne!(a, b);
579
580        let mut b = minimal_doc();
581        b.transferred_at_core_block_height = Some(9);
582        assert_ne!(a, b);
583    }
584
585    #[test]
586    fn not_equal_when_properties_differ() {
587        let a = minimal_doc();
588        let mut b = minimal_doc();
589        b.properties.insert("foo".to_string(), Value::U64(1));
590        assert_ne!(a, b);
591    }
592
593    #[test]
594    fn not_equal_when_id_differs() {
595        let a = minimal_doc();
596        let mut b = minimal_doc();
597        b.id = Identifier::new([99u8; 32]);
598        assert_ne!(a, b);
599    }
600
601    #[test]
602    fn not_equal_when_owner_id_differs() {
603        let a = minimal_doc();
604        let mut b = minimal_doc();
605        b.owner_id = Identifier::new([98u8; 32]);
606        assert_ne!(a, b);
607    }
608
609    // ================================================================
610    //  bump_revision: additional edge cases — starting at 0, and at
611    //  MAX-1 → MAX → MAX (saturating).
612    // ================================================================
613
614    #[test]
615    fn bump_revision_from_zero_increments_to_one() {
616        let mut doc = minimal_doc();
617        doc.set_revision(Some(0));
618        doc.bump_revision();
619        assert_eq!(doc.revision(), Some(1));
620    }
621
622    #[test]
623    fn bump_revision_from_max_minus_one_reaches_max_then_saturates() {
624        let mut doc = minimal_doc();
625        doc.set_revision(Some(Revision::MAX - 1));
626        doc.bump_revision();
627        assert_eq!(doc.revision(), Some(Revision::MAX));
628        doc.bump_revision();
629        assert_eq!(doc.revision(), Some(Revision::MAX));
630        // one more to make absolutely sure saturating_add really did saturate.
631        doc.bump_revision();
632        assert_eq!(doc.revision(), Some(Revision::MAX));
633    }
634
635    // ================================================================
636    //  Default + setters: mutate each setter and ensure the getter round-trips.
637    //  Exercises Setter::set_* arms that might otherwise not be executed.
638    // ================================================================
639
640    #[test]
641    fn setters_round_trip_every_field() {
642        use crate::document::{DocumentV0Getters, DocumentV0Setters};
643        let mut doc = DocumentV0::default();
644        doc.set_id(Identifier::new([1u8; 32]));
645        doc.set_owner_id(Identifier::new([2u8; 32]));
646        let mut props = BTreeMap::new();
647        props.insert("a".to_string(), Value::U64(99));
648        doc.set_properties(props.clone());
649        doc.set_revision(Some(4));
650        doc.set_created_at(Some(10));
651        doc.set_updated_at(Some(20));
652        doc.set_transferred_at(Some(30));
653        doc.set_created_at_block_height(Some(100));
654        doc.set_updated_at_block_height(Some(200));
655        doc.set_transferred_at_block_height(Some(300));
656        doc.set_created_at_core_block_height(Some(1));
657        doc.set_updated_at_core_block_height(Some(2));
658        doc.set_transferred_at_core_block_height(Some(3));
659        doc.set_creator_id(Some(Identifier::new([9u8; 32])));
660
661        assert_eq!(doc.id(), Identifier::new([1u8; 32]));
662        assert_eq!(doc.owner_id(), Identifier::new([2u8; 32]));
663        assert_eq!(doc.properties(), &props);
664        assert_eq!(doc.revision(), Some(4));
665        assert_eq!(doc.created_at(), Some(10));
666        assert_eq!(doc.updated_at(), Some(20));
667        assert_eq!(doc.transferred_at(), Some(30));
668        assert_eq!(doc.created_at_block_height(), Some(100));
669        assert_eq!(doc.updated_at_block_height(), Some(200));
670        assert_eq!(doc.transferred_at_block_height(), Some(300));
671        assert_eq!(doc.created_at_core_block_height(), Some(1));
672        assert_eq!(doc.updated_at_core_block_height(), Some(2));
673        assert_eq!(doc.transferred_at_core_block_height(), Some(3));
674        assert_eq!(doc.creator_id(), Some(Identifier::new([9u8; 32])));
675
676        // id_ref, owner_id_ref and properties_consumed exercise separate
677        // methods on DocumentV0Getters.
678        assert_eq!(doc.id_ref(), &Identifier::new([1u8; 32]));
679        assert_eq!(doc.owner_id_ref(), &Identifier::new([2u8; 32]));
680        assert_eq!(doc.clone().properties_consumed(), props);
681    }
682
683    // ================================================================
684    //  properties_mut actually allows mutation (exercises the &mut accessor
685    //  arm, not just the immutable getter).
686    // ================================================================
687
688    #[test]
689    fn properties_mut_allows_inserting_new_key() {
690        use crate::document::DocumentV0Getters;
691        let mut doc = minimal_doc();
692        doc.properties_mut().insert("k".into(), Value::U64(7));
693        assert_eq!(doc.properties().get("k"), Some(&Value::U64(7)));
694    }
695
696    // ================================================================
697    //  Debug impl: should include field names so tracing messages print
698    //  reasonable output (covers the auto-derived Debug arm without
699    //  duplicating other checks).
700    // ================================================================
701
702    #[test]
703    fn debug_format_contains_field_names() {
704        let doc = minimal_doc();
705        let dbg = format!("{:?}", doc);
706        assert!(dbg.contains("DocumentV0"), "expected struct name in Debug");
707        assert!(dbg.contains("id"));
708        assert!(dbg.contains("owner_id"));
709    }
710
711    // ================================================================
712    //  Display with transferred_at_core_block_height and creator_id set
713    //  but other transferred fields None: exercises the "Some(creator_id)
714    //  AFTER several optional system fields that ARE None" path.
715    // ================================================================
716
717    #[test]
718    fn display_with_only_creator_id_and_no_timestamps() {
719        let mut doc = minimal_doc();
720        doc.creator_id = Some(Identifier::new([7u8; 32]));
721        let s = format!("{}", doc);
722        assert!(s.contains("creator_id:"));
723        // No timestamp prefix should be rendered.
724        assert!(!s.contains("created_at:"));
725        assert!(!s.contains("updated_at:"));
726        assert!(!s.contains("transferred_at:"));
727        // With empty properties, the "no properties" trailer kicks in.
728        assert!(s.contains("no properties"));
729    }
730}