Skip to main content

doiget_core/sources/
arxiv.rs

1//! arXiv source — arXiv id → PDF + Atom-feed metadata.
2//!
3//! Spec: `docs/SOURCES.md` §4 arXiv. No auth. The API's Terms of Use cap
4//! requests at **one every three seconds, single connection** — stricter
5//! than the global 5/sec + 200 ms backoff, which this comment previously
6//! claimed "comfortably respects" it. It did not: that was 15x the rate and
7//! 5x the concurrency (#493, ADR-0045).
8//!
9//! The limit now comes from [`crate::SOURCE_RATE_OVERRIDES`], and because
10//! it caps REQUESTS rather than attempts, the PDF leg below calls
11//! [`crate::rate_limiter::RateLimiter::pace`] — one attempt issues two
12//! requests, and only the first was paced by the permit.
13//!
14//! # Fetch flow (full)
15//!
16//! 1. `can_serve` returns `true` only for `Ref::Arxiv(_)`; `Ref::Doi(_)` is
17//!    rejected up front.
18//! 2. `fetch` acquires a permit from the shared `RateLimiter`, then
19//!    best-effort fetches the Atom feed (`<base>/api/query?id_list=<id>`)
20//!    and parses it into a JSON metadata object via the private
21//!    `parse_atom_feed` helper. Atom failures degrade gracefully
22//!    (`metadata_json = None` + `tracing::warn!`) — the existing 1.0
23//!    PDF-leg semantics are preserved.
24//! 3. The PDF URL `<base>/pdf/<id>.pdf` is fetched via
25//!    [`crate::http::HttpClient::fetch_pdf`] which enforces the magic-byte
26//!    (`%PDF-`) check per `docs/SECURITY.md` §1.2.
27//! 4. ONE `LogEvent::Fetch` row is appended for the PDF leg. The Atom leg
28//!    does NOT emit its own row — the source-level audit unit is
29//!    "one fetch attempt = one row" and the Atom call is a supporting
30//!    leg of the same attempt.
31//!
32//! # Metadata-only path
33//!
34//! [`ArxivSource::fetch_metadata_only`] performs ONLY the Atom feed fetch
35//! and is the entry point for the `metadata_only` orchestrator
36//! (`crate::orchestrator::metadata_only`). It MUST NOT call
37//! [`crate::http::HttpClient::fetch_pdf`] — doing so would violate the
38//! `doiget_metadata_only` contract (`docs/MCP_TOOLS.md` §11). It emits
39//! one `LogEvent::Fetch` row under `Capability::Metadata` so the audit
40//! trail distinguishes metadata-only fetches from full fetches without
41//! breaking the schema (the `capability` field is the structured channel
42//! for this distinction; spec §3 documents it as one of `oa` / `metadata`
43//! / `tdm-*`).
44
45use 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
56/// Default base for the PDF endpoint. arXiv serves PDFs at
57/// `https://arxiv.org/pdf/<id>` (the trailing `.pdf` is optional but
58/// most reliable to include). PDFs may redirect to `cdn.arxiv.org` —
59/// the per-source allowlist in `crate::http::tier_1_allowlist()` covers
60/// this via the `*.arxiv.org` glob.
61const PDF_BASE: &str = "https://arxiv.org";
62
63/// Default base for the Atom metadata endpoint. arXiv serves the API at
64/// `https://export.arxiv.org/api/query` — a DIFFERENT host from the PDF
65/// endpoint. Hitting `arxiv.org/api/query` instead redirects and fails
66/// the metadata leg, so the two endpoints must use separate bases.
67/// `export.arxiv.org` is covered by the `*.arxiv.org` allowlist glob.
68const META_BASE: &str = "https://export.arxiv.org";
69
70/// arXiv [`Source`] impl. PDFs are served from `arxiv.org`; Atom metadata
71/// from `export.arxiv.org` (the `metadata_url` builder).
72#[derive(Clone, Debug)]
73pub struct ArxivSource {
74    /// PDF endpoint base (`arxiv.org` in production).
75    base: Url,
76    /// Atom metadata endpoint base (`export.arxiv.org` in production).
77    meta_base: Url,
78}
79
80impl ArxivSource {
81    /// Production constructor. PDFs from `arxiv.org`, Atom metadata from
82    /// `export.arxiv.org`.
83    pub fn new() -> Self {
84        // Both hard-coded constants are `'static` string literals known at
85        // compile time to be valid absolute URLs; the `expect`s can only
86        // fire if a constant regresses, which every `ArxivSource::new()`
87        // test exercises.
88        #[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    /// Construct with an arbitrary base URL.
96    ///
97    /// The orchestrator (`doiget-cli::commands::fetch`) uses this to honor
98    /// the `DOIGET_ARXIV_BASE` env var, which lets integration tests point
99    /// the source at a wiremock origin without resorting to compile-time
100    /// gates. Both the PDF and metadata legs share the one override base
101    /// (a single wiremock origin serves both paths). Production callers
102    /// use [`ArxivSource::new`].
103    pub fn with_base(base: Url) -> Self {
104        Self {
105            meta_base: base.clone(),
106            base,
107        }
108    }
109
110    /// Build the PDF URL for a given arXiv id. arXiv accepts both
111    /// `/pdf/<id>` and `/pdf/<id>.pdf`; we use the trailing-`.pdf` form to
112    /// make the URL self-describing.
113    ///
114    /// Old-style ids (`cond-mat/9501001`) contain a `/` in the id itself;
115    /// the resulting path `/pdf/cond-mat/9501001.pdf` is the form arXiv
116    /// expects. Because the base URL has no path beyond `/`, `Url::join`
117    /// resolves the absolute reference `/pdf/<id>.pdf` to exactly that
118    /// path for both new-style (`2401.12345`) and old-style
119    /// (`cond-mat/9501001`) ids. The `arxiv_fetch_old_style_id_*` test
120    /// pins this behavior.
121    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    /// Build the Atom-feed metadata URL for a given arXiv id.
129    ///
130    /// Production: `https://export.arxiv.org/api/query?id_list=<id>`. In
131    /// tests the base is the wiremock origin; the path is the same
132    /// (`/api/query?id_list=<id>`). The `export.arxiv.org` host is on the
133    /// `arxiv` redirect allowlist (per
134    /// `crate::http::tier_1_allowlist`) so the redirect closure does not
135    /// reject this leg.
136    ///
137    /// Old-style ids (`cond-mat/9501001`) contain a `/` which we
138    /// URL-encode via `query_pairs_mut().append_pair` so the wire form is
139    /// `id_list=cond-mat%2F9501001`.
140    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    /// Fetch ONLY the Atom-feed metadata for the given arXiv id. Does NOT
152    /// touch the PDF endpoint — this is the entry point for the
153    /// `metadata_only` orchestrator (`docs/MCP_TOOLS.md` §11).
154    ///
155    /// Emits a single `LogEvent::Fetch` row under `Capability::Metadata`
156    /// so the audit trail distinguishes metadata-only attempts from full
157    /// (PDF) fetches.
158    ///
159    /// # Errors
160    ///
161    /// - [`FetchError::Http`] on transport / status / size-cap failures.
162    /// - [`FetchError::SourceSchema`] if the response body is not
163    ///   well-formed Atom XML.
164    /// - [`FetchError::Log`] if the provenance row write fails
165    ///   (fail-closed per `docs/PROVENANCE_LOG.md` §5).
166    pub async fn fetch_metadata_only(
167        &self,
168        id: &ArxivId,
169        ctx: &FetchContext,
170    ) -> Result<Value, FetchError> {
171        // Same politeness gate as the full fetch path.
172        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        // ADR-0021 §1 canonical-digest under the "arxiv" resolver
179        // profile. version=None until a follow-up slice threads the
180        // Atom-feed-discovered version (`v2`, etc.) into this row.
181        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            // Distinguish metadata-only from full (PDF) fetches via the
188            // structured `capability` channel rather than mangling the
189            // `source` string — `docs/PROVENANCE_LOG.md` §3 lists
190            // `metadata` as a first-class capability value.
191            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        // Eligibility gate. The orchestrator is expected to call
228        // `can_serve` first, but a runtime check here gives a clean error
229        // path if it does not.
230        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        // Hold the rate-limiter permit for the duration of the HTTP
240        // fetch. Drop happens at end of scope after the log append below.
241        let _permit = ctx.rate_limiter.acquire(self.name()).await;
242
243        // ----- Atom-feed metadata leg (best-effort) -------------------
244        //
245        // Fetched BEFORE the PDF so that `FetchResult::metadata_json` is
246        // populated for a single-pass fetch (the orchestrator does not
247        // need to re-issue a metadata-only call). Failures here degrade
248        // gracefully: we set `metadata_json = None`, emit a tracing
249        // warning, and proceed with the PDF leg unchanged. NO log row
250        // is emitted from this leg — the source-level audit unit is
251        // "one fetch attempt = one row" and the row comes from the PDF
252        // leg below. This is what preserves the 4-row sequence asserted
253        // by `crates/doiget-cli/tests/fetch_arxiv_e2e.rs`.
254        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        // ----- PDF leg -------------------------------------------------
287        //
288        // #493: arXiv's terms cap REQUESTS, not attempts, and this is the
289        // second request of this attempt -- the Atom leg above was the
290        // first. The `acquire` permit paced that one; without this the two
291        // went out back to back, so even a perfectly serialised caller
292        // broke the published interval.
293        ctx.rate_limiter.pace(self.name()).await;
294
295        let url = self.pdf_url(id)?;
296
297        // `fetch_pdf` enforces the magic-byte check (`%PDF-`) per
298        // `docs/SECURITY.md` §1.2 — non-PDF response surfaces as
299        // `HttpError::NotAPdf`, which `From` converts to `FetchError::Http`.
300        let (body, final_url): (Bytes, Url) = ctx.http.fetch_pdf(self.name(), url).await?;
301
302        // One `event=fetch` row per attempt, per `docs/ARCHITECTURE.md` §6
303        // and `docs/PROVENANCE_LOG.md` §3. Per `docs/SECURITY.md` §1.8 a
304        // log write failure is fail-closed — the `?` aborts the fetch.
305        // ADR-0021 §1 canonical-digest: build under the "arxiv" resolver
306        // profile. version=None in Slice 4 — a follow-up may surface
307        // the `vN` discriminator from the Atom-feed `id` element.
308        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            // arXiv does not expose a per-item license string; the
318            // platform-wide license declaration lives at
319            // <https://info.arxiv.org/help/license/>. Phase 1 records
320            // `"arxiv-default"` so the value is informative without
321            // claiming a specific Creative Commons license.
322            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
337// ---------------------------------------------------------------------------
338// Atom-feed parser (B.1)
339// ---------------------------------------------------------------------------
340
341/// Parse the arXiv Atom-feed response body into a structured JSON
342/// metadata object.
343///
344/// Endpoint: `https://export.arxiv.org/api/query?id_list=<id>` (see
345/// arXiv API user manual §3.1). The response is an `<feed>` document
346/// containing one `<entry>` per requested id. We extract the fields
347/// listed in `docs/SOURCES.md` §4 arXiv (title, summary/abstract,
348/// authors, published, updated, categories) into the synthetic JSON
349/// shape:
350///
351/// ```jsonc
352/// {
353///   "title": "...",
354///   "abstract": "...",
355///   "authors": ["Family, Given", ...],
356///   "published": "YYYY-MM-DDTHH:MM:SSZ",  // RFC3339 UTC, passed through verbatim
357///   "updated":   "YYYY-MM-DDTHH:MM:SSZ",
358///   "categories": ["cs.LG", "stat.ML"],
359///   "doi": "10.1103/...",          // PUBLISHED (journal) DOI cross-ref, NOT this entry's id; omit-when-absent (#281 item 5)
360///   "journal_ref": "Phys. Rev. ..."  // omit-when-absent
361/// }
362/// ```
363///
364/// All fields are best-effort: any missing element is omitted from the
365/// JSON output (NOT serialized as `null`). The parser is a small
366/// `quick-xml` event walker — no DOM allocation. Only the FIRST `<entry>`
367/// element is consumed (we always query a single id).
368///
369/// # Errors
370///
371/// Returns [`FetchError::SourceSchema`] if the XML is malformed (parser
372/// reports a syntax error), or [`FetchError::NotFound`] if no `<entry>`
373/// element is present (arXiv returns HTTP 200 with an empty `<feed>` on an
374/// unknown id — an authoritative absence, not a parse error).
375pub(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    // Top-level state. `in_entry` tracks whether we are inside the first
381    // (and only) `<entry>` element; once we exit, we stop collecting.
382    let mut in_entry = false;
383    let mut saw_entry = false;
384    let mut depth = 0_i32; // depth WITHIN the entry; 0 = at <entry> root
385
386    // Accumulators. Per-author state is kept on a stack so a nested
387    // `<author><name>...</name></author>` populates the right slot.
388    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    // arXiv-namespaced elements (`<arxiv:doi>`, `<arxiv:journal_ref>`):
395    // present only when the submitter supplied a published DOI / journal
396    // reference. They are the canonical arXiv → published-DOI link source
397    // (#281 item 5), surfaced here so the metadata path carries them.
398    let mut doi: Option<String> = None;
399    let mut journal_ref: Option<String> = None;
400
401    // Current text-collection target — None when we are not inside a
402    // leaf element whose text we want.
403    #[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                // Depth==1 means a direct child of `<entry>`.
433                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                        // arXiv namespace; `local_name` strips the `arxiv:`
440                        // prefix, so these match `<arxiv:doi>` /
441                        // `<arxiv:journal_ref>`.
442                        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                    // <category term="cs.LG" scheme="..."/> — extract `term`.
460                    for attr in e.attributes().flatten() {
461                        if attr.key.as_ref() == b"term" {
462                            // quick-xml 0.40: `unescape_value()` is
463                            // deprecated in favour of `normalized_value()`
464                            // (attribute-value normalization resolves the
465                            // same character/entity references). arXiv's
466                            // Atom feed is XML 1.0.
467                            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                    // quick-xml 0.40 removed `BytesText::unescape`.
479                    // Reproduce the old behaviour: decode the bytes, then
480                    // unescape XML entities via `quick_xml::escape::unescape`.
481                    // Best-effort — skip the text on decode/unescape error.
482                    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                    // Done with the first entry — stop. We deliberately
519                    // ignore any subsequent entries since the orchestrator
520                    // always queries a single id.
521                    break;
522                }
523                depth -= 1;
524                if depth == 0 {
525                    if local == b"author" {
526                        in_author = false;
527                        // Drop empty author names (defensive).
528                        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            // CDATA / Comment / Decl / PI / DocType — ignored.
547            _ => {
548                buf.clear();
549            }
550        }
551    }
552
553    if !saw_entry {
554        // arXiv signals an unknown id with HTTP 200 + an empty `<feed>`
555        // (no `<entry>`), NOT a 404. Surface it as an authoritative
556        // absence so `doiget verify` classifies it `absent` (a dead
557        // reference) rather than a tolerable transport blip.
558        return Err(FetchError::NotFound {
559            hint: "arxiv Atom feed had no <entry> element (unknown id?)".into(),
560        });
561    }
562
563    // Build the JSON object, omitting empty optionals. `serde_json::Map`
564    // preserves insertion order so the output is stable.
565    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    // arXiv → published-DOI link (#281 item 5): omitted when the submitter
597    // did not supply a DOI / journal reference.
598    //
599    // HAZARD: this `doi` is the PUBLISHED (journal) DOI, NOT this arXiv
600    // record's own identifier. It must NOT be promoted to the reserved
601    // top-level `doi` of the store `Metadata` (STORE.md) — that field is the
602    // entry's own identity. `orchestrator::build_metadata_only_metadata`
603    // correctly forces an arXiv entry's `doi` to `None`; any future consumer
604    // mapping `metadata_json["doi"]` into `Metadata.doi` would write the
605    // wrong identity. Treat this strictly as a cross-reference.
606    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
627/// Strip an XML namespace prefix from a qualified name, returning the
628/// local-part bytes. `b"atom:entry"` -> `b"entry"`. Atom uses the default
629/// namespace so most names arrive unprefixed; this helper makes the
630/// parser robust to either form without depending on quick-xml's
631/// namespace resolver (which would require us to thread a
632/// `NsReader` and explicit prefix bindings through every event).
633fn 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// ---------------------------------------------------------------------------
641// Tests
642// ---------------------------------------------------------------------------
643
644#[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    /// Build a complete `FetchContext` against a wiremock host for use in
665    /// the source-level tests below.
666    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    // -----------------------------------------------------------------
704    // can_serve
705    // -----------------------------------------------------------------
706
707    #[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        // Regression guard: the Atom metadata leg MUST hit
718        // export.arxiv.org, while PDFs hit arxiv.org. Sending metadata to
719        // arxiv.org/api/query redirects and fails the resolve.
720        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        // The DOIGET_ARXIV_BASE override (wiremock) serves both paths from
732        // a single origin, so meta and PDF must resolve to the same host.
733        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    // -----------------------------------------------------------------
749    // fetch — happy paths
750    // -----------------------------------------------------------------
751
752    #[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        // Old-style id contains `/` (`cond-mat/9501001`); the URL must
790        // become `/pdf/cond-mat/9501001.pdf`. This pins the URL-builder
791        // behavior across both id shapes.
792        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    // -----------------------------------------------------------------
820    // fetch — error paths
821    // -----------------------------------------------------------------
822
823    #[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        // Capture the log path before the fetch call for later read-back.
867        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        // Wiremock returns 200 with a non-PDF body. The magic-byte check
887        // inside `HttpClient::fetch_pdf` rejects it as `HttpError::NotAPdf`,
888        // surfacing as `FetchError::Http`.
889        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    // -----------------------------------------------------------------
954    // parse_atom_feed (B.1) — unit tests
955    // -----------------------------------------------------------------
956
957    /// Synthetic Atom payload from the Slice 1 spec (deliverable B.3). Do
958    /// not hit real arXiv from tests.
959    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        // An unknown arXiv id yields HTTP 200 + an empty `<feed>`. That is
995        // an authoritative absence (→ `FetchError::NotFound` →
996        // `ErrorCode::NotFound` → verify `absent`), NOT a schema error.
997        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        // When the submitter supplied a published DOI / journal reference,
1014        // arXiv emits `<arxiv:doi>` / `<arxiv:journal_ref>` (the arXiv
1015        // namespace). They are the arXiv → published-DOI link (#281 item 5)
1016        // and must surface in the metadata JSON. Absent on most entries.
1017        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        // The common case: no published DOI yet → no `doi` / `journal_ref`
1040        // key (omitted, not null).
1041        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        // A real, common state: a journal_ref but no DOI. The `doi` key must
1053        // be absent while `journal_ref` is present (independent extraction).
1054        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        // A whitespace-only `<arxiv:doi>` trims to empty and must be omitted,
1074        // not emitted as `""` (exercises the trim→empty omit branch).
1075        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        // An entry with only an id and title — abstract/authors/categories
1093        // absent. The output must omit those keys entirely (not emit
1094        // `null`).
1095        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    // -----------------------------------------------------------------
1123    // fetch_metadata_only — orchestrator entry point
1124    // -----------------------------------------------------------------
1125
1126    #[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        // Full Source::fetch with BOTH Atom and PDF endpoints mocked must
1159        // populate `metadata_json` from the Atom response.
1160        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        // PDF endpoint mocked; Atom endpoint deliberately unmocked
1194        // (will 404). The fetch must still succeed with
1195        // `metadata_json = None` — the best-effort contract.
1196        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}