Skip to main content

cachekit/backend/
mod.rs

1use std::collections::HashMap;
2use std::time::Duration;
3
4use async_trait::async_trait;
5
6use crate::error::BackendError;
7use crate::metrics::MetricsProvider;
8
9// ── HealthStatus ─────────────────────────────────────────────────────────────
10
11/// Describes the health of a backend at a point in time.
12#[derive(Debug, Clone)]
13pub struct HealthStatus {
14    /// Whether the backend is considered healthy.
15    pub is_healthy: bool,
16    /// Round-trip latency of the health check in milliseconds.
17    pub latency_ms: f64,
18    /// Human-readable name for this backend implementation.
19    pub backend_type: String,
20    /// Optional key-value details (pool size, version, etc.).
21    pub details: HashMap<String, String>,
22}
23
24// ── Freshness ────────────────────────────────────────────────────────────────
25
26/// The server's freshness for one read: the protocol's `X-CacheKit-Freshness`
27/// label and `X-CacheKit-Fresh-For` bound (`spec/saas-api.md` § Remaining
28/// Freshness). The client reads it to bound the L1 backfill.
29///
30/// The default is a fresh read with no server bound, which leaves the client's
31/// configured local TTL unchanged.
32#[doc(hidden)]
33#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
34pub struct Freshness {
35    /// The read was labelled stale, or with a token the client does not know.
36    /// The L1 must not backfill it.
37    pub is_stale: bool,
38    /// Remaining server freshness, or `None` when the header was absent. Zero
39    /// forbids the backfill.
40    pub fresh_for: Option<Duration>,
41}
42
43// ── Backend trait ─────────────────────────────────────────────────────────────
44
45/// Async cache backend abstraction.
46///
47/// Implementors must be `Send + Sync` on native targets (unless the `unsync`
48/// feature is enabled). On `wasm32` targets or with `unsync`, `Send` is relaxed
49/// (`?Send`) because the runtime is single-threaded.
50#[cfg(not(any(target_arch = "wasm32", feature = "unsync")))]
51#[async_trait]
52pub trait Backend: Send + Sync {
53    /// Retrieve the raw bytes stored under `key`, or `None` if absent.
54    async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, BackendError>;
55
56    /// Retrieve `key` together with the server's [`Freshness`] for it.
57    ///
58    /// Client plumbing for the L1 backfill, not part of the documented surface.
59    /// The default wraps [`Self::get`] as a fresh read with no bound, so a
60    /// backend without a freshness signal keeps today's behaviour. A backend
61    /// that wraps another must forward this method, or the signal is lost.
62    #[doc(hidden)]
63    async fn get_with_freshness(
64        &self,
65        key: &str,
66    ) -> Result<Option<(Vec<u8>, Freshness)>, BackendError> {
67        Ok(self
68            .get(key)
69            .await?
70            .map(|bytes| (bytes, Freshness::default())))
71    }
72
73    /// Store `value` under `key`, optionally expiring after `ttl`.
74    async fn set(
75        &self,
76        key: &str,
77        value: Vec<u8>,
78        ttl: Option<Duration>,
79    ) -> Result<(), BackendError>;
80
81    /// Remove `key` and return `true` if it existed.
82    ///
83    /// `CachekitIO` and `WorkersCachekitIO` return `true` on every successful
84    /// delete, whether or not the key existed: the server does not report
85    /// existence on `DELETE`.
86    async fn delete(&self, key: &str) -> Result<bool, BackendError>;
87
88    /// Return `true` if `key` exists without fetching the value.
89    async fn exists(&self, key: &str) -> Result<bool, BackendError>;
90
91    /// Return health/status information for this backend.
92    async fn health(&self) -> Result<HealthStatus, BackendError>;
93
94    /// Expose this backend's [`LockableBackend`] capability, if it has one.
95    ///
96    /// Trait objects (`dyn Backend`) cannot be cross-cast to a sibling trait,
97    /// so backends that support distributed locking opt in by overriding this
98    /// to return `Some(self)`. Used by the client's cold-miss single-flight
99    /// for cross-process fill suppression. Default: `None`.
100    fn as_lockable(&self) -> Option<&dyn LockableBackend> {
101        None
102    }
103
104    /// Receive the client's live hit/miss statistics.
105    ///
106    /// `CacheKitBuilder::build` calls this once, on the raw backend before any
107    /// reliability decorator, so a backend that reports telemetry — the
108    /// cachekit.io backends' `X-CacheKit-*` headers — carries real numbers
109    /// with no user plumbing. A provider the user set on the backend's own
110    /// builder must take precedence; implementations keep the first value
111    /// they receive — so one backend instance reports one client, the first
112    /// built over it (build one backend per client, as the presets do, for
113    /// per-client attribution). The provider holds the client's counters
114    /// weakly and reports `None` once that client is gone. Backends with
115    /// nothing to report keep this default no-op.
116    fn attach_metrics(&self, _provider: MetricsProvider) {}
117}
118
119/// Async cache backend abstraction (`?Send` variant).
120///
121/// Active when compiling for `wasm32` or with the `unsync` feature.
122/// Identical API to the `Send + Sync` variant but without thread-safety bounds.
123#[cfg(any(target_arch = "wasm32", feature = "unsync"))]
124#[async_trait(?Send)]
125pub trait Backend {
126    /// Retrieve the raw bytes stored under `key`, or `None` if absent.
127    async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, BackendError>;
128
129    /// Retrieve `key` together with the server's [`Freshness`] for it.
130    ///
131    /// Client plumbing for the L1 backfill, not part of the documented surface.
132    /// The default wraps [`Self::get`] as a fresh read with no bound, so a
133    /// backend without a freshness signal keeps today's behaviour. A backend
134    /// that wraps another must forward this method, or the signal is lost.
135    #[doc(hidden)]
136    async fn get_with_freshness(
137        &self,
138        key: &str,
139    ) -> Result<Option<(Vec<u8>, Freshness)>, BackendError> {
140        Ok(self
141            .get(key)
142            .await?
143            .map(|bytes| (bytes, Freshness::default())))
144    }
145
146    /// Store `value` under `key`, optionally expiring after `ttl`.
147    async fn set(
148        &self,
149        key: &str,
150        value: Vec<u8>,
151        ttl: Option<Duration>,
152    ) -> Result<(), BackendError>;
153
154    /// Remove `key` and return `true` if it existed.
155    ///
156    /// `CachekitIO` and `WorkersCachekitIO` return `true` on every successful
157    /// delete, whether or not the key existed: the server does not report
158    /// existence on `DELETE`.
159    async fn delete(&self, key: &str) -> Result<bool, BackendError>;
160
161    /// Return `true` if `key` exists without fetching the value.
162    async fn exists(&self, key: &str) -> Result<bool, BackendError>;
163
164    /// Return health/status information for this backend.
165    async fn health(&self) -> Result<HealthStatus, BackendError>;
166
167    /// Expose this backend's [`LockableBackend`] capability, if it has one.
168    ///
169    /// Trait objects (`dyn Backend`) cannot be cross-cast to a sibling trait,
170    /// so backends that support distributed locking opt in by overriding this
171    /// to return `Some(self)`. Used by the client's cold-miss single-flight
172    /// for cross-process fill suppression. Default: `None`.
173    fn as_lockable(&self) -> Option<&dyn LockableBackend> {
174        None
175    }
176
177    /// Receive the client's live hit/miss statistics.
178    ///
179    /// `CacheKitBuilder::build` calls this once, on the raw backend before any
180    /// reliability decorator, so a backend that reports telemetry — the
181    /// cachekit.io backends' `X-CacheKit-*` headers — carries real numbers
182    /// with no user plumbing. A provider the user set on the backend's own
183    /// builder must take precedence; implementations keep the first value
184    /// they receive — so one backend instance reports one client, the first
185    /// built over it (build one backend per client, as the presets do, for
186    /// per-client attribution). The provider holds the client's counters
187    /// weakly and reports `None` once that client is gone. Backends with
188    /// nothing to report keep this default no-op.
189    fn attach_metrics(&self, _provider: MetricsProvider) {}
190}
191
192// ── TtlInspectable ───────────────────────────────────────────────────────────
193
194/// Optional extension for backends that can report the remaining TTL of a key.
195#[cfg(not(any(target_arch = "wasm32", feature = "unsync")))]
196#[async_trait]
197pub trait TtlInspectable: Backend {
198    /// Return the remaining TTL for `key`, or `None` if the key does not exist
199    /// or has no expiry.
200    async fn ttl(&self, key: &str) -> Result<Option<Duration>, BackendError>;
201
202    /// Refresh the TTL on an existing key. Default: not supported.
203    async fn refresh_ttl(&self, _key: &str, _ttl: Duration) -> Result<bool, BackendError> {
204        Err(BackendError::permanent(
205            "refresh_ttl not supported by this backend",
206        ))
207    }
208}
209
210/// Optional extension for backends that can report the remaining TTL of a key (`?Send` variant).
211#[cfg(any(target_arch = "wasm32", feature = "unsync"))]
212#[async_trait(?Send)]
213pub trait TtlInspectable: Backend {
214    /// Return the remaining TTL for `key`, or `None` if the key does not exist
215    /// or has no expiry.
216    async fn ttl(&self, key: &str) -> Result<Option<Duration>, BackendError>;
217
218    /// Refresh the TTL on an existing key. Default: not supported.
219    async fn refresh_ttl(&self, _key: &str, _ttl: Duration) -> Result<bool, BackendError> {
220        Err(BackendError::permanent(
221            "refresh_ttl not supported by this backend",
222        ))
223    }
224}
225
226// ── LockableBackend ─────────────────────────────────────────────────────────
227
228/// Optional extension for backends that support distributed locking.
229#[cfg(not(any(target_arch = "wasm32", feature = "unsync")))]
230#[async_trait]
231pub trait LockableBackend: Backend {
232    /// Acquire a distributed lock. Returns lock_id if acquired, None if contested.
233    async fn acquire_lock(
234        &self,
235        key: &str,
236        timeout_ms: u64,
237    ) -> Result<Option<String>, BackendError>;
238    /// Release a distributed lock. Returns true if released.
239    async fn release_lock(&self, key: &str, lock_id: &str) -> Result<bool, BackendError>;
240}
241
242/// Optional extension for backends that support distributed locking (`?Send` variant).
243#[cfg(any(target_arch = "wasm32", feature = "unsync"))]
244#[async_trait(?Send)]
245pub trait LockableBackend: Backend {
246    /// Acquire a distributed lock. Returns lock_id if acquired, None if contested.
247    async fn acquire_lock(
248        &self,
249        key: &str,
250        timeout_ms: u64,
251    ) -> Result<Option<String>, BackendError>;
252    /// Release a distributed lock. Returns true if released.
253    async fn release_lock(&self, key: &str, lock_id: &str) -> Result<bool, BackendError>;
254}
255
256// ── Blocking-pool bridge (file + memcached backends) ─────────────────────────
257
258/// Run sync I/O on tokio's blocking pool so the async executor never stalls.
259#[cfg(all(any(feature = "file", feature = "memcached"), not(feature = "unsync")))]
260pub(crate) async fn run_blocking<T: Send + 'static>(
261    f: impl FnOnce() -> Result<T, crate::error::BackendError> + Send + 'static,
262) -> Result<T, crate::error::BackendError> {
263    tokio::task::spawn_blocking(f).await.map_err(|e| {
264        crate::error::BackendError::permanent(format!("backend blocking task failed: {e}"))
265    })?
266}
267
268/// `unsync` opts into single-threaded runtimes and drops `Send` from
269/// `BackendError`, so results cannot cross `spawn_blocking`. Run the I/O
270/// inline instead — the same sync-in-async trade-off cachekit-py documents.
271#[cfg(all(any(feature = "file", feature = "memcached"), feature = "unsync"))]
272pub(crate) async fn run_blocking<T>(
273    f: impl FnOnce() -> Result<T, crate::error::BackendError>,
274) -> Result<T, crate::error::BackendError> {
275    f()
276}
277
278// ── Cache-key path encoding (CWE-22) ─────────────────────────────────────────
279
280/// Percent-encode a cache key for the `/v1/cache/{key}` path segment, shared by
281/// the native `cachekitio` and wasm `workers` backends (DRY: every CachekitIO
282/// path is built through this one fallible chokepoint).
283///
284/// Almost every key is just [`urlencoding::encode`]. The exceptions are the
285/// empty key and a key whose encoded form is one of the **five reserved path
286/// segments** — `.`, `..`, `health`, `ttl`, `lock` — which are **rejected** with
287/// a permanent [`BackendError`] rather than sent. This is the client's half of
288/// the protocol `spec/saas-api.md` § Cache-Key Path Encoding, rule 2.
289///
290/// Three distinct hazards, each taking the request off the key's own
291/// `/v1/cache/{key}` path and sending the app's bearer token somewhere the SaaS
292/// `cache-key-validator` never vets (CWE-22):
293///
294/// - **The empty key.** It encodes to an empty segment, so `/v1/cache/{key}`
295///   becomes `/v1/cache/` and `/v1/cache/{key}/ttl` becomes `/v1/cache//ttl`,
296///   neither of which addresses a stored entry.
297/// - **Dot segments (`.`, `..`).** A dot is RFC-3986 *unreserved*, so
298///   `urlencoding::encode("..") == ".."` unchanged, and `reqwest`'s WHATWG URL
299///   parser (rust-url) removes that dot-segment **before the request leaves the
300///   process**: `/v1/cache/..` → `/v1/`, `…/../ttl` → `…/ttl`. Percent-encoding
301///   does not help: WHATWG treats `%2e` / `%2e%2e` (case-insensitive) as
302///   dot-segments too, so `%2E%2E` → `/v1/` and `%2E` → `/v1/cache/` collapse
303///   just the same (verified in `repro_raw_dot_key_escapes_the_cache_prefix`).
304///   Since every representation that `decodeURIComponent`s once back to `.`/`..`
305///   is a WHATWG dot-segment, no encoding both reaches the wire intact and
306///   round-trips — the only safe action is to refuse to build the request.
307/// - **Route tokens (`health`, `ttl`, `lock`).** These are live path tokens at
308///   this level: `/v1/cache/health` IS the health endpoint (see `health_url`),
309///   and a trailing `ttl` / `lock` segment selects a sub-resource. A key of
310///   exactly one of those words routes elsewhere or reads as an empty key, so
311///   the spec reserves them client-side too.
312///
313/// Only an *entirely*-reserved segment is caught: `a:..`, `..a`, `x..y` are
314/// inert and sent per rule 1 with their dots raw. Canonical and interop keys
315/// always contain `:` and never meet this rule, so for every non-reserved key
316/// the output is byte-identical to `urlencoding::encode` — the same bytes as
317/// cachekit-py; cachekit-ts may leave `! * ' ( )` raw, which decodes to the same
318/// key (spec rule 4). rust-url's uniform rejection matches the cachekit-ts twin;
319/// it diverges from cachekit-py's older `%2E` rewrite
320/// (`src/cachekit/backends/cachekitio/backend.py:247-250` @ `f000ba3`), whose
321/// RFC-3986 client kept `%2E%2E` on the wire — the spec now mandates uniform
322/// client-side rejection on every stack.
323#[cfg(any(feature = "cachekitio", feature = "workers", test))]
324pub(crate) fn encode_key(key: &str) -> Result<std::borrow::Cow<'_, str>, BackendError> {
325    let encoded = urlencoding::encode(key);
326    // spec/saas-api.md § Cache-Key Path Encoding rule 2: reject the empty key
327    // and a key whose encoded form is exactly one of the five reserved segments.
328    if matches!(
329        encoded.as_ref(),
330        "" | "." | ".." | "health" | "ttl" | "lock"
331    ) {
332        return Err(BackendError::permanent(
333            "cache key must not be empty or a reserved path segment (`.`, `..`, \
334             `health`, `ttl`, `lock`): the request path would leave \
335             `/v1/cache/{key}` (CWE-22), so it cannot be addressed on the wire",
336        ));
337    }
338    Ok(encoded)
339}
340
341/// Whether a `DELETE /v1/cache/{key}` status is a success. The server answers
342/// `200` whether or not the key existed and never `404` (`spec/saas-api.md`),
343/// so success carries no existence signal and every other status, `404`
344/// included, takes the caller's error path. Shared by the native and Workers
345/// backends so their mappings cannot drift.
346#[cfg(any(feature = "cachekitio", feature = "workers", test))]
347pub(crate) fn delete_succeeded(status: u16) -> bool {
348    matches!(status, 200 | 204)
349}
350
351/// The whole seconds a write's TTL goes on the wire as. `spec/saas-api.md`
352/// says a positive sub-second TTL MUST be ceiled to 1, never truncated to 0.
353/// From 1 s up it truncates, so the wire TTL stays an upper bound on the
354/// requested one and the writer's L1 copy can be bounded by it. Zero stays
355/// zero: TTL 0 is an error, and the server rejects it with `400`.
356#[cfg(any(
357    feature = "cachekitio",
358    feature = "workers",
359    feature = "l1",
360    feature = "tracing",
361    test
362))]
363pub(crate) fn ttl_wire_secs(ttl: Duration) -> u64 {
364    if ttl.is_zero() {
365        return 0;
366    }
367    ttl.as_secs().max(1)
368}
369
370/// The `PUT /v1/cache/{key}` header that carries a write's TTL in whole
371/// seconds ([`ttl_wire_secs`]). `spec/saas-api.md` says SDKs MUST send
372/// `X-CacheKit-TTL` only: the legacy `X-TTL` goes away in protocol 2.0, and a
373/// write that sends only it would then be stored with no expiry. Shared by the
374/// native and Workers backends so their wire forms cannot drift.
375#[cfg(any(feature = "cachekitio", feature = "workers", test))]
376pub(crate) fn ttl_header(ttl: Duration) -> (&'static str, String) {
377    ("X-CacheKit-TTL", ttl_wire_secs(ttl).to_string())
378}
379
380// ── Feature-gated backend modules ─────────────────────────────────────────────
381
382/// JSON wire bodies for the SaaS lock/TTL endpoints. Compiled under `test`
383/// unconditionally so the wire-contract round-trip tests always run in CI,
384/// even though only the `workers` backend consumes the structs at runtime.
385#[cfg(any(feature = "workers", test))]
386mod saas_wire;
387
388/// HTTP backend for the cachekit.io SaaS API.
389#[cfg(feature = "cachekitio")]
390pub mod cachekitio;
391#[cfg(feature = "cachekitio")]
392mod cachekitio_lock;
393#[cfg(feature = "cachekitio")]
394mod cachekitio_ttl;
395
396/// Redis backend via the [`fred`](https://crates.io/crates/fred) client.
397#[cfg(feature = "redis")]
398pub mod redis;
399
400/// Memcached backend via the [`rust-memcache`](https://crates.io/crates/memcache) client.
401#[cfg(feature = "memcached")]
402pub mod memcached;
403
404/// Local filesystem backend, byte-compatible with cachekit-py's File backend.
405#[cfg(feature = "file")]
406pub mod file;
407
408/// Cloudflare Workers backend using `worker::Fetch`.
409#[cfg(feature = "workers")]
410pub mod workers;
411
412// ── Path-encoding protocol vectors (test-only) ───────────────────────────────
413
414/// Loader for `tests/vectors/path-encoding.json`, vendored verbatim from
415/// cachekit-io/protocol `test-vectors/path-encoding.json` 1.1.0 (merge commit
416/// `774281b09892a064feee6049ee29beb62f068804`). Do not edit the JSON here;
417/// change it upstream and re-vendor, then update `SHA256`.
418///
419/// Every path-encoding conformance assertion reads its keys from this file, so
420/// a row added upstream reaches the rs suite with no test edit. The only
421/// rs-local list is `RS_NEAR_MISSES`, which can add acceptances but never hide
422/// a reject row.
423#[cfg(test)]
424#[allow(clippy::expect_used)] // test-only: a malformed vendored fixture should panic loudly
425pub(crate) mod path_encoding_vectors {
426    use serde::Deserialize;
427
428    const JSON: &str = include_str!("../../tests/vectors/path-encoding.json");
429
430    /// sha256 of the vendored file, pinned so a local edit cannot drift from the
431    /// protocol copy unnoticed.
432    const SHA256: &str = "8f6fd4be5440da9cf4bbb1a112cb89c410c4e46734c7d8a9c023eaa034727ee3"; // pragma: allowlist secret
433
434    /// One fixture row. `deny_unknown_fields` makes a new row field upstream
435    /// fail loudly here rather than be silently ignored by the tests.
436    #[derive(Deserialize)]
437    #[serde(deny_unknown_fields)]
438    pub(crate) struct Vector {
439        pub(crate) key: String,
440        pub(crate) encoded: Option<String>,
441        #[serde(default)]
442        pub(crate) reject: bool,
443        // Declared only so `deny_unknown_fields` accepts them. `decoded` and the
444        // `encodeURIComponent` alternates are verified by the protocol's own CI.
445        #[serde(rename = "decoded")]
446        _decoded: Option<String>,
447        #[serde(rename = "encoded_alternates", default)]
448        _encoded_alternates: Vec<String>,
449        #[serde(rename = "note")]
450        _note: String,
451    }
452
453    #[derive(Deserialize)]
454    struct Fixture {
455        vectors: Vec<Vector>,
456    }
457
458    fn all() -> Vec<Vector> {
459        serde_json::from_str::<Fixture>(JSON)
460            .expect("vendored path-encoding.json must match the fixture schema")
461            .vectors
462    }
463
464    /// Keys spec rule 2 reserves: a conformant client refuses to build a URL.
465    pub(crate) fn reject_keys() -> Vec<String> {
466        all()
467            .into_iter()
468            .filter(|v| v.reject)
469            .map(|v| v.key)
470            .collect()
471    }
472
473    /// Rows a conformant client sends.
474    pub(crate) fn transmittable() -> Vec<Vector> {
475        all().into_iter().filter(|v| !v.reject).collect()
476    }
477
478    /// rs-local, acceptance-only regressions that are NOT fixture rows: route-token
479    /// near-misses that a prefix, case-insensitive or `contains` guard would
480    /// wrongly reject. The fixture has none of those; its `x/../../health` row
481    /// covers the suffix case, and `a:..` / `..a` cover the dot cases.
482    pub(crate) const RS_NEAR_MISSES: &[&str] = &["healthy", "HEALTH", "ttls", "unlock"];
483
484    #[test]
485    fn vendored_fixture_matches_the_pinned_sha256() {
486        use sha2::{Digest, Sha256};
487        assert_eq!(
488            hex::encode(Sha256::digest(JSON.as_bytes())),
489            SHA256,
490            "tests/vectors/path-encoding.json differs from the pinned protocol copy: \
491             re-vendor it from protocol and update SHA256"
492        );
493    }
494}
495
496// ── encode_key unit tests (CWE-22) ───────────────────────────────────────────
497
498#[cfg(test)]
499#[allow(clippy::expect_used)] // test-only: an encoding failure on a safe key should panic loudly
500mod encode_key_tests {
501    use super::encode_key;
502    use super::path_encoding_vectors::{reject_keys, transmittable, RS_NEAR_MISSES};
503
504    #[test]
505    fn fixture_reject_rows_are_rejected() {
506        // spec/saas-api.md rule 2: the empty key, `.`/`..` and the route tokens
507        // are not addressable on `/v1/cache/{key}` (builder-level coverage:
508        // `cachekitio::path_encoding_tests::reserved_segments_rejected_by_every_builder`).
509        let keys = reject_keys();
510        assert!(!keys.is_empty(), "fixture has no reject rows");
511        for k in &keys {
512            assert!(
513                encode_key(k).is_err(),
514                "reserved key {k:?} must be rejected"
515            );
516        }
517    }
518
519    #[test]
520    fn fixture_transmittable_rows_encode_to_the_reference_form() {
521        // The fixture accepts `[encoded] + encoded_alternates`; rs holds itself to
522        // `encoded`, the reference form, so its wire bytes stay identical to
523        // cachekit-py's (the alternates cover cachekit-ts's raw `! * ' ( )`).
524        let rows = transmittable();
525        assert!(!rows.is_empty(), "fixture has no transmittable rows");
526        for row in &rows {
527            let enc = encode_key(&row.key).expect("transmittable fixture key must encode");
528            assert_eq!(
529                Some(enc.as_ref()),
530                row.encoded.as_deref(),
531                "encode_key({:?}) is not the fixture's reference form",
532                row.key
533            );
534        }
535    }
536
537    #[test]
538    fn near_misses_are_not_rejected() {
539        for k in RS_NEAR_MISSES {
540            assert!(encode_key(k).is_ok(), "near-miss key {k:?} must encode");
541        }
542    }
543}
544
545// ── delete_succeeded unit tests ──────────────────────────────────────────────
546
547#[cfg(test)]
548mod delete_status_tests {
549    use super::delete_succeeded;
550
551    #[test]
552    fn delete_success_is_200_or_204() {
553        assert!(delete_succeeded(200));
554        assert!(delete_succeeded(204));
555    }
556
557    #[test]
558    fn delete_404_is_not_a_miss() {
559        // The server never answers DELETE with 404, so it is an error, not `Ok(false)`.
560        assert!(!delete_succeeded(404));
561        for status in [201, 400, 401, 403, 429, 500, 503] {
562            assert!(!delete_succeeded(status), "{status}");
563        }
564    }
565}
566
567// ── ttl_header unit tests ────────────────────────────────────────────────────
568
569#[cfg(test)]
570mod ttl_header_tests {
571    use std::time::Duration;
572
573    use super::{ttl_header, ttl_wire_secs};
574
575    #[test]
576    fn ttl_header_is_the_canonical_name_in_whole_seconds() {
577        assert_eq!(
578            ttl_header(Duration::from_millis(60_900)),
579            ("X-CacheKit-TTL", "60".to_owned())
580        );
581    }
582
583    #[test]
584    fn ttl_header_sends_a_sub_second_ttl_as_one() {
585        assert_eq!(
586            ttl_header(Duration::from_millis(500)),
587            ("X-CacheKit-TTL", "1".to_owned())
588        );
589    }
590
591    #[test]
592    fn wire_secs_ceil_sub_second_and_truncate_the_rest() {
593        assert_eq!(ttl_wire_secs(Duration::from_millis(500)), 1);
594        assert_eq!(ttl_wire_secs(Duration::from_nanos(1)), 1);
595        assert_eq!(ttl_wire_secs(Duration::from_secs(60)), 60);
596        assert_eq!(ttl_wire_secs(Duration::from_millis(60_900)), 60);
597    }
598
599    #[test]
600    fn wire_secs_keep_zero_as_zero() {
601        assert_eq!(ttl_wire_secs(Duration::ZERO), 0);
602    }
603}
604
605// ── In-memory backend for crate-internal tests ───────────────────────────────
606
607/// In-memory [`Backend`] for crate-internal tests that must see the bytes
608/// that actually reached the backend. Clones share one store.
609#[cfg(test)]
610#[allow(dead_code)] // its tests are feature-gated; builds without them still compile it
611#[derive(Clone, Default)]
612pub(crate) struct MemoryBackend {
613    pub(crate) store: std::sync::Arc<tokio::sync::Mutex<HashMap<String, Vec<u8>>>>,
614}
615
616#[cfg(test)]
617#[cfg_attr(not(any(target_arch = "wasm32", feature = "unsync")), async_trait)]
618#[cfg_attr(any(target_arch = "wasm32", feature = "unsync"), async_trait(?Send))]
619impl Backend for MemoryBackend {
620    async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, BackendError> {
621        Ok(self.store.lock().await.get(key).cloned())
622    }
623
624    async fn set(
625        &self,
626        key: &str,
627        value: Vec<u8>,
628        _ttl: Option<Duration>,
629    ) -> Result<(), BackendError> {
630        self.store.lock().await.insert(key.to_owned(), value);
631        Ok(())
632    }
633
634    async fn delete(&self, key: &str) -> Result<bool, BackendError> {
635        Ok(self.store.lock().await.remove(key).is_some())
636    }
637
638    async fn exists(&self, key: &str) -> Result<bool, BackendError> {
639        Ok(self.store.lock().await.contains_key(key))
640    }
641
642    async fn health(&self) -> Result<HealthStatus, BackendError> {
643        Ok(HealthStatus {
644            is_healthy: true,
645            latency_ms: 0.0,
646            backend_type: "memory".to_owned(),
647            details: HashMap::new(),
648        })
649    }
650}