• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

pulibrary / bibdata / 3165b919-56f4-4faa-baf4-b5a08621230c

18 Sep 2025 03:08PM UTC coverage: 90.866% (+0.2%) from 90.675%
3165b919-56f4-4faa-baf4-b5a08621230c

Pull #2927

circleci

Ryan Laddusaw
Add QA DSpace config
Pull Request #2927: Index theses from dspace 7+

1411 of 1519 new or added lines in 16 files covered. (92.89%)

41 existing lines in 7 files now uncovered.

8874 of 9766 relevant lines covered (90.87%)

338.18 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

99.68
/lib/bibdata_rs/src/ephemera/ephemera_folder.rs
1
use crate::solr::{self, AccessFacet, DigitalContent};
2

3
use super::{
4
    born_digital_collection::ephemera_folders_iterator,
5
    ephemera_folder_builder::EphemeraFolderBuilder,
6
};
7
use log::{debug, trace};
8
use serde::Deserialize;
9

10
pub mod country;
11
pub mod coverage;
12
pub mod format;
13
pub mod language;
14
pub mod origin_place;
15
pub mod subject;
16

17
use coverage::Coverage;
18
use format::Format;
19
use language::Language;
20
use origin_place::OriginPlace;
21
use serde_json::Value;
22
use subject::Subject;
23

24
#[derive(Deserialize, Debug)]
25
pub struct EphemeraFolder {
26
    pub alternative: Option<Vec<String>>,
27
    pub creator: Option<Vec<String>>,
28
    pub contributor: Option<Vec<String>>,
29
    pub coverage: Option<Vec<Coverage>>,
30
    pub date_created: Option<Vec<String>>,
31
    pub description: Option<Vec<String>>,
32
    pub electronic_access: Option<Vec<solr::ElectronicAccess>>,
33
    pub format: Option<Vec<Format>>,
34
    #[serde(rename = "@id")]
35
    pub id: String,
36
    pub language: Option<Vec<Language>>,
37
    #[serde(rename = "origin_place")]
38
    pub origin: Option<Vec<OriginPlace>>,
39
    pub page_count: Option<String>,
40
    pub provenance: Option<String>,
41
    pub publisher: Option<Vec<String>>,
42
    pub subject: Option<Vec<Subject>>,
43
    pub sort_title: Option<Vec<String>>,
44
    pub thumbnail: Option<Thumbnail>,
45
    pub title: Vec<String>,
46
    pub transliterated_title: Option<Vec<String>>,
47
}
48

49
#[derive(Debug, Deserialize, PartialEq, Clone)]
50
pub struct Thumbnail {
51
    #[serde(rename = "@id")]
52
    pub thumbnail_url: String,
53
}
54

