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 = e.name();
421                let local = local_name(name.as_ref());
422                if !in_entry {
423                    if local == "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                        "title" => target = Some(Target::Title),
436                        "summary" => target = Some(Target::Summary),
437                        "published" => target = Some(Target::Published),
438                        "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                        "doi" => target = Some(Target::Doi),
443                        "journal_ref" => target = Some(Target::JournalRef),
444                        "author" => {
445                            in_author = true;
446                            authors.push(String::new());
447                        }
448                        _ => {}
449                    }
450                } else if depth == 2 && in_author && local == "name" {
451                    target = Some(Target::AuthorName);
452                }
453                buf.clear();
454            }
455            Ok(Event::Empty(e)) => {
456                let name = e.name();
457                let local = local_name(name.as_ref());
458                if in_entry && depth == 0 && local == "category" {
459                    // <category term="cs.LG" scheme="..."/> — extract `term`.
460                    for attr in e.attributes().flatten() {
461                        if attr.key.as_ref() == "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`, and 0.42
479                    // made the reader UTF-8 throughout, so the decode step is
480                    // gone too -- `BytesText` derefs to `str`. Entities still
481                    // need `quick_xml::escape::unescape`, best-effort: skip
482                    // the text if it fails.
483                    if let Some(s) = quick_xml::escape::unescape(&t).ok().map(|c| c.into_owned()) {
484                        match tg {
485                            Target::Title => title.get_or_insert_with(String::new).push_str(&s),
486                            Target::Summary => {
487                                abstract_.get_or_insert_with(String::new).push_str(&s)
488                            }
489                            Target::Published => {
490                                published.get_or_insert_with(String::new).push_str(&s)
491                            }
492                            Target::Updated => updated.get_or_insert_with(String::new).push_str(&s),
493                            Target::Doi => doi.get_or_insert_with(String::new).push_str(&s),
494                            Target::JournalRef => {
495                                journal_ref.get_or_insert_with(String::new).push_str(&s)
496                            }
497                            Target::AuthorName => {
498                                if let Some(last) = authors.last_mut() {
499                                    last.push_str(&s);
500                                }
501                            }
502                        }
503                    }
504                }
505                buf.clear();
506            }
507            Ok(Event::End(e)) => {
508                if !in_entry {
509                    buf.clear();
510                    continue;
511                }
512                let name = e.name();
513                let local = local_name(name.as_ref());
514                if depth == 0 && local == "entry" {
515                    // Done with the first entry — stop. We deliberately
516                    // ignore any subsequent entries since the orchestrator
517                    // always queries a single id.
518                    break;
519                }
520                depth -= 1;
521                if depth == 0 {
522                    if local == "author" {
523                        in_author = false;
524                        // Drop empty author names (defensive).
525                        if let Some(last) = authors.last() {
526                            if last.is_empty() {
527                                authors.pop();
528                            }
529                        }
530                    }
531                    target = None;
532                } else if depth == 1 && in_author && local == "name" {
533                    target = None;
534                }
535                buf.clear();
536            }
537            Ok(Event::Eof) => break,
538            Err(e) => {
539                return Err(FetchError::SourceSchema {
540                    hint: format!("arxiv Atom XML parse error: {e}"),
541                });
542            }
543            // CDATA / Comment / Decl / PI / DocType — ignored.
544            _ => {
545                buf.clear();
546            }
547        }
548    }
549
550    if !saw_entry {
551        // arXiv signals an unknown id with HTTP 200 + an empty `<feed>`
552        // (no `<entry>`), NOT a 404. Surface it as an authoritative
553        // absence so `doiget verify` classifies it `absent` (a dead
554        // reference) rather than a tolerable transport blip.
555        return Err(FetchError::NotFound {
556            hint: "arxiv Atom feed had no <entry> element (unknown id?)".into(),
557        });
558    }
559
560    // Build the JSON object, omitting empty optionals. `serde_json::Map`
561    // preserves insertion order so the output is stable.
562    let mut obj = serde_json::Map::new();
563    if let Some(t) = title {
564        let trimmed = t.trim().to_string();
565        if !trimmed.is_empty() {
566            obj.insert("title".into(), Value::String(trimmed));
567        }
568    }
569    if let Some(a) = abstract_ {
570        let trimmed = a.trim().to_string();
571        if !trimmed.is_empty() {
572            obj.insert("abstract".into(), Value::String(trimmed));
573        }
574    }
575    if !authors.is_empty() {
576        obj.insert(
577            "authors".into(),
578            Value::Array(authors.into_iter().map(Value::String).collect()),
579        );
580    }
581    if let Some(p) = published {
582        let trimmed = p.trim().to_string();
583        if !trimmed.is_empty() {
584            obj.insert("published".into(), Value::String(trimmed));
585        }
586    }
587    if let Some(u) = updated {
588        let trimmed = u.trim().to_string();
589        if !trimmed.is_empty() {
590            obj.insert("updated".into(), Value::String(trimmed));
591        }
592    }
593    // arXiv → published-DOI link (#281 item 5): omitted when the submitter
594    // did not supply a DOI / journal reference.
595    //
596    // HAZARD: this `doi` is the PUBLISHED (journal) DOI, NOT this arXiv
597    // record's own identifier. It must NOT be promoted to the reserved
598    // top-level `doi` of the store `Metadata` (STORE.md) — that field is the
599    // entry's own identity. `orchestrator::build_metadata_only_metadata`
600    // correctly forces an arXiv entry's `doi` to `None`; any future consumer
601    // mapping `metadata_json["doi"]` into `Metadata.doi` would write the
602    // wrong identity. Treat this strictly as a cross-reference.
603    if let Some(d) = doi {
604        let trimmed = d.trim().to_string();
605        if !trimmed.is_empty() {
606            obj.insert("doi".into(), Value::String(trimmed));
607        }
608    }
609    if let Some(j) = journal_ref {
610        let trimmed = j.trim().to_string();
611        if !trimmed.is_empty() {
612            obj.insert("journal_ref".into(), Value::String(trimmed));
613        }
614    }
615    if !categories.is_empty() {
616        obj.insert(
617            "categories".into(),
618            Value::Array(categories.into_iter().map(Value::String).collect()),
619        );
620    }
621    Ok(json!(obj))
622}
623
624/// Strip an XML namespace prefix from a qualified name, returning the
625/// local part. `"atom:entry"` -> `"entry"`. Atom uses the default
626/// namespace so most names arrive unprefixed; this helper makes the
627/// parser robust to either form without depending on quick-xml's
628/// namespace resolver (which would require us to thread a
629/// `NsReader` and explicit prefix bindings through every event).
630fn local_name(qname: &str) -> &str {
631    match qname.rfind(':') {
632        Some(idx) => &qname[idx + 1..],
633        None => qname,
634    }
635}
636
637// ---------------------------------------------------------------------------
638// Tests
639// ---------------------------------------------------------------------------
640
641#[cfg(test)]
642#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
643mod tests {
644    use super::*;
645
646    use std::sync::Arc;
647
648    use camino::Utf8PathBuf;
649    use tempfile::TempDir;
650    use wiremock::matchers::{method, path};
651    use wiremock::{Mock, MockServer, ResponseTemplate};
652
653    use crate::http::{HttpClient, HttpError};
654    use crate::provenance::{LogRow, ProvenanceLog};
655    use crate::rate_limiter::RateLimiter;
656    use crate::source::FetchContext;
657    use crate::{ArxivId, CapabilityProfile, Doi, RateLimits, Ref};
658
659    const TEST_SESSION_ID: &str = "01J0000000000000000000TEST";
660
661    /// Build a complete `FetchContext` against a wiremock host for use in
662    /// the source-level tests below.
663    fn build_test_context(wiremock_host: &str) -> (TempDir, FetchContext) {
664        let td = TempDir::new().expect("tempdir");
665        let log_dir =
666            Utf8PathBuf::try_from(td.path().to_path_buf()).expect("temp dir path must be UTF-8");
667        let log_path = log_dir.join("test.jsonl");
668
669        let http = Arc::new(HttpClient::new_for_tests_allow_http("arxiv", wiremock_host));
670        let rate_limiter = Arc::new(RateLimiter::new(RateLimits::HARD_CODED));
671        let session_id = TEST_SESSION_ID.to_string();
672        let log = Arc::new(
673            ProvenanceLog::open(log_path, session_id.clone()).expect("provenance log opens"),
674        );
675
676        (
677            td,
678            FetchContext {
679                http,
680                rate_limiter,
681                log,
682                session_id,
683                cache_root: None,
684            },
685        )
686    }
687
688    fn read_rows(path: &camino::Utf8Path) -> Vec<LogRow> {
689        let raw = std::fs::read_to_string(path).expect("read log");
690        raw.lines()
691            .filter(|l| !l.is_empty())
692            .map(|l| serde_json::from_str::<LogRow>(l).expect("valid LogRow"))
693            .collect()
694    }
695
696    fn profile() -> CapabilityProfile {
697        CapabilityProfile::for_tests()
698    }
699
700    // -----------------------------------------------------------------
701    // can_serve
702    // -----------------------------------------------------------------
703
704    #[test]
705    fn arxiv_can_serve_returns_true_for_arxiv() {
706        let s = ArxivSource::new();
707        let id = ArxivId::parse("2401.12345").expect("valid id");
708        let r = Ref::Arxiv(id);
709        assert!(s.can_serve(&profile(), &r));
710    }
711
712    #[test]
713    fn production_metadata_url_uses_export_host_pdf_uses_arxiv() {
714        // Regression guard: the Atom metadata leg MUST hit
715        // export.arxiv.org, while PDFs hit arxiv.org. Sending metadata to
716        // arxiv.org/api/query redirects and fails the resolve.
717        let s = ArxivSource::new();
718        let id = ArxivId::parse("1706.03762").expect("valid id");
719        let meta = s.metadata_url(&id).expect("meta url");
720        assert_eq!(meta.host_str(), Some("export.arxiv.org"));
721        assert_eq!(meta.path(), "/api/query");
722        let pdf = s.pdf_url(&id).expect("pdf url");
723        assert_eq!(pdf.host_str(), Some("arxiv.org"));
724    }
725
726    #[test]
727    fn with_base_shares_one_origin_for_both_legs() {
728        // The DOIGET_ARXIV_BASE override (wiremock) serves both paths from
729        // a single origin, so meta and PDF must resolve to the same host.
730        let s = ArxivSource::with_base("http://127.0.0.1:9999".parse().expect("url"));
731        let id = ArxivId::parse("2401.12345").expect("valid id");
732        assert_eq!(
733            s.metadata_url(&id).expect("meta").host_str(),
734            s.pdf_url(&id).expect("pdf").host_str()
735        );
736    }
737
738    #[test]
739    fn arxiv_can_serve_returns_false_for_doi() {
740        let s = ArxivSource::new();
741        let r = Ref::Doi(Doi("10.1234/example".to_string()));
742        assert!(!s.can_serve(&profile(), &r));
743    }
744
745    // -----------------------------------------------------------------
746    // fetch — happy paths
747    // -----------------------------------------------------------------
748
749    #[tokio::test]
750    async fn arxiv_fetch_new_style_id_returns_pdf_bytes() {
751        let server = MockServer::start().await;
752        let body = b"%PDF-1.7\n%fixture\n".to_vec();
753        Mock::given(method("GET"))
754            .and(path("/pdf/2401.12345.pdf"))
755            .respond_with(ResponseTemplate::new(200).set_body_bytes(body.clone()))
756            .mount(&server)
757            .await;
758
759        let host = server
760            .uri()
761            .parse::<Url>()
762            .unwrap()
763            .host_str()
764            .unwrap()
765            .to_string();
766        let (_td, ctx) = build_test_context(&host);
767        let s = ArxivSource::with_base(server.uri().parse().unwrap());
768
769        let id = ArxivId::parse("2401.12345").unwrap();
770        let r = Ref::Arxiv(id);
771        let res = s.fetch(&r, &profile(), &ctx).await.expect("fetch ok");
772
773        assert_eq!(res.source, "arxiv");
774        assert_eq!(res.license, "arxiv-default");
775        let bytes = res.pdf_bytes.expect("pdf bytes set");
776        assert!(
777            bytes.starts_with(b"%PDF-"),
778            "expected PDF magic prefix, got {:?}",
779            &bytes[..bytes.len().min(8)]
780        );
781        assert_eq!(&bytes[..], &body[..]);
782    }
783
784    #[tokio::test]
785    async fn arxiv_fetch_old_style_id_returns_pdf_bytes() {
786        // Old-style id contains `/` (`cond-mat/9501001`); the URL must
787        // become `/pdf/cond-mat/9501001.pdf`. This pins the URL-builder
788        // behavior across both id shapes.
789        let server = MockServer::start().await;
790        let body = b"%PDF-1.4\n%old-style fixture\n".to_vec();
791        Mock::given(method("GET"))
792            .and(path("/pdf/cond-mat/9501001.pdf"))
793            .respond_with(ResponseTemplate::new(200).set_body_bytes(body.clone()))
794            .mount(&server)
795            .await;
796
797        let host = server
798            .uri()
799            .parse::<Url>()
800            .unwrap()
801            .host_str()
802            .unwrap()
803            .to_string();
804        let (_td, ctx) = build_test_context(&host);
805        let s = ArxivSource::with_base(server.uri().parse().unwrap());
806
807        let id = ArxivId::parse("cond-mat/9501001").expect("old-style id");
808        let r = Ref::Arxiv(id);
809        let res = s.fetch(&r, &profile(), &ctx).await.expect("fetch ok");
810
811        let bytes = res.pdf_bytes.expect("pdf bytes set");
812        assert!(bytes.starts_with(b"%PDF-"));
813        assert_eq!(&bytes[..], &body[..]);
814    }
815
816    // -----------------------------------------------------------------
817    // fetch — error paths
818    // -----------------------------------------------------------------
819
820    #[tokio::test]
821    async fn arxiv_fetch_with_doi_ref_errors_not_eligible() {
822        let server = MockServer::start().await;
823        let host = server
824            .uri()
825            .parse::<Url>()
826            .unwrap()
827            .host_str()
828            .unwrap()
829            .to_string();
830        let (_td, ctx) = build_test_context(&host);
831        let s = ArxivSource::with_base(server.uri().parse().unwrap());
832
833        let r = Ref::Doi(Doi("10.1234/example".to_string()));
834        let err = s
835            .fetch(&r, &profile(), &ctx)
836            .await
837            .expect_err("doi ref must not be eligible");
838        match err {
839            FetchError::NotEligible { source_key } => {
840                assert_eq!(source_key, "arxiv");
841            }
842            other => panic!("expected NotEligible, got {:?}", other),
843        }
844    }
845
846    #[tokio::test]
847    async fn arxiv_fetch_writes_log_row_with_arxiv_default_license() {
848        let server = MockServer::start().await;
849        let body = b"%PDF-1.7\n%log-row fixture\n".to_vec();
850        Mock::given(method("GET"))
851            .and(path("/pdf/2401.12345.pdf"))
852            .respond_with(ResponseTemplate::new(200).set_body_bytes(body.clone()))
853            .mount(&server)
854            .await;
855        let host = server
856            .uri()
857            .parse::<Url>()
858            .unwrap()
859            .host_str()
860            .unwrap()
861            .to_string();
862        let (_td, ctx) = build_test_context(&host);
863        // Capture the log path before the fetch call for later read-back.
864        let log_path = ctx.log.path().to_path_buf();
865        let s = ArxivSource::with_base(server.uri().parse().unwrap());
866
867        let id = ArxivId::parse("2401.12345").unwrap();
868        let r = Ref::Arxiv(id);
869        let _ = s.fetch(&r, &profile(), &ctx).await.expect("fetch ok");
870
871        let rows = read_rows(&log_path);
872        assert_eq!(rows.len(), 1, "exactly one fetch row expected");
873        let row = &rows[0];
874        assert_eq!(row.source.as_deref(), Some("arxiv"));
875        assert_eq!(row.ref_.as_deref(), Some("2401.12345"));
876        assert_eq!(row.license.as_deref(), Some("arxiv-default"));
877        assert_eq!(row.size_bytes, Some(body.len() as u64));
878        assert!(row.error_code.is_none());
879    }
880
881    #[tokio::test]
882    async fn arxiv_non_pdf_body_rejected() {
883        // Wiremock returns 200 with a non-PDF body. The magic-byte check
884        // inside `HttpClient::fetch_pdf` rejects it as `HttpError::NotAPdf`,
885        // surfacing as `FetchError::Http`.
886        let server = MockServer::start().await;
887        Mock::given(method("GET"))
888            .and(path("/pdf/2401.12345.pdf"))
889            .respond_with(
890                ResponseTemplate::new(200).set_body_bytes(b"<html>not a pdf</html>".to_vec()),
891            )
892            .mount(&server)
893            .await;
894        let host = server
895            .uri()
896            .parse::<Url>()
897            .unwrap()
898            .host_str()
899            .unwrap()
900            .to_string();
901        let (_td, ctx) = build_test_context(&host);
902        let s = ArxivSource::with_base(server.uri().parse().unwrap());
903
904        let id = ArxivId::parse("2401.12345").unwrap();
905        let r = Ref::Arxiv(id);
906        let err = s
907            .fetch(&r, &profile(), &ctx)
908            .await
909            .expect_err("non-pdf body must be rejected");
910        match err {
911            FetchError::Http(HttpError::NotAPdf { got }) => {
912                assert_eq!(&got, b"<html");
913            }
914            other => panic!("expected FetchError::Http(NotAPdf), got {:?}", other),
915        }
916    }
917
918    #[tokio::test]
919    async fn arxiv_404_maps_to_http_error() {
920        let server = MockServer::start().await;
921        Mock::given(method("GET"))
922            .and(path("/pdf/2401.99999.pdf"))
923            .respond_with(ResponseTemplate::new(404))
924            .mount(&server)
925            .await;
926        let host = server
927            .uri()
928            .parse::<Url>()
929            .unwrap()
930            .host_str()
931            .unwrap()
932            .to_string();
933        let (_td, ctx) = build_test_context(&host);
934        let s = ArxivSource::with_base(server.uri().parse().unwrap());
935
936        let id = ArxivId::parse("2401.99999").unwrap();
937        let r = Ref::Arxiv(id);
938        let err = s
939            .fetch(&r, &profile(), &ctx)
940            .await
941            .expect_err("404 must surface");
942        match err {
943            FetchError::Http(HttpError::HttpStatus { status, .. }) => {
944                assert_eq!(status, 404);
945            }
946            other => panic!("expected FetchError::Http(HttpStatus), got {:?}", other),
947        }
948    }
949
950    // -----------------------------------------------------------------
951    // parse_atom_feed (B.1) — unit tests
952    // -----------------------------------------------------------------
953
954    /// Synthetic Atom payload from the Slice 1 spec (deliverable B.3). Do
955    /// not hit real arXiv from tests.
956    const SAMPLE_ATOM_FEED: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
957<feed xmlns="http://www.w3.org/2005/Atom">
958  <entry>
959    <id>http://arxiv.org/abs/2401.12345v1</id>
960    <updated>2024-02-01T00:00:00Z</updated>
961    <published>2024-01-15T00:00:00Z</published>
962    <title>Example arXiv Paper Title</title>
963    <summary>This is an example abstract.</summary>
964    <author>
965      <name>Jane Doe</name>
966    </author>
967    <author>
968      <name>John Roe</name>
969    </author>
970    <category term="cs.LG" scheme="http://arxiv.org/schemas/atom"/>
971    <category term="stat.ML" scheme="http://arxiv.org/schemas/atom"/>
972  </entry>
973</feed>"#;
974
975    #[test]
976    fn parse_atom_feed_extracts_all_fields() {
977        let v = parse_atom_feed(SAMPLE_ATOM_FEED.as_bytes()).expect("Atom parses");
978        assert_eq!(v["title"], serde_json::json!("Example arXiv Paper Title"));
979        assert_eq!(
980            v["abstract"],
981            serde_json::json!("This is an example abstract.")
982        );
983        assert_eq!(v["authors"], serde_json::json!(["Jane Doe", "John Roe"]));
984        assert_eq!(v["published"], serde_json::json!("2024-01-15T00:00:00Z"));
985        assert_eq!(v["updated"], serde_json::json!("2024-02-01T00:00:00Z"));
986        assert_eq!(v["categories"], serde_json::json!(["cs.LG", "stat.ML"]));
987    }
988
989    #[test]
990    fn parse_atom_feed_empty_feed_is_not_found() {
991        // An unknown arXiv id yields HTTP 200 + an empty `<feed>`. That is
992        // an authoritative absence (→ `FetchError::NotFound` →
993        // `ErrorCode::NotFound` → verify `absent`), NOT a schema error.
994        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
995<feed xmlns="http://www.w3.org/2005/Atom"></feed>"#;
996        let err = parse_atom_feed(xml.as_bytes()).expect_err("empty feed must error");
997        match err {
998            FetchError::NotFound { hint } => {
999                assert!(
1000                    hint.contains("entry"),
1001                    "expected mention of <entry>; got {hint}"
1002                );
1003            }
1004            other => panic!("expected NotFound, got {other:?}"),
1005        }
1006    }
1007
1008    #[test]
1009    fn parse_atom_feed_captures_published_doi_and_journal_ref() {
1010        // When the submitter supplied a published DOI / journal reference,
1011        // arXiv emits `<arxiv:doi>` / `<arxiv:journal_ref>` (the arXiv
1012        // namespace). They are the arXiv → published-DOI link (#281 item 5)
1013        // and must surface in the metadata JSON. Absent on most entries.
1014        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1015<feed xmlns="http://www.w3.org/2005/Atom" xmlns:arxiv="http://arxiv.org/schemas/atom">
1016  <entry>
1017    <id>http://arxiv.org/abs/2101.54321v2</id>
1018    <title>Published Later</title>
1019    <arxiv:doi>10.1103/PhysRevLett.130.200601</arxiv:doi>
1020    <arxiv:journal_ref>Phys. Rev. Lett. 130, 200601 (2023)</arxiv:journal_ref>
1021  </entry>
1022</feed>"#;
1023        let v = parse_atom_feed(xml.as_bytes()).expect("parses");
1024        assert_eq!(
1025            v["doi"],
1026            serde_json::json!("10.1103/PhysRevLett.130.200601")
1027        );
1028        assert_eq!(
1029            v["journal_ref"],
1030            serde_json::json!("Phys. Rev. Lett. 130, 200601 (2023)")
1031        );
1032    }
1033
1034    #[test]
1035    fn parse_atom_feed_omits_doi_when_absent() {
1036        // The common case: no published DOI yet → no `doi` / `journal_ref`
1037        // key (omitted, not null).
1038        let v = parse_atom_feed(SAMPLE_ATOM_FEED.as_bytes()).expect("parses");
1039        let obj = v.as_object().expect("object");
1040        assert!(!obj.contains_key("doi"), "doi must be omitted: {obj:?}");
1041        assert!(
1042            !obj.contains_key("journal_ref"),
1043            "journal_ref must be omitted: {obj:?}"
1044        );
1045    }
1046
1047    #[test]
1048    fn parse_atom_feed_journal_ref_only_without_doi() {
1049        // A real, common state: a journal_ref but no DOI. The `doi` key must
1050        // be absent while `journal_ref` is present (independent extraction).
1051        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1052<feed xmlns="http://www.w3.org/2005/Atom" xmlns:arxiv="http://arxiv.org/schemas/atom">
1053  <entry>
1054    <id>http://arxiv.org/abs/2101.00001v1</id>
1055    <title>Journal Ref Only</title>
1056    <arxiv:journal_ref>J. Stat. Mech. (2021) 013203</arxiv:journal_ref>
1057  </entry>
1058</feed>"#;
1059        let v = parse_atom_feed(xml.as_bytes()).expect("parses");
1060        let obj = v.as_object().expect("object");
1061        assert!(!obj.contains_key("doi"), "doi must be omitted: {obj:?}");
1062        assert_eq!(
1063            obj.get("journal_ref").and_then(Value::as_str),
1064            Some("J. Stat. Mech. (2021) 013203")
1065        );
1066    }
1067
1068    #[test]
1069    fn parse_atom_feed_whitespace_doi_is_omitted() {
1070        // A whitespace-only `<arxiv:doi>` trims to empty and must be omitted,
1071        // not emitted as `""` (exercises the trim→empty omit branch).
1072        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1073<feed xmlns="http://www.w3.org/2005/Atom" xmlns:arxiv="http://arxiv.org/schemas/atom">
1074  <entry>
1075    <id>http://arxiv.org/abs/2101.00002v1</id>
1076    <title>Blank DOI</title>
1077    <arxiv:doi>   </arxiv:doi>
1078  </entry>
1079</feed>"#;
1080        let v = parse_atom_feed(xml.as_bytes()).expect("parses");
1081        assert!(
1082            !v.as_object().expect("object").contains_key("doi"),
1083            "whitespace-only doi must be omitted: {v:?}"
1084        );
1085    }
1086
1087    #[test]
1088    fn parse_atom_feed_omits_missing_optional_fields() {
1089        // An entry with only an id and title — abstract/authors/categories
1090        // absent. The output must omit those keys entirely (not emit
1091        // `null`).
1092        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1093<feed xmlns="http://www.w3.org/2005/Atom">
1094  <entry>
1095    <id>http://arxiv.org/abs/2401.00001v1</id>
1096    <title>Minimal Entry</title>
1097  </entry>
1098</feed>"#;
1099        let v = parse_atom_feed(xml.as_bytes()).expect("parses");
1100        let obj = v.as_object().expect("object");
1101        assert_eq!(
1102            obj.get("title").and_then(Value::as_str),
1103            Some("Minimal Entry")
1104        );
1105        assert!(
1106            !obj.contains_key("abstract"),
1107            "abstract should be omitted: {obj:?}"
1108        );
1109        assert!(
1110            !obj.contains_key("authors"),
1111            "authors should be omitted: {obj:?}"
1112        );
1113        assert!(
1114            !obj.contains_key("categories"),
1115            "categories should be omitted: {obj:?}"
1116        );
1117    }
1118
1119    // -----------------------------------------------------------------
1120    // fetch_metadata_only — orchestrator entry point
1121    // -----------------------------------------------------------------
1122
1123    #[tokio::test]
1124    async fn arxiv_fetch_metadata_only_returns_atom_metadata() {
1125        let server = MockServer::start().await;
1126        Mock::given(method("GET"))
1127            .and(path("/api/query"))
1128            .respond_with(ResponseTemplate::new(200).set_body_string(SAMPLE_ATOM_FEED))
1129            .mount(&server)
1130            .await;
1131        let host = server
1132            .uri()
1133            .parse::<Url>()
1134            .unwrap()
1135            .host_str()
1136            .unwrap()
1137            .to_string();
1138        let (_td, ctx) = build_test_context(&host);
1139        let s = ArxivSource::with_base(server.uri().parse().unwrap());
1140        let id = ArxivId::parse("2401.12345").unwrap();
1141
1142        let meta = s
1143            .fetch_metadata_only(&id, &ctx)
1144            .await
1145            .expect("metadata_only ok");
1146        assert_eq!(
1147            meta["title"],
1148            serde_json::json!("Example arXiv Paper Title")
1149        );
1150        assert_eq!(meta["authors"], serde_json::json!(["Jane Doe", "John Roe"]));
1151    }
1152
1153    #[tokio::test]
1154    async fn arxiv_fetch_populates_metadata_json_when_atom_endpoint_mocked() {
1155        // Full Source::fetch with BOTH Atom and PDF endpoints mocked must
1156        // populate `metadata_json` from the Atom response.
1157        let server = MockServer::start().await;
1158        Mock::given(method("GET"))
1159            .and(path("/api/query"))
1160            .respond_with(ResponseTemplate::new(200).set_body_string(SAMPLE_ATOM_FEED))
1161            .mount(&server)
1162            .await;
1163        Mock::given(method("GET"))
1164            .and(path("/pdf/2401.12345.pdf"))
1165            .respond_with(ResponseTemplate::new(200).set_body_bytes(b"%PDF-1.7\n%fix\n".to_vec()))
1166            .mount(&server)
1167            .await;
1168        let host = server
1169            .uri()
1170            .parse::<Url>()
1171            .unwrap()
1172            .host_str()
1173            .unwrap()
1174            .to_string();
1175        let (_td, ctx) = build_test_context(&host);
1176        let s = ArxivSource::with_base(server.uri().parse().unwrap());
1177        let id = ArxivId::parse("2401.12345").unwrap();
1178        let r = Ref::Arxiv(id);
1179
1180        let res = s.fetch(&r, &profile(), &ctx).await.expect("fetch ok");
1181        let meta = res.metadata_json.expect("metadata_json populated");
1182        assert_eq!(
1183            meta["title"],
1184            serde_json::json!("Example arXiv Paper Title")
1185        );
1186    }
1187
1188    #[tokio::test]
1189    async fn arxiv_fetch_atom_failure_falls_back_to_pdf_only() {
1190        // PDF endpoint mocked; Atom endpoint deliberately unmocked
1191        // (will 404). The fetch must still succeed with
1192        // `metadata_json = None` — the best-effort contract.
1193        let server = MockServer::start().await;
1194        Mock::given(method("GET"))
1195            .and(path("/pdf/2401.12345.pdf"))
1196            .respond_with(ResponseTemplate::new(200).set_body_bytes(b"%PDF-1.7\nx".to_vec()))
1197            .mount(&server)
1198            .await;
1199        let host = server
1200            .uri()
1201            .parse::<Url>()
1202            .unwrap()
1203            .host_str()
1204            .unwrap()
1205            .to_string();
1206        let (_td, ctx) = build_test_context(&host);
1207        let s = ArxivSource::with_base(server.uri().parse().unwrap());
1208        let id = ArxivId::parse("2401.12345").unwrap();
1209        let r = Ref::Arxiv(id);
1210
1211        let res = s.fetch(&r, &profile(), &ctx).await.expect("fetch ok");
1212        assert!(res.metadata_json.is_none());
1213        assert!(res.pdf_bytes.is_some());
1214    }
1215}