Skip to main content

oxirs_gql/
edge_caching.rs

1//! Edge Caching for GraphQL Responses
2//!
3//! This module implements edge caching strategies to improve GraphQL API performance
4//! by caching responses at the edge (CDN or edge servers):
5//! - **Cache-Control Headers**: Automatic generation of cache headers
6//! - **ETags**: Entity tags for validation-based caching
7//! - **Vary Headers**: Proper cache key computation
8//! - **Purging**: Selective cache invalidation
9//! - **CDN Integration**: Support for major CDN providers
10//! - **Query Analysis**: Automatic cache policy selection
11//!
12//! ## Features
13//!
14//! ### Cache-Control Header Generation
15//! Automatically generates appropriate `Cache-Control` headers based on:
16//! - Query type (query vs mutation vs subscription)
17//! - Field volatility (static vs dynamic data)
18//! - User authentication status
19//! - Custom directives (@cacheControl)
20//!
21//! ### CDN Support
22//! - Cloudflare
23//! - Fastly
24//! - AWS CloudFront
25//! - Akamai
26//! - Generic CDN
27//!
28//! ## Usage
29//!
30//! ```rust,ignore
31//! use oxirs_gql::edge_caching::{EdgeCache, CachePolicy, CdnProvider};
32//!
33//! // Create edge cache manager
34//! let cache = EdgeCache::new(CdnProvider::Cloudflare);
35//!
36//! // Determine cache policy for query
37//! let policy = cache.analyze_query("{ user { name } }");
38//!
39//! // Generate cache headers
40//! let headers = policy.policy.to_headers();
41//! ```
42
43use serde::{Deserialize, Serialize};
44use std::collections::HashMap;
45use std::time::Duration;
46
47/// CDN provider type
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
49pub enum CdnProvider {
50    /// Cloudflare CDN
51    Cloudflare,
52    /// Fastly CDN
53    Fastly,
54    /// AWS CloudFront
55    CloudFront,
56    /// Akamai
57    Akamai,
58    /// Generic CDN
59    Generic,
60}
61
62impl CdnProvider {
63    /// Get provider-specific cache tag header name
64    pub fn cache_tag_header(&self) -> &'static str {
65        match self {
66            Self::Cloudflare => "Cache-Tag",
67            Self::Fastly => "Surrogate-Key",
68            Self::CloudFront => "x-amz-meta-cache-tags",
69            Self::Akamai => "Edge-Cache-Tag",
70            Self::Generic => "X-Cache-Tags",
71        }
72    }
73
74    /// Get provider-specific purge API endpoint pattern
75    pub fn purge_api_pattern(&self) -> &'static str {
76        match self {
77            Self::Cloudflare => "https://api.cloudflare.com/client/v4/zones/{zone}/purge_cache",
78            Self::Fastly => "https://api.fastly.com/service/{service}/purge/{key}",
79            Self::CloudFront => "cloudfront:CreateInvalidation",
80            Self::Akamai => "https://api.akamai.com/ccu/v3/invalidate/tag/{network}",
81            Self::Generic => "/purge",
82        }
83    }
84
85    /// Check if provider supports cache tags
86    pub fn supports_cache_tags(&self) -> bool {
87        matches!(
88            self,
89            Self::Cloudflare | Self::Fastly | Self::Akamai | Self::Generic
90        )
91    }
92}
93
94/// Cache directive type
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
96pub enum CacheDirective {
97    /// public - response can be cached by any cache
98    Public,
99    /// private - response is for single user
100    Private,
101    /// no-cache - must revalidate with server
102    NoCache,
103    /// no-store - must not be cached
104    NoStore,
105    /// immutable - response will never change
106    Immutable,
107    /// must-revalidate - cache must revalidate stale responses
108    MustRevalidate,
109    /// proxy-revalidate - shared caches must revalidate
110    ProxyRevalidate,
111}
112
113impl CacheDirective {
114    /// Convert to string
115    pub fn as_str(&self) -> &'static str {
116        match self {
117            Self::Public => "public",
118            Self::Private => "private",
119            Self::NoCache => "no-cache",
120            Self::NoStore => "no-store",
121            Self::Immutable => "immutable",
122            Self::MustRevalidate => "must-revalidate",
123            Self::ProxyRevalidate => "proxy-revalidate",
124        }
125    }
126}
127
128/// Cache policy configuration
129#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct CachePolicy {
131    /// Cache directives
132    pub directives: Vec<CacheDirective>,
133    /// Max age in seconds
134    pub max_age: Option<Duration>,
135    /// Shared max age (s-maxage)
136    pub shared_max_age: Option<Duration>,
137    /// Stale-while-revalidate window
138    pub stale_while_revalidate: Option<Duration>,
139    /// Stale-if-error window
140    pub stale_if_error: Option<Duration>,
141    /// Cache tags for selective purging
142    pub cache_tags: Vec<String>,
143    /// Vary headers
144    pub vary_headers: Vec<String>,
145    /// ETag generation enabled
146    pub enable_etag: bool,
147}
148
149impl CachePolicy {
150    /// Create a new cache policy
151    pub fn new() -> Self {
152        Self {
153            directives: Vec::new(),
154            max_age: None,
155            shared_max_age: None,
156            stale_while_revalidate: None,
157            stale_if_error: None,
158            cache_tags: Vec::new(),
159            vary_headers: Vec::new(),
160            enable_etag: false,
161        }
162    }
163
164    /// Create a public cache policy
165    pub fn public(max_age: Duration) -> Self {
166        Self {
167            directives: vec![CacheDirective::Public],
168            max_age: Some(max_age),
169            shared_max_age: None,
170            stale_while_revalidate: None,
171            stale_if_error: None,
172            cache_tags: Vec::new(),
173            vary_headers: vec!["Accept-Encoding".to_string()],
174            enable_etag: true,
175        }
176    }
177
178    /// Create a private cache policy
179    pub fn private(max_age: Duration) -> Self {
180        Self {
181            directives: vec![CacheDirective::Private],
182            max_age: Some(max_age),
183            shared_max_age: None,
184            stale_while_revalidate: None,
185            stale_if_error: None,
186            cache_tags: Vec::new(),
187            vary_headers: vec!["Authorization".to_string(), "Accept-Encoding".to_string()],
188            enable_etag: true,
189        }
190    }
191
192    /// Create a no-cache policy
193    pub fn no_cache() -> Self {
194        Self {
195            directives: vec![CacheDirective::NoCache, CacheDirective::NoStore],
196            max_age: Some(Duration::from_secs(0)),
197            shared_max_age: None,
198            stale_while_revalidate: None,
199            stale_if_error: None,
200            cache_tags: Vec::new(),
201            vary_headers: Vec::new(),
202            enable_etag: false,
203        }
204    }
205
206    /// Create an immutable cache policy (for versioned resources)
207    pub fn immutable(max_age: Duration) -> Self {
208        Self {
209            directives: vec![CacheDirective::Public, CacheDirective::Immutable],
210            max_age: Some(max_age),
211            shared_max_age: Some(max_age),
212            stale_while_revalidate: None,
213            stale_if_error: None,
214            cache_tags: Vec::new(),
215            vary_headers: vec!["Accept-Encoding".to_string()],
216            enable_etag: false, // Immutable resources don't need ETags
217        }
218    }
219
220    /// Add cache tag
221    pub fn add_tag(&mut self, tag: String) {
222        if !self.cache_tags.contains(&tag) {
223            self.cache_tags.push(tag);
224        }
225    }
226
227    /// Add vary header
228    pub fn add_vary(&mut self, header: String) {
229        if !self.vary_headers.contains(&header) {
230            self.vary_headers.push(header);
231        }
232    }
233
234    /// Generate Cache-Control header value
235    pub fn to_cache_control(&self) -> String {
236        let mut parts = Vec::new();
237
238        // Add directives
239        for directive in &self.directives {
240            parts.push(directive.as_str().to_string());
241        }
242
243        // Add max-age
244        if let Some(max_age) = self.max_age {
245            parts.push(format!("max-age={}", max_age.as_secs()));
246        }
247
248        // Add s-maxage
249        if let Some(shared_max_age) = self.shared_max_age {
250            parts.push(format!("s-maxage={}", shared_max_age.as_secs()));
251        }
252
253        // Add stale-while-revalidate
254        if let Some(stale) = self.stale_while_revalidate {
255            parts.push(format!("stale-while-revalidate={}", stale.as_secs()));
256        }
257
258        // Add stale-if-error
259        if let Some(stale) = self.stale_if_error {
260            parts.push(format!("stale-if-error={}", stale.as_secs()));
261        }
262
263        parts.join(", ")
264    }
265
266    /// Generate headers map
267    pub fn to_headers(&self) -> HashMap<String, String> {
268        let mut headers = HashMap::new();
269
270        // Cache-Control header
271        let cache_control = self.to_cache_control();
272        if !cache_control.is_empty() {
273            headers.insert("Cache-Control".to_string(), cache_control);
274        }
275
276        // Vary header
277        if !self.vary_headers.is_empty() {
278            headers.insert("Vary".to_string(), self.vary_headers.join(", "));
279        }
280
281        headers
282    }
283
284    /// Check if response is cacheable
285    pub fn is_cacheable(&self) -> bool {
286        !self.directives.contains(&CacheDirective::NoStore)
287            && !self.directives.contains(&CacheDirective::NoCache)
288    }
289}
290
291impl Default for CachePolicy {
292    fn default() -> Self {
293        Self::new()
294    }
295}
296
297/// Query cache analysis result
298#[derive(Debug, Clone, Serialize, Deserialize)]
299pub struct QueryCacheability {
300    /// Whether query is cacheable
301    pub cacheable: bool,
302    /// Recommended cache policy
303    pub policy: CachePolicy,
304    /// Reasons why query is/isn't cacheable
305    pub reasons: Vec<String>,
306    /// Detected entity types
307    pub entity_types: Vec<String>,
308}
309
310/// Edge cache manager
311#[derive(Debug, Clone)]
312pub struct EdgeCache {
313    /// CDN provider
314    provider: CdnProvider,
315    /// Default cache policy
316    default_policy: CachePolicy,
317    /// Query-specific policies
318    query_policies: HashMap<String, CachePolicy>,
319}
320
321impl EdgeCache {
322    /// Create a new edge cache manager
323    pub fn new(provider: CdnProvider) -> Self {
324        Self {
325            provider,
326            default_policy: CachePolicy::public(Duration::from_secs(300)), // 5 minutes
327            query_policies: HashMap::new(),
328        }
329    }
330
331    /// Set default cache policy
332    pub fn set_default_policy(&mut self, policy: CachePolicy) {
333        self.default_policy = policy;
334    }
335
336    /// Set policy for specific query pattern
337    pub fn set_query_policy(&mut self, query_pattern: String, policy: CachePolicy) {
338        self.query_policies.insert(query_pattern, policy);
339    }
340
341    /// Analyze query for cacheability
342    pub fn analyze_query(&self, query: &str) -> QueryCacheability {
343        let mut reasons = Vec::new();
344        let mut entity_types = Vec::new();
345
346        // Check for mutations
347        if query.trim().starts_with("mutation") {
348            return QueryCacheability {
349                cacheable: false,
350                policy: CachePolicy::no_cache(),
351                reasons: vec!["Mutations are not cacheable".to_string()],
352                entity_types,
353            };
354        }
355
356        // Check for subscriptions
357        if query.trim().starts_with("subscription") {
358            return QueryCacheability {
359                cacheable: false,
360                policy: CachePolicy::no_cache(),
361                reasons: vec!["Subscriptions are not cacheable".to_string()],
362                entity_types,
363            };
364        }
365
366        // Extract entity types from query
367        entity_types = self.extract_entity_types(query);
368
369        // Determine cache policy
370        let mut policy = self.default_policy.clone();
371
372        // Add cache tags for entity types
373        for entity_type in &entity_types {
374            policy.add_tag(entity_type.clone());
375        }
376
377        // Check for authentication requirements
378        if self.requires_authentication(query) {
379            policy.directives = vec![CacheDirective::Private];
380            policy.add_vary("Authorization".to_string());
381            reasons.push("Query requires authentication - using private cache".to_string());
382        } else {
383            reasons.push("Public cache policy applied".to_string());
384        }
385
386        // Check for volatile fields
387        if self.has_volatile_fields(query) {
388            policy.max_age = Some(Duration::from_secs(60)); // 1 minute
389            reasons.push("Query contains volatile fields - shorter TTL".to_string());
390        }
391
392        QueryCacheability {
393            cacheable: true,
394            policy,
395            reasons,
396            entity_types,
397        }
398    }
399
400    /// Extract entity types from query
401    fn extract_entity_types(&self, query: &str) -> Vec<String> {
402        let mut types = Vec::new();
403
404        // Simple regex-like extraction (in production, use proper GraphQL parsing)
405        for word in query.split_whitespace() {
406            let clean = word.trim_matches(|c: char| !c.is_alphanumeric());
407            if !clean.is_empty()
408                && clean
409                    .chars()
410                    .next()
411                    .expect("clean should not be empty after non-empty check")
412                    .is_uppercase()
413                && !["Query", "Mutation", "Subscription"].contains(&clean)
414            {
415                types.push(clean.to_string());
416            }
417        }
418
419        types
420    }
421
422    /// Check if query requires authentication
423    fn requires_authentication(&self, query: &str) -> bool {
424        // Check for common authenticated fields
425        let auth_indicators = ["currentUser", "me", "myProfile", "private"];
426        auth_indicators
427            .iter()
428            .any(|&indicator| query.contains(indicator))
429    }
430
431    /// Check if query has volatile fields
432    fn has_volatile_fields(&self, query: &str) -> bool {
433        // Check for commonly volatile fields
434        let volatile_fields = ["timestamp", "now", "current", "random", "live"];
435        volatile_fields.iter().any(|&field| query.contains(field))
436    }
437
438    /// Generate ETag for response
439    pub fn generate_etag(response: &str) -> String {
440        use sha2::{Digest, Sha256};
441        let mut hasher = Sha256::new();
442        hasher.update(response.as_bytes());
443        let result = hasher.finalize();
444        format!("\"{}\"", hex::encode(&result[..16])) // Use first 16 bytes
445    }
446
447    /// Check if ETag matches
448    pub fn etag_matches(etag: &str, if_none_match: &str) -> bool {
449        if_none_match
450            .split(',')
451            .map(|s| s.trim())
452            .any(|tag| tag == etag || tag == "*")
453    }
454
455    /// Create purge request for cache tags
456    pub fn create_purge_request(&self, tags: Vec<String>) -> PurgeRequest {
457        PurgeRequest {
458            provider: self.provider,
459            tags,
460            timestamp: std::time::SystemTime::now(),
461        }
462    }
463
464    /// Get CDN provider
465    pub fn provider(&self) -> CdnProvider {
466        self.provider
467    }
468}
469
470/// Cache purge request
471#[derive(Debug, Clone, Serialize, Deserialize)]
472pub struct PurgeRequest {
473    /// CDN provider
474    pub provider: CdnProvider,
475    /// Cache tags to purge
476    pub tags: Vec<String>,
477    /// Timestamp of purge request
478    pub timestamp: std::time::SystemTime,
479}
480
481impl PurgeRequest {
482    /// Convert to provider-specific API request
483    pub fn to_api_request(&self, _credentials: &CdnCredentials) -> Result<String, String> {
484        match self.provider {
485            CdnProvider::Cloudflare => {
486                let tags_json = serde_json::to_string(&self.tags)
487                    .map_err(|e| format!("Failed to serialize tags: {}", e))?;
488                Ok(format!(r#"{{"tags":{}}}"#, tags_json))
489            }
490            CdnProvider::Fastly => {
491                // Fastly purges by surrogate key
492                Ok(self.tags.join(","))
493            }
494            CdnProvider::CloudFront => {
495                // CloudFront uses invalidation paths
496                let paths: Vec<String> = self.tags.iter().map(|t| format!("/*{}*", t)).collect();
497                let paths_json = serde_json::to_string(&paths)
498                    .map_err(|e| format!("Failed to serialize paths: {}", e))?;
499                Ok(format!(
500                    r#"{{"Paths":{{"Quantity":{},"Items":{}}}}}"#,
501                    paths.len(),
502                    paths_json
503                ))
504            }
505            CdnProvider::Akamai => {
506                let tags_json = serde_json::to_string(&self.tags)
507                    .map_err(|e| format!("Failed to serialize tags: {}", e))?;
508                Ok(format!(r#"{{"objects":{}}}"#, tags_json))
509            }
510            CdnProvider::Generic => {
511                let tags_json = serde_json::to_string(&self.tags)
512                    .map_err(|e| format!("Failed to serialize tags: {}", e))?;
513                Ok(tags_json)
514            }
515        }
516    }
517}
518
519/// CDN credentials
520#[derive(Debug, Clone, Serialize, Deserialize)]
521pub struct CdnCredentials {
522    /// API key or token
523    pub api_key: String,
524    /// Zone ID (Cloudflare) or Service ID (Fastly)
525    pub zone_or_service_id: Option<String>,
526    /// Additional credentials
527    pub additional: HashMap<String, String>,
528}
529
530/// Cache statistics
531#[derive(Debug, Clone, Default, Serialize, Deserialize)]
532pub struct CacheStats {
533    /// Total requests
534    pub total_requests: u64,
535    /// Cache hits
536    pub cache_hits: u64,
537    /// Cache misses
538    pub cache_misses: u64,
539    /// Stale hits
540    pub stale_hits: u64,
541    /// Total purge requests
542    pub purge_requests: u64,
543    /// Total bytes cached
544    pub bytes_cached: u64,
545}
546
547impl CacheStats {
548    /// Calculate hit rate
549    pub fn hit_rate(&self) -> f64 {
550        if self.total_requests == 0 {
551            0.0
552        } else {
553            (self.cache_hits as f64) / (self.total_requests as f64)
554        }
555    }
556
557    /// Calculate miss rate
558    pub fn miss_rate(&self) -> f64 {
559        if self.total_requests == 0 {
560            0.0
561        } else {
562            (self.cache_misses as f64) / (self.total_requests as f64)
563        }
564    }
565
566    /// Record cache hit
567    pub fn record_hit(&mut self, bytes: u64) {
568        self.total_requests += 1;
569        self.cache_hits += 1;
570        self.bytes_cached += bytes;
571    }
572
573    /// Record cache miss
574    pub fn record_miss(&mut self) {
575        self.total_requests += 1;
576        self.cache_misses += 1;
577    }
578
579    /// Record stale hit
580    pub fn record_stale_hit(&mut self, bytes: u64) {
581        self.total_requests += 1;
582        self.stale_hits += 1;
583        self.bytes_cached += bytes;
584    }
585
586    /// Record purge request
587    pub fn record_purge(&mut self) {
588        self.purge_requests += 1;
589    }
590}
591
592#[cfg(test)]
593mod tests {
594    use super::*;
595
596    #[test]
597    fn test_cdn_provider_cache_tag_header() {
598        assert_eq!(CdnProvider::Cloudflare.cache_tag_header(), "Cache-Tag");
599        assert_eq!(CdnProvider::Fastly.cache_tag_header(), "Surrogate-Key");
600        assert_eq!(
601            CdnProvider::CloudFront.cache_tag_header(),
602            "x-amz-meta-cache-tags"
603        );
604        assert_eq!(CdnProvider::Akamai.cache_tag_header(), "Edge-Cache-Tag");
605    }
606
607    #[test]
608    fn test_cdn_provider_supports_cache_tags() {
609        assert!(CdnProvider::Cloudflare.supports_cache_tags());
610        assert!(CdnProvider::Fastly.supports_cache_tags());
611        assert!(!CdnProvider::CloudFront.supports_cache_tags());
612        assert!(CdnProvider::Akamai.supports_cache_tags());
613    }
614
615    #[test]
616    fn test_cache_directive_as_str() {
617        assert_eq!(CacheDirective::Public.as_str(), "public");
618        assert_eq!(CacheDirective::Private.as_str(), "private");
619        assert_eq!(CacheDirective::NoCache.as_str(), "no-cache");
620        assert_eq!(CacheDirective::Immutable.as_str(), "immutable");
621    }
622
623    #[test]
624    fn test_cache_policy_public() {
625        let policy = CachePolicy::public(Duration::from_secs(300));
626        assert!(policy.directives.contains(&CacheDirective::Public));
627        assert_eq!(policy.max_age, Some(Duration::from_secs(300)));
628        assert!(policy.enable_etag);
629    }
630
631    #[test]
632    fn test_cache_policy_private() {
633        let policy = CachePolicy::private(Duration::from_secs(60));
634        assert!(policy.directives.contains(&CacheDirective::Private));
635        assert_eq!(policy.max_age, Some(Duration::from_secs(60)));
636        assert!(policy.vary_headers.contains(&"Authorization".to_string()));
637    }
638
639    #[test]
640    fn test_cache_policy_no_cache() {
641        let policy = CachePolicy::no_cache();
642        assert!(policy.directives.contains(&CacheDirective::NoCache));
643        assert!(policy.directives.contains(&CacheDirective::NoStore));
644        assert!(!policy.enable_etag);
645    }
646
647    #[test]
648    fn test_cache_policy_immutable() {
649        let policy = CachePolicy::immutable(Duration::from_secs(31536000));
650        assert!(policy.directives.contains(&CacheDirective::Immutable));
651        assert_eq!(policy.max_age, Some(Duration::from_secs(31536000)));
652        assert!(!policy.enable_etag); // Immutable resources don't need ETags
653    }
654
655    #[test]
656    fn test_cache_policy_to_cache_control() {
657        let policy = CachePolicy::public(Duration::from_secs(300));
658        let cache_control = policy.to_cache_control();
659        assert!(cache_control.contains("public"));
660        assert!(cache_control.contains("max-age=300"));
661    }
662
663    #[test]
664    fn test_cache_policy_with_stale_while_revalidate() {
665        let mut policy = CachePolicy::public(Duration::from_secs(300));
666        policy.stale_while_revalidate = Some(Duration::from_secs(60));
667
668        let cache_control = policy.to_cache_control();
669        assert!(cache_control.contains("stale-while-revalidate=60"));
670    }
671
672    #[test]
673    fn test_cache_policy_add_tag() {
674        let mut policy = CachePolicy::new();
675        policy.add_tag("User".to_string());
676        policy.add_tag("Post".to_string());
677        policy.add_tag("User".to_string()); // Duplicate
678
679        assert_eq!(policy.cache_tags.len(), 2);
680        assert!(policy.cache_tags.contains(&"User".to_string()));
681        assert!(policy.cache_tags.contains(&"Post".to_string()));
682    }
683
684    #[test]
685    fn test_cache_policy_is_cacheable() {
686        let public_policy = CachePolicy::public(Duration::from_secs(300));
687        assert!(public_policy.is_cacheable());
688
689        let no_cache_policy = CachePolicy::no_cache();
690        assert!(!no_cache_policy.is_cacheable());
691    }
692
693    #[test]
694    fn test_edge_cache_analyze_query_mutation() {
695        let cache = EdgeCache::new(CdnProvider::Cloudflare);
696        let result = cache.analyze_query("mutation { updateUser(id: 1) { name } }");
697
698        assert!(!result.cacheable);
699        assert!(!result.policy.is_cacheable());
700    }
701
702    #[test]
703    fn test_edge_cache_analyze_query_subscription() {
704        let cache = EdgeCache::new(CdnProvider::Fastly);
705        let result = cache.analyze_query("subscription { userUpdated { id name } }");
706
707        assert!(!result.cacheable);
708    }
709
710    #[test]
711    fn test_edge_cache_analyze_query_public() {
712        let cache = EdgeCache::new(CdnProvider::Cloudflare);
713        let result = cache.analyze_query("{ posts { title author } }");
714
715        assert!(result.cacheable);
716        assert!(result.policy.is_cacheable());
717    }
718
719    #[test]
720    fn test_edge_cache_analyze_query_authenticated() {
721        let cache = EdgeCache::new(CdnProvider::Cloudflare);
722        let result = cache.analyze_query("{ currentUser { email } }");
723
724        assert!(result.cacheable);
725        assert!(result.policy.directives.contains(&CacheDirective::Private));
726    }
727
728    #[test]
729    fn test_edge_cache_extract_entity_types() {
730        let cache = EdgeCache::new(CdnProvider::Cloudflare);
731        let types = cache.extract_entity_types("{ User { name } Post { title } }");
732
733        assert!(types.contains(&"User".to_string()));
734        assert!(types.contains(&"Post".to_string()));
735    }
736
737    #[test]
738    fn test_edge_cache_requires_authentication() {
739        let cache = EdgeCache::new(CdnProvider::Cloudflare);
740
741        assert!(cache.requires_authentication("{ currentUser { name } }"));
742        assert!(cache.requires_authentication("{ me { email } }"));
743        assert!(!cache.requires_authentication("{ posts { title } }"));
744    }
745
746    #[test]
747    fn test_edge_cache_has_volatile_fields() {
748        let cache = EdgeCache::new(CdnProvider::Cloudflare);
749
750        assert!(cache.has_volatile_fields("{ posts { timestamp } }"));
751        assert!(cache.has_volatile_fields("{ stats { current } }"));
752        assert!(!cache.has_volatile_fields("{ posts { title } }"));
753    }
754
755    #[test]
756    fn test_edge_cache_generate_etag() {
757        let response1 = r#"{"data":{"user":{"name":"Alice"}}}"#;
758        let response2 = r#"{"data":{"user":{"name":"Bob"}}}"#;
759
760        let etag1 = EdgeCache::generate_etag(response1);
761        let etag2 = EdgeCache::generate_etag(response2);
762
763        assert_ne!(etag1, etag2);
764        assert!(etag1.starts_with('"'));
765        assert!(etag1.ends_with('"'));
766
767        // Same content should produce same ETag
768        assert_eq!(etag1, EdgeCache::generate_etag(response1));
769    }
770
771    #[test]
772    fn test_edge_cache_etag_matches() {
773        let etag = r#""abc123""#;
774
775        assert!(EdgeCache::etag_matches(etag, r#""abc123""#));
776        assert!(EdgeCache::etag_matches(etag, "*"));
777        assert!(EdgeCache::etag_matches(etag, r#""abc123", "def456""#));
778        assert!(!EdgeCache::etag_matches(etag, r#""def456""#));
779    }
780
781    #[test]
782    fn test_purge_request_cloudflare() {
783        let purge = PurgeRequest {
784            provider: CdnProvider::Cloudflare,
785            tags: vec!["User".to_string(), "Post".to_string()],
786            timestamp: std::time::SystemTime::now(),
787        };
788
789        let credentials = CdnCredentials {
790            api_key: "test".to_string(),
791            zone_or_service_id: Some("zone123".to_string()),
792            additional: HashMap::new(),
793        };
794
795        let request = purge.to_api_request(&credentials).expect("should succeed");
796        assert!(request.contains("tags"));
797        assert!(request.contains("User"));
798    }
799
800    #[test]
801    fn test_cache_stats_hit_rate() {
802        let mut stats = CacheStats::default();
803        stats.record_hit(100);
804        stats.record_hit(200);
805        stats.record_miss();
806
807        assert_eq!(stats.total_requests, 3);
808        assert_eq!(stats.cache_hits, 2);
809        assert_eq!(stats.cache_misses, 1);
810        assert!((stats.hit_rate() - 0.666).abs() < 0.01);
811    }
812
813    #[test]
814    fn test_cache_stats_miss_rate() {
815        let mut stats = CacheStats::default();
816        stats.record_hit(100);
817        stats.record_miss();
818        stats.record_miss();
819
820        assert_eq!(stats.total_requests, 3);
821        assert!((stats.miss_rate() - 0.666).abs() < 0.01);
822    }
823
824    #[test]
825    fn test_cache_stats_empty() {
826        let stats = CacheStats::default();
827        assert_eq!(stats.hit_rate(), 0.0);
828        assert_eq!(stats.miss_rate(), 0.0);
829    }
830}