55
impl EphemeraFolder {
56
    pub fn builder() -> EphemeraFolderBuilder {
25✔
57
        EphemeraFolderBuilder::new()
25✔
58
    }
25✔
59
    pub fn thumbnail_url(&self) -> Option<String> {
60✔
60
        self.thumbnail.as_ref().map(|t| t.thumbnail_url.clone())
60✔
61
    }
60✔
62
    pub fn solr_formats(&self) -> Vec<solr::FormatFacet> {
60✔
63
        match &self.format {
60✔
64
            Some(formats) => formats.iter().filter_map(|f| f.pref_label).collect(),
39✔
65
            None => vec![],
21✔
66
        }
67
    }
60✔
68

69
    pub fn coverage_labels(&self) -> Vec<String> {
61✔
70
        match &self.coverage {
61✔
71
            Some(coverage_vector) => coverage_vector
40✔
72
                .iter()
40✔
73
                .filter(|coverage| coverage.exact_match.accepted_vocabulary())
117✔
74
                .map(|coverage| coverage.label.clone())
116✔
75
                .collect(),
40✔
76
            None => vec![],
21✔
77
        }
78
    }
61✔
79

80
    pub fn origin_place_labels(&self) -> Vec<String> {
180✔
81
        match &self.origin {
180✔
82
            Some(origin_vector) => origin_vector
120✔
83
                .iter()
120✔
84
                .filter(|origin| origin.exact_match.accepted_vocabulary())
120✔
85
                .map(|origin| origin.label.clone())
120✔
86
                .collect(),
120✔
87
            None => vec![],
60✔
88
        }
89
    }
180✔
90

91
    pub fn homoit_subject_labels(&self) -> Option<Vec<String>> {
120✔
92
        self.subject.as_ref().map(|subjects| {
120✔
93
            subjects
80✔
94
                .iter()
80✔
95
                .filter(|s| s.exact_match.accepted_homoit_vocabulary())
156✔
96
                .map(|s| s.label.clone())
80✔
97
                .collect()
80✔
98
        })
80✔
99
    }
120✔
100

101
    pub fn lc_subject_labels(&self) -> Option<Vec<String>> {
120✔
102
        self.subject.as_ref().map(|subjects| {
120✔
103
            subjects
80✔
104
                .iter()
80✔
105
                .filter(|s| s.exact_match.accepted_loc_vocabulary())
156✔
106
                .map(|s| s.label.clone())
154✔
107
                .collect()
80✔
108
        })
80✔
109
    }
120✔
110

111
    pub fn language_labels(&self) -> Vec<String> {
120✔
112
        match &self.language {
120✔
113
            Some(languages) => languages.iter().map(|l| l.label.clone()).collect(),
152✔
114
            None => vec![],
44✔
115
        }
116
    }
120✔
117

118
    pub fn all_contributors(&self) -> Vec<String> {
120✔
119
        let mut all_contributors = Vec::default();
120✔
120
        all_contributors.extend(self.creator.clone().unwrap_or_default());
120✔
121
        all_contributors.extend(self.contributor.clone().unwrap_or_default());
120✔
122
        all_contributors
120✔
123
    }
120✔
124

125
    pub fn first_contibutor(&self) -> Option<String> {
60✔
126
        self.all_contributors().first().cloned()
60✔
127
    }
60✔
128

129
    pub fn date_created_year(&self) -> Option<i16> {
60✔
130
        self.date_created
60✔
131
            .as_ref()?
60✔
132
            .iter()
41✔
133
            .find_map(|date_str| date_str.get(0..4)?.parse::<i16>().ok())
41✔
134
    }
60✔
135

136
    pub fn other_title_display_combined(&self) -> Vec<String> {
60✔
137
        let mut combined = self.alternative.clone().unwrap_or_default();
60✔
138
        combined.extend(self.transliterated_title.clone().unwrap_or_default());
60✔
139
        combined
60✔
140
    }
60✔
141

142
    pub fn concat_page_count(&self) -> Vec<String> {
60✔
143
        match self.page_count.clone() {
60✔
144
            Some(page_count) => vec![format!("pages: {}", page_count)],
39✔
145
            None => Vec::new(),
21✔
146
        }
147
    }
60✔
148

149
    pub fn date_created_publisher_combined(&self) -> Vec<String> {
60✔
150
        let mut combined = self.date_created.clone().unwrap_or_default();
60✔
151
        combined.extend(self.publisher.clone().unwrap_or_default());
60✔
152
        combined
60✔
153
    }
60✔
154

155
    pub fn origin_place_publisher_date_created_combined(&self) -> Vec<String> {
120✔
156
        let origin = self
120✔
157
            .origin_place_labels()
120✔
158
            .first()
120✔
159
            .cloned()
120✔
160
            .unwrap_or_default();
120✔
161
        let publisher = self
120✔
162
            .publisher
120✔
163
            .clone()
120✔
164
            .unwrap_or_default()
120✔
165
            .first()
120✔
166
            .cloned()
120✔
167
            .unwrap_or_default();
120✔
168
        let date = self
120✔
169
            .date_created
120✔
170
            .clone()
120✔
171
            .unwrap_or_default()
120✔
172
            .first()
120✔
173
            .cloned()
120✔
174
            .unwrap_or_default();
120✔
175
        let mut result = Vec::new();
120✔
176
        if !origin.is_empty() || !publisher.is_empty() || !date.is_empty() {
120✔
177
            let mut s = String::new();
86✔
178
            if !origin.is_empty() {
86✔
179
                s.push_str(&origin);
80✔
180
            }
80✔
181
            if !publisher.is_empty() {
86✔
182
                if !s.is_empty() {
82✔
183
                    s.push_str(": ");
78✔
184
                }
78✔
185
                s.push_str(&publisher);
82✔
186
            }
4✔
187
            if !date.is_empty() {
86✔
188
                if !s.is_empty() {
82✔
189
                    s.push_str(", ");
80✔
190
                }
80✔
191
                s.push_str(&date);
82✔
192
            }
4✔
193
            result.push(s);
86✔
194
        }
34✔
195
        result
120✔
196
    }
120✔
197
    pub fn first_sort_title(&self) -> Option<String> {
60✔
198
        self.sort_title
60✔
199
            .as_ref()
60✔
200
            .or(Some(&self.title))?
60✔
201
            .first()
60✔
202
            .cloned()
60✔
203
    }
60✔
204
    pub fn access_facet(&self) -> Option<AccessFacet> {
60✔
205
        Some(AccessFacet::Online)
60✔
206
    }
60✔
207
    pub async fn fetch_thumbnail(
38✔
208
        &self,
38✔
209
        domain: &str,
38✔
210
        id: &str,
38✔
211
    ) -> Result<Option<Thumbnail>, anyhow::Error> {
38✔
212
        let manifest_url = format!("{domain}/concern/ephemera_folders/{}/manifest", id);
38✔
213
        debug!("Fetching manifest from {manifest_url}");
38✔
214
        let resp = reqwest::get(&manifest_url).await?.text().await?;
38✔
215
        let manifest: Value = serde_json::from_str(&resp)?;
38✔
216
        if let Some(thumbnail_json) = manifest.get("thumbnail") {
2✔
217
            let thumbnail: Thumbnail = serde_json::from_value(thumbnail_json.clone())?;
2✔
218
            Ok(Some(thumbnail))
2✔
219
        } else {
UNCOV
220
            Ok(None)
×
221
        }
222
    }
38✔
223
    pub fn electronic_access(&self) -> Option<solr::ElectronicAccess> {
61✔
224
        Some(solr::ElectronicAccess {
61✔
225
            url: self.id.clone(),
61✔
226
            link_text: "Online Content".to_owned(),
61✔
227
            link_description: Some("Born Digital Monographic Reports and Papers".to_owned()),
61✔
228
            iiif_manifest_paths: Some(format!(
61✔
229
                "https://figgy.princeton.edu/concern/ephemera_folders/{}/manifest",
61✔
230
                self.normalized_id()
61✔
231
            )),
61✔
232
            digital_content: Some(DigitalContent {
61✔
233
                link_text: vec!["Digital content".to_owned()],
61✔
234
                url: format!(
61✔
235
                    "https://catalog-staging.princeton.edu/catalog/{}#view",
61✔
236
                    self.normalized_id()
61✔
237
                ),
61✔
238
            }),
61✔
239
        })
61✔
240
    }
61✔
241
    pub fn normalized_id(&self) -> String {
182✔
242
        self.id
182✔
243
            .split('/')
182✔
244
            .last()
182✔
245
            .unwrap_or(&self.id)
182✔
246
            .trim()
182✔
247
            .to_string()
182✔
248
    }
182✔
249
}
250

