Skip to main content

kaccy_reputation/
cache.rs

1//! # Caching Layer for Reputation Data
2//!
3//! Provides high-performance in-memory caching for frequently accessed reputation scores
4//! and tier information to significantly reduce database load.
5//!
6//! ## Overview
7//!
8//! The caching layer uses an in-memory HashMap with TTL (Time-To-Live) support,
9//! thread-safe RwLock for concurrent access, and automatic expiration handling.
10//!
11//! ## Performance Benefits
12//!
13//! - **Reduces database queries** by 80-95% for frequently accessed data
14//! - **O(1) lookups** using HashMap
15//! - **Thread-safe** with RwLock (many readers, single writer)
16//! - **Automatic cleanup** of expired entries
17//!
18//! ## Usage Example
19//!
20//! ```rust
21//! use kaccy_reputation::*;
22//! use rust_decimal_macros::dec;
23//! use uuid::Uuid;
24//!
25//! # fn example() -> Result<(), Box<dyn std::error::Error>> {
26//! // Create cache with 5-minute TTL
27//! let cache = ReputationCache::new(300);
28//! let user_id = Uuid::new_v4();
29//!
30//! // Cache a reputation score
31//! let score = ReputationScore {
32//!     user_id,
33//!     overall_score: dec!(750),
34//!     tier: ReputationTier::Gold,
35//!     components: ScoreComponents::default(),
36//! };
37//! cache.set_score(user_id, score)?;
38//!
39//! // Retrieve from cache (very fast!)
40//! if let Some(cached) = cache.get_score(user_id) {
41//!     println!("Score: {}", cached.overall_score);
42//! }
43//!
44//! // Invalidate on update
45//! cache.invalidate_user(user_id);
46//! # Ok(())
47//! # }
48//! ```
49//!
50//! ## Cache Invalidation Pattern
51//!
52//! ```rust,no_run
53//! # use kaccy_reputation::*;
54//! # use uuid::Uuid;
55//! # use rust_decimal::Decimal;
56//! # async fn example(
57//! #     cache: &ReputationCache,
58//! #     db: &sqlx::PgPool,
59//! #     user_id: Uuid,
60//! #     delta: Decimal
61//! # ) -> Result<(), Box<dyn std::error::Error>> {
62//! // Update database
63//! sqlx::query("UPDATE users SET reputation_score = reputation_score + $1 WHERE user_id = $2")
64//!     .bind(delta)
65//!     .bind(user_id)
66//!     .execute(db)
67//!     .await?;
68//!
69//! // Invalidate cache to force refresh on next read
70//! cache.invalidate_user(user_id);
71//! # Ok(())
72//! # }
73//! ```
74//!
75//! ## Best Practices
76//!
77//! - **Set appropriate TTL**: 60-300 seconds for high-frequency updates, 300-900 for moderate
78//! - **Always invalidate** when data changes in the database
79//! - **Monitor hit rates** using `get_stats()` to tune TTL
80//! - **Run cleanup** periodically (e.g., hourly) with `cleanup_expired()`
81//! - **Don't cache** rarely accessed data (wastes memory)
82//!
83//! ## Thread Safety
84//!
85//! All cache operations are thread-safe using `RwLock`:
86//! - Multiple threads can read simultaneously
87//! - Writes block all access but are fast (just HashMap insert)
88
89use chrono::{DateTime, Utc};
90use rust_decimal::Decimal;
91use std::collections::HashMap;
92use std::sync::{Arc, RwLock};
93use uuid::Uuid;
94
95use crate::error::ReputationError;
96use crate::score::ReputationScore;
97use crate::tier::ReputationTier;
98
99/// TTL for cache entries in seconds
100const DEFAULT_TTL_SECONDS: i64 = 300; // 5 minutes
101
102/// Cached reputation score entry
103#[derive(Debug, Clone)]
104pub struct CachedScore {
105    /// The cached reputation score.
106    pub score: ReputationScore,
107    /// Timestamp at which this entry was placed in the cache.
108    pub cached_at: DateTime<Utc>,
109    /// Cache time-to-live in seconds.
110    pub ttl_seconds: i64,
111}
112
113impl CachedScore {
114    /// Check if cache entry is still valid
115    pub fn is_valid(&self) -> bool {
116        let now = Utc::now();
117        let age = now.signed_duration_since(self.cached_at).num_seconds();
118        age < self.ttl_seconds
119    }
120
121    /// Get remaining TTL in seconds
122    pub fn remaining_ttl(&self) -> i64 {
123        let now = Utc::now();
124        let age = now.signed_duration_since(self.cached_at).num_seconds();
125        (self.ttl_seconds - age).max(0)
126    }
127}
128
129/// Cached tier information
130#[derive(Debug, Clone)]
131pub struct CachedTier {
132    /// The cached reputation tier.
133    pub tier: ReputationTier,
134    /// Reputation score at the time of caching.
135    pub score: Decimal,
136    /// Timestamp at which this entry was placed in the cache.
137    pub cached_at: DateTime<Utc>,
138}
139
140/// Cache hit rate metrics
141#[derive(Debug, Clone, Default)]
142struct HitRateMetrics {
143    score_hits: usize,
144    score_misses: usize,
145    tier_hits: usize,
146    tier_misses: usize,
147}
148
149/// In-memory cache for reputation data
150#[derive(Debug, Clone)]
151pub struct ReputationCache {
152    scores: Arc<RwLock<HashMap<Uuid, CachedScore>>>,
153    tiers: Arc<RwLock<HashMap<Uuid, CachedTier>>>,
154    ttl_seconds: i64,
155    metrics: Arc<RwLock<HitRateMetrics>>,
156}
157
158impl Default for ReputationCache {
159    fn default() -> Self {
160        Self::new(DEFAULT_TTL_SECONDS)
161    }
162}
163
164impl ReputationCache {
165    /// Create a new reputation cache with specified TTL
166    pub fn new(ttl_seconds: i64) -> Self {
167        Self {
168            scores: Arc::new(RwLock::new(HashMap::new())),
169            tiers: Arc::new(RwLock::new(HashMap::new())),
170            ttl_seconds,
171            metrics: Arc::new(RwLock::new(HitRateMetrics::default())),
172        }
173    }
174
175    /// Get cached score for a user
176    pub fn get_score(&self, user_id: Uuid) -> Option<ReputationScore> {
177        let scores = self.scores.read().ok()?;
178        let cached = scores.get(&user_id);
179
180        if let Some(cached_entry) = cached {
181            if cached_entry.is_valid() {
182                // Record hit
183                if let Ok(mut metrics) = self.metrics.write() {
184                    metrics.score_hits += 1;
185                }
186                Some(cached_entry.score.clone())
187            } else {
188                // Expired entry - invalidate and record miss
189                drop(scores);
190                self.invalidate_score(user_id);
191                if let Ok(mut metrics) = self.metrics.write() {
192                    metrics.score_misses += 1;
193                }
194                None
195            }
196        } else {
197            // Not in cache - record miss
198            if let Ok(mut metrics) = self.metrics.write() {
199                metrics.score_misses += 1;
200            }
201            None
202        }
203    }
204
205    /// Cache a reputation score
206    pub fn set_score(&self, user_id: Uuid, score: ReputationScore) -> Result<(), ReputationError> {
207        let mut scores = self
208            .scores
209            .write()
210            .map_err(|_| ReputationError::Validation("Cache lock poisoned".to_string()))?;
211
212        scores.insert(
213            user_id,
214            CachedScore {
215                score,
216                cached_at: Utc::now(),
217                ttl_seconds: self.ttl_seconds,
218            },
219        );
220
221        Ok(())
222    }
223
224    /// Invalidate cached score for a user
225    pub fn invalidate_score(&self, user_id: Uuid) {
226        if let Ok(mut scores) = self.scores.write() {
227            scores.remove(&user_id);
228        }
229    }
230
231    /// Get cached tier for a user
232    pub fn get_tier(&self, user_id: Uuid) -> Option<(ReputationTier, Decimal)> {
233        let tiers = self.tiers.read().ok()?;
234        let cached = tiers.get(&user_id);
235
236        if let Some(cached_entry) = cached {
237            // Record hit
238            if let Ok(mut metrics) = self.metrics.write() {
239                metrics.tier_hits += 1;
240            }
241            Some((cached_entry.tier, cached_entry.score))
242        } else {
243            // Record miss
244            if let Ok(mut metrics) = self.metrics.write() {
245                metrics.tier_misses += 1;
246            }
247            None
248        }
249    }
250
251    /// Cache tier information
252    pub fn set_tier(
253        &self,
254        user_id: Uuid,
255        tier: ReputationTier,
256        score: Decimal,
257    ) -> Result<(), ReputationError> {
258        let mut tiers = self
259            .tiers
260            .write()
261            .map_err(|_| ReputationError::Validation("Cache lock poisoned".to_string()))?;
262
263        tiers.insert(
264            user_id,
265            CachedTier {
266                tier,
267                score,
268                cached_at: Utc::now(),
269            },
270        );
271
272        Ok(())
273    }
274
275    /// Invalidate cached tier for a user
276    pub fn invalidate_tier(&self, user_id: Uuid) {
277        if let Ok(mut tiers) = self.tiers.write() {
278            tiers.remove(&user_id);
279        }
280    }
281
282    /// Invalidate all cached data for a user
283    pub fn invalidate_user(&self, user_id: Uuid) {
284        self.invalidate_score(user_id);
285        self.invalidate_tier(user_id);
286    }
287
288    /// Clear all cached scores
289    pub fn clear_scores(&self) {
290        if let Ok(mut scores) = self.scores.write() {
291            scores.clear();
292        }
293    }
294
295    /// Clear all cached tiers
296    pub fn clear_tiers(&self) {
297        if let Ok(mut tiers) = self.tiers.write() {
298            tiers.clear();
299        }
300    }
301
302    /// Clear entire cache
303    pub fn clear_all(&self) {
304        self.clear_scores();
305        self.clear_tiers();
306    }
307
308    /// Remove expired entries from cache
309    pub fn cleanup_expired(&self) -> CacheCleanupStats {
310        let mut expired_scores = 0;
311
312        if let Ok(mut scores) = self.scores.write() {
313            scores.retain(|_, cached| {
314                let valid = cached.is_valid();
315                if !valid {
316                    expired_scores += 1;
317                }
318                valid
319            });
320        }
321
322        CacheCleanupStats {
323            expired_scores,
324            cleaned_at: Utc::now(),
325        }
326    }
327
328    /// Get cache statistics
329    pub fn get_stats(&self) -> CacheStats {
330        let scores_count = self.scores.read().map(|s| s.len()).unwrap_or(0);
331        let tiers_count = self.tiers.read().map(|t| t.len()).unwrap_or(0);
332
333        let metrics = self.metrics.read().ok();
334        let (score_hits, score_misses, tier_hits, tier_misses) = if let Some(m) = metrics {
335            (m.score_hits, m.score_misses, m.tier_hits, m.tier_misses)
336        } else {
337            (0, 0, 0, 0)
338        };
339
340        // Calculate hit rates
341        let score_hit_rate = if score_hits + score_misses > 0 {
342            score_hits as f64 / (score_hits + score_misses) as f64
343        } else {
344            0.0
345        };
346
347        let tier_hit_rate = if tier_hits + tier_misses > 0 {
348            tier_hits as f64 / (tier_hits + tier_misses) as f64
349        } else {
350            0.0
351        };
352
353        let total_hits = score_hits + tier_hits;
354        let total_ops = score_hits + score_misses + tier_hits + tier_misses;
355        let overall_hit_rate = if total_ops > 0 {
356            total_hits as f64 / total_ops as f64
357        } else {
358            0.0
359        };
360
361        CacheStats {
362            cached_scores: scores_count,
363            cached_tiers: tiers_count,
364            ttl_seconds: self.ttl_seconds,
365            score_hits,
366            score_misses,
367            tier_hits,
368            tier_misses,
369            score_hit_rate,
370            tier_hit_rate,
371            overall_hit_rate,
372        }
373    }
374
375    /// Reset cache metrics (hit/miss counters)
376    pub fn reset_metrics(&self) {
377        if let Ok(mut metrics) = self.metrics.write() {
378            metrics.score_hits = 0;
379            metrics.score_misses = 0;
380            metrics.tier_hits = 0;
381            metrics.tier_misses = 0;
382        }
383    }
384}
385
386/// Cache statistics with hit rate tracking
387#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
388pub struct CacheStats {
389    /// Number of score entries currently in the cache.
390    pub cached_scores: usize,
391    /// Number of tier entries currently in the cache.
392    pub cached_tiers: usize,
393    /// Configured TTL in seconds for cache entries.
394    pub ttl_seconds: i64,
395    /// Number of score cache hits since last reset.
396    pub score_hits: usize,
397    /// Number of score cache misses since last reset.
398    pub score_misses: usize,
399    /// Number of tier cache hits since last reset.
400    pub tier_hits: usize,
401    /// Number of tier cache misses since last reset.
402    pub tier_misses: usize,
403    /// Fraction of score lookups that were cache hits (0.0–1.0).
404    pub score_hit_rate: f64,
405    /// Fraction of tier lookups that were cache hits (0.0–1.0).
406    pub tier_hit_rate: f64,
407    /// Overall fraction of all lookups that were cache hits (0.0–1.0).
408    pub overall_hit_rate: f64,
409}
410
411impl CacheStats {
412    /// Check if cache hit rate is healthy (> 70%)
413    pub fn is_healthy(&self) -> bool {
414        self.overall_hit_rate > 0.7
415    }
416
417    /// Check if cache hit rate is poor (< 30%)
418    pub fn is_poor(&self) -> bool {
419        self.overall_hit_rate < 0.3
420    }
421
422    /// Get total number of cache operations
423    pub fn total_operations(&self) -> usize {
424        self.score_hits + self.score_misses + self.tier_hits + self.tier_misses
425    }
426}
427
428/// Cache cleanup statistics
429#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
430pub struct CacheCleanupStats {
431    /// Number of expired score entries removed during cleanup.
432    pub expired_scores: usize,
433    /// Timestamp at which the cleanup was performed.
434    pub cleaned_at: DateTime<Utc>,
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440    use rust_decimal_macros::dec;
441
442    #[test]
443    fn test_cached_score_is_valid() {
444        let score = ReputationScore {
445            user_id: Uuid::new_v4(),
446            overall_score: dec!(500),
447            tier: ReputationTier::Silver,
448            components: Default::default(),
449        };
450
451        let cached = CachedScore {
452            score,
453            cached_at: Utc::now(),
454            ttl_seconds: 300,
455        };
456
457        assert!(cached.is_valid());
458    }
459
460    #[test]
461    fn test_cached_score_expired() {
462        let score = ReputationScore {
463            user_id: Uuid::new_v4(),
464            overall_score: dec!(500),
465            tier: ReputationTier::Silver,
466            components: Default::default(),
467        };
468
469        let cached = CachedScore {
470            score,
471            cached_at: Utc::now() - chrono::Duration::seconds(400),
472            ttl_seconds: 300,
473        };
474
475        assert!(!cached.is_valid());
476    }
477
478    #[test]
479    fn test_cache_set_and_get_score() {
480        let cache = ReputationCache::new(300);
481        let user_id = Uuid::new_v4();
482        let score = ReputationScore {
483            user_id,
484            overall_score: dec!(500),
485            tier: ReputationTier::Silver,
486            components: Default::default(),
487        };
488
489        cache.set_score(user_id, score.clone()).unwrap();
490        let cached_score = cache.get_score(user_id).unwrap();
491
492        assert_eq!(cached_score.overall_score, score.overall_score);
493        assert_eq!(cached_score.tier, score.tier);
494    }
495
496    #[test]
497    fn test_cache_invalidate_score() {
498        let cache = ReputationCache::new(300);
499        let user_id = Uuid::new_v4();
500        let score = ReputationScore {
501            user_id,
502            overall_score: dec!(500),
503            tier: ReputationTier::Silver,
504            components: Default::default(),
505        };
506
507        cache.set_score(user_id, score).unwrap();
508        assert!(cache.get_score(user_id).is_some());
509
510        cache.invalidate_score(user_id);
511        assert!(cache.get_score(user_id).is_none());
512    }
513
514    #[test]
515    fn test_cache_tier() {
516        let cache = ReputationCache::new(300);
517        let user_id = Uuid::new_v4();
518        let tier = ReputationTier::Gold;
519        let score = dec!(750);
520
521        cache.set_tier(user_id, tier, score).unwrap();
522        let cached = cache.get_tier(user_id).unwrap();
523
524        assert_eq!(cached.0, tier);
525        assert_eq!(cached.1, score);
526    }
527
528    #[test]
529    fn test_cache_stats() {
530        let cache = ReputationCache::new(300);
531        let user_id = Uuid::new_v4();
532        let score = ReputationScore {
533            user_id,
534            overall_score: dec!(500),
535            tier: ReputationTier::Silver,
536            components: Default::default(),
537        };
538
539        cache.set_score(user_id, score).unwrap();
540        cache
541            .set_tier(user_id, ReputationTier::Silver, dec!(500))
542            .unwrap();
543
544        let stats = cache.get_stats();
545        assert_eq!(stats.cached_scores, 1);
546        assert_eq!(stats.cached_tiers, 1);
547        assert_eq!(stats.ttl_seconds, 300);
548    }
549
550    #[test]
551    fn test_cache_hit_rate_tracking() {
552        let cache = ReputationCache::new(300);
553        let user_id = Uuid::new_v4();
554        let score = ReputationScore {
555            user_id,
556            overall_score: dec!(500),
557            tier: ReputationTier::Silver,
558            components: Default::default(),
559        };
560
561        // Set cache
562        cache.set_score(user_id, score).unwrap();
563        cache
564            .set_tier(user_id, ReputationTier::Silver, dec!(500))
565            .unwrap();
566
567        // Hit the cache
568        assert!(cache.get_score(user_id).is_some());
569        assert!(cache.get_tier(user_id).is_some());
570
571        // Miss the cache
572        let other_user = Uuid::new_v4();
573        assert!(cache.get_score(other_user).is_none());
574        assert!(cache.get_tier(other_user).is_none());
575
576        let stats = cache.get_stats();
577        assert_eq!(stats.score_hits, 1);
578        assert_eq!(stats.score_misses, 1);
579        assert_eq!(stats.tier_hits, 1);
580        assert_eq!(stats.tier_misses, 1);
581        assert_eq!(stats.score_hit_rate, 0.5);
582        assert_eq!(stats.tier_hit_rate, 0.5);
583        assert_eq!(stats.overall_hit_rate, 0.5);
584    }
585
586    #[test]
587    fn test_cache_hit_rate_all_hits() {
588        let cache = ReputationCache::new(300);
589        let user_id = Uuid::new_v4();
590        let score = ReputationScore {
591            user_id,
592            overall_score: dec!(500),
593            tier: ReputationTier::Silver,
594            components: Default::default(),
595        };
596
597        cache.set_score(user_id, score).unwrap();
598        cache
599            .set_tier(user_id, ReputationTier::Silver, dec!(500))
600            .unwrap();
601
602        // Multiple hits
603        for _ in 0..5 {
604            assert!(cache.get_score(user_id).is_some());
605            assert!(cache.get_tier(user_id).is_some());
606        }
607
608        let stats = cache.get_stats();
609        assert_eq!(stats.score_hits, 5);
610        assert_eq!(stats.score_misses, 0);
611        assert_eq!(stats.tier_hits, 5);
612        assert_eq!(stats.tier_misses, 0);
613        assert_eq!(stats.score_hit_rate, 1.0);
614        assert_eq!(stats.tier_hit_rate, 1.0);
615        assert_eq!(stats.overall_hit_rate, 1.0);
616        assert!(stats.is_healthy());
617    }
618
619    #[test]
620    fn test_cache_hit_rate_all_misses() {
621        let cache = ReputationCache::new(300);
622
623        // All misses
624        for _ in 0..5 {
625            let user_id = Uuid::new_v4();
626            assert!(cache.get_score(user_id).is_none());
627            assert!(cache.get_tier(user_id).is_none());
628        }
629
630        let stats = cache.get_stats();
631        assert_eq!(stats.score_hits, 0);
632        assert_eq!(stats.score_misses, 5);
633        assert_eq!(stats.tier_hits, 0);
634        assert_eq!(stats.tier_misses, 5);
635        assert_eq!(stats.score_hit_rate, 0.0);
636        assert_eq!(stats.tier_hit_rate, 0.0);
637        assert_eq!(stats.overall_hit_rate, 0.0);
638        assert!(stats.is_poor());
639    }
640
641    #[test]
642    fn test_cache_reset_metrics() {
643        let cache = ReputationCache::new(300);
644        let user_id = Uuid::new_v4();
645        let score = ReputationScore {
646            user_id,
647            overall_score: dec!(500),
648            tier: ReputationTier::Silver,
649            components: Default::default(),
650        };
651
652        cache.set_score(user_id, score).unwrap();
653        cache.get_score(user_id);
654        cache.get_score(Uuid::new_v4());
655
656        let stats_before = cache.get_stats();
657        assert_eq!(stats_before.score_hits, 1);
658        assert_eq!(stats_before.score_misses, 1);
659
660        cache.reset_metrics();
661
662        let stats_after = cache.get_stats();
663        assert_eq!(stats_after.score_hits, 0);
664        assert_eq!(stats_after.score_misses, 0);
665        assert_eq!(stats_after.tier_hits, 0);
666        assert_eq!(stats_after.tier_misses, 0);
667    }
668
669    #[test]
670    fn test_cache_stats_helpers() {
671        let cache = ReputationCache::new(300);
672        let user_id = Uuid::new_v4();
673        let score = ReputationScore {
674            user_id,
675            overall_score: dec!(500),
676            tier: ReputationTier::Silver,
677            components: Default::default(),
678        };
679
680        cache.set_score(user_id, score).unwrap();
681
682        // Generate 8 hits and 2 misses (80% hit rate)
683        for _ in 0..8 {
684            cache.get_score(user_id);
685        }
686        for _ in 0..2 {
687            cache.get_score(Uuid::new_v4());
688        }
689
690        let stats = cache.get_stats();
691        assert_eq!(stats.total_operations(), 10);
692        assert!(stats.is_healthy()); // > 70%
693        assert!(!stats.is_poor());
694    }
695
696    #[test]
697    fn test_cache_clear_all() {
698        let cache = ReputationCache::new(300);
699        let user_id = Uuid::new_v4();
700        let score = ReputationScore {
701            user_id,
702            overall_score: dec!(500),
703            tier: ReputationTier::Silver,
704            components: Default::default(),
705        };
706
707        cache.set_score(user_id, score).unwrap();
708        cache
709            .set_tier(user_id, ReputationTier::Silver, dec!(500))
710            .unwrap();
711
712        cache.clear_all();
713
714        let stats = cache.get_stats();
715        assert_eq!(stats.cached_scores, 0);
716        assert_eq!(stats.cached_tiers, 0);
717    }
718
719    #[test]
720    fn test_remaining_ttl() {
721        let score = ReputationScore {
722            user_id: Uuid::new_v4(),
723            overall_score: dec!(500),
724            tier: ReputationTier::Silver,
725            components: Default::default(),
726        };
727
728        let cached = CachedScore {
729            score,
730            cached_at: Utc::now(),
731            ttl_seconds: 300,
732        };
733
734        let remaining = cached.remaining_ttl();
735        assert!(remaining > 0 && remaining <= 300);
736    }
737
738    // ========================================================================
739    // Property-Based Tests
740    // ========================================================================
741
742    mod proptest_cache {
743        use super::*;
744        use proptest::prelude::*;
745
746        // Strategy for generating valid TTL values (1-3600 seconds)
747        fn ttl_strategy() -> impl Strategy<Value = i64> {
748            1i64..=3600i64
749        }
750
751        // Strategy for generating valid scores (0-1000)
752        fn score_strategy() -> impl Strategy<Value = i64> {
753            0i64..=1000i64
754        }
755
756        proptest! {
757            /// Property: Setting and getting score should be consistent
758            #[test]
759            fn prop_cache_score_consistency(
760                score_val in score_strategy(),
761                ttl in ttl_strategy(),
762            ) {
763                let cache = ReputationCache::new(ttl);
764                let user_id = Uuid::new_v4();
765                let score = ReputationScore {
766                    user_id,
767                    overall_score: Decimal::new(score_val, 0),
768                    tier: ReputationTier::from_score(Decimal::new(score_val, 0)),
769                    components: Default::default(),
770                };
771
772                cache.set_score(user_id, score.clone()).unwrap();
773                let retrieved = cache.get_score(user_id);
774
775                prop_assert!(retrieved.is_some());
776                if let Some(cached_score) = retrieved {
777                    prop_assert_eq!(cached_score.overall_score, score.overall_score);
778                    prop_assert_eq!(cached_score.user_id, user_id);
779                }
780            }
781
782            /// Property: Setting and getting tier should be consistent
783            #[test]
784            fn prop_cache_tier_consistency(
785                score_val in score_strategy(),
786                ttl in ttl_strategy(),
787            ) {
788                let cache = ReputationCache::new(ttl);
789                let user_id = Uuid::new_v4();
790                let tier = ReputationTier::from_score(Decimal::new(score_val, 0));
791                let score_dec = Decimal::new(score_val, 0);
792
793                cache.set_tier(user_id, tier, score_dec).unwrap();
794                let retrieved = cache.get_tier(user_id);
795
796                prop_assert!(retrieved.is_some());
797                if let Some((cached_tier, cached_score)) = retrieved {
798                    prop_assert_eq!(cached_tier, tier);
799                    prop_assert_eq!(cached_score, score_dec);
800                }
801            }
802
803            /// Property: Invalidating a user should remove all cached data
804            #[test]
805            fn prop_cache_invalidation(
806                score_val in score_strategy(),
807                ttl in ttl_strategy(),
808            ) {
809                let cache = ReputationCache::new(ttl);
810                let user_id = Uuid::new_v4();
811                let score = ReputationScore {
812                    user_id,
813                    overall_score: Decimal::new(score_val, 0),
814                    tier: ReputationTier::from_score(Decimal::new(score_val, 0)),
815                    components: Default::default(),
816                };
817
818                cache.set_score(user_id, score.clone()).unwrap();
819                cache.set_tier(user_id, score.tier, score.overall_score).unwrap();
820
821                cache.invalidate_user(user_id);
822
823                prop_assert!(cache.get_score(user_id).is_none());
824                prop_assert!(cache.get_tier(user_id).is_none());
825            }
826
827            /// Property: Cache stats should accurately reflect cached items
828            #[test]
829            fn prop_cache_stats_accuracy(
830                num_users in 1usize..=10usize,
831                score_val in score_strategy(),
832                ttl in ttl_strategy(),
833            ) {
834                let cache = ReputationCache::new(ttl);
835                let user_ids: Vec<Uuid> = (0..num_users).map(|_| Uuid::new_v4()).collect();
836
837                for user_id in &user_ids {
838                    let score = ReputationScore {
839                        user_id: *user_id,
840                        overall_score: Decimal::new(score_val, 0),
841                        tier: ReputationTier::from_score(Decimal::new(score_val, 0)),
842                        components: Default::default(),
843                    };
844                    cache.set_score(*user_id, score).unwrap();
845                }
846
847                let stats = cache.get_stats();
848                prop_assert_eq!(stats.cached_scores, num_users);
849            }
850
851            /// Property: Clear all should remove all cached entries
852            #[test]
853            fn prop_cache_clear_all(
854                num_users in 1usize..=10usize,
855                score_val in score_strategy(),
856                ttl in ttl_strategy(),
857            ) {
858                let cache = ReputationCache::new(ttl);
859                let user_ids: Vec<Uuid> = (0..num_users).map(|_| Uuid::new_v4()).collect();
860
861                for user_id in &user_ids {
862                    let score = ReputationScore {
863                        user_id: *user_id,
864                        overall_score: Decimal::new(score_val, 0),
865                        tier: ReputationTier::from_score(Decimal::new(score_val, 0)),
866                        components: Default::default(),
867                    };
868                    cache.set_score(*user_id, score).unwrap();
869                    cache.set_tier(*user_id, ReputationTier::from_score(Decimal::new(score_val, 0)), Decimal::new(score_val, 0)).unwrap();
870                }
871
872                cache.clear_all();
873
874                let stats = cache.get_stats();
875                prop_assert_eq!(stats.cached_scores, 0);
876                prop_assert_eq!(stats.cached_tiers, 0);
877            }
878
879            /// Property: Remaining TTL should always be <= original TTL
880            #[test]
881            fn prop_remaining_ttl_bounds(
882                score_val in score_strategy(),
883                ttl in ttl_strategy(),
884            ) {
885                let score = ReputationScore {
886                    user_id: Uuid::new_v4(),
887                    overall_score: Decimal::new(score_val, 0),
888                    tier: ReputationTier::from_score(Decimal::new(score_val, 0)),
889                    components: Default::default(),
890                };
891
892                let cached = CachedScore {
893                    score,
894                    cached_at: Utc::now(),
895                    ttl_seconds: ttl,
896                };
897
898                let remaining = cached.remaining_ttl();
899                prop_assert!(remaining >= 0);
900                prop_assert!(remaining <= ttl);
901            }
902        }
903    }
904}