1use std::collections::BTreeMap;
15
16use chrono::{DateTime, Utc};
17use serde::{Deserialize, Deserializer, Serialize, Serializer};
18use serde_json::Value;
19use url::Url;
20
21use crate::ids::{BucketUrl, ConceptRecId, DepositionFileId, DepositionId, Doi, RecordId};
22use crate::metadata::RecordMetadata;
23use crate::serde_util::{deserialize_option_u64ish, deserialize_stringish, deserialize_u64ish};
24
25macro_rules! string_enum {
26 ($(#[$enum_meta:meta])* $name:ident { $($(#[$variant_meta:meta])* $variant:ident => $value:literal),+ $(,)? }) => {
27 $(#[$enum_meta])*
28 #[derive(Clone, Debug, PartialEq, Eq)]
29 #[non_exhaustive]
30 pub enum $name {
31 $($(#[$variant_meta])* $variant,)+
32 Unknown(
34 String
36 ),
37 }
38
39 impl Serialize for $name {
40 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
41 where
42 S: Serializer,
43 {
44 serializer.serialize_str(match self {
45 $(Self::$variant => $value,)+
46 Self::Unknown(value) => value.as_str(),
47 })
48 }
49 }
50
51 impl<'de> Deserialize<'de> for $name {
52 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
53 where
54 D: Deserializer<'de>,
55 {
56 let value = String::deserialize(deserializer)?;
57 Ok(match value.as_str() {
58 $($value => Self::$variant,)+
59 _ => Self::Unknown(value),
60 })
61 }
62 }
63 };
64}
65
66string_enum!(
67 #[derive(Default)]
68 DepositState {
70 #[default]
72 InProgress => "inprogress",
73 Unsubmitted => "unsubmitted",
75 Done => "done",
77 Error => "error"
79 }
80);
81
82string_enum!(
83 #[derive(Default)]
84 RecordPublicationStatus {
86 #[default]
88 Published => "published",
89 Draft => "draft"
91 }
92);
93
94#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
96pub struct DepositionStatus {
97 #[serde(default)]
99 pub submitted: bool,
100 #[serde(default)]
102 pub state: DepositState,
103}
104
105#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
107pub struct DepositionFile {
108 pub id: DepositionFileId,
110 pub filename: String,
112 #[serde(default, deserialize_with = "deserialize_u64ish")]
114 pub filesize: u64,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub checksum: Option<String>,
118 #[serde(flatten, default)]
120 pub extra: BTreeMap<String, Value>,
121}
122
123#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
125pub struct DepositionLinks {
126 #[serde(rename = "self", default, skip_serializing_if = "Option::is_none")]
128 pub self_: Option<Url>,
129 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub bucket: Option<BucketUrl>,
132 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub files: Option<Url>,
135 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub publish: Option<Url>,
138 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub edit: Option<Url>,
141 #[serde(default, skip_serializing_if = "Option::is_none")]
143 pub discard: Option<Url>,
144 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub latest_draft: Option<Url>,
147 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub latest: Option<Url>,
150 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub versions: Option<Url>,
153 #[serde(flatten, default)]
155 pub extra: BTreeMap<String, Value>,
156}
157
158#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
160pub struct Deposition {
161 pub id: DepositionId,
163 #[serde(default, skip_serializing_if = "Option::is_none")]
165 pub conceptrecid: Option<ConceptRecId>,
166 #[serde(default, skip_serializing_if = "Option::is_none")]
168 pub record_id: Option<RecordId>,
169 #[serde(default, skip_serializing_if = "Option::is_none")]
171 pub doi: Option<Doi>,
172 #[serde(default, skip_serializing_if = "Option::is_none")]
174 pub conceptdoi: Option<Doi>,
175 #[serde(flatten)]
177 pub status: DepositionStatus,
178 #[serde(default)]
180 pub metadata: Value,
181 #[serde(default)]
183 pub files: Vec<DepositionFile>,
184 #[serde(default)]
186 pub links: DepositionLinks,
187 #[serde(flatten, default)]
189 pub extra: BTreeMap<String, Value>,
190}
191
192impl Deposition {
193 #[must_use]
195 pub fn is_published(&self) -> bool {
196 self.status.submitted
197 }
198
199 #[must_use]
201 pub fn allows_metadata_edits(&self) -> bool {
202 matches!(
203 self.status.state,
204 DepositState::InProgress | DepositState::Unsubmitted
205 )
206 }
207
208 #[must_use]
210 pub fn latest_draft_url(&self) -> Option<&Url> {
211 self.links.latest_draft.as_ref()
212 }
213
214 #[must_use]
216 pub fn bucket_url(&self) -> Option<&BucketUrl> {
217 self.links.bucket.as_ref()
218 }
219}
220
221#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
223pub struct BucketObject {
224 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub id: Option<String>,
227 pub key: String,
229 #[serde(default, deserialize_with = "deserialize_u64ish")]
231 pub size: u64,
232 #[serde(default, skip_serializing_if = "Option::is_none")]
234 pub checksum: Option<String>,
235 #[serde(flatten, default)]
237 pub extra: BTreeMap<String, Value>,
238}
239
240#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
242pub struct RecordFileLinks {
243 #[serde(rename = "self", default, skip_serializing_if = "Option::is_none")]
245 pub self_: Option<Url>,
246 #[serde(default, skip_serializing_if = "Option::is_none")]
248 pub content: Option<Url>,
249 #[serde(flatten, default)]
251 pub extra: BTreeMap<String, Value>,
252}
253
254#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
256pub struct RecordFile {
257 pub id: String,
259 pub key: String,
261 #[serde(default, deserialize_with = "deserialize_u64ish")]
263 pub size: u64,
264 #[serde(default, skip_serializing_if = "Option::is_none")]
266 pub checksum: Option<String>,
267 #[serde(default)]
269 pub links: RecordFileLinks,
270 #[serde(flatten, default)]
272 pub extra: BTreeMap<String, Value>,
273}
274
275impl RecordFile {
276 #[must_use]
278 pub fn download_url(&self) -> Option<&Url> {
279 self.links.content.as_ref().or(self.links.self_.as_ref())
280 }
281}
282
283#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
285pub struct RecordLinks {
286 #[serde(rename = "self", default, skip_serializing_if = "Option::is_none")]
288 pub self_: Option<Url>,
289 #[serde(default, skip_serializing_if = "Option::is_none")]
291 pub self_html: Option<Url>,
292 #[serde(default, skip_serializing_if = "Option::is_none")]
294 pub html: Option<Url>,
295 #[serde(default, skip_serializing_if = "Option::is_none")]
297 pub latest: Option<Url>,
298 #[serde(default, skip_serializing_if = "Option::is_none")]
300 pub versions: Option<Url>,
301 #[serde(default, skip_serializing_if = "Option::is_none")]
303 pub files: Option<Url>,
304 #[serde(default, skip_serializing_if = "Option::is_none")]
306 pub archive: Option<Url>,
307 #[serde(default, skip_serializing_if = "Option::is_none")]
309 pub doi: Option<Url>,
310 #[serde(flatten, default)]
312 pub extra: BTreeMap<String, Value>,
313}
314
315#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
317pub struct RecordStats {
318 #[serde(
320 default,
321 deserialize_with = "deserialize_option_u64ish",
322 skip_serializing_if = "Option::is_none"
323 )]
324 pub downloads: Option<u64>,
325 #[serde(
327 default,
328 deserialize_with = "deserialize_option_u64ish",
329 skip_serializing_if = "Option::is_none"
330 )]
331 pub views: Option<u64>,
332 #[serde(flatten, default)]
334 pub extra: BTreeMap<String, Value>,
335}
336
337#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
339pub struct PersistentIdentifier {
340 pub identifier: String,
342 #[serde(default, skip_serializing_if = "Option::is_none")]
344 pub provider: Option<String>,
345 #[serde(default, skip_serializing_if = "Option::is_none")]
347 pub client: Option<String>,
348 #[serde(flatten, default)]
350 pub extra: BTreeMap<String, Value>,
351}
352
353#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
355pub struct RecordPids {
356 #[serde(default, skip_serializing_if = "Option::is_none")]
358 pub doi: Option<PersistentIdentifier>,
359 #[serde(
361 rename = "concept-doi",
362 default,
363 skip_serializing_if = "Option::is_none"
364 )]
365 pub concept_doi: Option<PersistentIdentifier>,
366 #[serde(default, skip_serializing_if = "Option::is_none")]
368 pub recid: Option<PersistentIdentifier>,
369 #[serde(default, skip_serializing_if = "Option::is_none")]
371 pub oai: Option<PersistentIdentifier>,
372 #[serde(flatten, default)]
374 pub extra: BTreeMap<String, Value>,
375}
376
377#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
379pub struct RecordParentCommunities {
380 #[serde(default, skip_serializing_if = "Option::is_none")]
382 pub default: Option<String>,
383 #[serde(default)]
385 pub ids: Vec<String>,
386 #[serde(flatten, default)]
388 pub extra: BTreeMap<String, Value>,
389}
390
391#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
393pub struct RecordParent {
394 #[serde(default, skip_serializing_if = "Option::is_none")]
396 pub id: Option<String>,
397 #[serde(default, skip_serializing_if = "Option::is_none")]
399 pub communities: Option<RecordParentCommunities>,
400 #[serde(default, skip_serializing_if = "Option::is_none")]
402 pub pids: Option<RecordPids>,
403 #[serde(flatten, default)]
405 pub extra: BTreeMap<String, Value>,
406}
407
408#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
410pub struct Record {
411 #[serde(default, skip_serializing_if = "Option::is_none")]
413 pub created: Option<DateTime<Utc>>,
414 #[serde(default, skip_serializing_if = "Option::is_none")]
416 pub modified: Option<DateTime<Utc>>,
417 pub id: RecordId,
419 #[serde(deserialize_with = "deserialize_stringish")]
421 pub recid: String,
422 #[serde(default, skip_serializing_if = "Option::is_none")]
424 pub conceptrecid: Option<ConceptRecId>,
425 #[serde(default, skip_serializing_if = "Option::is_none")]
427 pub doi: Option<Doi>,
428 #[serde(default, skip_serializing_if = "Option::is_none")]
430 pub conceptdoi: Option<Doi>,
431 #[serde(default)]
433 pub metadata: RecordMetadata,
434 #[serde(default)]
436 pub files: Vec<RecordFile>,
437 #[serde(default)]
439 pub links: RecordLinks,
440 #[serde(default, skip_serializing_if = "Option::is_none")]
442 pub parent: Option<RecordParent>,
443 #[serde(default, skip_serializing_if = "Option::is_none")]
445 pub pids: Option<RecordPids>,
446 #[serde(default, skip_serializing_if = "Option::is_none")]
448 pub stats: Option<RecordStats>,
449 #[serde(default)]
451 pub status: RecordPublicationStatus,
452 #[serde(
454 default,
455 deserialize_with = "deserialize_option_u64ish",
456 skip_serializing_if = "Option::is_none"
457 )]
458 pub revision: Option<u64>,
459 #[serde(flatten, default)]
461 pub extra: BTreeMap<String, Value>,
462}
463
464#[derive(Deserialize)]
465struct RecordWire {
466 #[serde(default)]
467 created: Option<DateTime<Utc>>,
468 #[serde(default)]
469 modified: Option<DateTime<Utc>>,
470 #[serde(default)]
471 updated: Option<DateTime<Utc>>,
472 id: RecordId,
473 #[serde(deserialize_with = "deserialize_stringish")]
474 recid: String,
475 #[serde(default)]
476 conceptrecid: Option<ConceptRecId>,
477 #[serde(default)]
478 doi: Option<Doi>,
479 #[serde(default)]
480 conceptdoi: Option<Doi>,
481 #[serde(default)]
482 metadata: RecordMetadata,
483 #[serde(default)]
484 files: Vec<RecordFile>,
485 #[serde(default)]
486 links: RecordLinks,
487 #[serde(default)]
488 parent: Option<RecordParent>,
489 #[serde(default)]
490 pids: Option<RecordPids>,
491 #[serde(default)]
492 stats: Option<RecordStats>,
493 #[serde(default)]
494 status: RecordPublicationStatus,
495 #[serde(default, deserialize_with = "deserialize_option_u64ish")]
496 revision: Option<u64>,
497 #[serde(flatten, default)]
498 extra: BTreeMap<String, Value>,
499}
500
501impl<'de> Deserialize<'de> for Record {
502 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
503 where
504 D: Deserializer<'de>,
505 {
506 let wire = RecordWire::deserialize(deserializer)?;
507 Ok(Self {
508 created: wire.created,
509 modified: wire.modified.or(wire.updated),
510 id: wire.id,
511 recid: wire.recid,
512 conceptrecid: wire.conceptrecid,
513 doi: wire.doi,
514 conceptdoi: wire.conceptdoi,
515 metadata: wire.metadata,
516 files: wire.files,
517 links: wire.links,
518 parent: wire.parent,
519 pids: wire.pids,
520 stats: wire.stats,
521 status: wire.status,
522 revision: wire.revision,
523 extra: wire.extra,
524 })
525 }
526}
527
528impl Record {
529 #[must_use]
531 pub fn latest_url(&self) -> Option<&Url> {
532 self.links.latest.as_ref()
533 }
534
535 #[must_use]
537 pub fn archive_url(&self) -> Option<&Url> {
538 self.links.archive.as_ref()
539 }
540
541 #[must_use]
543 pub fn file_by_key(&self, key: &str) -> Option<&RecordFile> {
544 self.files.iter().find(|file| file.key == key)
545 }
546}
547
548#[derive(Clone, Debug, PartialEq, Eq)]
550pub struct ArtifactInfo {
551 pub record: Record,
553 pub latest: Record,
555 pub files_by_key: BTreeMap<String, RecordFile>,
557}
558
559#[derive(Clone, Debug, PartialEq, Eq)]
561pub struct PublishedRecord {
562 pub deposition: Deposition,
564 pub record: Record,
566}
567
568#[cfg(test)]
569mod tests {
570 use super::{
571 BucketObject, DepositState, Deposition, DepositionFile, Record, RecordFile, RecordLinks,
572 RecordPublicationStatus,
573 };
574 use serde_json::json;
575
576 #[test]
577 fn preserves_unknown_record_fields() {
578 let record: Record = serde_json::from_value(json!({
579 "created": "2026-04-03T12:00:00+00:00",
580 "updated": "2026-04-03T13:00:00+00:00",
581 "id": 42,
582 "recid": "42",
583 "metadata": { "title": "artifact" },
584 "files": [],
585 "links": {},
586 "parent": {
587 "id": "parent-1",
588 "communities": {
589 "default": "zenodo",
590 "ids": ["zenodo", "sandbox"]
591 },
592 "pids": {
593 "doi": {
594 "identifier": "10.5281/zenodo.42"
595 }
596 }
597 },
598 "pids": {
599 "doi": {
600 "identifier": "10.5281/zenodo.42"
601 },
602 "concept-doi": {
603 "identifier": "10.5281/zenodo.41"
604 }
605 },
606 "mystery": "value"
607 }))
608 .unwrap();
609
610 assert!(record.created.is_some());
611 assert!(record.modified.is_some());
612 assert_eq!(
613 record
614 .parent
615 .as_ref()
616 .and_then(|parent| parent.id.as_deref()),
617 Some("parent-1")
618 );
619 assert_eq!(
620 record
621 .pids
622 .as_ref()
623 .and_then(|pids| pids.doi.as_ref())
624 .map(|pid| pid.identifier.as_str()),
625 Some("10.5281/zenodo.42")
626 );
627 assert_eq!(record.extra.get("mystery"), Some(&json!("value")));
628 }
629
630 #[test]
631 fn record_accepts_community_id_alias_in_metadata() {
632 let record: Record = serde_json::from_value(json!({
633 "created": "2026-04-03T12:00:00+00:00",
634 "updated": "2026-04-03T13:00:00+00:00",
635 "id": 42,
636 "recid": "42",
637 "metadata": {
638 "title": "artifact",
639 "communities": [
640 { "id": "earth-metabolome" }
641 ]
642 },
643 "files": [],
644 "links": {}
645 }))
646 .unwrap();
647
648 assert_eq!(record.metadata.communities.len(), 1);
649 assert_eq!(
650 record.metadata.communities[0].identifier,
651 "earth-metabolome"
652 );
653 }
654
655 #[test]
656 fn record_deserializes_minimized_live_19701295_shape() {
657 let record: Record = serde_json::from_value(json!({
659 "created": "2026-04-03T12:00:00+00:00",
660 "updated": "2026-04-03T13:00:00+00:00",
661 "id": 19_701_295,
662 "recid": "19701295",
663 "doi": "10.5281/zenodo.19701295",
664 "metadata": {
665 "title": "NPClassifier distilled dataset",
666 "publication_date": "2026-04-03",
667 "creators": [{ "name": "Doe, Jane" }],
668 "access_right": "open",
669 "resource_type": { "type": "dataset", "title": "Dataset" },
670 "communities": [
671 { "id": "earth-metabolome" }
672 ]
673 },
674 "files": [
675 {
676 "id": "f1",
677 "key": "npclassifier-distilled.parquet",
678 "size": 123_456,
679 "checksum": "md5:0123456789abcdef0123456789abcdef",
680 "links": {
681 "self": "https://zenodo.org/api/records/19701295/files/npclassifier-distilled.parquet",
682 "content": "https://zenodo.org/records/19701295/files/npclassifier-distilled.parquet/content"
683 }
684 }
685 ],
686 "links": {
687 "self": "https://zenodo.org/api/records/19701295",
688 "self_html": "https://zenodo.org/records/19701295",
689 "html": "https://zenodo.org/records/19701295",
690 "latest": "https://zenodo.org/api/records/19701295",
691 "versions": "https://zenodo.org/api/records/19701295/versions",
692 "files": "https://zenodo.org/api/records/19701295/files",
693 "archive": "https://zenodo.org/api/records/19701295/files-archive",
694 "doi": "https://doi.org/10.5281/zenodo.19701295"
695 },
696 "pids": {
697 "doi": {
698 "identifier": "10.5281/zenodo.19701295"
699 },
700 "recid": {
701 "identifier": "19701295"
702 }
703 },
704 "stats": {
705 "downloads": 0,
706 "views": 0
707 },
708 "status": "published"
709 }))
710 .unwrap();
711
712 assert_eq!(record.id.0, 19_701_295);
713 assert_eq!(record.recid, "19701295");
714 assert_eq!(
715 record.doi.as_ref().map(crate::Doi::as_str),
716 Some("10.5281/zenodo.19701295")
717 );
718 assert_eq!(record.metadata.communities.len(), 1);
719 assert_eq!(
720 record.metadata.communities[0].identifier,
721 "earth-metabolome"
722 );
723 assert_eq!(
724 record
725 .file_by_key("npclassifier-distilled.parquet")
726 .and_then(|file| file.download_url())
727 .map(url::Url::as_str),
728 Some(
729 "https://zenodo.org/records/19701295/files/npclassifier-distilled.parquet/content"
730 )
731 );
732 }
733
734 #[test]
735 fn record_accepts_both_modified_and_updated_timestamps() {
736 let record: Record = serde_json::from_value(json!({
737 "created": "2026-04-03T12:00:00+00:00",
738 "modified": "2026-04-03T14:00:00+00:00",
739 "updated": "2026-04-03T13:00:00+00:00",
740 "id": 42,
741 "recid": 42.0,
742 "metadata": { "title": "artifact" },
743 "files": [],
744 "links": {}
745 }))
746 .unwrap();
747
748 assert!(record.created.is_some());
749 assert_eq!(
750 record.modified.unwrap().to_rfc3339(),
751 "2026-04-03T14:00:00+00:00"
752 );
753 }
754
755 #[test]
756 fn model_string_enums_preserve_unknown_values() {
757 let state: DepositState = serde_json::from_value(json!("queued")).unwrap();
758 let status: RecordPublicationStatus = serde_json::from_value(json!("embargoed")).unwrap();
759 let unsubmitted: DepositState = serde_json::from_value(json!("unsubmitted")).unwrap();
760
761 assert_eq!(serde_json::to_value(&state).unwrap(), json!("queued"));
762 assert_eq!(serde_json::to_value(&status).unwrap(), json!("embargoed"));
763 assert_eq!(
764 serde_json::to_value(&unsubmitted).unwrap(),
765 json!("unsubmitted")
766 );
767 assert!(matches!(state, DepositState::Unknown(value) if value == "queued"));
768 assert_eq!(unsubmitted, DepositState::Unsubmitted);
769 assert!(matches!(status, RecordPublicationStatus::Unknown(value) if value == "embargoed"));
770 }
771
772 #[test]
773 fn follows_latest_draft_link() {
774 let deposition: Deposition = serde_json::from_value(serde_json::json!({
775 "id": 7,
776 "submitted": true,
777 "state": "done",
778 "metadata": {},
779 "files": [],
780 "links": {
781 "latest_draft": "https://zenodo.org/api/deposit/depositions/8"
782 }
783 }))
784 .unwrap();
785
786 assert_eq!(
787 deposition.latest_draft_url().unwrap().as_str(),
788 "https://zenodo.org/api/deposit/depositions/8"
789 );
790 }
791
792 #[test]
793 fn record_file_prefers_content_link() {
794 let file: RecordFile = serde_json::from_value(serde_json::json!({
795 "id": "f1",
796 "key": "artifact.bin",
797 "links": {
798 "self": "https://zenodo.org/api/files/self",
799 "content": "https://zenodo.org/api/files/content"
800 }
801 }))
802 .unwrap();
803
804 assert_eq!(
805 file.download_url().unwrap().as_str(),
806 "https://zenodo.org/api/files/content"
807 );
808 }
809
810 #[test]
811 fn deposition_file_accepts_integer_like_numeric_shapes() {
812 let float_file: DepositionFile = serde_json::from_value(serde_json::json!({
813 "id": "f1",
814 "filename": "artifact.bin",
815 "filesize": 14.0
816 }))
817 .unwrap();
818 assert_eq!(float_file.filesize, 14);
819
820 let string_file: DepositionFile = serde_json::from_value(serde_json::json!({
821 "id": "f2",
822 "filename": "artifact.bin",
823 "filesize": "18"
824 }))
825 .unwrap();
826 assert_eq!(string_file.filesize, 18);
827 }
828
829 #[test]
830 fn deposition_file_rejects_non_integral_sizes() {
831 let error = serde_json::from_value::<DepositionFile>(serde_json::json!({
832 "id": "f1",
833 "filename": "artifact.bin",
834 "filesize": 14.5
835 }))
836 .unwrap_err();
837 assert!(error.to_string().contains("integer-like"));
838 }
839
840 #[test]
841 fn other_numeric_response_fields_accept_integer_like_shapes() {
842 let uploaded: BucketObject = serde_json::from_value(serde_json::json!({
843 "key": "artifact.bin",
844 "size": "14"
845 }))
846 .unwrap();
847 let published_file: RecordFile = serde_json::from_value(serde_json::json!({
848 "id": "f1",
849 "key": "artifact.bin",
850 "size": 15.0,
851 "links": {}
852 }))
853 .unwrap();
854 let record: Record = serde_json::from_value(serde_json::json!({
855 "id": 42.0,
856 "recid": 42.0,
857 "metadata": { "title": "artifact" },
858 "files": [],
859 "links": {},
860 "stats": {
861 "downloads": "16",
862 "views": 17.0
863 },
864 "revision": "18"
865 }))
866 .unwrap();
867
868 assert_eq!(uploaded.size, 14);
869 assert_eq!(published_file.size, 15);
870 assert_eq!(record.id.0, 42);
871 assert_eq!(
872 record.stats.as_ref().and_then(|stats| stats.downloads),
873 Some(16)
874 );
875 assert_eq!(
876 record.stats.as_ref().and_then(|stats| stats.views),
877 Some(17)
878 );
879 assert_eq!(record.revision, Some(18));
880 }
881
882 #[test]
883 fn record_and_deposition_helpers_expose_status_and_links() {
884 let deposition: Deposition = serde_json::from_value(serde_json::json!({
885 "id": 9,
886 "submitted": false,
887 "state": "mystery-state",
888 "metadata": {},
889 "files": [],
890 "links": {
891 "bucket": "https://zenodo.org/api/files/bucket-9"
892 }
893 }))
894 .unwrap();
895 let record = Record {
896 created: None,
897 modified: None,
898 id: crate::RecordId(10),
899 recid: "10".into(),
900 conceptrecid: None,
901 doi: None,
902 conceptdoi: None,
903 metadata: crate::RecordMetadata::default(),
904 files: Vec::new(),
905 links: RecordLinks::default(),
906 parent: None,
907 pids: None,
908 stats: None,
909 status: RecordPublicationStatus::Draft,
910 revision: None,
911 extra: std::collections::BTreeMap::new(),
912 };
913
914 assert!(!deposition.is_published());
915 assert!(deposition.bucket_url().is_some());
916 assert!(matches!(deposition.status.state, DepositState::Unknown(_)));
917 assert!(record.latest_url().is_none());
918 assert!(record.archive_url().is_none());
919 }
920
921 #[test]
922 fn deposition_helpers_treat_unsubmitted_as_editable() {
923 let deposition: Deposition = serde_json::from_value(serde_json::json!({
924 "id": 11,
925 "submitted": false,
926 "state": "unsubmitted",
927 "metadata": {},
928 "files": [],
929 "links": {}
930 }))
931 .unwrap();
932
933 assert_eq!(deposition.status.state, DepositState::Unsubmitted);
934 assert!(deposition.allows_metadata_edits());
935 }
936}