251
#[allow(dead_code)]
252
#[derive(Deserialize, Debug)]
253
pub struct ItemResponse {
254
    pub data: Vec<EphemeraFolder>,
255
}
256

257
pub fn json_ephemera_document(url: String) -> Result<String, magnus::Error> {
2✔
258
    #[cfg(not(test))]
259
    env_logger::init();
260
    let rt = tokio::runtime::Runtime::new()
2✔
261
        .map_err(|e| magnus::Error::new(magnus::exception::runtime_error(), e.to_string()))?;
2✔
262
    rt.block_on(async {
2✔
263
        let folder_results = ephemera_folders_iterator(&url, 1_000)
2✔
264
            .await
2✔
265
            .map_err(|e| magnus::Error::new(magnus::exception::runtime_error(), e.to_string()))?;
2✔
266
        let combined_json = folder_results.join(",");
2✔
267
        trace!("The JSON that we will post to Solr is {}", combined_json);
2✔
268
        Ok(combined_json)
2✔
269
    })
2✔
270
}
2✔
271

272
#[cfg(test)]
273
mod tests {
274
    use crate::{
275
        ephemera::{ephemera_folder::country::ExactMatch, CatalogClient},
276
        solr,
277
        testing_support::{preserving_envvar, preserving_envvar_async},
278
    };
279

280
    use super::*;
281
    use std::fs::File;
282
    use std::io::BufReader;
283

284
    #[tokio::test]
285
    async fn test_get_item_data() {
1✔
286
        preserving_envvar_async("FIGGY_BORN_DIGITAL_EPHEMERA_URL", || async {
1✔
287
            let mut server = mockito::Server::new_async().await;
1✔
288

1✔
289
            let mock = server
1✔
290
                .mock(
1✔
291
                    "GET",
1✔
292
                    "/catalog/af4a941d-96a4-463e-9043-cfa512e5eddd.jsonld",
1✔
293
                )
1✔
294
                .with_status(200)
1✔
295
                .with_header("content-type", "application/json")
1✔
296
                .with_body_from_file("../../spec/fixtures/files/ephemera/ephemera1.json")
1✔
297
                .create_async()
1✔
298
                .await;
1✔
299

1✔
300
            let client = CatalogClient::new(server.url());
1✔
301
            let result = client
1✔
302
                .get_item_data("af4a941d-96a4-463e-9043-cfa512e5eddd")
1✔
303
                .await;
1✔
304

1✔
305
            mock.assert_async().await;
1✔
306

1✔
307
            match result {
1✔
308
                Ok(_response) => assert!(true),
1✔
309
                Err(e) => panic!("Request failed: {}", e),
1✔
310
            }
1✔
311
        })
2✔
312
        .await
1✔
313
    }
1✔
314

315
    #[test]
316
    fn it_can_read_the_format_from_json_ld() {
1✔
317
        let file = File::open("../../spec/fixtures/files/ephemera/ephemera1.json").unwrap();
1✔
318
        let reader = BufReader::new(file);
1✔
319

320
        let ephemera_folder_item: EphemeraFolder = serde_json::from_reader(reader).unwrap();
1✔
321
        assert_eq!(
1✔
322
            ephemera_folder_item.format.unwrap()[0].pref_label,
1✔
323
            Some(solr::FormatFacet::Book)
324
        );
325
    }
1✔
326

327
    #[tokio::test]
328
    async fn it_can_read_the_digital_content_from_json_ld() {
1✔
329
        let file = File::open("../../spec/fixtures/files/ephemera/ephemera1.json").unwrap();
1✔
330
        let reader = BufReader::new(file);
1✔
331

332
        let ephemera_folder_item: EphemeraFolder = serde_json::from_reader(reader).unwrap();
1✔
333
        let digital_content = ephemera_folder_item
1✔
334
            .electronic_access()
1✔
335
            .unwrap()
1✔
336
            .digital_content
1✔
337
            .unwrap();
1✔
338
        assert_eq!(
1✔
339
            digital_content.link_text,
340
            vec!["Digital content".to_string()]
1✔
341
        );
342
        assert_eq!(
1✔
343
            digital_content.url,
1✔
344
            "https://catalog-staging.princeton.edu/catalog/af4a941d-96a4-463e-9043-cfa512e5eddd#view".to_string()
1✔
345
        );
1✔
346
    }
1✔
347

348
    #[test]
349
    fn test_json_ephemera_document() {
1✔
350
        preserving_envvar("FIGGY_BORN_DIGITAL_EPHEMERA_URL", || {
1✔
351
            let mut server = mockito::Server::new();
1✔
352

353
            let folder_mock = server
1✔
354
                .mock("GET", "/catalog.json?f%5Bephemera_project_ssim%5D%5B%5D=Born+Digital+Monographic+Reports+and+Papers&f%5Bhuman_readable_type_ssim%5D%5B%5D=Ephemera+Folder&f%5Bstate_ssim%5D%5B%5D=complete&per_page=100&q=")
1✔
355
                .with_status(200)
1✔
356
                .with_header("content-type", "application/json")
1✔
357
                .with_body_from_file("../../spec/fixtures/files/ephemera/ephemera_folders.json")
1✔
358
                .create();
1✔
359

360
            let item_mock = server
1✔
361
                .mock(
1✔
362
                    "GET",
1✔
363
                    mockito::Matcher::Regex(
1✔
364
                        r"^/catalog/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}.jsonld$"
1✔
365
                            .to_string(),
1✔
366
                    ),
1✔
367
                )
1✔
368
                .with_status(200)
1✔
369
                .with_header("content-type", "application/json")
1✔
370
                .with_body_from_file("../../spec/fixtures/files/ephemera/ephemera1.json")
1✔
371
                .expect(12)
1✔
372
                .create();
1✔
373

374
            let result = json_ephemera_document(server.url().to_string()).unwrap();
1✔
375

376
            let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1✔
377
            assert!(parsed.is_array());
1✔
378

379
            folder_mock.assert();
1✔
380
            item_mock.assert();
1✔
381
        });
1✔
382
    }
1✔
383

384
    mod no_transliterated_title {
385

386
        use rb_sys_test_helpers::ruby_test;
387

388
        use super::*;
389
        #[ruby_test]
390
        fn test_json_ephemera_document_with_no_transliterated_title() {
391
            let mut server = mockito::Server::new();
392

393
            let folder_mock = server
394
                .mock("GET", "/catalog.json?f%5Bephemera_project_ssim%5D%5B%5D=Born+Digital+Monographic+Reports+and+Papers&f%5Bhuman_readable_type_ssim%5D%5B%5D=Ephemera+Folder&f%5Bstate_ssim%5D%5B%5D=complete&per_page=100&q=")
395
                .with_status(200)
396
                .with_header("content-type", "application/json")
397
                .with_body_from_file("../../spec/fixtures/files/ephemera/ephemera_folders.json")
398
                .create();
399

400
            let item_mock = server
401
                .mock(
402
                    "GET",
403
                    mockito::Matcher::Regex(
404
                        r"^/catalog/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}.jsonld$"
405
                            .to_string(),
406
                    ),
407
                )
408
                .with_status(200)
409
                .with_header("content-type", "application/json")
410
                .with_body_from_file(
411
                    "../../spec/fixtures/files/ephemera/ephemera_no_transliterated_title.json",
412
                )
413
                .expect(12)
414
                .create();
415

416
            let result = json_ephemera_document(server.url().to_string()).unwrap();
417
            let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
418
            assert!(parsed.is_array());
419
            folder_mock.assert();
420
            item_mock.assert();
421
        }
422
    }
423

424
    #[test]
425
    fn it_can_return_coverage_labels_for_authorized_vocabularies() {
1✔
426
        let item = EphemeraFolder::builder()
1✔
427
            .id("123ABC".to_string())
1✔
428
            .title(vec!["The worst book ever!".to_string()])
1✔
429
            .coverage(vec![
1✔
430
                Coverage {
1✔
431
                    exact_match: ExactMatch {
1✔
432
                        id: "http://id.loc.gov/vocabulary/countries/an".to_string(),
1✔
433
                    },
1✔
434
                    label: "Andorra".to_string(),
1✔
435
                },
1✔
436
                Coverage {
1✔
437
                    exact_match: ExactMatch {
1✔
438
                        id: "http://bad-bad-bad".to_string(),
1✔
439
                    },
1✔
440
                    label: "Anguilla".to_string(),
1✔
441
                },
1✔
442
            ])
443
            .build()
1✔
444
            .unwrap();
1✔
445
        assert_eq!(item.coverage_labels(), vec!["Andorra".to_string()]);
1✔
446
    }
1✔
447
    #[tokio::test]
448
    async fn it_can_fetch_thumbnail() {
1✔
449
        let folder = EphemeraFolder::builder()
1✔
450
            .id("fa30780e-dfd8-4545-b1b0-b3eec9fca96b".to_string())
1✔
451
            .title(vec!["The worst book ever!".to_string()])
1✔
452
            .build()
1✔
453
            .unwrap();
1✔
454
        let mut server = mockito::Server::new_async().await;
1✔
455

456
        let mock = server
1✔
457
            .mock(
1✔
458
                "GET",
1✔
459
                "/concern/ephemera_folders/fa30780e-dfd8-4545-b1b0-b3eec9fca96b/manifest",
1✔
460
            )
1✔
461
            .with_status(200)
1✔
462
            .with_header("content-type", "application/json")
1✔
463
            .with_body_from_file("../../spec/fixtures/files/ephemera/ephemera_manifest.json")
1✔
464
            .create_async()
1✔
465
            .await;
1✔
466
        let result = folder
1✔
467
            .fetch_thumbnail(&server.url(), &folder.id)
1✔
468
            .await
1✔
469
            .unwrap()
1✔
470
            .unwrap();
1✔
471
        mock.assert_async().await;
1✔
472

473
        assert_eq!(result.thumbnail_url, "https://iiif-cloud.princeton.edu/iiif/2/c9%2Fa6%2F2b%2Fc9a62b81f8014b13933f4cf462c092dc%2Fintermediate_file/full/!200,150/0/default.jpg".to_string());
1✔
474
    }
1✔
475
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc