Skip to main content

rustial_engine/
tile_source_http.rs

1//! HTTP tile source using the abstract [`HttpClient`] trait.
2//!
3//! [`HttpTileSource`] is the standard [`TileSource`] implementation for
4//! fetching raster map tiles from slippy-map HTTP endpoints (OpenStreetMap,
5//! Mapbox, Stamen, MapTiler, custom servers, etc.).
6//!
7//! ## URL template
8//!
9//! The source is constructed with a URL template containing `{z}`, `{x}`,
10//! and `{y}` placeholders that are substituted with the tile's zoom level
11//! and column/row indices:
12//!
13//! ```text
14//! https://tile.openstreetmap.org/{z}/{x}/{y}.png
15//! ```
16//!
17//! ## Image decoding
18//!
19//! | Constructor | Decoder | Use case |
20//! |-------------|---------|----------|
21//! | [`new`](HttpTileSource::new) | None | Raw RGBA8 256x256 payloads (test fixtures, pre-decoded caches) |
22//! | [`with_decoder`](HttpTileSource::with_decoder) | `Box<dyn TileDecoder>` | Real providers returning PNG / JPEG / WebP |
23//!
24//! ## Request lifecycle
25//!
26//! ```text
27//! TileManager                     HttpTileSource                  HttpClient
28//!     |                                |                              |
29//!     |-- TileSource::request(id) ---->|                              |
30//!     |                                |-- url = tile_url(id)         |
31//!     |                                |-- pending[url] = id          |
32//!     |                                |-- HttpClient::send(req) ---->|
33//!     |                                |                              |
34//!     |-- TileSource::poll() --------->|                              |
35//!     |                                |-- HttpClient::poll() ------->|
36//!     |                                |<-- (url, Result) ------------|
37//!     |                                |-- pending.remove(url)        |
38//!     |                                |-- decode (if decoder)        |
39//!     |<-- Vec<(TileId, Result)> ------|                              |
40//! ```
41//!
42//! ## Error mapping
43//!
44//! | HTTP status | `TileError` variant |
45//! |-------------|---------------------|
46//! | 2xx | (success -- decoded body is returned) |
47//! | 404 | `NotFound` |
48//! | Other 4xx / 5xx | `Network("HTTP {status}")` |
49//! | Transport failure | `Network(error_string)` |
50//! | Decoder failure | `Decode(error_string)` |
51//!
52//! ## Thread safety
53//!
54//! `HttpTileSource` is `Send + Sync`.  The `pending` map is guarded by a
55//! [`Mutex`] and the `HttpClient` + `TileDecoder` trait objects are
56//! required to be `Send + Sync`.
57//!
58//! [`HttpClient`]: crate::io::HttpClient
59//! [`TileSource`]: crate::tile_source::TileSource
60
61use crate::io::{HttpClient, HttpRequest, HttpResponse};
62use crate::tile_source::{
63    DecodedImage, RevalidationHint, TileData, TileDecoder, TileError, TileFreshness, TileResponse,
64    TileSource, TileSourceDiagnostics, TileSourceFailureDiagnostics,
65};
66use rustial_math::TileId;
67use std::collections::HashMap;
68use std::sync::Mutex;
69use std::time::{Duration, SystemTime};
70
71fn parse_cache_control_max_age(value: &str) -> Option<u64> {
72    for directive in value.split(',') {
73        let directive = directive.trim();
74        if let Some(rest) = directive.strip_prefix("max-age=") {
75            if let Ok(seconds) = rest.trim_matches('"').parse::<u64>() {
76                return Some(seconds);
77            }
78        }
79    }
80    None
81}
82
83fn parse_age_seconds(response: &HttpResponse) -> u64 {
84    response
85        .header("age")
86        .and_then(|value| value.parse::<u64>().ok())
87        .unwrap_or(0)
88}
89
90fn parse_http_freshness(response: &HttpResponse) -> TileFreshness {
91    let now = SystemTime::now();
92    let age = parse_age_seconds(response);
93
94    let expires_at = response
95        .header("cache-control")
96        .and_then(parse_cache_control_max_age)
97        .map(|max_age| max_age.saturating_sub(age))
98        .map(Duration::from_secs)
99        .and_then(|ttl| now.checked_add(ttl))
100        .or_else(|| {
101            response
102                .header("expires")
103                .and_then(|value| httpdate::parse_http_date(value).ok())
104        });
105
106    TileFreshness {
107        expires_at,
108        etag: response.header("etag").map(ToOwned::to_owned),
109        last_modified: response.header("last-modified").map(ToOwned::to_owned),
110    }
111}
112
113fn is_timeout_error(error: &str) -> bool {
114    error.to_ascii_lowercase().contains("timeout")
115}
116
117// ---------------------------------------------------------------------------
118// HttpTileSource
119// ---------------------------------------------------------------------------
120
121/// A [`TileSource`] that fetches raster tiles over HTTP.
122///
123/// Uses a URL template with `{z}`, `{x}`, `{y}` placeholders and an
124/// optional [`TileDecoder`] for image formats.
125///
126/// See the [module-level documentation](self) for the full request
127/// lifecycle and error mapping.
128pub struct HttpTileSource {
129    /// URL template, e.g. `"https://tile.openstreetmap.org/{z}/{x}/{y}.png"`.
130    url_template: String,
131
132    /// The HTTP client provided by the host application.
133    client: Box<dyn HttpClient>,
134
135    /// Optional image decoder (PNG / JPEG / WebP -> RGBA8).
136    ///
137    /// When `None`, response bodies are assumed to already be raw RGBA8
138    /// 256x256 pixel data (useful for test fixtures or pre-decoded caches).
139    decoder: Option<Box<dyn TileDecoder>>,
140
141    /// Extra headers added to every outgoing request.
142    ///
143    /// Common uses: `Authorization` (API keys), `User-Agent`.
144    default_headers: Vec<(String, String)>,
145
146    /// Mapping from request URL to the originating `TileId`.
147    ///
148    /// Entries are inserted in [`request`](Self::request) and removed
149    /// in [`poll`](Self::poll) when the corresponding HTTP response
150    /// arrives.  A stale entry (URL that the `HttpClient` never
151    /// completes) will persist until the `HttpTileSource` is dropped.
152    pending: Mutex<HashMap<String, TileId>>,
153    /// Categorized source-side failure counters.
154    failure_diagnostics: Mutex<TileSourceFailureDiagnostics>,
155}
156
157impl std::fmt::Debug for HttpTileSource {
158    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
159        let pending_count = self.pending.lock().map(|p| p.len()).unwrap_or(0);
160        f.debug_struct("HttpTileSource")
161            .field("url_template", &self.url_template)
162            .field("has_decoder", &self.decoder.is_some())
163            .field("default_headers", &self.default_headers.len())
164            .field("pending", &pending_count)
165            .finish()
166    }
167}
168
169// ---------------------------------------------------------------------------
170// Construction
171// ---------------------------------------------------------------------------
172
173impl HttpTileSource {
174    /// Create a new HTTP tile source without image decoding.
175    ///
176    /// Response bodies are assumed to be raw RGBA8 256x256 pixel data.
177    /// For real tile providers (OSM, Mapbox, etc.) use
178    /// [`with_decoder`](Self::with_decoder) instead.
179    pub fn new(url_template: impl Into<String>, client: Box<dyn HttpClient>) -> Self {
180        Self {
181            url_template: url_template.into(),
182            client,
183            decoder: None,
184            default_headers: Vec::new(),
185            pending: Mutex::new(HashMap::new()),
186            failure_diagnostics: Mutex::new(TileSourceFailureDiagnostics::default()),
187        }
188    }
189
190    /// Create a new HTTP tile source with a [`TileDecoder`] for
191    /// decoding PNG / JPEG / WebP response bodies into RGBA8.
192    pub fn with_decoder(
193        url_template: impl Into<String>,
194        client: Box<dyn HttpClient>,
195        decoder: Box<dyn TileDecoder>,
196    ) -> Self {
197        Self {
198            url_template: url_template.into(),
199            client,
200            decoder: Some(decoder),
201            default_headers: Vec::new(),
202            pending: Mutex::new(HashMap::new()),
203            failure_diagnostics: Mutex::new(TileSourceFailureDiagnostics::default()),
204        }
205    }
206
207    // -- Configuration ----------------------------------------------------
208
209    /// Add a default header that will be sent with every tile request.
210    ///
211    /// Useful for API keys, `User-Agent` overrides, or custom
212    /// authentication tokens.  Returns `self` for chaining.
213    ///
214    /// # Example
215    ///
216    /// ```rust,ignore
217    /// let source = HttpTileSource::new(template, client)
218    ///     .with_header("User-Agent", "rustial/0.1")
219    ///     .with_header("Authorization", "Bearer my-token");
220    /// ```
221    pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
222        self.default_headers.push((name.into(), value.into()));
223        self
224    }
225
226    // -- URL generation ---------------------------------------------------
227
228    /// Expand the URL template for a given tile ID.
229    ///
230    /// Replaces `{z}`, `{x}`, `{y}` with the tile's zoom, column, and
231    /// row respectively.
232    pub fn tile_url(&self, id: &TileId) -> String {
233        self.url_template
234            .replace("{z}", &id.zoom.to_string())
235            .replace("{x}", &id.x.to_string())
236            .replace("{y}", &id.y.to_string())
237    }
238
239    /// The URL template this source was constructed with.
240    #[inline]
241    pub fn url_template(&self) -> &str {
242        &self.url_template
243    }
244
245    /// The number of requests currently in flight (awaiting HTTP response).
246    pub fn pending_count(&self) -> usize {
247        self.pending.lock().map(|p| p.len()).unwrap_or(0)
248    }
249}
250
251// ---------------------------------------------------------------------------
252// TileSource implementation
253// ---------------------------------------------------------------------------
254
255impl TileSource for HttpTileSource {
256    fn request(&self, id: TileId) {
257        let url = self.tile_url(&id);
258
259        // Track the URL -> TileId mapping so we can correlate the
260        // response in poll().
261        if let Ok(mut pending) = self.pending.lock() {
262            pending.insert(url.clone(), id);
263        }
264
265        // Build the HTTP request with default headers.
266        let mut req = HttpRequest::get(&url);
267        for (name, value) in &self.default_headers {
268            req = req.with_header(name.clone(), value.clone());
269        }
270
271        self.client.send(req);
272    }
273
274    fn request_revalidate(&self, id: TileId, hint: RevalidationHint) {
275        let url = self.tile_url(&id);
276
277        if let Ok(mut pending) = self.pending.lock() {
278            pending.insert(url.clone(), id);
279        }
280
281        let mut req = HttpRequest::get(&url);
282        for (name, value) in &self.default_headers {
283            req = req.with_header(name.clone(), value.clone());
284        }
285
286        // Attach conditional-request headers from the stale entry.
287        if let Some(etag) = &hint.etag {
288            req = req.with_header("If-None-Match", etag.clone());
289        }
290        if let Some(last_modified) = &hint.last_modified {
291            req = req.with_header("If-Modified-Since", last_modified.clone());
292        }
293
294        self.client.send(req);
295    }
296
297    fn poll(&self) -> Vec<(TileId, Result<TileResponse, TileError>)> {
298        let responses = self.client.poll();
299        if responses.is_empty() {
300            return Vec::new();
301        }
302
303        let mut pending = match self.pending.lock() {
304            Ok(p) => p,
305            Err(_) => return Vec::new(),
306        };
307
308        let mut results = Vec::with_capacity(responses.len());
309
310        for (url, response) in responses {
311            let tile_id = match pending.remove(&url) {
312                Some(id) => id,
313                None => {
314                    if let Ok(mut diagnostics) = self.failure_diagnostics.lock() {
315                        diagnostics.ignored_completed_responses += 1;
316                    }
317                    continue;
318                }
319            };
320
321            match response {
322                Ok(resp) if resp.status == 304 => {
323                    // Server confirmed the cached payload is still fresh.
324                    // Return a not-modified marker so the cache refreshes
325                    // its TTL without decoding or replacing data.
326                    let freshness = parse_http_freshness(&resp);
327                    results.push((tile_id, Ok(TileResponse::not_modified(freshness))));
328                }
329                Ok(resp) if resp.is_success() => {
330                    let freshness = parse_http_freshness(&resp);
331                    let tile_result = if let Some(ref decoder) = self.decoder {
332                        decoder
333                            .decode(&resp.body)
334                            .map(TileData::Raster)
335                            .map(|data| TileResponse {
336                                data,
337                                freshness,
338                                not_modified: false,
339                            })
340                    } else {
341                        Ok(TileResponse {
342                            data: TileData::Raster(DecodedImage {
343                                width: 256,
344                                height: 256,
345                                data: std::sync::Arc::new(resp.body),
346                            }),
347                            freshness,
348                            not_modified: false,
349                        })
350                    };
351                    if tile_result.is_err() {
352                        if let Ok(mut diagnostics) = self.failure_diagnostics.lock() {
353                            diagnostics.decode_failures += 1;
354                        }
355                    }
356                    results.push((tile_id, tile_result));
357                }
358                Ok(resp) if resp.status == 404 => {
359                    if let Ok(mut diagnostics) = self.failure_diagnostics.lock() {
360                        diagnostics.not_found_failures += 1;
361                    }
362                    results.push((tile_id, Err(TileError::NotFound(tile_id))));
363                }
364                Ok(resp) => {
365                    if let Ok(mut diagnostics) = self.failure_diagnostics.lock() {
366                        diagnostics.http_status_failures += 1;
367                    }
368                    results.push((
369                        tile_id,
370                        Err(TileError::Network(format!("HTTP {}", resp.status))),
371                    ));
372                }
373                Err(err) => {
374                    if let Ok(mut diagnostics) = self.failure_diagnostics.lock() {
375                        diagnostics.transport_failures += 1;
376                        if is_timeout_error(&err) {
377                            diagnostics.timeout_failures += 1;
378                        }
379                    }
380                    results.push((tile_id, Err(TileError::Network(err))));
381                }
382            }
383        }
384
385        results
386    }
387
388    fn cancel(&self, id: TileId) {
389        // Remove the pending entry so the response (if it arrives) is
390        // silently discarded in poll().  The underlying HTTP request
391        // may still complete -- HttpClient does not guarantee
392        // cancellation -- but the engine will not process the result.
393        if let Ok(mut pending) = self.pending.lock() {
394            let url = self.tile_url(&id);
395            if pending.remove(&url).is_some() {
396                if let Ok(mut diagnostics) = self.failure_diagnostics.lock() {
397                    diagnostics.forced_cancellations += 1;
398                }
399            }
400        }
401    }
402
403    fn diagnostics(&self) -> Option<TileSourceDiagnostics> {
404        let failure_diagnostics = self
405            .failure_diagnostics
406            .lock()
407            .map(|diagnostics| diagnostics.clone())
408            .unwrap_or_default();
409
410        Some(TileSourceDiagnostics {
411            queued_requests: 0,
412            in_flight_requests: self.pending_count(),
413            known_requests: self.pending_count(),
414            cancelled_in_flight_requests: 0,
415            max_concurrent_requests: 0,
416            pending_decode_tasks: 0,
417            failure_diagnostics,
418        })
419    }
420}
421
422// ---------------------------------------------------------------------------
423// Tests
424// ---------------------------------------------------------------------------
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429    use crate::io::HttpResponse;
430    use std::sync::{Arc, Mutex as StdMutex};
431
432    struct FailingDecoder;
433
434    impl TileDecoder for FailingDecoder {
435        fn decode(&self, _bytes: &[u8]) -> Result<DecodedImage, TileError> {
436            Err(TileError::Decode("bad image".into()))
437        }
438    }
439
440    // =====================================================================
441    // Mock HttpClient
442    // =====================================================================
443
444    /// Shared inner state of [`MockHttpClient`], accessible via `Arc`.
445    struct MockHttpClientInner {
446        sent: StdMutex<Vec<HttpRequest>>,
447        responses: StdMutex<Vec<(String, Result<HttpResponse, String>)>>,
448    }
449
450    /// A mock HTTP client that records sent requests and returns
451    /// pre-configured responses on poll.
452    ///
453    /// Internally uses `Arc` so the test can retain access to the sent
454    /// requests and queued responses after the client is moved into
455    /// `Box<dyn HttpClient>`.
456    #[derive(Clone)]
457    struct MockHttpClient {
458        inner: Arc<MockHttpClientInner>,
459    }
460
461    impl MockHttpClient {
462        fn new() -> Self {
463            Self {
464                inner: Arc::new(MockHttpClientInner {
465                    sent: StdMutex::new(Vec::new()),
466                    responses: StdMutex::new(Vec::new()),
467                }),
468            }
469        }
470
471        /// Queue a successful response for a URL.
472        fn queue_response(&self, url: &str, status: u16, body: Vec<u8>) {
473            self.inner.responses.lock().unwrap().push((
474                url.to_string(),
475                Ok(HttpResponse {
476                    status,
477                    body,
478                    headers: vec![],
479                }),
480            ));
481        }
482
483        /// Queue a response with custom headers.
484        fn queue_response_with_headers(
485            &self,
486            url: &str,
487            status: u16,
488            body: Vec<u8>,
489            headers: Vec<(String, String)>,
490        ) {
491            self.inner.responses.lock().unwrap().push((
492                url.to_string(),
493                Ok(HttpResponse {
494                    status,
495                    body,
496                    headers,
497                }),
498            ));
499        }
500
501        /// Queue a transport-level error for a URL.
502        fn queue_error(&self, url: &str, error: &str) {
503            self.inner
504                .responses
505                .lock()
506                .unwrap()
507                .push((url.to_string(), Err(error.to_string())));
508        }
509
510        /// Return the list of URLs that were sent.
511        fn sent_urls(&self) -> Vec<String> {
512            self.inner
513                .sent
514                .lock()
515                .unwrap()
516                .iter()
517                .map(|r| r.url.clone())
518                .collect()
519        }
520
521        /// Return the headers from the last sent request.
522        fn last_sent_headers(&self) -> Vec<(String, String)> {
523            self.inner
524                .sent
525                .lock()
526                .unwrap()
527                .last()
528                .map(|r| r.headers.clone())
529                .unwrap_or_default()
530        }
531    }
532
533    impl HttpClient for MockHttpClient {
534        fn send(&self, request: HttpRequest) {
535            self.inner.sent.lock().unwrap().push(request);
536        }
537
538        fn poll(&self) -> Vec<(String, Result<HttpResponse, String>)> {
539            std::mem::take(&mut *self.inner.responses.lock().unwrap())
540        }
541    }
542
543    /// A mock decoder that returns a fixed 2x2 RGBA8 image.
544    struct MockDecoder;
545
546    impl TileDecoder for MockDecoder {
547        fn decode(&self, _bytes: &[u8]) -> Result<DecodedImage, TileError> {
548            Ok(DecodedImage {
549                width: 2,
550                height: 2,
551                data: std::sync::Arc::new(vec![255u8; 2 * 2 * 4]),
552            })
553        }
554    }
555
556    // =====================================================================
557    // Helpers
558    // =====================================================================
559
560    const TEMPLATE: &str = "https://tiles.example.com/{z}/{x}/{y}.png";
561
562    fn tile(z: u8, x: u32, y: u32) -> TileId {
563        TileId::new(z, x, y)
564    }
565
566    // =====================================================================
567    // URL generation
568    // =====================================================================
569
570    #[test]
571    fn tile_url_substitution() {
572        let client = MockHttpClient::new();
573        let source = HttpTileSource::new(TEMPLATE, Box::new(client));
574        let url = source.tile_url(&tile(10, 512, 340));
575        assert_eq!(url, "https://tiles.example.com/10/512/340.png");
576    }
577
578    #[test]
579    fn tile_url_zoom_zero() {
580        let client = MockHttpClient::new();
581        let source = HttpTileSource::new(TEMPLATE, Box::new(client));
582        let url = source.tile_url(&tile(0, 0, 0));
583        assert_eq!(url, "https://tiles.example.com/0/0/0.png");
584    }
585
586    #[test]
587    fn url_template_accessor() {
588        let client = MockHttpClient::new();
589        let source = HttpTileSource::new(TEMPLATE, Box::new(client));
590        assert_eq!(source.url_template(), TEMPLATE);
591    }
592
593    // =====================================================================
594    // Request / poll cycle
595    // =====================================================================
596
597    #[test]
598    fn request_sends_http_get() {
599        let client = MockHttpClient::new();
600        let handle = client.clone();
601        let source = HttpTileSource::new(TEMPLATE, Box::new(client));
602
603        source.request(tile(5, 10, 20));
604
605        let urls = handle.sent_urls();
606        assert_eq!(urls.len(), 1);
607        assert_eq!(urls[0], "https://tiles.example.com/5/10/20.png");
608    }
609
610    #[test]
611    fn poll_returns_empty_when_no_responses() {
612        let client = MockHttpClient::new();
613        let source = HttpTileSource::new(TEMPLATE, Box::new(client));
614        let results = source.poll();
615        assert!(results.is_empty());
616    }
617
618    #[test]
619    fn successful_fetch_without_decoder() {
620        let client = MockHttpClient::new();
621        let url = "https://tiles.example.com/0/0/0.png";
622        let body = vec![0u8; 256 * 256 * 4];
623        client.queue_response(url, 200, body.clone());
624
625        let source = HttpTileSource::new(TEMPLATE, Box::new(client));
626        source.request(tile(0, 0, 0));
627
628        let results = source.poll();
629        assert_eq!(results.len(), 1);
630        let (id, result) = &results[0];
631        assert_eq!(*id, tile(0, 0, 0));
632        let data = result.as_ref().expect("should succeed");
633        match &data.data {
634            TileData::Raster(img) => {
635                assert_eq!(img.width, 256);
636                assert_eq!(img.height, 256);
637                assert_eq!(img.data.len(), 256 * 256 * 4);
638            }
639            TileData::Vector(_) | TileData::RawVector(_) => panic!("expected raster tile data"),
640        }
641    }
642
643    #[test]
644    fn successful_fetch_with_decoder() {
645        let client = MockHttpClient::new();
646        let url = "https://tiles.example.com/0/0/0.png";
647        client.queue_response(url, 200, vec![1, 2, 3]);
648
649        let source =
650            HttpTileSource::with_decoder(TEMPLATE, Box::new(client), Box::new(MockDecoder));
651        source.request(tile(0, 0, 0));
652
653        let results = source.poll();
654        assert_eq!(results.len(), 1);
655        let (_, result) = &results[0];
656        match &result.as_ref().unwrap().data {
657            TileData::Raster(img) => {
658                assert_eq!(img.width, 2);
659                assert_eq!(img.height, 2);
660            }
661            TileData::Vector(_) | TileData::RawVector(_) => panic!("expected raster tile data"),
662        }
663    }
664
665    #[test]
666    fn cache_control_max_age_populates_freshness() {
667        let client = MockHttpClient::new();
668        let url = "https://tiles.example.com/0/0/0.png";
669        client.inner.responses.lock().unwrap().push((
670            url.to_string(),
671            Ok(HttpResponse {
672                status: 200,
673                body: vec![0u8; 256 * 256 * 4],
674                headers: vec![("Cache-Control".into(), "public, max-age=60".into())],
675            }),
676        ));
677
678        let source = HttpTileSource::new(TEMPLATE, Box::new(client));
679        source.request(tile(0, 0, 0));
680
681        let results = source.poll();
682        let freshness = &results[0].1.as_ref().unwrap().freshness;
683        assert!(freshness.expires_at.is_some());
684        assert!(!freshness.is_expired());
685    }
686
687    #[test]
688    fn expires_header_populates_freshness() {
689        let client = MockHttpClient::new();
690        let url = "https://tiles.example.com/0/0/0.png";
691        client.inner.responses.lock().unwrap().push((
692            url.to_string(),
693            Ok(HttpResponse {
694                status: 200,
695                body: vec![0u8; 256 * 256 * 4],
696                headers: vec![("Expires".into(), "Wed, 21 Oct 2099 07:28:00 GMT".into())],
697            }),
698        ));
699
700        let source = HttpTileSource::new(TEMPLATE, Box::new(client));
701        source.request(tile(0, 0, 0));
702
703        let results = source.poll();
704        let freshness = &results[0].1.as_ref().unwrap().freshness;
705        assert!(freshness.expires_at.is_some());
706    }
707
708    // =====================================================================
709    // HTTP error handling
710    // =====================================================================
711
712    #[test]
713    fn http_404_returns_not_found() {
714        let client = MockHttpClient::new();
715        let url = "https://tiles.example.com/0/0/0.png";
716        client.queue_response(url, 404, vec![]);
717
718        let source = HttpTileSource::new(TEMPLATE, Box::new(client));
719        source.request(tile(0, 0, 0));
720
721        let results = source.poll();
722        assert_eq!(results.len(), 1);
723        assert!(matches!(results[0].1, Err(TileError::NotFound(_))));
724    }
725
726    #[test]
727    fn http_500_returns_network_error() {
728        let client = MockHttpClient::new();
729        let url = "https://tiles.example.com/0/0/0.png";
730        client.queue_response(url, 500, vec![]);
731
732        let source = HttpTileSource::new(TEMPLATE, Box::new(client));
733        source.request(tile(0, 0, 0));
734
735        let results = source.poll();
736        assert_eq!(results.len(), 1);
737        let (_, result) = &results[0];
738        match result {
739            Err(TileError::Network(msg)) => assert!(msg.contains("500")),
740            other => panic!("expected Network error, got {other:?}"),
741        }
742    }
743
744    #[test]
745    fn http_2xx_range_is_success() {
746        let client = MockHttpClient::new();
747        let url = "https://tiles.example.com/0/0/0.png";
748        client.queue_response(url, 204, vec![0u8; 256 * 256 * 4]);
749
750        let source = HttpTileSource::new(TEMPLATE, Box::new(client));
751        source.request(tile(0, 0, 0));
752
753        let results = source.poll();
754        assert_eq!(results.len(), 1);
755        assert!(results[0].1.is_ok(), "2xx should be treated as success");
756    }
757
758    #[test]
759    fn transport_error_returns_network_error() {
760        let client = MockHttpClient::new();
761        let url = "https://tiles.example.com/0/0/0.png";
762        client.queue_error(url, "connection refused");
763
764        let source = HttpTileSource::new(TEMPLATE, Box::new(client));
765        source.request(tile(0, 0, 0));
766
767        let results = source.poll();
768        assert_eq!(results.len(), 1);
769        match &results[0].1 {
770            Err(TileError::Network(msg)) => assert!(msg.contains("connection refused")),
771            other => panic!("expected Network error, got {other:?}"),
772        }
773    }
774
775    // =====================================================================
776    // Default headers
777    // =====================================================================
778
779    #[test]
780    fn default_headers_are_sent() {
781        let client = MockHttpClient::new();
782        let handle = client.clone();
783
784        let source = HttpTileSource::new(TEMPLATE, Box::new(client))
785            .with_header("Authorization", "Bearer test-key")
786            .with_header("User-Agent", "rustial/0.1");
787
788        source.request(tile(0, 0, 0));
789
790        let headers = handle.last_sent_headers();
791        assert_eq!(headers.len(), 2);
792        assert_eq!(headers[0].0, "Authorization");
793        assert_eq!(headers[0].1, "Bearer test-key");
794        assert_eq!(headers[1].0, "User-Agent");
795        assert_eq!(headers[1].1, "rustial/0.1");
796    }
797
798    // =====================================================================
799    // Cancellation
800    // =====================================================================
801
802    #[test]
803    fn cancel_removes_pending_entry() {
804        let client = MockHttpClient::new();
805        let url = "https://tiles.example.com/0/0/0.png";
806        client.queue_response(url, 200, vec![0u8; 256 * 256 * 4]);
807
808        let source = HttpTileSource::new(TEMPLATE, Box::new(client));
809        source.request(tile(0, 0, 0));
810        assert_eq!(source.pending_count(), 1);
811
812        source.cancel(tile(0, 0, 0));
813        assert_eq!(source.pending_count(), 0);
814
815        let results = source.poll();
816        assert!(
817            results.is_empty(),
818            "cancelled tile should not appear in poll results"
819        );
820    }
821
822    // =====================================================================
823    // Pending count
824    // =====================================================================
825
826    #[test]
827    fn pending_count_tracks_inflight() {
828        let client = MockHttpClient::new();
829        let source = HttpTileSource::new(TEMPLATE, Box::new(client));
830
831        assert_eq!(source.pending_count(), 0);
832        source.request(tile(0, 0, 0));
833        assert_eq!(source.pending_count(), 1);
834        source.request(tile(1, 0, 0));
835        assert_eq!(source.pending_count(), 2);
836    }
837
838    // =====================================================================
839    // Unknown responses are ignored
840    // =====================================================================
841
842    #[test]
843    fn unknown_url_response_is_ignored() {
844        let client = MockHttpClient::new();
845        client.queue_response("https://other.example.com/tile.png", 200, vec![]);
846
847        let source = HttpTileSource::new(TEMPLATE, Box::new(client));
848        let results = source.poll();
849        assert!(results.is_empty());
850    }
851
852    // =====================================================================
853    // Debug
854    // =====================================================================
855
856    #[test]
857    fn debug_impl() {
858        let client = MockHttpClient::new();
859        let source = HttpTileSource::new(TEMPLATE, Box::new(client));
860        let dbg = format!("{source:?}");
861        assert!(dbg.contains("HttpTileSource"));
862        assert!(dbg.contains("url_template"));
863    }
864
865    // =====================================================================
866    // Conditional revalidation (304 Not Modified)
867    // =====================================================================
868
869    #[test]
870    fn request_revalidate_sends_if_none_match_header() {
871        let client = MockHttpClient::new();
872        let source = HttpTileSource::new(TEMPLATE, Box::new(client.clone()));
873        let id = TileId::new(1, 0, 0);
874
875        source.request_revalidate(
876            id,
877            RevalidationHint {
878                etag: Some("abc123".into()),
879                last_modified: None,
880            },
881        );
882
883        let headers = client.last_sent_headers();
884        let etag_header = headers.iter().find(|(k, _)| k == "If-None-Match");
885        assert!(etag_header.is_some(), "should include If-None-Match header");
886        assert_eq!(etag_header.unwrap().1, "abc123");
887    }
888
889    #[test]
890    fn request_revalidate_sends_if_modified_since_header() {
891        let client = MockHttpClient::new();
892        let source = HttpTileSource::new(TEMPLATE, Box::new(client.clone()));
893        let id = TileId::new(1, 0, 0);
894
895        source.request_revalidate(
896            id,
897            RevalidationHint {
898                etag: None,
899                last_modified: Some("Wed, 01 Jan 2025 00:00:00 GMT".into()),
900            },
901        );
902
903        let headers = client.last_sent_headers();
904        let lm_header = headers.iter().find(|(k, _)| k == "If-Modified-Since");
905        assert!(
906            lm_header.is_some(),
907            "should include If-Modified-Since header"
908        );
909        assert_eq!(lm_header.unwrap().1, "Wed, 01 Jan 2025 00:00:00 GMT");
910    }
911
912    #[test]
913    fn poll_returns_not_modified_for_304_response() {
914        let client = MockHttpClient::new();
915        let source = HttpTileSource::new(TEMPLATE, Box::new(client.clone()));
916        let id = TileId::new(1, 0, 0);
917        let url = source.tile_url(&id);
918
919        source.request(id);
920
921        client.queue_response_with_headers(
922            &url,
923            304,
924            vec![],
925            vec![
926                ("cache-control".into(), "max-age=600".into()),
927                ("etag".into(), "new-etag".into()),
928            ],
929        );
930
931        let results = source.poll();
932        assert_eq!(results.len(), 1);
933        let (result_id, result) = &results[0];
934        assert_eq!(*result_id, id);
935        let response = result.as_ref().expect("should be Ok");
936        assert!(response.not_modified, "should be a not-modified response");
937        assert_eq!(response.freshness.etag.as_deref(), Some("new-etag"));
938        assert!(response.freshness.expires_at.is_some());
939    }
940
941    #[test]
942    fn diagnostics_count_categorized_failures() {
943        let client = MockHttpClient::new();
944        let source = HttpTileSource::with_decoder(
945            TEMPLATE,
946            Box::new(client.clone()),
947            Box::new(FailingDecoder),
948        );
949
950        let decode_tile = tile(3, 0, 0);
951        let timeout_tile = tile(3, 0, 1);
952        let status_tile = tile(3, 0, 2);
953        let not_found_tile = tile(3, 0, 3);
954        let cancelled_tile = tile(3, 0, 4);
955
956        for tile_id in [
957            decode_tile,
958            timeout_tile,
959            status_tile,
960            not_found_tile,
961            cancelled_tile,
962        ] {
963            source.request(tile_id);
964        }
965
966        client.queue_response(&source.tile_url(&decode_tile), 200, vec![1, 2, 3]);
967        client.queue_error(&source.tile_url(&timeout_tile), "request timeout");
968        client.queue_response(&source.tile_url(&status_tile), 500, Vec::new());
969        client.queue_response(&source.tile_url(&not_found_tile), 404, Vec::new());
970
971        source.cancel(cancelled_tile);
972        client.queue_response(&source.tile_url(&cancelled_tile), 200, vec![0; 4]);
973
974        let _ = source.poll();
975        let diagnostics = source.diagnostics().expect("http source diagnostics");
976
977        assert_eq!(diagnostics.failure_diagnostics.decode_failures, 1);
978        assert_eq!(diagnostics.failure_diagnostics.transport_failures, 1);
979        assert_eq!(diagnostics.failure_diagnostics.timeout_failures, 1);
980        assert_eq!(diagnostics.failure_diagnostics.http_status_failures, 1);
981        assert_eq!(diagnostics.failure_diagnostics.not_found_failures, 1);
982        assert_eq!(diagnostics.failure_diagnostics.forced_cancellations, 1);
983        assert_eq!(
984            diagnostics.failure_diagnostics.ignored_completed_responses,
985            1
986        );
987    }
988}