Skip to main content

rightkit_qa/
mock.rs

1//! Local HTTP mock for PAID providers: paid-tier scenarios assert the exact
2//! requests the product would send, and never make a billed call.
3//!
4//! Two ways to answer:
5//! * [`MockServer::start`]: static [`Route`]s with JSON bodies (the declarative `mock_start` step).
6//! * [`MockServer::start_with`]: a handler closure over the request and the mock's own base URL,
7//!   returning a [`Reply`] with any status, content type, headers and binary body. Use it for
8//!   stateful flows (poll until done, fail the Nth call) and generated media (PNG/MP3/MP4).
9//!
10//! * [`MockServer::start_static`]: static routes answered with full [`Reply`]s (binary files).
11//!
12//! **HTTP `Range`.** A `200` reply to a `GET`/`HEAD` honours a single byte range
13//! (`bytes=a-b`, `bytes=a-`, suffix `bytes=-n`): `206` with `Content-Range`, or `416` with
14//! `Content-Range: bytes */<len>` when unsatisfiable. A missing, malformed or multi-range
15//! `Range`, or an `If-Range` that does not match the reply's `ETag`/`Last-Modified`, gets the
16//! full `200` body. [`Reply::ignore_range`] always sends the full body (a server without
17//! resume); [`Reply::cut_after`] drops the connection after N body bytes (a dropped download).
18//!
19//! Every connection is served on its own thread, so a slow handler or a client holding a
20//! connection open never blocks the next request. Every request is recorded ([`MockServer::seen`]).
21use crate::util::Result;
22use serde_json::{json, Value};
23use std::io::{Read, Write};
24use std::net::{SocketAddr, TcpListener, TcpStream};
25use std::sync::atomic::{AtomicBool, Ordering};
26use std::sync::{Arc, Mutex};
27use std::time::Duration;
28
29/// Static route: first route whose method (if any) and path (query ignored) match answers.
30#[derive(Debug, Clone)]
31pub struct Route {
32    pub method: Option<String>,
33    pub path: String,
34    pub status: u16,
35    pub body: Value,
36    pub content_type: Option<String>,
37}
38
39/// One recorded request.
40#[derive(Debug, Clone)]
41pub struct Seen {
42    pub method: String,
43    /// Request target as sent, including any query string.
44    pub path: String,
45    /// Header names as sent; use [`Seen::header`] for case-insensitive lookup.
46    pub headers: Vec<(String, String)>,
47    /// Body as (lossy) UTF-8 text.
48    pub body: String,
49    /// Raw body bytes (multipart uploads, binary posts).
50    pub body_bytes: Vec<u8>,
51}
52
53/// The request a [`MockServer::start_with`] handler receives.
54pub type Request = Seen;
55
56impl Seen {
57    /// Path without the query string.
58    pub fn route(&self) -> &str {
59        self.path.split('?').next().unwrap_or("")
60    }
61    /// The query string, without `?`.
62    pub fn query(&self) -> Option<&str> {
63        self.path.split_once('?').map(|(_, q)| q)
64    }
65    /// One query parameter (no percent-decoding).
66    pub fn query_param(&self, name: &str) -> Option<&str> {
67        self.query()?
68            .split('&')
69            .filter_map(|kv| kv.split_once('=').or(Some((kv, ""))))
70            .find(|(k, _)| *k == name)
71            .map(|(_, v)| v)
72    }
73    /// Case-insensitive header value.
74    pub fn header(&self, name: &str) -> Option<&str> {
75        self.headers
76            .iter()
77            .find(|(k, _)| k.eq_ignore_ascii_case(name))
78            .map(|(_, v)| v.as_str())
79    }
80    /// Body parsed as JSON, or `Null`.
81    pub fn json(&self) -> Value {
82        serde_json::from_slice(&self.body_bytes).unwrap_or(Value::Null)
83    }
84    pub fn is(&self, method: &str, route: &str) -> bool {
85        self.method.eq_ignore_ascii_case(method) && self.route() == route
86    }
87    pub fn to_value(&self) -> Value {
88        let headers: serde_json::Map<String, Value> = self
89            .headers
90            .iter()
91            .map(|(k, v)| (k.to_ascii_lowercase(), json!(v)))
92            .collect();
93        json!({"method": self.method, "path": self.path, "headers": headers, "body": self.body,
94               "json": self.json()})
95    }
96}
97
98/// A handler's answer: any status, content type, extra headers and binary body.
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct Reply {
101    pub status: u16,
102    pub content_type: String,
103    pub headers: Vec<(String, String)>,
104    pub body: Vec<u8>,
105}
106
107impl Reply {
108    /// 200 with a JSON body.
109    pub fn json(v: Value) -> Self {
110        Self::status(200, v)
111    }
112    /// Any status with a JSON body.
113    pub fn status(status: u16, v: Value) -> Self {
114        Self {
115            status,
116            content_type: "application/json".into(),
117            headers: vec![],
118            body: v.to_string().into_bytes(),
119        }
120    }
121    /// 200 with a binary body (generated media: `image/png`, `audio/mpeg`, `video/mp4`, ...).
122    pub fn bytes(content_type: &str, body: impl Into<Vec<u8>>) -> Self {
123        Self {
124            status: 200,
125            content_type: content_type.into(),
126            headers: vec![],
127            body: body.into(),
128        }
129    }
130    /// 200 with a file's bytes; an unreadable file is a 500 naming it.
131    pub fn file(content_type: &str, path: &std::path::Path) -> Self {
132        match std::fs::read(path) {
133            Ok(b) => Self::bytes(content_type, b),
134            Err(e) => Self::status(
135                500,
136                json!({"error": format!("mock could not read {}: {e}", path.display())}),
137            ),
138        }
139    }
140    /// 200 `text/plain`.
141    pub fn text(body: &str) -> Self {
142        Self::bytes("text/plain; charset=utf-8", body.as_bytes().to_vec())
143    }
144    /// 404 `{"error":"unmocked"}` (what an unmatched request gets).
145    pub fn unmocked() -> Self {
146        Self::status(404, json!({"error": "unmocked"}))
147    }
148    pub fn with_status(mut self, status: u16) -> Self {
149        self.status = status;
150        self
151    }
152    pub fn with_header(mut self, name: &str, value: &str) -> Self {
153        self.headers.push((name.into(), value.into()));
154        self
155    }
156    /// Answer every request with the full body, ignoring any `Range` (a server
157    /// that cannot resume). Recorded as the reserved [`IGNORE_RANGE_HEADER`],
158    /// which the mock consumes and never sends.
159    pub fn ignore_range(self) -> Self {
160        self.with_header(IGNORE_RANGE_HEADER, "1")
161    }
162    /// Send the response head (with the full `Content-Length`), then only the first
163    /// `bytes` body bytes, then close the connection: a download dropped mid-transfer.
164    /// Applies after any `Range` slicing. Recorded as the reserved [`CUT_AFTER_HEADER`],
165    /// which the mock consumes and never sends.
166    pub fn cut_after(self, bytes: u64) -> Self {
167        self.with_header(CUT_AFTER_HEADER, &bytes.to_string())
168    }
169}
170
171/// Reserved reply header behind [`Reply::ignore_range`]; consumed, never sent.
172pub const IGNORE_RANGE_HEADER: &str = "X-RightKit-Mock-Ignore-Range";
173/// Reserved reply header behind [`Reply::cut_after`]; consumed, never sent.
174pub const CUT_AFTER_HEADER: &str = "X-RightKit-Mock-Cut-After";
175
176/// How a reply's body is sent for one request.
177#[derive(Debug, Clone, PartialEq, Eq)]
178enum BodyPlan {
179    Full,
180    /// Inclusive byte range of the body.
181    Partial(u64, u64),
182    Unsatisfiable,
183}
184
185/// Parse a single `bytes=` range against a body of `len` bytes. `None` means
186/// "ignore the header" (absent, malformed or multi-range: serve the full body).
187fn plan_range(range: Option<&str>, len: u64) -> Option<BodyPlan> {
188    let spec = range?.trim().strip_prefix("bytes=")?.trim();
189    if spec.contains(',') {
190        return None;
191    }
192    let (a, b) = spec.split_once('-')?;
193    let (a, b) = (a.trim(), b.trim());
194    let digits = |s: &str| !s.is_empty() && s.bytes().all(|c| c.is_ascii_digit());
195    if a.is_empty() {
196        if !digits(b) {
197            return None;
198        }
199        let n: u64 = b.parse().ok()?;
200        if n == 0 || len == 0 {
201            return Some(BodyPlan::Unsatisfiable);
202        }
203        return Some(BodyPlan::Partial(len.saturating_sub(n), len - 1));
204    }
205    if !digits(a) || !(b.is_empty() || digits(b)) {
206        return None;
207    }
208    let start: u64 = a.parse().ok()?;
209    let end = if b.is_empty() {
210        None
211    } else {
212        Some(b.parse::<u64>().ok()?)
213    };
214    if end.is_some_and(|e| e < start) {
215        return None;
216    }
217    if start >= len {
218        return Some(BodyPlan::Unsatisfiable);
219    }
220    Some(BodyPlan::Partial(
221        start,
222        end.map_or(len - 1, |e| e.min(len - 1)),
223    ))
224}
225
226/// `If-Range` holds when it equals the reply's strong `ETag` or its `Last-Modified`.
227fn if_range_holds(if_range: &str, headers: &[(String, String)]) -> bool {
228    let v = if_range.trim();
229    let get = |n: &str| {
230        headers
231            .iter()
232            .find(|(k, _)| k.eq_ignore_ascii_case(n))
233            .map(|(_, v)| v.trim())
234    };
235    if v.starts_with('"') || v.starts_with("W/") {
236        !v.starts_with("W/") && get("etag").is_some_and(|e| e == v && !e.starts_with("W/"))
237    } else {
238        get("last-modified").is_some_and(|m| m == v)
239    }
240}
241
242/// `None` from a handler means "not mocked" (404).
243impl From<Option<Reply>> for Reply {
244    fn from(r: Option<Reply>) -> Self {
245        r.unwrap_or_else(Reply::unmocked)
246    }
247}
248
249type Handler = dyn Fn(&Request, &str) -> Reply + Send + Sync;
250
251pub struct MockServer {
252    pub base: String,
253    addr: SocketAddr,
254    seen: Arc<Mutex<Vec<Seen>>>,
255    stop: Arc<AtomicBool>,
256}
257
258impl MockServer {
259    /// Static JSON routes; unmatched requests get 404 `{"error":"unmocked"}`.
260    pub fn start(routes: Vec<Route>) -> Result<Self> {
261        Self::start_with(move |req: &Request, _base: &str| {
262            routes
263                .iter()
264                .find(|r| {
265                    r.path == req.route()
266                        && r.method
267                            .as_deref()
268                            .map(|m| m.eq_ignore_ascii_case(&req.method))
269                            .unwrap_or(true)
270                })
271                .map(|r| Reply {
272                    status: r.status,
273                    content_type: r
274                        .content_type
275                        .clone()
276                        .unwrap_or_else(|| "application/json".into()),
277                    headers: vec![],
278                    body: r.body.to_string().into_bytes(),
279                })
280        })
281    }
282
283    /// Static routes answered with full [`Reply`]s (binary bodies, extra headers such as a
284    /// strong `ETag`, [`Reply::ignore_range`], [`Reply::cut_after`]): `(method, path, reply)`,
285    /// first match wins, `None` method matches any, query ignored; unmatched requests get 404.
286    pub fn start_static(routes: Vec<(Option<String>, String, Reply)>) -> Result<Self> {
287        Self::start_with(move |req: &Request, _base: &str| {
288            routes
289                .iter()
290                .find(|(m, p, _)| {
291                    *p == req.route()
292                        && m.as_deref()
293                            .is_none_or(|m| m.eq_ignore_ascii_case(&req.method))
294                })
295                .map(|(_, _, r)| r.clone())
296        })
297    }
298
299    /// Dynamic handler: `handler(request, base_url)` answers every request. `base_url`
300    /// (`http://127.0.0.1:<port>`) lets a reply point the client back at the mock (a job's
301    /// result URL). Return a [`Reply`], or an `Option<Reply>` where `None` means 404. A
302    /// panicking handler yields a 500 instead of killing the server.
303    pub fn start_with<R: Into<Reply>>(
304        handler: impl Fn(&Request, &str) -> R + Send + Sync + 'static,
305    ) -> Result<Self> {
306        let listener = TcpListener::bind(("127.0.0.1", 0))?;
307        let addr = listener.local_addr()?;
308        let base = format!("http://{addr}");
309        let seen = Arc::new(Mutex::new(Vec::new()));
310        let stop = Arc::new(AtomicBool::new(false));
311        let handler: Arc<Handler> = Arc::new(move |r: &Request, b: &str| handler(r, b).into());
312        let (s2, st2, b2) = (seen.clone(), stop.clone(), base.clone());
313        std::thread::spawn(move || {
314            for conn in listener.incoming().flatten() {
315                if st2.load(Ordering::SeqCst) {
316                    break;
317                }
318                let (s3, h, b) = (s2.clone(), handler.clone(), b2.clone());
319                std::thread::spawn(move || {
320                    let _ = serve(conn, &s3, &b, &*h);
321                });
322            }
323        });
324        Ok(Self {
325            base,
326            addr,
327            seen,
328            stop,
329        })
330    }
331
332    pub fn port(&self) -> u16 {
333        self.addr.port()
334    }
335    /// `base + path`.
336    pub fn url(&self, path: &str) -> String {
337        format!("{}{path}", self.base)
338    }
339    pub fn seen(&self) -> Vec<Seen> {
340        self.seen.lock().unwrap().clone()
341    }
342    pub fn close(&self) {
343        self.stop.store(true, Ordering::SeqCst);
344        let _ = TcpStream::connect_timeout(&self.addr, Duration::from_millis(200));
345    }
346}
347
348impl Drop for MockServer {
349    fn drop(&mut self) {
350        self.close();
351    }
352}
353
354fn serve(
355    mut s: TcpStream,
356    seen: &Mutex<Vec<Seen>>,
357    base: &str,
358    handler: &Handler,
359) -> std::io::Result<()> {
360    s.set_read_timeout(Some(Duration::from_secs(10)))?;
361    let mut buf = Vec::new();
362    let mut chunk = [0u8; 16384];
363    let (head_end, content_len) = loop {
364        let n = s.read(&mut chunk)?;
365        if n == 0 {
366            return Ok(());
367        }
368        buf.extend_from_slice(&chunk[..n]);
369        if let Some(p) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
370            let head = String::from_utf8_lossy(&buf[..p]).to_ascii_lowercase();
371            let len = head
372                .lines()
373                .find_map(|l| {
374                    l.strip_prefix("content-length:")
375                        .and_then(|v| v.trim().parse::<usize>().ok())
376                })
377                .unwrap_or(0);
378            break (p + 4, len);
379        }
380    };
381    while buf.len() < head_end + content_len {
382        let n = s.read(&mut chunk)?;
383        if n == 0 {
384            break;
385        }
386        buf.extend_from_slice(&chunk[..n]);
387    }
388    let head = String::from_utf8_lossy(&buf[..head_end]).to_string();
389    let mut lines = head.lines();
390    let mut first = lines.next().unwrap_or("").split_whitespace();
391    let (method, path) = (
392        first.next().unwrap_or("").to_string(),
393        first.next().unwrap_or("").to_string(),
394    );
395    let headers = lines
396        .filter_map(|l| l.split_once(':'))
397        .map(|(k, v)| (k.trim().to_string(), v.trim().to_string()))
398        .collect();
399    let body_bytes = buf[head_end..(head_end + content_len).min(buf.len())].to_vec();
400    let req = Seen {
401        method,
402        path,
403        headers,
404        body: String::from_utf8_lossy(&body_bytes).into_owned(),
405        body_bytes,
406    };
407    seen.lock().unwrap().push(req.clone());
408    let reply = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| handler(&req, base)))
409        .unwrap_or_else(|_| Reply::status(500, json!({"error": "mock handler panicked"})));
410    write_reply(&mut s, &req, reply)
411}
412
413fn write_reply(s: &mut TcpStream, req: &Request, mut reply: Reply) -> std::io::Result<()> {
414    let take = |reply: &mut Reply, name: &str| -> Option<String> {
415        let i = reply
416            .headers
417            .iter()
418            .position(|(k, _)| k.eq_ignore_ascii_case(name))?;
419        Some(reply.headers.remove(i).1)
420    };
421    let ignore_range = take(&mut reply, IGNORE_RANGE_HEADER).is_some();
422    let cut_after = take(&mut reply, CUT_AFTER_HEADER).and_then(|v| v.trim().parse::<u64>().ok());
423    let len = reply.body.len() as u64;
424    let ranged_method =
425        req.method.eq_ignore_ascii_case("GET") || req.method.eq_ignore_ascii_case("HEAD");
426    let plan = if reply.status == 200 && ranged_method && !ignore_range {
427        let honoured = req
428            .header("if-range")
429            .is_none_or(|v| if_range_holds(v, &reply.headers));
430        honoured
431            .then(|| plan_range(req.header("range"), len))
432            .flatten()
433            .unwrap_or(BodyPlan::Full)
434    } else {
435        BodyPlan::Full
436    };
437    let (status, reason, body, extra): (u16, &str, &[u8], Option<String>) = match plan {
438        BodyPlan::Full => (reply.status, "X", &reply.body[..], None),
439        BodyPlan::Partial(a, b) => (
440            206,
441            "Partial Content",
442            &reply.body[a as usize..=b as usize],
443            Some(format!(
444                "Content-Range: bytes {a}-{b}/{len}\r\nAccept-Ranges: bytes\r\n"
445            )),
446        ),
447        BodyPlan::Unsatisfiable => (
448            416,
449            "Range Not Satisfiable",
450            &[][..],
451            Some(format!(
452                "Content-Range: bytes */{len}\r\nAccept-Ranges: bytes\r\n"
453            )),
454        ),
455    };
456    let mut head = format!(
457        "HTTP/1.1 {status} {reason}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n",
458        reply.content_type,
459        body.len()
460    );
461    head.push_str(extra.as_deref().unwrap_or(""));
462    for (k, v) in &reply.headers {
463        head.push_str(&format!("{k}: {v}\r\n"));
464    }
465    head.push_str("\r\n");
466    s.write_all(head.as_bytes())?;
467    if !req.method.eq_ignore_ascii_case("HEAD") {
468        let n = cut_after.map_or(body.len(), |c| (c as usize).min(body.len()));
469        s.write_all(&body[..n])?;
470        if n < body.len() {
471            s.flush()?;
472            return s.shutdown(std::net::Shutdown::Both);
473        }
474    }
475    s.flush()
476}
477
478#[cfg(test)]
479mod tests {
480    use super::*;
481    use crate::http::request;
482    use std::sync::atomic::AtomicUsize;
483
484    fn get(m: &MockServer, method: &str, path: &str, body: Option<&str>) -> crate::http::Response {
485        request(
486            m.addr,
487            method,
488            path,
489            Some("Bearer k"),
490            body,
491            Duration::from_secs(5),
492        )
493        .unwrap()
494    }
495
496    #[test]
497    fn static_routes_still_answer_json_and_404() {
498        let m = MockServer::start(vec![Route {
499            method: Some("POST".into()),
500            path: "/v1/gen".into(),
501            status: 201,
502            body: json!({"id": "j1"}),
503            content_type: None,
504        }])
505        .unwrap();
506        let r = get(&m, "POST", "/v1/gen?x=1", Some(r#"{"prompt":"p"}"#));
507        assert_eq!(r.status, 201);
508        assert_eq!(r.content_type, "application/json");
509        assert_eq!(
510            serde_json::from_slice::<Value>(&r.body).unwrap()["id"],
511            "j1"
512        );
513        assert_eq!(get(&m, "GET", "/v1/gen", None).status, 404);
514        let seen = m.seen();
515        assert_eq!(seen.len(), 2);
516        assert_eq!(seen[0].json()["prompt"], "p");
517        assert_eq!(seen[0].header("authorization"), Some("Bearer k"));
518        assert_eq!(seen[0].query_param("x"), Some("1"));
519        assert_eq!(seen[0].to_value()["json"]["prompt"], "p");
520    }
521
522    #[test]
523    fn dynamic_handler_is_stateful_binary_and_knows_its_base() {
524        let png: Vec<u8> = (0..=255u8).cycle().take(70_000).collect();
525        let polls = Arc::new(AtomicUsize::new(0));
526        let (p2, png2) = (polls.clone(), png.clone());
527        let m = MockServer::start_with(move |req: &Request, base: &str| {
528            if req.is("POST", "/jobs") {
529                Some(Reply::json(json!({"poll": format!("{base}/jobs/1")})))
530            } else if req.is("GET", "/jobs/1") {
531                let n = p2.fetch_add(1, Ordering::SeqCst);
532                Some(if n < 2 {
533                    Reply::json(json!({"status": "running"}))
534                } else {
535                    Reply::json(json!({"status": "done", "url": format!("{base}/out.png")}))
536                })
537            } else if req.is("GET", "/out.png") {
538                Some(Reply::bytes("image/png", png2.clone()).with_header("X-Mock", "1"))
539            } else if req.is("GET", "/boom") {
540                panic!("handler bug")
541            } else {
542                None
543            }
544        })
545        .unwrap();
546        let first: Value =
547            serde_json::from_slice(&get(&m, "POST", "/jobs", Some("{}")).body).unwrap();
548        assert_eq!(first["poll"], m.url("/jobs/1"));
549        let states: Vec<String> = (0..3)
550            .map(|_| {
551                serde_json::from_slice::<Value>(&get(&m, "GET", "/jobs/1", None).body).unwrap()
552                    ["status"]
553                    .as_str()
554                    .unwrap()
555                    .to_string()
556            })
557            .collect();
558        assert_eq!(states, ["running", "running", "done"]);
559        let media = get(&m, "GET", "/out.png", None);
560        assert_eq!(media.status, 200);
561        assert_eq!(media.content_type, "image/png");
562        assert_eq!(media.body, png, "binary body survives byte-for-byte");
563        assert_eq!(get(&m, "GET", "/boom", None).status, 500);
564        assert_eq!(get(&m, "GET", "/nope", None).status, 404);
565        assert_eq!(m.seen().len(), 7);
566    }
567
568    #[test]
569    fn a_held_connection_does_not_block_other_requests() {
570        let m = MockServer::start_with(|_: &Request, _: &str| Reply::text("ok")).unwrap();
571        // A client that connects and never sends a request.
572        let _idle = TcpStream::connect(m.addr).unwrap();
573        let started = std::time::Instant::now();
574        let r = get(&m, "GET", "/", None);
575        assert_eq!(r.text(), "ok");
576        assert!(started.elapsed() < Duration::from_secs(3));
577    }
578
579    /// Raw GET with extra headers: (status, lowercase headers, body, eof_reached_cleanly).
580    fn raw(
581        m: &MockServer,
582        method: &str,
583        path: &str,
584        extra: &[(&str, &str)],
585    ) -> (u16, Vec<(String, String)>, Vec<u8>) {
586        let mut s = TcpStream::connect(m.addr).unwrap();
587        s.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
588        let mut head = format!("{method} {path} HTTP/1.1\r\nHost: x\r\nConnection: close\r\n");
589        for (k, v) in extra {
590            head.push_str(&format!("{k}: {v}\r\n"));
591        }
592        head.push_str("\r\n");
593        s.write_all(head.as_bytes()).unwrap();
594        let mut buf = Vec::new();
595        let _ = s.read_to_end(&mut buf);
596        let split = buf.windows(4).position(|w| w == b"\r\n\r\n").unwrap();
597        let text = String::from_utf8_lossy(&buf[..split]).to_string();
598        let mut lines = text.lines();
599        let status = lines
600            .next()
601            .unwrap()
602            .split_whitespace()
603            .nth(1)
604            .unwrap()
605            .parse()
606            .unwrap();
607        let headers = lines
608            .filter_map(|l| l.split_once(':'))
609            .map(|(k, v)| (k.trim().to_ascii_lowercase(), v.trim().to_string()))
610            .collect();
611        (status, headers, buf[split + 4..].to_vec())
612    }
613
614    fn hdr<'a>(h: &'a [(String, String)], name: &str) -> Option<&'a str> {
615        h.iter().find(|(k, _)| k == name).map(|(_, v)| v.as_str())
616    }
617
618    fn data() -> Vec<u8> {
619        (0..1000u32).map(|i| (i * 7 % 251) as u8).collect()
620    }
621
622    #[test]
623    fn static_and_dynamic_binary_replies_serve_byte_ranges() {
624        let body = data();
625        let file =
626            Reply::bytes("application/octet-stream", body.clone()).with_header("ETag", "\"abc\"");
627        let st = MockServer::start_static(vec![
628            (Some("GET".into()), "/f.bin".into(), file.clone()),
629            (Some("HEAD".into()), "/f.bin".into(), file.clone()),
630        ])
631        .unwrap();
632        let f2 = file.clone();
633        let dy = MockServer::start_with(move |_: &Request, _: &str| f2.clone()).unwrap();
634        for m in [&st, &dy] {
635            let (s, h, b) = raw(m, "GET", "/f.bin", &[]);
636            assert_eq!((s, b.as_slice()), (200, &body[..]), "no Range: full 200");
637            assert_eq!(hdr(&h, "content-range"), None);
638
639            let (s, h, b) = raw(m, "GET", "/f.bin", &[("Range", "bytes=10-19")]);
640            assert_eq!(s, 206);
641            assert_eq!(b, &body[10..20]);
642            assert_eq!(hdr(&h, "content-range"), Some("bytes 10-19/1000"));
643            assert_eq!(hdr(&h, "content-length"), Some("10"));
644            assert_eq!(hdr(&h, "etag"), Some("\"abc\""));
645
646            let (s, h, b) = raw(m, "GET", "/f.bin", &[("Range", "bytes=990-")]);
647            assert_eq!((s, b.as_slice()), (206, &body[990..]));
648            assert_eq!(hdr(&h, "content-range"), Some("bytes 990-999/1000"));
649
650            let (s, h, b) = raw(m, "GET", "/f.bin", &[("Range", "bytes=-5")]);
651            assert_eq!((s, b.as_slice()), (206, &body[995..]));
652            assert_eq!(hdr(&h, "content-range"), Some("bytes 995-999/1000"));
653
654            let (s, h, b) = raw(m, "GET", "/f.bin", &[("Range", "bytes=-5000")]);
655            assert_eq!(
656                (s, b.as_slice()),
657                (206, &body[..]),
658                "suffix longer than the body"
659            );
660            assert_eq!(hdr(&h, "content-range"), Some("bytes 0-999/1000"));
661
662            let (s, h, b) = raw(m, "GET", "/f.bin", &[("Range", "bytes=995-5000")]);
663            assert_eq!((s, b.as_slice()), (206, &body[995..]), "end clamped");
664            assert_eq!(hdr(&h, "content-range"), Some("bytes 995-999/1000"));
665
666            for unsat in ["bytes=1000-", "bytes=5000-6000", "bytes=-0"] {
667                let (s, h, b) = raw(m, "GET", "/f.bin", &[("Range", unsat)]);
668                assert_eq!(s, 416, "{unsat}");
669                assert!(b.is_empty());
670                assert_eq!(hdr(&h, "content-range"), Some("bytes */1000"));
671            }
672            for ignored in ["bytes=5-1", "bytes=0-1,5-6", "items=0-1", "bytes=x-"] {
673                let (s, _, b) = raw(m, "GET", "/f.bin", &[("Range", ignored)]);
674                assert_eq!((s, b.as_slice()), (200, &body[..]), "{ignored}");
675            }
676            // If-Range: matching ETag resumes, anything else gets the full body.
677            let (s, _, b) = raw(
678                m,
679                "GET",
680                "/f.bin",
681                &[("Range", "bytes=10-"), ("If-Range", "\"abc\"")],
682            );
683            assert_eq!((s, b.as_slice()), (206, &body[10..]));
684            let (s, _, b) = raw(
685                m,
686                "GET",
687                "/f.bin",
688                &[("Range", "bytes=10-"), ("If-Range", "\"old\"")],
689            );
690            assert_eq!((s, b.as_slice()), (200, &body[..]));
691            // HEAD: the range head, no body.
692            let (s, h, b) = raw(m, "HEAD", "/f.bin", &[("Range", "bytes=0-9")]);
693            assert_eq!(s, 206);
694            assert!(b.is_empty());
695            assert_eq!(hdr(&h, "content-length"), Some("10"));
696        }
697        assert_eq!(raw(&st, "POST", "/f.bin", &[]).0, 404, "method filter");
698        // Non-200 replies are never sliced.
699        let err =
700            MockServer::start_with(|_: &Request, _: &str| Reply::text("nope").with_status(500))
701                .unwrap();
702        let (s, _, b) = raw(&err, "GET", "/", &[("Range", "bytes=0-1")]);
703        assert_eq!((s, b.as_slice()), (500, &b"nope"[..]));
704    }
705
706    #[test]
707    fn ignore_range_and_cut_after_simulate_no_resume_and_dropped_connections() {
708        let body = data();
709        let b2 = body.clone();
710        let m = MockServer::start_with(move |req: &Request, _: &str| {
711            let r = Reply::bytes("application/octet-stream", b2.clone());
712            match req.route() {
713                "/norange" => r.ignore_range(),
714                "/drop" => r.cut_after(100),
715                _ => r,
716            }
717        })
718        .unwrap();
719        let (s, h, b) = raw(&m, "GET", "/norange", &[("Range", "bytes=10-")]);
720        assert_eq!((s, b.as_slice()), (200, &body[..]));
721        assert_eq!(hdr(&h, "content-range"), None);
722        assert!(
723            h.iter().all(|(k, _)| !k.starts_with("x-rightkit-mock")),
724            "reserved headers are never sent: {h:?}"
725        );
726
727        let (s, h, b) = raw(&m, "GET", "/drop", &[]);
728        assert_eq!(s, 200);
729        assert_eq!(
730            hdr(&h, "content-length"),
731            Some("1000"),
732            "head promises the full body"
733        );
734        assert_eq!(b, &body[..100], "then the connection drops");
735        let (s, h, b) = raw(&m, "GET", "/drop", &[("Range", "bytes=500-")]);
736        assert_eq!(s, 206);
737        assert_eq!(hdr(&h, "content-length"), Some("500"));
738        assert_eq!(b, &body[500..600], "cut applies after range slicing");
739        // The reserved headers are consumed even on static replies.
740        let st =
741            MockServer::start_static(vec![(None, "/x".into(), Reply::text("hello").cut_after(2))])
742                .unwrap();
743        let (_, h, b) = raw(&st, "GET", "/x", &[]);
744        assert_eq!(b, b"he");
745        assert!(h.iter().all(|(k, _)| !k.starts_with("x-rightkit-mock")));
746    }
747}