Skip to main content

oxirs_gql/cache/
response_cache.rs

1//! GraphQL Response Cache with CachePolicy and @cacheControl directive support.
2//!
3//! Provides:
4//! - `GraphQlResponseCache`: persisted cache keyed by `(tenant_id, query_hash, variables_hash)`
5//!   with per-entry `CachePolicy` controlling max-age and stale-while-revalidate.
6//! - `FieldLevelCacheDirective`: parses `@cacheControl(maxAge: N)` annotations and
7//!   computes the *effective* max-age as the minimum across all resolved fields.
8
9use std::collections::{HashMap, HashSet};
10use std::sync::{Arc, Mutex};
11use std::time::Instant;
12
13// ---------------------------------------------------------------------------
14// Cache key
15// ---------------------------------------------------------------------------
16
17/// Per-tenant, per-query cache key derived from FNV-1a hashes.
18#[derive(Debug, Clone, Hash, PartialEq, Eq)]
19pub struct ResponseCacheKey {
20    /// Optional tenant isolator — `None` means "global / no tenancy".
21    pub tenant_id: Option<String>,
22    /// FNV-1a hash of the normalised GraphQL query string.
23    pub query_hash: u64,
24    /// FNV-1a hash of the serialised variables JSON, or `0` when absent.
25    pub variables_hash: u64,
26}
27
28impl ResponseCacheKey {
29    /// Construct a key from raw string inputs.
30    pub fn new(tenant_id: Option<&str>, query: &str, variables: Option<&str>) -> Self {
31        Self {
32            tenant_id: tenant_id.map(|s| s.to_string()),
33            query_hash: fnv1a(query),
34            variables_hash: variables.map(fnv1a).unwrap_or(0),
35        }
36    }
37}
38
39fn fnv1a(s: &str) -> u64 {
40    const BASIS: u64 = 14_695_981_039_346_656_037;
41    const PRIME: u64 = 1_099_511_628_211;
42    s.bytes()
43        .fold(BASIS, |h, b| h.wrapping_mul(PRIME) ^ u64::from(b))
44}
45
46// ---------------------------------------------------------------------------
47// Cache policy
48// ---------------------------------------------------------------------------
49
50/// Controls how long a cached GraphQL response remains valid.
51#[derive(Debug, Clone)]
52pub struct CachePolicy {
53    /// Time in milliseconds for which the entry is considered *fresh*.
54    pub max_age_ms: u64,
55    /// Additional window (ms) during which the stale entry may be served
56    /// while a background revalidation is in flight.
57    pub stale_while_revalidate_ms: u64,
58    /// When `true` the cache key is further distinguished by the authenticated
59    /// user identity so different users never see each other's responses.
60    pub vary_by_user: bool,
61}
62
63impl CachePolicy {
64    /// Construct a simple policy without stale-while-revalidate or per-user
65    /// variation.
66    pub fn simple(max_age_ms: u64) -> Self {
67        Self {
68            max_age_ms,
69            stale_while_revalidate_ms: 0,
70            vary_by_user: false,
71        }
72    }
73
74    /// Returns `true` if the policy allows serving a stale entry that is `age_ms`
75    /// old.
76    pub fn is_stale_usable(&self, age_ms: u64) -> bool {
77        age_ms < self.max_age_ms + self.stale_while_revalidate_ms
78    }
79}
80
81impl Default for CachePolicy {
82    fn default() -> Self {
83        Self::simple(60_000)
84    }
85}
86
87// ---------------------------------------------------------------------------
88// Cached response
89// ---------------------------------------------------------------------------
90
91/// A serialised GraphQL response together with its caching metadata.
92#[derive(Debug, Clone)]
93pub struct CachedResponse {
94    /// The serialised JSON response body.
95    pub body: String,
96    /// When this entry was stored.
97    pub stored_at: Instant,
98    /// The policy in effect when the entry was stored.
99    pub policy: CachePolicy,
100    /// GraphQL type names touched when producing this response.
101    /// Used for type-level invalidation.
102    pub touched_types: Vec<String>,
103    /// `"TypeName.fieldName"` strings for field-level invalidation.
104    pub touched_fields: Vec<String>,
105    /// Number of times this entry has been served.
106    pub hit_count: u64,
107}
108
109impl CachedResponse {
110    fn age_ms(&self) -> u64 {
111        self.stored_at.elapsed().as_millis() as u64
112    }
113
114    /// Returns `true` if the entry is within the max-age window.
115    pub fn is_fresh(&self) -> bool {
116        self.age_ms() < self.policy.max_age_ms
117    }
118
119    /// Returns `true` if the entry is stale but still usable under the
120    /// stale-while-revalidate window.
121    pub fn is_stale_usable(&self) -> bool {
122        self.policy.is_stale_usable(self.age_ms())
123    }
124
125    /// Returns the remaining fresh time in milliseconds (0 if expired).
126    pub fn remaining_fresh_ms(&self) -> u64 {
127        self.policy.max_age_ms.saturating_sub(self.age_ms())
128    }
129}
130
131// ---------------------------------------------------------------------------
132// Internal store
133// ---------------------------------------------------------------------------
134
135struct ResponseStore {
136    entries: HashMap<ResponseCacheKey, CachedResponse>,
137    /// type_name -> keys that touched that type.
138    type_index: HashMap<String, HashSet<ResponseCacheKey>>,
139    /// "TypeName.field" -> keys that touched that field.
140    field_index: HashMap<String, HashSet<ResponseCacheKey>>,
141    /// tenant_id -> keys for that tenant.
142    tenant_index: HashMap<String, HashSet<ResponseCacheKey>>,
143    max_entries: usize,
144}
145
146impl ResponseStore {
147    fn new(max_entries: usize) -> Self {
148        Self {
149            entries: HashMap::new(),
150            type_index: HashMap::new(),
151            field_index: HashMap::new(),
152            tenant_index: HashMap::new(),
153            max_entries,
154        }
155    }
156
157    fn insert(&mut self, key: ResponseCacheKey, entry: CachedResponse) {
158        // Index by type names
159        for type_name in &entry.touched_types {
160            self.type_index
161                .entry(type_name.clone())
162                .or_default()
163                .insert(key.clone());
164        }
165        // Index by field paths
166        for field_path in &entry.touched_fields {
167            self.field_index
168                .entry(field_path.clone())
169                .or_default()
170                .insert(key.clone());
171        }
172        // Index by tenant
173        if let Some(tid) = &key.tenant_id {
174            self.tenant_index
175                .entry(tid.clone())
176                .or_default()
177                .insert(key.clone());
178        }
179
180        // Simple capacity cap: evict one old entry when full
181        if self.entries.len() >= self.max_entries {
182            if let Some(oldest) = self.entries.keys().next().cloned() {
183                self.remove(&oldest);
184            }
185        }
186
187        self.entries.insert(key, entry);
188    }
189
190    fn remove(&mut self, key: &ResponseCacheKey) {
191        if let Some(entry) = self.entries.remove(key) {
192            for t in &entry.touched_types {
193                if let Some(s) = self.type_index.get_mut(t) {
194                    s.remove(key);
195                }
196            }
197            for f in &entry.touched_fields {
198                if let Some(s) = self.field_index.get_mut(f) {
199                    s.remove(key);
200                }
201            }
202            if let Some(tid) = &key.tenant_id {
203                if let Some(s) = self.tenant_index.get_mut(tid) {
204                    s.remove(key);
205                }
206            }
207        }
208    }
209
210    fn remove_keys(&mut self, keys: Vec<ResponseCacheKey>) -> usize {
211        let n = keys.len();
212        for k in keys {
213            self.remove(&k);
214        }
215        n
216    }
217}
218
219// ---------------------------------------------------------------------------
220// Public cache
221// ---------------------------------------------------------------------------
222
223/// Thread-safe GraphQL response cache supporting type- and field-level
224/// invalidation and stale-while-revalidate semantics.
225pub struct GraphQlResponseCache {
226    store: Arc<Mutex<ResponseStore>>,
227}
228
229impl std::fmt::Debug for GraphQlResponseCache {
230    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
231        f.debug_struct("GraphQlResponseCache").finish()
232    }
233}
234
235impl GraphQlResponseCache {
236    /// Create a new cache with the given capacity.
237    pub fn new(max_entries: usize) -> Self {
238        Self {
239            store: Arc::new(Mutex::new(ResponseStore::new(max_entries))),
240        }
241    }
242
243    /// Look up a cached response.
244    ///
245    /// Returns `Some(CachedResponse)` when the entry exists and is either
246    /// fresh or within the stale-while-revalidate window.  Expired entries
247    /// are removed eagerly.
248    pub fn cached_response(&self, key: &ResponseCacheKey) -> Option<CachedResponse> {
249        let mut store = self.store.lock().unwrap_or_else(|p| p.into_inner());
250        match store.entries.get(key) {
251            None => None,
252            Some(entry) if !entry.is_fresh() && !entry.is_stale_usable() => {
253                let k = key.clone();
254                store.remove(&k);
255                None
256            }
257            Some(entry) => {
258                let mut response = entry.clone();
259                response.hit_count += 1;
260                let k = key.clone();
261                store.entries.entry(k).and_modify(|e| e.hit_count += 1);
262                Some(response)
263            }
264        }
265    }
266
267    /// Store a response in the cache.
268    pub fn store_response(
269        &self,
270        key: ResponseCacheKey,
271        body: String,
272        policy: CachePolicy,
273        touched_types: Vec<String>,
274        touched_fields: Vec<String>,
275    ) {
276        let entry = CachedResponse {
277            body,
278            stored_at: Instant::now(),
279            policy,
280            touched_types,
281            touched_fields,
282            hit_count: 0,
283        };
284        let mut store = self.store.lock().unwrap_or_else(|p| p.into_inner());
285        store.insert(key, entry);
286    }
287
288    /// Invalidate all entries that touched `type_name`.
289    ///
290    /// Returns the number of entries evicted.
291    pub fn invalidate_type(&self, type_name: &str) -> usize {
292        let mut store = self.store.lock().unwrap_or_else(|p| p.into_inner());
293        let keys: Vec<ResponseCacheKey> = store
294            .type_index
295            .get(type_name)
296            .cloned()
297            .unwrap_or_default()
298            .into_iter()
299            .collect();
300        store.remove_keys(keys)
301    }
302
303    /// Invalidate all entries that touched field `"type_name.field_name"`.
304    ///
305    /// Returns the number of entries evicted.
306    pub fn invalidate_field(&self, type_name: &str, field: &str) -> usize {
307        let path = format!("{type_name}.{field}");
308        let mut store = self.store.lock().unwrap_or_else(|p| p.into_inner());
309        let keys: Vec<ResponseCacheKey> = store
310            .field_index
311            .get(&path)
312            .cloned()
313            .unwrap_or_default()
314            .into_iter()
315            .collect();
316        store.remove_keys(keys)
317    }
318
319    /// Pre-populate the cache by computing keys for a list of `(query, variables)` pairs.
320    ///
321    /// This marks the slot as *absent* so callers know they need to prime the
322    /// actual responses. In practice, the warm-up caller would follow up with
323    /// `store_response` calls once results are fetched.  Here we return the
324    /// computed keys so the caller can do so.
325    pub fn warm_up(
326        &self,
327        tenant_id: Option<&str>,
328        queries: &[(String, serde_json::Value)],
329    ) -> Vec<ResponseCacheKey> {
330        queries
331            .iter()
332            .map(|(q, v)| {
333                let vars_json = v.to_string();
334                let vars_str = if vars_json == "null" {
335                    None
336                } else {
337                    Some(vars_json.as_str())
338                };
339                ResponseCacheKey::new(tenant_id, q, vars_str)
340            })
341            .collect()
342    }
343
344    /// Remove all entries for a given tenant.
345    pub fn invalidate_tenant(&self, tenant_id: &str) -> usize {
346        let mut store = self.store.lock().unwrap_or_else(|p| p.into_inner());
347        let keys: Vec<ResponseCacheKey> = store
348            .tenant_index
349            .get(tenant_id)
350            .cloned()
351            .unwrap_or_default()
352            .into_iter()
353            .collect();
354        store.remove_keys(keys)
355    }
356
357    /// Current number of entries in the cache.
358    pub fn size(&self) -> usize {
359        self.store.lock().map(|s| s.entries.len()).unwrap_or(0)
360    }
361
362    /// Remove all entries from the cache.
363    pub fn clear(&self) {
364        let mut store = self.store.lock().unwrap_or_else(|p| p.into_inner());
365        store.entries.clear();
366        store.type_index.clear();
367        store.field_index.clear();
368        store.tenant_index.clear();
369    }
370}
371
372// ---------------------------------------------------------------------------
373// @cacheControl directive support
374// ---------------------------------------------------------------------------
375
376/// A parsed `@cacheControl` directive extracted from a GraphQL field or type.
377#[derive(Debug, Clone, PartialEq, Eq)]
378pub struct FieldLevelCacheDirective {
379    /// The `maxAge` argument value in seconds, if specified.
380    pub max_age_secs: Option<u64>,
381    /// The `scope` argument (`PUBLIC` or `PRIVATE`), if specified.
382    pub scope: Option<CacheScope>,
383    /// `inheritMaxAge: true` means the field inherits its parent's max-age.
384    pub inherit_max_age: bool,
385}
386
387/// Cache scope argument for `@cacheControl`.
388#[derive(Debug, Clone, PartialEq, Eq)]
389pub enum CacheScope {
390    Public,
391    Private,
392}
393
394impl FieldLevelCacheDirective {
395    /// Parse a `@cacheControl(maxAge: N, scope: "PUBLIC")` directive string.
396    ///
397    /// Returns `None` if the input does not contain `@cacheControl`.
398    pub fn parse(input: &str) -> Option<Self> {
399        if !input.contains("@cacheControl") {
400            return None;
401        }
402
403        let max_age_secs = Self::extract_u64_arg(input, "maxAge");
404        let scope = if input.contains("scope:") {
405            if input.contains("PRIVATE") {
406                Some(CacheScope::Private)
407            } else {
408                Some(CacheScope::Public)
409            }
410        } else {
411            None
412        };
413        let inherit_max_age =
414            input.contains("inheritMaxAge: true") || input.contains("inheritMaxAge:true");
415
416        Some(Self {
417            max_age_secs,
418            scope,
419            inherit_max_age,
420        })
421    }
422
423    fn extract_u64_arg(input: &str, arg: &str) -> Option<u64> {
424        let needle = format!("{arg}:");
425        let pos = input.find(needle.as_str())?;
426        let after = input[pos + needle.len()..].trim_start();
427        // Parse digits until a non-digit char
428        let digits: String = after.chars().take_while(|c| c.is_ascii_digit()).collect();
429        digits.parse().ok()
430    }
431
432    /// Compute the effective `max_age_ms` for a set of directives collected
433    /// from all fields/types resolved during a query.
434    ///
435    /// The effective value is the *minimum* `maxAge` across all directives
436    /// that have an explicit `maxAge`. Returns `None` if no directive
437    /// specifies `maxAge`.
438    pub fn effective_max_age_ms(directives: &[FieldLevelCacheDirective]) -> Option<u64> {
439        directives
440            .iter()
441            .filter_map(|d| d.max_age_secs.map(|s| s * 1_000))
442            .reduce(u64::min)
443    }
444}
445
446// ---------------------------------------------------------------------------
447// Tests
448// ---------------------------------------------------------------------------
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453    use std::time::Duration;
454
455    fn key(tenant: Option<&str>, query: &str) -> ResponseCacheKey {
456        ResponseCacheKey::new(tenant, query, None)
457    }
458
459    fn store_simple(cache: &GraphQlResponseCache, key: ResponseCacheKey, body: &str) {
460        cache.store_response(
461            key,
462            body.to_string(),
463            CachePolicy::simple(60_000),
464            vec![],
465            vec![],
466        );
467    }
468
469    // --- ResponseCacheKey ---
470
471    #[test]
472    fn test_key_same_query_same_tenant_equal() {
473        let k1 = ResponseCacheKey::new(Some("t1"), "{ hello }", None);
474        let k2 = ResponseCacheKey::new(Some("t1"), "{ hello }", None);
475        assert_eq!(k1, k2);
476    }
477
478    #[test]
479    fn test_key_different_tenant_not_equal() {
480        let k1 = ResponseCacheKey::new(Some("t1"), "{ hello }", None);
481        let k2 = ResponseCacheKey::new(Some("t2"), "{ hello }", None);
482        assert_ne!(k1, k2);
483    }
484
485    #[test]
486    fn test_key_none_tenant_vs_some_not_equal() {
487        let k1 = ResponseCacheKey::new(None, "{ hello }", None);
488        let k2 = ResponseCacheKey::new(Some("t1"), "{ hello }", None);
489        assert_ne!(k1, k2);
490    }
491
492    #[test]
493    fn test_key_different_variables_not_equal() {
494        let k1 = ResponseCacheKey::new(None, "{ q }", Some(r#"{"id":1}"#));
495        let k2 = ResponseCacheKey::new(None, "{ q }", Some(r#"{"id":2}"#));
496        assert_ne!(k1, k2);
497    }
498
499    // --- CachePolicy ---
500
501    #[test]
502    fn test_policy_fresh_entry() {
503        let policy = CachePolicy::simple(10_000);
504        // 9999ms is within max_age of 10000ms; the entry is still usable
505        assert!(policy.is_stale_usable(9_999));
506        // 10001ms exceeds max_age with no SWR window; not usable
507        assert!(!policy.is_stale_usable(10_001));
508    }
509
510    #[test]
511    fn test_policy_stale_within_swr_window() {
512        let policy = CachePolicy {
513            max_age_ms: 1_000,
514            stale_while_revalidate_ms: 5_000,
515            vary_by_user: false,
516        };
517        assert!(policy.is_stale_usable(5_999));
518        assert!(!policy.is_stale_usable(6_001));
519    }
520
521    // --- GraphQlResponseCache ---
522
523    #[test]
524    fn test_store_and_retrieve() {
525        let cache = GraphQlResponseCache::new(100);
526        let k = key(Some("tenant1"), "{ data }");
527        store_simple(&cache, k.clone(), r#"{"data":{}}"#);
528
529        let resp = cache.cached_response(&k).expect("should be in cache");
530        assert_eq!(resp.body, r#"{"data":{}}"#);
531    }
532
533    #[test]
534    fn test_miss_returns_none() {
535        let cache = GraphQlResponseCache::new(100);
536        let k = key(None, "{ missing }");
537        assert!(cache.cached_response(&k).is_none());
538    }
539
540    #[test]
541    fn test_expired_entry_returns_none() {
542        let cache = GraphQlResponseCache::new(100);
543        let k = key(None, "{ expiring }");
544        cache.store_response(
545            k.clone(),
546            "body".to_string(),
547            CachePolicy {
548                max_age_ms: 1,
549                stale_while_revalidate_ms: 0,
550                vary_by_user: false,
551            },
552            vec![],
553            vec![],
554        );
555        std::thread::sleep(Duration::from_millis(5));
556        assert!(cache.cached_response(&k).is_none());
557        assert_eq!(cache.size(), 0);
558    }
559
560    #[test]
561    fn test_stale_while_revalidate_still_served() {
562        let cache = GraphQlResponseCache::new(100);
563        let k = key(None, "{ swr }");
564        // max_age = 1ms, swr = 5000ms → stale but usable for 5 seconds
565        cache.store_response(
566            k.clone(),
567            "stale-body".to_string(),
568            CachePolicy {
569                max_age_ms: 1,
570                stale_while_revalidate_ms: 5_000,
571                vary_by_user: false,
572            },
573            vec![],
574            vec![],
575        );
576        std::thread::sleep(Duration::from_millis(5));
577        // Should still be served (within SWR window)
578        let resp = cache.cached_response(&k);
579        assert!(
580            resp.is_some(),
581            "stale entry should be served within SWR window"
582        );
583    }
584
585    #[test]
586    fn test_invalidate_type_removes_matching_entries() {
587        let cache = GraphQlResponseCache::new(100);
588        let k1 = key(None, "q1");
589        let k2 = key(None, "q2");
590
591        cache.store_response(
592            k1.clone(),
593            "r1".to_string(),
594            CachePolicy::default(),
595            vec!["User".to_string()],
596            vec![],
597        );
598        cache.store_response(
599            k2.clone(),
600            "r2".to_string(),
601            CachePolicy::default(),
602            vec!["Product".to_string()],
603            vec![],
604        );
605
606        let evicted = cache.invalidate_type("User");
607        assert_eq!(evicted, 1);
608        assert!(cache.cached_response(&k1).is_none());
609        assert!(cache.cached_response(&k2).is_some());
610    }
611
612    #[test]
613    fn test_invalidate_field_removes_matching_entries() {
614        let cache = GraphQlResponseCache::new(100);
615        let k1 = key(None, "q1");
616        let k2 = key(None, "q2");
617
618        cache.store_response(
619            k1.clone(),
620            "r1".to_string(),
621            CachePolicy::default(),
622            vec![],
623            vec!["User.email".to_string()],
624        );
625        cache.store_response(
626            k2.clone(),
627            "r2".to_string(),
628            CachePolicy::default(),
629            vec![],
630            vec!["User.name".to_string()],
631        );
632
633        let evicted = cache.invalidate_field("User", "email");
634        assert_eq!(evicted, 1);
635        assert!(cache.cached_response(&k1).is_none());
636        assert!(cache.cached_response(&k2).is_some());
637    }
638
639    #[test]
640    fn test_invalidate_type_no_match_returns_zero() {
641        let cache = GraphQlResponseCache::new(100);
642        let evicted = cache.invalidate_type("NonExistent");
643        assert_eq!(evicted, 0);
644    }
645
646    #[test]
647    fn test_invalidate_field_no_match_returns_zero() {
648        let cache = GraphQlResponseCache::new(100);
649        let evicted = cache.invalidate_field("Ghost", "field");
650        assert_eq!(evicted, 0);
651    }
652
653    #[test]
654    fn test_invalidate_tenant() {
655        let cache = GraphQlResponseCache::new(100);
656        let k_a = key(Some("acme"), "q1");
657        let k_b = key(Some("widgets"), "q1");
658
659        store_simple(&cache, k_a.clone(), "ra");
660        store_simple(&cache, k_b.clone(), "rb");
661
662        let evicted = cache.invalidate_tenant("acme");
663        assert_eq!(evicted, 1);
664        assert!(cache.cached_response(&k_a).is_none());
665        assert!(cache.cached_response(&k_b).is_some());
666    }
667
668    #[test]
669    fn test_warm_up_returns_keys() {
670        let cache = GraphQlResponseCache::new(100);
671        let queries = vec![
672            ("{ users }".to_string(), serde_json::Value::Null),
673            ("{ products }".to_string(), serde_json::json!({"limit": 10})),
674        ];
675        let keys = cache.warm_up(Some("tenant1"), &queries);
676        assert_eq!(keys.len(), 2);
677        assert_eq!(keys[0].query_hash, fnv1a("{ users }"));
678    }
679
680    #[test]
681    fn test_clear() {
682        let cache = GraphQlResponseCache::new(100);
683        store_simple(&cache, key(None, "q1"), "r1");
684        store_simple(&cache, key(None, "q2"), "r2");
685        cache.clear();
686        assert_eq!(cache.size(), 0);
687    }
688
689    #[test]
690    fn test_hit_count_increments() {
691        let cache = GraphQlResponseCache::new(100);
692        let k = key(Some("t"), "q");
693        store_simple(&cache, k.clone(), "body");
694
695        let resp1 = cache.cached_response(&k).expect("hit 1");
696        assert_eq!(resp1.hit_count, 1);
697        let resp2 = cache.cached_response(&k).expect("hit 2");
698        assert_eq!(resp2.hit_count, 2);
699    }
700
701    // --- FieldLevelCacheDirective ---
702
703    #[test]
704    fn test_parse_cache_control_max_age() {
705        let directive = FieldLevelCacheDirective::parse("@cacheControl(maxAge: 60)");
706        assert!(directive.is_some());
707        let d = directive.expect("should succeed");
708        assert_eq!(d.max_age_secs, Some(60));
709        assert_eq!(d.scope, None);
710        assert!(!d.inherit_max_age);
711    }
712
713    #[test]
714    fn test_parse_cache_control_with_scope() {
715        let d = FieldLevelCacheDirective::parse("@cacheControl(maxAge: 30, scope: PUBLIC)")
716            .expect("should parse");
717        assert_eq!(d.max_age_secs, Some(30));
718        assert_eq!(d.scope, Some(CacheScope::Public));
719    }
720
721    #[test]
722    fn test_parse_cache_control_private_scope() {
723        let d =
724            FieldLevelCacheDirective::parse("@cacheControl(scope: PRIVATE)").expect("should parse");
725        assert_eq!(d.scope, Some(CacheScope::Private));
726    }
727
728    #[test]
729    fn test_parse_no_cache_control_returns_none() {
730        assert!(FieldLevelCacheDirective::parse("{ user { name } }").is_none());
731    }
732
733    #[test]
734    fn test_parse_inherit_max_age() {
735        let d = FieldLevelCacheDirective::parse("@cacheControl(inheritMaxAge: true)")
736            .expect("should parse");
737        assert!(d.inherit_max_age);
738    }
739
740    #[test]
741    fn test_effective_max_age_ms_minimum() {
742        let directives = vec![
743            FieldLevelCacheDirective {
744                max_age_secs: Some(120),
745                scope: None,
746                inherit_max_age: false,
747            },
748            FieldLevelCacheDirective {
749                max_age_secs: Some(30),
750                scope: None,
751                inherit_max_age: false,
752            },
753            FieldLevelCacheDirective {
754                max_age_secs: Some(60),
755                scope: None,
756                inherit_max_age: false,
757            },
758        ];
759        let effective = FieldLevelCacheDirective::effective_max_age_ms(&directives);
760        assert_eq!(effective, Some(30_000)); // 30s → 30 000ms
761    }
762
763    #[test]
764    fn test_effective_max_age_ms_no_directives() {
765        let directives: Vec<FieldLevelCacheDirective> = vec![];
766        assert_eq!(
767            FieldLevelCacheDirective::effective_max_age_ms(&directives),
768            None
769        );
770    }
771
772    #[test]
773    fn test_effective_max_age_ms_mixed_none() {
774        let directives = vec![
775            FieldLevelCacheDirective {
776                max_age_secs: None,
777                scope: None,
778                inherit_max_age: true,
779            },
780            FieldLevelCacheDirective {
781                max_age_secs: Some(90),
782                scope: None,
783                inherit_max_age: false,
784            },
785        ];
786        // Only the directive with max_age_secs set contributes
787        assert_eq!(
788            FieldLevelCacheDirective::effective_max_age_ms(&directives),
789            Some(90_000)
790        );
791    }
792
793    #[test]
794    fn test_remaining_fresh_ms_positive() {
795        let resp = CachedResponse {
796            body: "x".to_string(),
797            stored_at: Instant::now(),
798            policy: CachePolicy::simple(60_000),
799            touched_types: vec![],
800            touched_fields: vec![],
801            hit_count: 0,
802        };
803        assert!(resp.remaining_fresh_ms() > 0);
804    }
805
806    #[test]
807    fn test_capacity_cap() {
808        let cache = GraphQlResponseCache::new(3);
809        for i in 0..5u32 {
810            store_simple(&cache, key(None, &format!("q{i}")), "r");
811        }
812        assert!(cache.size() <= 3);
813    }
814}