1use async_trait::async_trait;
46use bytes::Bytes;
47use quick_xml::events::Event;
48use quick_xml::Reader;
49use serde_json::{json, Value};
50use url::Url;
51
52use crate::provenance::{Capability, LogEvent, LogResult, RowInput};
53use crate::source::{FetchContext, FetchError, FetchResult, Source};
54use crate::{ArxivId, CapabilityProfile, Ref};
55
56const PDF_BASE: &str = "https://arxiv.org";
62
63const META_BASE: &str = "https://export.arxiv.org";
69
70#[derive(Clone, Debug)]
73pub struct ArxivSource {
74 base: Url,
76 meta_base: Url,
78}
79
80impl ArxivSource {
81 pub fn new() -> Self {
84 #[allow(clippy::expect_used)]
89 let base = Url::parse(PDF_BASE).expect("hard-coded PDF base URL is valid");
90 #[allow(clippy::expect_used)]
91 let meta_base = Url::parse(META_BASE).expect("hard-coded meta base URL is valid");
92 Self { base, meta_base }
93 }
94
95 pub fn with_base(base: Url) -> Self {
104 Self {
105 meta_base: base.clone(),
106 base,
107 }
108 }
109
110 fn pdf_url(&self, id: &ArxivId) -> Result<Url, FetchError> {
122 let path = format!("/pdf/{}.pdf", id.as_str());
123 self.base.join(&path).map_err(|e| FetchError::SourceSchema {
124 hint: format!("arxiv URL construction failed: {e}"),
125 })
126 }
127
128 fn metadata_url(&self, id: &ArxivId) -> Result<Url, FetchError> {
141 let mut url = self
142 .meta_base
143 .join("/api/query")
144 .map_err(|e| FetchError::SourceSchema {
145 hint: format!("arxiv metadata URL construction failed: {e}"),
146 })?;
147 url.query_pairs_mut().append_pair("id_list", id.as_str());
148 Ok(url)
149 }
150
151 pub async fn fetch_metadata_only(
167 &self,
168 id: &ArxivId,
169 ctx: &FetchContext,
170 ) -> Result<Value, FetchError> {
171 let _permit = ctx.rate_limiter.acquire(self.name()).await;
173
174 let url = self.metadata_url(id)?;
175 let (body, _final_url) = ctx.http.fetch_bytes(self.name(), url).await?;
176 let metadata = parse_atom_feed(&body)?;
177
178 let canonical =
182 crate::CanonicalRef::new(crate::SourceType::Arxiv, id.as_str(), self.name(), None)
183 .digest_hex();
184 ctx.log.append(RowInput {
185 event: LogEvent::Fetch,
186 result: LogResult::Ok,
187 capability: Capability::Metadata,
192 ref_: Some(id.as_str()),
193 source: Some(self.name()),
194 error_code: None,
195 size_bytes: Some(body.len() as u64),
196 license: Some("arxiv-default"),
197 store_path: None,
198 canonical_digest: Some(&canonical),
199 })?;
200
201 Ok(metadata)
202 }
203}
204
205impl Default for ArxivSource {
206 fn default() -> Self {
207 Self::new()
208 }
209}
210
211#[async_trait]
212impl Source for ArxivSource {
213 fn name(&self) -> &str {
214 "arxiv"
215 }
216
217 fn can_serve(&self, _profile: &CapabilityProfile, ref_: &Ref) -> bool {
218 matches!(ref_, Ref::Arxiv(_))
219 }
220
221 async fn fetch(
222 &self,
223 ref_: &Ref,
224 _profile: &CapabilityProfile,
225 ctx: &FetchContext,
226 ) -> Result<FetchResult, FetchError> {
227 let id = match ref_ {
231 Ref::Arxiv(a) => a,
232 Ref::Doi(_) => {
233 return Err(FetchError::NotEligible {
234 source_key: "arxiv".into(),
235 });
236 }
237 };
238
239 let _permit = ctx.rate_limiter.acquire(self.name()).await;
242
243 let metadata_json = match self.metadata_url(id) {
255 Ok(meta_url) => match ctx.http.fetch_bytes(self.name(), meta_url).await {
256 Ok((bytes, _final)) => match parse_atom_feed(&bytes) {
257 Ok(v) => Some(v),
258 Err(e) => {
259 tracing::warn!(
260 arxiv_id = %id.as_str(),
261 error = %e,
262 "arxiv Atom feed parse failed; continuing with PDF-only fetch"
263 );
264 None
265 }
266 },
267 Err(e) => {
268 tracing::warn!(
269 arxiv_id = %id.as_str(),
270 error = %e,
271 "arxiv Atom feed fetch failed; continuing with PDF-only fetch"
272 );
273 None
274 }
275 },
276 Err(e) => {
277 tracing::warn!(
278 arxiv_id = %id.as_str(),
279 error = %e,
280 "arxiv metadata URL construction failed; continuing with PDF-only fetch"
281 );
282 None
283 }
284 };
285
286 ctx.rate_limiter.pace(self.name()).await;
294
295 let url = self.pdf_url(id)?;
296
297 let (body, final_url): (Bytes, Url) = ctx.http.fetch_pdf(self.name(), url).await?;
301
302 let canonical = ref_.promote(self.name(), None).digest_hex();
309 ctx.log.append(RowInput {
310 event: LogEvent::Fetch,
311 result: LogResult::Ok,
312 capability: Capability::Oa,
313 ref_: Some(id.as_str()),
314 source: Some(self.name()),
315 error_code: None,
316 size_bytes: Some(body.len() as u64),
317 license: Some("arxiv-default"),
323 store_path: None,
324 canonical_digest: Some(&canonical),
325 })?;
326
327 Ok(FetchResult {
328 source: self.name().to_string(),
329 license: "arxiv-default".into(),
330 pdf_bytes: Some(body),
331 final_url: Some(final_url),
332 metadata_json,
333 })
334 }
335}
336
337pub(crate) fn parse_atom_feed(xml: &[u8]) -> Result<Value, FetchError> {
376 let mut reader = Reader::from_reader(xml);
377 let config = reader.config_mut();
378 config.trim_text(true);
379
380 let mut in_entry = false;
383 let mut saw_entry = false;
384 let mut depth = 0_i32; let mut title: Option<String> = None;
389 let mut abstract_: Option<String> = None;
390 let mut published: Option<String> = None;
391 let mut updated: Option<String> = None;
392 let mut authors: Vec<String> = Vec::new();
393 let mut categories: Vec<String> = Vec::new();
394 let mut doi: Option<String> = None;
399 let mut journal_ref: Option<String> = None;
400
401 #[derive(Clone, Copy)]
404 enum Target {
405 Title,
406 Summary,
407 Published,
408 Updated,
409 AuthorName,
410 Doi,
411 JournalRef,
412 }
413 let mut target: Option<Target> = None;
414 let mut in_author = false;
415 let mut buf: Vec<u8> = Vec::new();
416
417 loop {
418 match reader.read_event_into(&mut buf) {
419 Ok(Event::Start(e)) => {
420 let name_bytes = e.name();
421 let local = local_name(name_bytes.as_ref());
422 if !in_entry {
423 if local == b"entry" {
424 in_entry = true;
425 saw_entry = true;
426 depth = 0;
427 }
428 buf.clear();
429 continue;
430 }
431 depth += 1;
432 if depth == 1 {
434 match local {
435 b"title" => target = Some(Target::Title),
436 b"summary" => target = Some(Target::Summary),
437 b"published" => target = Some(Target::Published),
438 b"updated" => target = Some(Target::Updated),
439 b"doi" => target = Some(Target::Doi),
443 b"journal_ref" => target = Some(Target::JournalRef),
444 b"author" => {
445 in_author = true;
446 authors.push(String::new());
447 }
448 _ => {}
449 }
450 } else if depth == 2 && in_author && local == b"name" {
451 target = Some(Target::AuthorName);
452 }
453 buf.clear();
454 }
455 Ok(Event::Empty(e)) => {
456 let name_bytes = e.name();
457 let local = local_name(name_bytes.as_ref());
458 if in_entry && depth == 0 && local == b"category" {
459 for attr in e.attributes().flatten() {
461 if attr.key.as_ref() == b"term" {
462 if let Ok(v) = attr.normalized_value(quick_xml::XmlVersion::Explicit1_0)
468 {
469 categories.push(v.into_owned());
470 }
471 }
472 }
473 }
474 buf.clear();
475 }
476 Ok(Event::Text(t)) => {
477 if let Some(tg) = target {
478 if let Some(s) = t.decode().ok().and_then(|raw| {
483 quick_xml::escape::unescape(&raw)
484 .ok()
485 .map(|c| c.into_owned())
486 }) {
487 match tg {
488 Target::Title => title.get_or_insert_with(String::new).push_str(&s),
489 Target::Summary => {
490 abstract_.get_or_insert_with(String::new).push_str(&s)
491 }
492 Target::Published => {
493 published.get_or_insert_with(String::new).push_str(&s)
494 }
495 Target::Updated => updated.get_or_insert_with(String::new).push_str(&s),
496 Target::Doi => doi.get_or_insert_with(String::new).push_str(&s),
497 Target::JournalRef => {
498 journal_ref.get_or_insert_with(String::new).push_str(&s)
499 }
500 Target::AuthorName => {
501 if let Some(last) = authors.last_mut() {
502 last.push_str(&s);
503 }
504 }
505 }
506 }
507 }
508 buf.clear();
509 }
510 Ok(Event::End(e)) => {
511 if !in_entry {
512 buf.clear();
513 continue;
514 }
515 let name_bytes = e.name();
516 let local = local_name(name_bytes.as_ref());
517 if depth == 0 && local == b"entry" {
518 break;
522 }
523 depth -= 1;
524 if depth == 0 {
525 if local == b"author" {
526 in_author = false;
527 if let Some(last) = authors.last() {
529 if last.is_empty() {
530 authors.pop();
531 }
532 }
533 }
534 target = None;
535 } else if depth == 1 && in_author && local == b"name" {
536 target = None;
537 }
538 buf.clear();
539 }
540 Ok(Event::Eof) => break,
541 Err(e) => {
542 return Err(FetchError::SourceSchema {
543 hint: format!("arxiv Atom XML parse error: {e}"),
544 });
545 }
546 _ => {
548 buf.clear();
549 }
550 }
551 }
552
553 if !saw_entry {
554 return Err(FetchError::NotFound {
559 hint: "arxiv Atom feed had no <entry> element (unknown id?)".into(),
560 });
561 }
562
563 let mut obj = serde_json::Map::new();
566 if let Some(t) = title {
567 let trimmed = t.trim().to_string();
568 if !trimmed.is_empty() {
569 obj.insert("title".into(), Value::String(trimmed));
570 }
571 }
572 if let Some(a) = abstract_ {
573 let trimmed = a.trim().to_string();
574 if !trimmed.is_empty() {
575 obj.insert("abstract".into(), Value::String(trimmed));
576 }
577 }
578 if !authors.is_empty() {
579 obj.insert(
580 "authors".into(),
581 Value::Array(authors.into_iter().map(Value::String).collect()),
582 );
583 }
584 if let Some(p) = published {
585 let trimmed = p.trim().to_string();
586 if !trimmed.is_empty() {
587 obj.insert("published".into(), Value::String(trimmed));
588 }
589 }
590 if let Some(u) = updated {
591 let trimmed = u.trim().to_string();
592 if !trimmed.is_empty() {
593 obj.insert("updated".into(), Value::String(trimmed));
594 }
595 }
596 if let Some(d) = doi {
607 let trimmed = d.trim().to_string();
608 if !trimmed.is_empty() {
609 obj.insert("doi".into(), Value::String(trimmed));
610 }
611 }
612 if let Some(j) = journal_ref {
613 let trimmed = j.trim().to_string();
614 if !trimmed.is_empty() {
615 obj.insert("journal_ref".into(), Value::String(trimmed));
616 }
617 }
618 if !categories.is_empty() {
619 obj.insert(
620 "categories".into(),
621 Value::Array(categories.into_iter().map(Value::String).collect()),
622 );
623 }
624 Ok(json!(obj))
625}
626
627fn local_name(qname: &[u8]) -> &[u8] {
634 match qname.iter().rposition(|&b| b == b':') {
635 Some(idx) => &qname[idx + 1..],
636 None => qname,
637 }
638}
639
640#[cfg(test)]
645#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
646mod tests {
647 use super::*;
648
649 use std::sync::Arc;
650
651 use camino::Utf8PathBuf;
652 use tempfile::TempDir;
653 use wiremock::matchers::{method, path};
654 use wiremock::{Mock, MockServer, ResponseTemplate};
655
656 use crate::http::{HttpClient, HttpError};
657 use crate::provenance::{LogRow, ProvenanceLog};
658 use crate::rate_limiter::RateLimiter;
659 use crate::source::FetchContext;
660 use crate::{ArxivId, CapabilityProfile, Doi, RateLimits, Ref};
661
662 const TEST_SESSION_ID: &str = "01J0000000000000000000TEST";
663
664 fn build_test_context(wiremock_host: &str) -> (TempDir, FetchContext) {
667 let td = TempDir::new().expect("tempdir");
668 let log_dir =
669 Utf8PathBuf::try_from(td.path().to_path_buf()).expect("temp dir path must be UTF-8");
670 let log_path = log_dir.join("test.jsonl");
671
672 let http = Arc::new(HttpClient::new_for_tests_allow_http("arxiv", wiremock_host));
673 let rate_limiter = Arc::new(RateLimiter::new(RateLimits::HARD_CODED));
674 let session_id = TEST_SESSION_ID.to_string();
675 let log = Arc::new(
676 ProvenanceLog::open(log_path, session_id.clone()).expect("provenance log opens"),
677 );
678
679 (
680 td,
681 FetchContext {
682 http,
683 rate_limiter,
684 log,
685 session_id,
686 cache_root: None,
687 },
688 )
689 }
690
691 fn read_rows(path: &camino::Utf8Path) -> Vec<LogRow> {
692 let raw = std::fs::read_to_string(path).expect("read log");
693 raw.lines()
694 .filter(|l| !l.is_empty())
695 .map(|l| serde_json::from_str::<LogRow>(l).expect("valid LogRow"))
696 .collect()
697 }
698
699 fn profile() -> CapabilityProfile {
700 CapabilityProfile::for_tests()
701 }
702
703 #[test]
708 fn arxiv_can_serve_returns_true_for_arxiv() {
709 let s = ArxivSource::new();
710 let id = ArxivId::parse("2401.12345").expect("valid id");
711 let r = Ref::Arxiv(id);
712 assert!(s.can_serve(&profile(), &r));
713 }
714
715 #[test]
716 fn production_metadata_url_uses_export_host_pdf_uses_arxiv() {
717 let s = ArxivSource::new();
721 let id = ArxivId::parse("1706.03762").expect("valid id");
722 let meta = s.metadata_url(&id).expect("meta url");
723 assert_eq!(meta.host_str(), Some("export.arxiv.org"));
724 assert_eq!(meta.path(), "/api/query");
725 let pdf = s.pdf_url(&id).expect("pdf url");
726 assert_eq!(pdf.host_str(), Some("arxiv.org"));
727 }
728
729 #[test]
730 fn with_base_shares_one_origin_for_both_legs() {
731 let s = ArxivSource::with_base("http://127.0.0.1:9999".parse().expect("url"));
734 let id = ArxivId::parse("2401.12345").expect("valid id");
735 assert_eq!(
736 s.metadata_url(&id).expect("meta").host_str(),
737 s.pdf_url(&id).expect("pdf").host_str()
738 );
739 }
740
741 #[test]
742 fn arxiv_can_serve_returns_false_for_doi() {
743 let s = ArxivSource::new();
744 let r = Ref::Doi(Doi("10.1234/example".to_string()));
745 assert!(!s.can_serve(&profile(), &r));
746 }
747
748 #[tokio::test]
753 async fn arxiv_fetch_new_style_id_returns_pdf_bytes() {
754 let server = MockServer::start().await;
755 let body = b"%PDF-1.7\n%fixture\n".to_vec();
756 Mock::given(method("GET"))
757 .and(path("/pdf/2401.12345.pdf"))
758 .respond_with(ResponseTemplate::new(200).set_body_bytes(body.clone()))
759 .mount(&server)
760 .await;
761
762 let host = server
763 .uri()
764 .parse::<Url>()
765 .unwrap()
766 .host_str()
767 .unwrap()
768 .to_string();
769 let (_td, ctx) = build_test_context(&host);
770 let s = ArxivSource::with_base(server.uri().parse().unwrap());
771
772 let id = ArxivId::parse("2401.12345").unwrap();
773 let r = Ref::Arxiv(id);
774 let res = s.fetch(&r, &profile(), &ctx).await.expect("fetch ok");
775
776 assert_eq!(res.source, "arxiv");
777 assert_eq!(res.license, "arxiv-default");
778 let bytes = res.pdf_bytes.expect("pdf bytes set");
779 assert!(
780 bytes.starts_with(b"%PDF-"),
781 "expected PDF magic prefix, got {:?}",
782 &bytes[..bytes.len().min(8)]
783 );
784 assert_eq!(&bytes[..], &body[..]);
785 }
786
787 #[tokio::test]
788 async fn arxiv_fetch_old_style_id_returns_pdf_bytes() {
789 let server = MockServer::start().await;
793 let body = b"%PDF-1.4\n%old-style fixture\n".to_vec();
794 Mock::given(method("GET"))
795 .and(path("/pdf/cond-mat/9501001.pdf"))
796 .respond_with(ResponseTemplate::new(200).set_body_bytes(body.clone()))
797 .mount(&server)
798 .await;
799
800 let host = server
801 .uri()
802 .parse::<Url>()
803 .unwrap()
804 .host_str()
805 .unwrap()
806 .to_string();
807 let (_td, ctx) = build_test_context(&host);
808 let s = ArxivSource::with_base(server.uri().parse().unwrap());
809
810 let id = ArxivId::parse("cond-mat/9501001").expect("old-style id");
811 let r = Ref::Arxiv(id);
812 let res = s.fetch(&r, &profile(), &ctx).await.expect("fetch ok");
813
814 let bytes = res.pdf_bytes.expect("pdf bytes set");
815 assert!(bytes.starts_with(b"%PDF-"));
816 assert_eq!(&bytes[..], &body[..]);
817 }
818
819 #[tokio::test]
824 async fn arxiv_fetch_with_doi_ref_errors_not_eligible() {
825 let server = MockServer::start().await;
826 let host = server
827 .uri()
828 .parse::<Url>()
829 .unwrap()
830 .host_str()
831 .unwrap()
832 .to_string();
833 let (_td, ctx) = build_test_context(&host);
834 let s = ArxivSource::with_base(server.uri().parse().unwrap());
835
836 let r = Ref::Doi(Doi("10.1234/example".to_string()));
837 let err = s
838 .fetch(&r, &profile(), &ctx)
839 .await
840 .expect_err("doi ref must not be eligible");
841 match err {
842 FetchError::NotEligible { source_key } => {
843 assert_eq!(source_key, "arxiv");
844 }
845 other => panic!("expected NotEligible, got {:?}", other),
846 }
847 }
848
849 #[tokio::test]
850 async fn arxiv_fetch_writes_log_row_with_arxiv_default_license() {
851 let server = MockServer::start().await;
852 let body = b"%PDF-1.7\n%log-row fixture\n".to_vec();
853 Mock::given(method("GET"))
854 .and(path("/pdf/2401.12345.pdf"))
855 .respond_with(ResponseTemplate::new(200).set_body_bytes(body.clone()))
856 .mount(&server)
857 .await;
858 let host = server
859 .uri()
860 .parse::<Url>()
861 .unwrap()
862 .host_str()
863 .unwrap()
864 .to_string();
865 let (_td, ctx) = build_test_context(&host);
866 let log_path = ctx.log.path().to_path_buf();
868 let s = ArxivSource::with_base(server.uri().parse().unwrap());
869
870 let id = ArxivId::parse("2401.12345").unwrap();
871 let r = Ref::Arxiv(id);
872 let _ = s.fetch(&r, &profile(), &ctx).await.expect("fetch ok");
873
874 let rows = read_rows(&log_path);
875 assert_eq!(rows.len(), 1, "exactly one fetch row expected");
876 let row = &rows[0];
877 assert_eq!(row.source.as_deref(), Some("arxiv"));
878 assert_eq!(row.ref_.as_deref(), Some("2401.12345"));
879 assert_eq!(row.license.as_deref(), Some("arxiv-default"));
880 assert_eq!(row.size_bytes, Some(body.len() as u64));
881 assert!(row.error_code.is_none());
882 }
883
884 #[tokio::test]
885 async fn arxiv_non_pdf_body_rejected() {
886 let server = MockServer::start().await;
890 Mock::given(method("GET"))
891 .and(path("/pdf/2401.12345.pdf"))
892 .respond_with(
893 ResponseTemplate::new(200).set_body_bytes(b"<html>not a pdf</html>".to_vec()),
894 )
895 .mount(&server)
896 .await;
897 let host = server
898 .uri()
899 .parse::<Url>()
900 .unwrap()
901 .host_str()
902 .unwrap()
903 .to_string();
904 let (_td, ctx) = build_test_context(&host);
905 let s = ArxivSource::with_base(server.uri().parse().unwrap());
906
907 let id = ArxivId::parse("2401.12345").unwrap();
908 let r = Ref::Arxiv(id);
909 let err = s
910 .fetch(&r, &profile(), &ctx)
911 .await
912 .expect_err("non-pdf body must be rejected");
913 match err {
914 FetchError::Http(HttpError::NotAPdf { got }) => {
915 assert_eq!(&got, b"<html");
916 }
917 other => panic!("expected FetchError::Http(NotAPdf), got {:?}", other),
918 }
919 }
920
921 #[tokio::test]
922 async fn arxiv_404_maps_to_http_error() {
923 let server = MockServer::start().await;
924 Mock::given(method("GET"))
925 .and(path("/pdf/2401.99999.pdf"))
926 .respond_with(ResponseTemplate::new(404))
927 .mount(&server)
928 .await;
929 let host = server
930 .uri()
931 .parse::<Url>()
932 .unwrap()
933 .host_str()
934 .unwrap()
935 .to_string();
936 let (_td, ctx) = build_test_context(&host);
937 let s = ArxivSource::with_base(server.uri().parse().unwrap());
938
939 let id = ArxivId::parse("2401.99999").unwrap();
940 let r = Ref::Arxiv(id);
941 let err = s
942 .fetch(&r, &profile(), &ctx)
943 .await
944 .expect_err("404 must surface");
945 match err {
946 FetchError::Http(HttpError::HttpStatus { status, .. }) => {
947 assert_eq!(status, 404);
948 }
949 other => panic!("expected FetchError::Http(HttpStatus), got {:?}", other),
950 }
951 }
952
953 const SAMPLE_ATOM_FEED: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
960<feed xmlns="http://www.w3.org/2005/Atom">
961 <entry>
962 <id>http://arxiv.org/abs/2401.12345v1</id>
963 <updated>2024-02-01T00:00:00Z</updated>
964 <published>2024-01-15T00:00:00Z</published>
965 <title>Example arXiv Paper Title</title>
966 <summary>This is an example abstract.</summary>
967 <author>
968 <name>Jane Doe</name>
969 </author>
970 <author>
971 <name>John Roe</name>
972 </author>
973 <category term="cs.LG" scheme="http://arxiv.org/schemas/atom"/>
974 <category term="stat.ML" scheme="http://arxiv.org/schemas/atom"/>
975 </entry>
976</feed>"#;
977
978 #[test]
979 fn parse_atom_feed_extracts_all_fields() {
980 let v = parse_atom_feed(SAMPLE_ATOM_FEED.as_bytes()).expect("Atom parses");
981 assert_eq!(v["title"], serde_json::json!("Example arXiv Paper Title"));
982 assert_eq!(
983 v["abstract"],
984 serde_json::json!("This is an example abstract.")
985 );
986 assert_eq!(v["authors"], serde_json::json!(["Jane Doe", "John Roe"]));
987 assert_eq!(v["published"], serde_json::json!("2024-01-15T00:00:00Z"));
988 assert_eq!(v["updated"], serde_json::json!("2024-02-01T00:00:00Z"));
989 assert_eq!(v["categories"], serde_json::json!(["cs.LG", "stat.ML"]));
990 }
991
992 #[test]
993 fn parse_atom_feed_empty_feed_is_not_found() {
994 let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
998<feed xmlns="http://www.w3.org/2005/Atom"></feed>"#;
999 let err = parse_atom_feed(xml.as_bytes()).expect_err("empty feed must error");
1000 match err {
1001 FetchError::NotFound { hint } => {
1002 assert!(
1003 hint.contains("entry"),
1004 "expected mention of <entry>; got {hint}"
1005 );
1006 }
1007 other => panic!("expected NotFound, got {other:?}"),
1008 }
1009 }
1010
1011 #[test]
1012 fn parse_atom_feed_captures_published_doi_and_journal_ref() {
1013 let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1018<feed xmlns="http://www.w3.org/2005/Atom" xmlns:arxiv="http://arxiv.org/schemas/atom">
1019 <entry>
1020 <id>http://arxiv.org/abs/2101.54321v2</id>
1021 <title>Published Later</title>
1022 <arxiv:doi>10.1103/PhysRevLett.130.200601</arxiv:doi>
1023 <arxiv:journal_ref>Phys. Rev. Lett. 130, 200601 (2023)</arxiv:journal_ref>
1024 </entry>
1025</feed>"#;
1026 let v = parse_atom_feed(xml.as_bytes()).expect("parses");
1027 assert_eq!(
1028 v["doi"],
1029 serde_json::json!("10.1103/PhysRevLett.130.200601")
1030 );
1031 assert_eq!(
1032 v["journal_ref"],
1033 serde_json::json!("Phys. Rev. Lett. 130, 200601 (2023)")
1034 );
1035 }
1036
1037 #[test]
1038 fn parse_atom_feed_omits_doi_when_absent() {
1039 let v = parse_atom_feed(SAMPLE_ATOM_FEED.as_bytes()).expect("parses");
1042 let obj = v.as_object().expect("object");
1043 assert!(!obj.contains_key("doi"), "doi must be omitted: {obj:?}");
1044 assert!(
1045 !obj.contains_key("journal_ref"),
1046 "journal_ref must be omitted: {obj:?}"
1047 );
1048 }
1049
1050 #[test]
1051 fn parse_atom_feed_journal_ref_only_without_doi() {
1052 let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1055<feed xmlns="http://www.w3.org/2005/Atom" xmlns:arxiv="http://arxiv.org/schemas/atom">
1056 <entry>
1057 <id>http://arxiv.org/abs/2101.00001v1</id>
1058 <title>Journal Ref Only</title>
1059 <arxiv:journal_ref>J. Stat. Mech. (2021) 013203</arxiv:journal_ref>
1060 </entry>
1061</feed>"#;
1062 let v = parse_atom_feed(xml.as_bytes()).expect("parses");
1063 let obj = v.as_object().expect("object");
1064 assert!(!obj.contains_key("doi"), "doi must be omitted: {obj:?}");
1065 assert_eq!(
1066 obj.get("journal_ref").and_then(Value::as_str),
1067 Some("J. Stat. Mech. (2021) 013203")
1068 );
1069 }
1070
1071 #[test]
1072 fn parse_atom_feed_whitespace_doi_is_omitted() {
1073 let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1076<feed xmlns="http://www.w3.org/2005/Atom" xmlns:arxiv="http://arxiv.org/schemas/atom">
1077 <entry>
1078 <id>http://arxiv.org/abs/2101.00002v1</id>
1079 <title>Blank DOI</title>
1080 <arxiv:doi> </arxiv:doi>
1081 </entry>
1082</feed>"#;
1083 let v = parse_atom_feed(xml.as_bytes()).expect("parses");
1084 assert!(
1085 !v.as_object().expect("object").contains_key("doi"),
1086 "whitespace-only doi must be omitted: {v:?}"
1087 );
1088 }
1089
1090 #[test]
1091 fn parse_atom_feed_omits_missing_optional_fields() {
1092 let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1096<feed xmlns="http://www.w3.org/2005/Atom">
1097 <entry>
1098 <id>http://arxiv.org/abs/2401.00001v1</id>
1099 <title>Minimal Entry</title>
1100 </entry>
1101</feed>"#;
1102 let v = parse_atom_feed(xml.as_bytes()).expect("parses");
1103 let obj = v.as_object().expect("object");
1104 assert_eq!(
1105 obj.get("title").and_then(Value::as_str),
1106 Some("Minimal Entry")
1107 );
1108 assert!(
1109 !obj.contains_key("abstract"),
1110 "abstract should be omitted: {obj:?}"
1111 );
1112 assert!(
1113 !obj.contains_key("authors"),
1114 "authors should be omitted: {obj:?}"
1115 );
1116 assert!(
1117 !obj.contains_key("categories"),
1118 "categories should be omitted: {obj:?}"
1119 );
1120 }
1121
1122 #[tokio::test]
1127 async fn arxiv_fetch_metadata_only_returns_atom_metadata() {
1128 let server = MockServer::start().await;
1129 Mock::given(method("GET"))
1130 .and(path("/api/query"))
1131 .respond_with(ResponseTemplate::new(200).set_body_string(SAMPLE_ATOM_FEED))
1132 .mount(&server)
1133 .await;
1134 let host = server
1135 .uri()
1136 .parse::<Url>()
1137 .unwrap()
1138 .host_str()
1139 .unwrap()
1140 .to_string();
1141 let (_td, ctx) = build_test_context(&host);
1142 let s = ArxivSource::with_base(server.uri().parse().unwrap());
1143 let id = ArxivId::parse("2401.12345").unwrap();
1144
1145 let meta = s
1146 .fetch_metadata_only(&id, &ctx)
1147 .await
1148 .expect("metadata_only ok");
1149 assert_eq!(
1150 meta["title"],
1151 serde_json::json!("Example arXiv Paper Title")
1152 );
1153 assert_eq!(meta["authors"], serde_json::json!(["Jane Doe", "John Roe"]));
1154 }
1155
1156 #[tokio::test]
1157 async fn arxiv_fetch_populates_metadata_json_when_atom_endpoint_mocked() {
1158 let server = MockServer::start().await;
1161 Mock::given(method("GET"))
1162 .and(path("/api/query"))
1163 .respond_with(ResponseTemplate::new(200).set_body_string(SAMPLE_ATOM_FEED))
1164 .mount(&server)
1165 .await;
1166 Mock::given(method("GET"))
1167 .and(path("/pdf/2401.12345.pdf"))
1168 .respond_with(ResponseTemplate::new(200).set_body_bytes(b"%PDF-1.7\n%fix\n".to_vec()))
1169 .mount(&server)
1170 .await;
1171 let host = server
1172 .uri()
1173 .parse::<Url>()
1174 .unwrap()
1175 .host_str()
1176 .unwrap()
1177 .to_string();
1178 let (_td, ctx) = build_test_context(&host);
1179 let s = ArxivSource::with_base(server.uri().parse().unwrap());
1180 let id = ArxivId::parse("2401.12345").unwrap();
1181 let r = Ref::Arxiv(id);
1182
1183 let res = s.fetch(&r, &profile(), &ctx).await.expect("fetch ok");
1184 let meta = res.metadata_json.expect("metadata_json populated");
1185 assert_eq!(
1186 meta["title"],
1187 serde_json::json!("Example arXiv Paper Title")
1188 );
1189 }
1190
1191 #[tokio::test]
1192 async fn arxiv_fetch_atom_failure_falls_back_to_pdf_only() {
1193 let server = MockServer::start().await;
1197 Mock::given(method("GET"))
1198 .and(path("/pdf/2401.12345.pdf"))
1199 .respond_with(ResponseTemplate::new(200).set_body_bytes(b"%PDF-1.7\nx".to_vec()))
1200 .mount(&server)
1201 .await;
1202 let host = server
1203 .uri()
1204 .parse::<Url>()
1205 .unwrap()
1206 .host_str()
1207 .unwrap()
1208 .to_string();
1209 let (_td, ctx) = build_test_context(&host);
1210 let s = ArxivSource::with_base(server.uri().parse().unwrap());
1211 let id = ArxivId::parse("2401.12345").unwrap();
1212 let r = Ref::Arxiv(id);
1213
1214 let res = s.fetch(&r, &profile(), &ctx).await.expect("fetch ok");
1215 assert!(res.metadata_json.is_none());
1216 assert!(res.pdf_bytes.is_some());
1217 }
1218}