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