Skip to main content

dpp/document/v0/
cbor_conversion.rs

1use crate::document::property_names;
2
3use crate::identity::TimestampMillis;
4use crate::prelude::{BlockHeight, CoreBlockHeight, Revision};
5
6use crate::ProtocolError;
7
8use crate::document::serialization_traits::DocumentCborMethodsV0;
9use crate::document::v0::DocumentV0;
10use crate::version::PlatformVersion;
11use ciborium::Value as CborValue;
12use integer_encoding::VarIntWriter;
13use platform_value::btreemap_extensions::BTreeValueRemoveFromMapHelper;
14use platform_value::{Identifier, Value};
15use serde::{Deserialize, Serialize};
16use std::collections::BTreeMap;
17use std::convert::{TryFrom, TryInto};
18
19#[cfg(feature = "cbor")]
20#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
21pub struct DocumentForCbor {
22    /// The unique document ID.
23    #[serde(rename = "$id")]
24    pub id: [u8; 32],
25
26    /// The document's properties (data).
27    #[serde(flatten)]
28    pub properties: BTreeMap<String, CborValue>,
29
30    /// The ID of the document's owner.
31    #[serde(rename = "$ownerId")]
32    pub owner_id: [u8; 32],
33
34    /// The document revision.
35    #[serde(rename = "$revision")]
36    pub revision: Option<Revision>,
37
38    #[serde(rename = "$createdAt")]
39    pub created_at: Option<TimestampMillis>,
40    #[serde(rename = "$updatedAt")]
41    pub updated_at: Option<TimestampMillis>,
42    #[serde(rename = "$transferredAt")]
43    pub transferred_at: Option<TimestampMillis>,
44
45    #[serde(rename = "$createdAtBlockHeight")]
46    pub created_at_block_height: Option<BlockHeight>,
47    #[serde(rename = "$updatedAtBlockHeight")]
48    pub updated_at_block_height: Option<BlockHeight>,
49    #[serde(rename = "$transferredAtBlockHeight")]
50    pub transferred_at_block_height: Option<BlockHeight>,
51
52    #[serde(rename = "$createdAtCoreBlockHeight")]
53    pub created_at_core_block_height: Option<CoreBlockHeight>,
54    #[serde(rename = "$updatedAtCoreBlockHeight")]
55    pub updated_at_core_block_height: Option<CoreBlockHeight>,
56    #[serde(rename = "$transferredAtCoreBlockHeight")]
57    pub transferred_at_core_block_height: Option<CoreBlockHeight>,
58
59    #[serde(rename = "$creatorId")]
60    pub creator_id: Option<Identifier>,
61
62    /// The contract-version stamp. Skipped when absent so pre-stamp CBOR
63    /// output stays byte-identical; `default` keeps old CBOR readable.
64    #[serde(
65        rename = "$contractVersion",
66        default,
67        skip_serializing_if = "Option::is_none"
68    )]
69    pub contract_version: Option<u32>,
70}
71
72#[cfg(feature = "cbor")]
73impl TryFrom<DocumentV0> for DocumentForCbor {
74    type Error = ProtocolError;
75
76    fn try_from(value: DocumentV0) -> Result<Self, Self::Error> {
77        let DocumentV0 {
78            id,
79            properties,
80            owner_id,
81            revision,
82            created_at,
83            updated_at,
84            transferred_at,
85            created_at_block_height,
86            updated_at_block_height,
87            transferred_at_block_height,
88            created_at_core_block_height,
89            updated_at_core_block_height,
90            transferred_at_core_block_height,
91            creator_id,
92            contract_version,
93        } = value;
94        Ok(DocumentForCbor {
95            contract_version,
96            id: id.to_buffer(),
97            properties: Value::convert_to_cbor_map(properties)
98                .map_err(ProtocolError::ValueError)?,
99            owner_id: owner_id.to_buffer(),
100            revision,
101            created_at,
102            updated_at,
103            transferred_at,
104            created_at_block_height,
105            updated_at_block_height,
106            transferred_at_block_height,
107            created_at_core_block_height,
108            updated_at_core_block_height,
109            transferred_at_core_block_height,
110            creator_id,
111        })
112    }
113}
114
115impl DocumentV0 {
116    /// Reads a CBOR-serialized document and creates a Document from it.
117    /// If Document and Owner IDs are provided, they are used, otherwise they are created.
118    fn from_map(
119        mut document_map: BTreeMap<String, Value>,
120        document_id: Option<[u8; 32]>,
121        owner_id: Option<[u8; 32]>,
122    ) -> Result<Self, ProtocolError> {
123        let owner_id = match owner_id {
124            None => document_map
125                .remove_hash256_bytes(property_names::OWNER_ID)
126                .map_err(ProtocolError::ValueError)?,
127            Some(owner_id) => owner_id,
128        };
129
130        let id = match document_id {
131            None => document_map
132                .remove_hash256_bytes(property_names::ID)
133                .map_err(ProtocolError::ValueError)?,
134            Some(document_id) => document_id,
135        };
136
137        let revision = document_map.remove_optional_integer(property_names::REVISION)?;
138
139        let created_at = document_map.remove_optional_integer(property_names::CREATED_AT)?;
140        let updated_at = document_map.remove_optional_integer(property_names::UPDATED_AT)?;
141        let transferred_at =
142            document_map.remove_optional_integer(property_names::TRANSFERRED_AT)?;
143        let created_at_block_height =
144            document_map.remove_optional_integer(property_names::CREATED_AT_BLOCK_HEIGHT)?;
145        let updated_at_block_height =
146            document_map.remove_optional_integer(property_names::UPDATED_AT_BLOCK_HEIGHT)?;
147        let transferred_at_block_height =
148            document_map.remove_optional_integer(property_names::TRANSFERRED_AT_BLOCK_HEIGHT)?;
149        let created_at_core_block_height =
150            document_map.remove_optional_integer(property_names::CREATED_AT_CORE_BLOCK_HEIGHT)?;
151        let updated_at_core_block_height =
152            document_map.remove_optional_integer(property_names::UPDATED_AT_CORE_BLOCK_HEIGHT)?;
153        let transferred_at_core_block_height = document_map
154            .remove_optional_integer(property_names::TRANSFERRED_AT_CORE_BLOCK_HEIGHT)?;
155
156        let creator_id = document_map
157            .remove_optional_identifier(property_names::CREATOR_ID)
158            .map_err(ProtocolError::ValueError)?;
159
160        let contract_version =
161            document_map.remove_optional_integer(property_names::CONTRACT_VERSION)?;
162
163        // dev-note: properties is everything other than the id and owner id
164        Ok(DocumentV0 {
165            contract_version,
166            properties: document_map,
167            owner_id: Identifier::new(owner_id),
168            id: Identifier::new(id),
169            revision,
170            created_at,
171            updated_at,
172            transferred_at,
173            created_at_block_height,
174            updated_at_block_height,
175            transferred_at_block_height,
176            created_at_core_block_height,
177            updated_at_core_block_height,
178            transferred_at_core_block_height,
179            creator_id,
180        })
181    }
182}
183
184impl DocumentCborMethodsV0 for DocumentV0 {
185    /// Reads a CBOR-serialized document and creates a Document from it.
186    /// If Document and Owner IDs are provided, they are used, otherwise they are created.
187    fn from_cbor(
188        document_cbor: &[u8],
189        document_id: Option<[u8; 32]>,
190        owner_id: Option<[u8; 32]>,
191        _platform_version: &PlatformVersion,
192    ) -> Result<Self, ProtocolError> {
193        // first we need to deserialize the document and contract indices
194        // we would need dedicated deserialization functions based on the document type
195        let document_cbor_map: BTreeMap<String, CborValue> =
196            ciborium::de::from_reader(document_cbor).map_err(|_| {
197                ProtocolError::InvalidCBOR(
198                    "unable to decode document for document call".to_string(),
199                )
200            })?;
201        let document_map: BTreeMap<String, Value> =
202            Value::convert_from_cbor_map(document_cbor_map).map_err(ProtocolError::ValueError)?;
203        Self::from_map(document_map, document_id, owner_id)
204    }
205
206    fn to_cbor_value(&self) -> Result<CborValue, ProtocolError> {
207        // After Phase D step 8 slice A, the V0 trait `to_object` was
208        // deleted (1:1 canonical equivalent). Inline the body —
209        // `IdentityPublicKeyV0` doesn't derive `ValueConvertible` directly
210        // (only the outer `Document` enum does), so we go through
211        // `platform_value::to_value` directly.
212        let value = platform_value::to_value(self).map_err(ProtocolError::ValueError)?;
213        value.try_into().map_err(ProtocolError::ValueError)
214    }
215
216    /// Serializes the Document to CBOR.
217    fn to_cbor(&self) -> Result<Vec<u8>, ProtocolError> {
218        let mut buffer: Vec<u8> = Vec::new();
219        buffer.write_varint(0).map_err(|_| {
220            ProtocolError::EncodingError("error writing protocol version".to_string())
221        })?;
222        let cbor_document = DocumentForCbor::try_from(self.clone())?;
223        ciborium::ser::into_writer(&cbor_document, &mut buffer).map_err(|_| {
224            ProtocolError::EncodingError("unable to serialize into cbor".to_string())
225        })?;
226        Ok(buffer)
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use crate::data_contract::accessors::v0::DataContractV0Getters;
234    use crate::data_contract::document_type::random_document::CreateRandomDocument;
235    use crate::document::serialization_traits::DocumentCborMethodsV0;
236    use crate::document::DocumentV0Getters;
237    use crate::tests::json_document::json_document_to_contract;
238    use platform_version::version::PlatformVersion;
239
240    fn make_document_v0_with_timestamps() -> DocumentV0 {
241        let id = Identifier::new([1u8; 32]);
242        let owner_id = Identifier::new([2u8; 32]);
243        let mut properties = BTreeMap::new();
244        properties.insert("name".to_string(), Value::Text("Alice".to_string()));
245        properties.insert("age".to_string(), Value::U64(30));
246        DocumentV0 {
247            contract_version: None,
248            id,
249            owner_id,
250            properties,
251            revision: Some(1),
252            created_at: Some(1_700_000_000_000),
253            updated_at: Some(1_700_000_100_000),
254            transferred_at: None,
255            created_at_block_height: Some(100),
256            updated_at_block_height: Some(200),
257            transferred_at_block_height: None,
258            created_at_core_block_height: Some(50),
259            updated_at_core_block_height: Some(60),
260            transferred_at_core_block_height: None,
261            creator_id: None,
262        }
263    }
264
265    // ================================================================
266    //  Round-trip: to_cbor -> from_cbor preserves document data
267    // ================================================================
268
269    #[test]
270    fn cbor_round_trip_preserves_contract_version_stamp() {
271        use crate::document::Document;
272
273        let platform_version = PlatformVersion::latest();
274        let mut document = make_document_v0_with_timestamps();
275        document.contract_version = Some(7);
276
277        let cbor = document.to_cbor().expect("expected to serialize to cbor");
278        let restored = Document::from_cbor(&cbor, None, None, platform_version)
279            .expect("expected to deserialize from cbor");
280
281        let Document::V0(restored) = restored;
282        assert_eq!(restored.contract_version, Some(7));
283        assert_eq!(restored.id, document.id);
284        assert_eq!(restored.revision, document.revision);
285        // (full property equality is not asserted: CBOR decodes integers as
286        // I128, a pre-existing normalization of this legacy path)
287        assert_eq!(
288            restored.properties.get("name"),
289            document.properties.get("name")
290        );
291
292        // An unstamped document round-trips to no stamp (the key is skipped
293        // entirely when absent, keeping pre-stamp CBOR byte-identical)
294        let unstamped = make_document_v0_with_timestamps();
295        let unstamped_cbor = unstamped.to_cbor().expect("expected to serialize to cbor");
296        let restored_unstamped = Document::from_cbor(&unstamped_cbor, None, None, platform_version)
297            .expect("expected to deserialize from cbor");
298        let Document::V0(restored_unstamped) = restored_unstamped;
299        assert_eq!(restored_unstamped.contract_version, None);
300    }
301
302    #[test]
303    fn cbor_round_trip_with_random_dashpay_profile() {
304        let platform_version = PlatformVersion::latest();
305        let contract = json_document_to_contract(
306            "../rs-drive/tests/supporting_files/contract/dashpay/dashpay-contract.json",
307            false,
308            platform_version,
309        )
310        .expect("expected to load dashpay contract");
311
312        let document_type = contract
313            .document_type_for_name("profile")
314            .expect("expected profile document type");
315
316        for seed in 0..10u64 {
317            let document = document_type
318                .random_document(Some(seed), platform_version)
319                .expect("expected random document");
320
321            // Use Document-level from_cbor which handles the version prefix
322            let cbor_bytes = document.to_cbor().expect("to_cbor should succeed");
323            let recovered =
324                crate::document::Document::from_cbor(&cbor_bytes, None, None, platform_version)
325                    .expect("from_cbor should succeed");
326
327            assert_eq!(document.id(), recovered.id(), "id mismatch for seed {seed}");
328            assert_eq!(
329                document.owner_id(),
330                recovered.owner_id(),
331                "owner_id mismatch for seed {seed}"
332            );
333            assert_eq!(
334                document.revision(),
335                recovered.revision(),
336                "revision mismatch for seed {seed}"
337            );
338            assert_eq!(
339                document.properties(),
340                recovered.properties(),
341                "properties mismatch for seed {seed}"
342            );
343        }
344    }
345
346    #[test]
347    fn cbor_round_trip_with_explicit_ids_overrides_embedded_ids() {
348        let platform_version = PlatformVersion::latest();
349        let contract = json_document_to_contract(
350            "../rs-drive/tests/supporting_files/contract/dashpay/dashpay-contract.json",
351            false,
352            platform_version,
353        )
354        .expect("expected to load dashpay contract");
355
356        let document_type = contract
357            .document_type_for_name("profile")
358            .expect("expected profile document type");
359
360        let document = document_type
361            .random_document(Some(42), platform_version)
362            .expect("expected random document");
363
364        let cbor_bytes = document.to_cbor().expect("to_cbor should succeed");
365
366        let override_id = [0xAA; 32];
367        let override_owner = [0xBB; 32];
368
369        let recovered = crate::document::Document::from_cbor(
370            &cbor_bytes,
371            Some(override_id),
372            Some(override_owner),
373            platform_version,
374        )
375        .expect("from_cbor with explicit ids should succeed");
376
377        assert_eq!(
378            recovered.id(),
379            Identifier::new(override_id),
380            "explicit document_id should override the one in CBOR"
381        );
382        assert_eq!(
383            recovered.owner_id(),
384            Identifier::new(override_owner),
385            "explicit owner_id should override the one in CBOR"
386        );
387    }
388
389    // ================================================================
390    //  to_cbor_value produces a valid CborValue
391    // ================================================================
392
393    #[test]
394    fn to_cbor_value_returns_map_for_document_with_properties() {
395        let doc = make_document_v0_with_timestamps();
396        let cbor_val = doc.to_cbor_value().expect("to_cbor_value should succeed");
397        // CborValue should be a Map at the top level
398        assert!(
399            cbor_val.is_map(),
400            "CBOR value of a document should be a Map, got {:?}",
401            cbor_val
402        );
403    }
404
405    // ================================================================
406    //  to_cbor output starts with varint-encoded version prefix (0)
407    // ================================================================
408
409    #[test]
410    fn to_cbor_starts_with_version_zero_varint() {
411        let doc = make_document_v0_with_timestamps();
412        let cbor_bytes = doc.to_cbor().expect("to_cbor should succeed");
413        // The first byte should be the varint encoding of 0
414        assert!(!cbor_bytes.is_empty(), "CBOR output should not be empty");
415        assert_eq!(
416            cbor_bytes[0], 0,
417            "first byte should be varint(0) for version"
418        );
419    }
420
421    // ================================================================
422    //  from_cbor rejects invalid CBOR data
423    // ================================================================
424
425    #[test]
426    fn from_cbor_rejects_invalid_cbor_bytes() {
427        let platform_version = PlatformVersion::latest();
428        let garbage = vec![0xFF, 0xFE, 0xFD, 0x00, 0x01];
429        let result = DocumentV0::from_cbor(&garbage, None, None, platform_version);
430        assert!(
431            result.is_err(),
432            "from_cbor should fail on invalid CBOR bytes"
433        );
434    }
435
436    // ================================================================
437    //  DocumentForCbor TryFrom preserves all timestamp fields
438    // ================================================================
439
440    #[test]
441    fn document_for_cbor_preserves_all_fields() {
442        let doc = make_document_v0_with_timestamps();
443        let cbor_doc = DocumentForCbor::try_from(doc.clone()).expect("TryFrom should succeed");
444        assert_eq!(cbor_doc.id, doc.id.to_buffer());
445        assert_eq!(cbor_doc.owner_id, doc.owner_id.to_buffer());
446        assert_eq!(cbor_doc.revision, doc.revision);
447        assert_eq!(cbor_doc.created_at, doc.created_at);
448        assert_eq!(cbor_doc.updated_at, doc.updated_at);
449        assert_eq!(cbor_doc.transferred_at, doc.transferred_at);
450        assert_eq!(
451            cbor_doc.created_at_block_height,
452            doc.created_at_block_height
453        );
454        assert_eq!(
455            cbor_doc.updated_at_block_height,
456            doc.updated_at_block_height
457        );
458        assert_eq!(
459            cbor_doc.transferred_at_block_height,
460            doc.transferred_at_block_height
461        );
462        assert_eq!(
463            cbor_doc.created_at_core_block_height,
464            doc.created_at_core_block_height
465        );
466        assert_eq!(
467            cbor_doc.updated_at_core_block_height,
468            doc.updated_at_core_block_height
469        );
470        assert_eq!(
471            cbor_doc.transferred_at_core_block_height,
472            doc.transferred_at_core_block_height
473        );
474    }
475
476    // ================================================================
477    //  from_map populates fields correctly from a BTreeMap<String, Value>
478    // ================================================================
479
480    #[test]
481    fn from_map_extracts_system_fields_and_leaves_properties() {
482        let id_bytes = [3u8; 32];
483        let owner_bytes = [4u8; 32];
484
485        let mut map = BTreeMap::new();
486        map.insert(property_names::ID.to_string(), Value::Bytes32(id_bytes));
487        map.insert(
488            property_names::OWNER_ID.to_string(),
489            Value::Bytes32(owner_bytes),
490        );
491        map.insert(property_names::REVISION.to_string(), Value::U64(5));
492        map.insert(
493            property_names::CREATED_AT.to_string(),
494            Value::U64(1_000_000),
495        );
496        map.insert(
497            property_names::UPDATED_AT.to_string(),
498            Value::U64(2_000_000),
499        );
500        map.insert("customField".to_string(), Value::Text("hello".to_string()));
501
502        let doc = DocumentV0::from_map(map, None, None).expect("from_map should succeed");
503
504        assert_eq!(doc.id, Identifier::new(id_bytes));
505        assert_eq!(doc.owner_id, Identifier::new(owner_bytes));
506        assert_eq!(doc.revision, Some(5));
507        assert_eq!(doc.created_at, Some(1_000_000));
508        assert_eq!(doc.updated_at, Some(2_000_000));
509        // The custom field should remain in properties
510        assert_eq!(
511            doc.properties.get("customField"),
512            Some(&Value::Text("hello".to_string()))
513        );
514        // System fields should NOT be in properties
515        assert!(!doc.properties.contains_key(property_names::ID));
516        assert!(!doc.properties.contains_key(property_names::OWNER_ID));
517        assert!(!doc.properties.contains_key(property_names::REVISION));
518    }
519
520    #[test]
521    fn from_map_with_explicit_ids_overrides_map_ids() {
522        let map_id = [10u8; 32];
523        let map_owner = [11u8; 32];
524        let override_id = [20u8; 32];
525        let override_owner = [21u8; 32];
526
527        let mut map = BTreeMap::new();
528        map.insert(property_names::ID.to_string(), Value::Bytes32(map_id));
529        map.insert(
530            property_names::OWNER_ID.to_string(),
531            Value::Bytes32(map_owner),
532        );
533
534        let doc = DocumentV0::from_map(map, Some(override_id), Some(override_owner))
535            .expect("from_map should succeed");
536
537        assert_eq!(
538            doc.id,
539            Identifier::new(override_id),
540            "explicit document_id should take precedence"
541        );
542        assert_eq!(
543            doc.owner_id,
544            Identifier::new(override_owner),
545            "explicit owner_id should take precedence"
546        );
547    }
548
549    // ================================================================
550    //  Round-trip via from_map: construct map, parse, verify
551    // ================================================================
552
553    // ================================================================
554    //  from_map missing $id / $ownerId errors
555    // ================================================================
556
557    #[test]
558    fn from_map_missing_id_fails_when_not_provided() {
559        // Only owner_id in the map, no explicit document_id — from_map should
560        // error on the ID extraction step.
561        let mut map = BTreeMap::new();
562        map.insert(
563            property_names::OWNER_ID.to_string(),
564            Value::Bytes32([4u8; 32]),
565        );
566
567        let result = DocumentV0::from_map(map, None, None);
568        assert!(
569            result.is_err(),
570            "from_map without $id or explicit document_id should fail"
571        );
572    }
573
574    #[test]
575    fn from_map_missing_owner_id_fails_when_not_provided() {
576        let mut map = BTreeMap::new();
577        map.insert(property_names::ID.to_string(), Value::Bytes32([3u8; 32]));
578
579        let result = DocumentV0::from_map(map, None, None);
580        assert!(
581            result.is_err(),
582            "from_map without $ownerId or explicit owner_id should fail"
583        );
584    }
585
586    // ================================================================
587    //  from_map: creator id parsing
588    // ================================================================
589
590    #[test]
591    fn from_map_extracts_creator_id_when_present_as_identifier() {
592        let creator = Identifier::new([0xCD; 32]);
593        let mut map = BTreeMap::new();
594        map.insert(property_names::ID.to_string(), Value::Bytes32([1u8; 32]));
595        map.insert(
596            property_names::OWNER_ID.to_string(),
597            Value::Bytes32([2u8; 32]),
598        );
599        map.insert(
600            property_names::CREATOR_ID.to_string(),
601            Value::Identifier(creator.to_buffer()),
602        );
603
604        let doc = DocumentV0::from_map(map, None, None).expect("from_map should succeed");
605        assert_eq!(doc.creator_id, Some(creator));
606    }
607
608    #[test]
609    fn from_map_creator_id_missing_stays_none() {
610        let mut map = BTreeMap::new();
611        map.insert(property_names::ID.to_string(), Value::Bytes32([1u8; 32]));
612        map.insert(
613            property_names::OWNER_ID.to_string(),
614            Value::Bytes32([2u8; 32]),
615        );
616
617        let doc = DocumentV0::from_map(map, None, None).expect("from_map should succeed");
618        assert_eq!(doc.creator_id, None);
619    }
620
621    // ================================================================
622    //  from_cbor: bytes starting with truncated ciborium data fail
623    // ================================================================
624
625    #[test]
626    fn from_cbor_rejects_empty_buffer() {
627        let platform_version = PlatformVersion::latest();
628        let result = DocumentV0::from_cbor(&[], None, None, platform_version);
629        assert!(
630            result.is_err(),
631            "from_cbor should fail on an empty input buffer"
632        );
633    }
634
635    #[test]
636    fn from_cbor_rejects_truncated_map_bytes() {
637        // A map header byte that claims to be a map but has no content.
638        let platform_version = PlatformVersion::latest();
639        let result = DocumentV0::from_cbor(&[0xA1], None, None, platform_version);
640        assert!(
641            result.is_err(),
642            "from_cbor should fail on a truncated map prefix"
643        );
644    }
645
646    // ================================================================
647    //  to_cbor round-trip preserves owner_id/creator_id etc
648    // ================================================================
649
650    #[test]
651    fn cbor_round_trip_via_to_cbor_and_from_cbor_preserves_fields() {
652        let platform_version = PlatformVersion::latest();
653        let doc = make_document_v0_with_timestamps();
654
655        let bytes = doc.to_cbor().expect("to_cbor succeeds");
656        let recovered = crate::document::Document::from_cbor(&bytes, None, None, platform_version)
657            .expect("from_cbor succeeds");
658        assert_eq!(doc.id, recovered.id());
659        assert_eq!(doc.owner_id, recovered.owner_id());
660        assert_eq!(doc.revision, recovered.revision());
661    }
662
663    // ================================================================
664    //  DocumentForCbor: TryFrom returns a CBOR-ready structure whose
665    //  `properties` map preserves user-defined keys.
666    // ================================================================
667
668    #[test]
669    fn document_for_cbor_preserves_user_properties() {
670        let doc = make_document_v0_with_timestamps();
671        let cbor = DocumentForCbor::try_from(doc.clone()).expect("try_from succeeds");
672        // "name" and "age" are part of the test fixture
673        assert!(cbor.properties.contains_key("name"));
674        assert!(cbor.properties.contains_key("age"));
675    }
676
677    #[test]
678    fn from_map_with_all_timestamp_variants() {
679        let mut map = BTreeMap::new();
680        map.insert(property_names::ID.to_string(), Value::Bytes32([5u8; 32]));
681        map.insert(
682            property_names::OWNER_ID.to_string(),
683            Value::Bytes32([6u8; 32]),
684        );
685        map.insert(
686            property_names::CREATED_AT_BLOCK_HEIGHT.to_string(),
687            Value::U64(100),
688        );
689        map.insert(
690            property_names::UPDATED_AT_BLOCK_HEIGHT.to_string(),
691            Value::U64(200),
692        );
693        map.insert(
694            property_names::TRANSFERRED_AT.to_string(),
695            Value::U64(3_000_000),
696        );
697        map.insert(
698            property_names::TRANSFERRED_AT_BLOCK_HEIGHT.to_string(),
699            Value::U64(300),
700        );
701        map.insert(
702            property_names::CREATED_AT_CORE_BLOCK_HEIGHT.to_string(),
703            Value::U32(50),
704        );
705        map.insert(
706            property_names::UPDATED_AT_CORE_BLOCK_HEIGHT.to_string(),
707            Value::U32(60),
708        );
709        map.insert(
710            property_names::TRANSFERRED_AT_CORE_BLOCK_HEIGHT.to_string(),
711            Value::U32(70),
712        );
713
714        let doc = DocumentV0::from_map(map, None, None).expect("from_map should succeed");
715
716        assert_eq!(doc.created_at_block_height, Some(100));
717        assert_eq!(doc.updated_at_block_height, Some(200));
718        assert_eq!(doc.transferred_at, Some(3_000_000));
719        assert_eq!(doc.transferred_at_block_height, Some(300));
720        assert_eq!(doc.created_at_core_block_height, Some(50));
721        assert_eq!(doc.updated_at_core_block_height, Some(60));
722        assert_eq!(doc.transferred_at_core_block_height, Some(70));
723    }
724}