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