Skip to main content

things3_core/cache/
mod.rs

1//! Caching layer for frequently accessed Things 3 data
2
3mod config;
4mod operations;
5mod preloader;
6mod stats;
7
8pub use config::{CacheConfig, CacheDependency, InvalidationStrategy};
9pub use preloader::{keys, DefaultPreloader};
10pub use stats::{CachePreloader, CacheStats, CachedData};
11
12use crate::models::{Area, Project, Task};
13use moka::future::Cache;
14use parking_lot::RwLock;
15use std::collections::HashMap;
16use std::sync::Arc;
17
18/// Main cache manager for Things 3 data with intelligent invalidation
19pub struct ThingsCache {
20    /// Tasks cache
21    tasks: Cache<String, CachedData<Vec<Task>>>,
22    /// Projects cache
23    projects: Cache<String, CachedData<Vec<Project>>>,
24    /// Areas cache
25    areas: Cache<String, CachedData<Vec<Area>>>,
26    /// Search results cache
27    search_results: Cache<String, CachedData<Vec<Task>>>,
28    /// Statistics
29    stats: Arc<RwLock<CacheStats>>,
30    /// Configuration
31    config: CacheConfig,
32    /// Cache warming entries (key -> priority)
33    warming_entries: Arc<RwLock<HashMap<String, u32>>>,
34    /// Optional preloader consulted on every `get_*` access and on every
35    /// warming-loop tick. `None` means no predictive preloading.
36    preloader: Arc<RwLock<Option<Arc<dyn CachePreloader>>>>,
37    /// Cache warming task handle
38    warming_task: Option<tokio::task::JoinHandle<()>>,
39}
40
41#[cfg(test)]
42mod tests {
43    use super::*;
44    use crate::test_utils::{create_mock_areas, create_mock_projects, create_mock_tasks};
45    use std::time::Duration;
46
47    #[test]
48    fn test_cache_config_default() {
49        let config = CacheConfig::default();
50
51        assert_eq!(config.max_capacity, 1000);
52        assert_eq!(config.ttl, Duration::from_secs(300));
53        assert_eq!(config.tti, Duration::from_secs(60));
54    }
55
56    #[test]
57    fn test_cache_config_custom() {
58        let config = CacheConfig {
59            max_capacity: 500,
60            ttl: Duration::from_secs(600),
61            tti: Duration::from_secs(120),
62            invalidation_strategy: InvalidationStrategy::Hybrid,
63            enable_cache_warming: true,
64            warming_interval: Duration::from_secs(60),
65            max_warming_entries: 50,
66        };
67
68        assert_eq!(config.max_capacity, 500);
69        assert_eq!(config.ttl, Duration::from_secs(600));
70        assert_eq!(config.tti, Duration::from_secs(120));
71    }
72
73    #[test]
74    fn test_cached_data_creation() {
75        let data = vec![1, 2, 3];
76        let ttl = Duration::from_secs(60);
77        let cached = CachedData::new(data.clone(), ttl);
78
79        assert_eq!(cached.data, data);
80        assert!(cached.cached_at <= chrono::Utc::now());
81        assert!(cached.expires_at > cached.cached_at);
82        assert!(!cached.is_expired());
83    }
84
85    #[test]
86    fn test_cached_data_expiration() {
87        let data = vec![1, 2, 3];
88        let ttl = Duration::from_millis(1);
89        let cached = CachedData::new(data, ttl);
90
91        // Should not be expired immediately
92        assert!(!cached.is_expired());
93
94        // Wait a bit and check again
95        std::thread::sleep(Duration::from_millis(10));
96        // Note: This test might be flaky due to timing, but it's testing the logic
97    }
98
99    #[test]
100    fn test_cached_data_serialization() {
101        let data = vec![1, 2, 3];
102        let ttl = Duration::from_secs(60);
103        let cached = CachedData::new(data, ttl);
104
105        // Test serialization
106        let json = serde_json::to_string(&cached).unwrap();
107        assert!(json.contains("data"));
108        assert!(json.contains("cached_at"));
109        assert!(json.contains("expires_at"));
110
111        // Test deserialization
112        let deserialized: CachedData<Vec<i32>> = serde_json::from_str(&json).unwrap();
113        assert_eq!(deserialized.data, cached.data);
114    }
115
116    #[test]
117    fn test_cache_stats_default() {
118        let stats = CacheStats::default();
119
120        assert_eq!(stats.hits, 0);
121        assert_eq!(stats.misses, 0);
122        assert_eq!(stats.entries, 0);
123        assert!((stats.hit_rate - 0.0).abs() < f64::EPSILON);
124    }
125
126    #[test]
127    fn test_cache_stats_calculation() {
128        let mut stats = CacheStats {
129            hits: 8,
130            misses: 2,
131            entries: 5,
132            hit_rate: 0.0,
133            ..Default::default()
134        };
135
136        stats.calculate_hit_rate();
137        assert!((stats.hit_rate - 0.8).abs() < f64::EPSILON);
138    }
139
140    #[test]
141    fn test_cache_stats_zero_total() {
142        let mut stats = CacheStats {
143            hits: 0,
144            misses: 0,
145            entries: 0,
146            hit_rate: 0.0,
147            ..Default::default()
148        };
149
150        stats.calculate_hit_rate();
151        assert!((stats.hit_rate - 0.0).abs() < f64::EPSILON);
152    }
153
154    #[test]
155    fn test_cache_stats_serialization() {
156        let stats = CacheStats {
157            hits: 10,
158            misses: 5,
159            entries: 3,
160            hit_rate: 0.67,
161            ..Default::default()
162        };
163
164        // Test serialization
165        let json = serde_json::to_string(&stats).unwrap();
166        assert!(json.contains("hits"));
167        assert!(json.contains("misses"));
168        assert!(json.contains("entries"));
169        assert!(json.contains("hit_rate"));
170
171        // Test deserialization
172        let deserialized: CacheStats = serde_json::from_str(&json).unwrap();
173        assert_eq!(deserialized.hits, stats.hits);
174        assert_eq!(deserialized.misses, stats.misses);
175        assert_eq!(deserialized.entries, stats.entries);
176        assert!((deserialized.hit_rate - stats.hit_rate).abs() < f64::EPSILON);
177    }
178
179    #[test]
180    fn test_cache_stats_clone() {
181        let stats = CacheStats {
182            hits: 5,
183            misses: 3,
184            entries: 2,
185            hit_rate: 0.625,
186            ..Default::default()
187        };
188
189        let cloned = stats.clone();
190        assert_eq!(cloned.hits, stats.hits);
191        assert_eq!(cloned.misses, stats.misses);
192        assert_eq!(cloned.entries, stats.entries);
193        assert!((cloned.hit_rate - stats.hit_rate).abs() < f64::EPSILON);
194    }
195
196    #[test]
197    fn test_cache_stats_debug() {
198        let stats = CacheStats {
199            hits: 1,
200            misses: 1,
201            entries: 1,
202            hit_rate: 0.5,
203            ..Default::default()
204        };
205
206        let debug_str = format!("{stats:?}");
207        assert!(debug_str.contains("CacheStats"));
208        assert!(debug_str.contains("hits"));
209        assert!(debug_str.contains("misses"));
210    }
211
212    #[tokio::test]
213    async fn test_cache_new() {
214        let config = CacheConfig::default();
215        let _cache = ThingsCache::new(&config);
216
217        // Just test that it can be created
218        // Test passes if we reach this point
219    }
220
221    #[tokio::test]
222    async fn test_cache_new_default() {
223        let _cache = ThingsCache::new_default();
224
225        // Just test that it can be created
226        // Test passes if we reach this point
227    }
228
229    #[tokio::test]
230    async fn test_cache_basic_operations() {
231        let cache = ThingsCache::new_default();
232
233        // Test cache miss
234        let result = cache.get_tasks("test", || async { Ok(vec![]) }).await;
235        assert!(result.is_ok());
236
237        // Test cache hit
238        let result = cache.get_tasks("test", || async { Ok(vec![]) }).await;
239        assert!(result.is_ok());
240
241        let stats = cache.get_stats();
242        assert_eq!(stats.hits, 1);
243        assert_eq!(stats.misses, 1);
244    }
245
246    #[tokio::test]
247    async fn test_cache_tasks_with_data() {
248        let cache = ThingsCache::new_default();
249        let mock_tasks = create_mock_tasks();
250
251        // Test cache miss with data
252        let result = cache
253            .get_tasks("tasks", || async { Ok(mock_tasks.clone()) })
254            .await;
255        assert!(result.is_ok());
256        assert_eq!(result.unwrap().len(), mock_tasks.len());
257
258        // Test cache hit
259        let result = cache.get_tasks("tasks", || async { Ok(vec![]) }).await;
260        assert!(result.is_ok());
261        assert_eq!(result.unwrap().len(), mock_tasks.len());
262
263        let stats = cache.get_stats();
264        assert_eq!(stats.hits, 1);
265        assert_eq!(stats.misses, 1);
266    }
267
268    #[tokio::test]
269    async fn test_cache_projects() {
270        let cache = ThingsCache::new_default();
271        let mock_projects = create_mock_projects();
272
273        // Test cache miss
274        let result = cache
275            .get_projects("projects", || async { Ok(mock_projects.clone()) })
276            .await;
277        assert!(result.is_ok());
278
279        // Test cache hit
280        let result = cache
281            .get_projects("projects", || async { Ok(vec![]) })
282            .await;
283        assert!(result.is_ok());
284
285        let stats = cache.get_stats();
286        assert_eq!(stats.hits, 1);
287        assert_eq!(stats.misses, 1);
288    }
289
290    #[tokio::test]
291    async fn test_cache_areas() {
292        let cache = ThingsCache::new_default();
293        let mock_areas = create_mock_areas();
294
295        // Test cache miss
296        let result = cache
297            .get_areas("areas", || async { Ok(mock_areas.clone()) })
298            .await;
299        assert!(result.is_ok());
300
301        // Test cache hit
302        let result = cache.get_areas("areas", || async { Ok(vec![]) }).await;
303        assert!(result.is_ok());
304
305        let stats = cache.get_stats();
306        assert_eq!(stats.hits, 1);
307        assert_eq!(stats.misses, 1);
308    }
309
310    #[tokio::test]
311    async fn test_cache_search_results() {
312        let cache = ThingsCache::new_default();
313        let mock_tasks = create_mock_tasks();
314
315        // Test cache miss
316        let result = cache
317            .get_search_results("search:test", || async { Ok(mock_tasks.clone()) })
318            .await;
319        assert!(result.is_ok());
320
321        // Test cache hit
322        let result = cache
323            .get_search_results("search:test", || async { Ok(vec![]) })
324            .await;
325        assert!(result.is_ok());
326
327        let stats = cache.get_stats();
328        assert_eq!(stats.hits, 1);
329        assert_eq!(stats.misses, 1);
330    }
331
332    #[tokio::test]
333    async fn test_cache_fetcher_error() {
334        let cache = ThingsCache::new_default();
335
336        // Test that fetcher errors are propagated
337        let result = cache
338            .get_tasks("error", || async { Err(anyhow::anyhow!("Test error")) })
339            .await;
340
341        assert!(result.is_err());
342        assert!(result.unwrap_err().to_string().contains("Test error"));
343
344        let stats = cache.get_stats();
345        assert_eq!(stats.hits, 0);
346        assert_eq!(stats.misses, 1);
347    }
348
349    #[tokio::test]
350    async fn test_cache_expiration() {
351        let config = CacheConfig {
352            max_capacity: 100,
353            ttl: Duration::from_millis(10),
354            tti: Duration::from_millis(5),
355            invalidation_strategy: InvalidationStrategy::Hybrid,
356            enable_cache_warming: true,
357            warming_interval: Duration::from_secs(60),
358            max_warming_entries: 50,
359        };
360        let cache = ThingsCache::new(&config);
361
362        // Insert data
363        let _ = cache.get_tasks("test", || async { Ok(vec![]) }).await;
364
365        // Wait for expiration
366        tokio::time::sleep(Duration::from_millis(20)).await;
367
368        // Should be a miss due to expiration
369        let _ = cache.get_tasks("test", || async { Ok(vec![]) }).await;
370
371        let stats = cache.get_stats();
372        assert_eq!(stats.misses, 2);
373    }
374
375    #[tokio::test]
376    async fn test_cache_invalidate_all() {
377        let cache = ThingsCache::new_default();
378
379        // Insert data into all caches
380        let _ = cache.get_tasks("tasks", || async { Ok(vec![]) }).await;
381        let _ = cache
382            .get_projects("projects", || async { Ok(vec![]) })
383            .await;
384        let _ = cache.get_areas("areas", || async { Ok(vec![]) }).await;
385        let _ = cache
386            .get_search_results("search", || async { Ok(vec![]) })
387            .await;
388
389        // Invalidate all
390        cache.invalidate_all();
391
392        // All should be misses now
393        let _ = cache.get_tasks("tasks", || async { Ok(vec![]) }).await;
394        let _ = cache
395            .get_projects("projects", || async { Ok(vec![]) })
396            .await;
397        let _ = cache.get_areas("areas", || async { Ok(vec![]) }).await;
398        let _ = cache
399            .get_search_results("search", || async { Ok(vec![]) })
400            .await;
401
402        let stats = cache.get_stats();
403        assert_eq!(stats.misses, 8); // 4 initial + 4 after invalidation
404    }
405
406    #[tokio::test]
407    async fn test_cache_invalidate_specific() {
408        let cache = ThingsCache::new_default();
409
410        // Insert data
411        let _ = cache.get_tasks("key1", || async { Ok(vec![]) }).await;
412        let _ = cache.get_tasks("key2", || async { Ok(vec![]) }).await;
413
414        // Invalidate specific key
415        cache.invalidate("key1").await;
416
417        // key1 should be a miss, key2 should be a hit
418        let _ = cache.get_tasks("key1", || async { Ok(vec![]) }).await;
419        let _ = cache.get_tasks("key2", || async { Ok(vec![]) }).await;
420
421        let stats = cache.get_stats();
422        assert_eq!(stats.hits, 1); // key2 hit
423        assert_eq!(stats.misses, 3); // key1 initial + key1 after invalidation + key2 initial
424    }
425
426    #[tokio::test]
427    async fn test_cache_reset_stats() {
428        let cache = ThingsCache::new_default();
429
430        // Generate some stats
431        let _ = cache.get_tasks("test", || async { Ok(vec![]) }).await;
432        let _ = cache.get_tasks("test", || async { Ok(vec![]) }).await;
433
434        let stats_before = cache.get_stats();
435        assert!(stats_before.hits > 0 || stats_before.misses > 0);
436
437        // Reset stats
438        cache.reset_stats();
439
440        let stats_after = cache.get_stats();
441        assert_eq!(stats_after.hits, 0);
442        assert_eq!(stats_after.misses, 0);
443        assert!((stats_after.hit_rate - 0.0).abs() < f64::EPSILON);
444    }
445
446    #[test]
447    fn test_cache_keys_inbox() {
448        assert_eq!(keys::inbox(None), "inbox:all");
449        assert_eq!(keys::inbox(Some(10)), "inbox:10");
450        assert_eq!(keys::inbox(Some(0)), "inbox:0");
451    }
452
453    #[test]
454    fn test_cache_keys_today() {
455        assert_eq!(keys::today(None), "today:all");
456        assert_eq!(keys::today(Some(5)), "today:5");
457        assert_eq!(keys::today(Some(100)), "today:100");
458    }
459
460    #[test]
461    fn test_cache_keys_projects() {
462        assert_eq!(keys::projects(None), "projects:all");
463        assert_eq!(keys::projects(Some("uuid-123")), "projects:uuid-123");
464        assert_eq!(keys::projects(Some("")), "projects:");
465    }
466
467    #[test]
468    fn test_cache_keys_areas() {
469        assert_eq!(keys::areas(), "areas:all");
470    }
471
472    #[test]
473    fn test_cache_keys_search() {
474        assert_eq!(keys::search("test query", None), "search:test query:all");
475        assert_eq!(keys::search("test query", Some(10)), "search:test query:10");
476        assert_eq!(keys::search("", Some(5)), "search::5");
477    }
478
479    #[tokio::test]
480    async fn test_cache_multiple_keys() {
481        let cache = ThingsCache::new_default();
482        let mock_tasks1 = create_mock_tasks();
483        let mock_tasks2 = create_mock_tasks();
484
485        // Test different keys don't interfere
486        let _ = cache
487            .get_tasks("key1", || async { Ok(mock_tasks1.clone()) })
488            .await;
489        let _ = cache
490            .get_tasks("key2", || async { Ok(mock_tasks2.clone()) })
491            .await;
492
493        // Both should be hits
494        let result1 = cache
495            .get_tasks("key1", || async { Ok(vec![]) })
496            .await
497            .unwrap();
498        let result2 = cache
499            .get_tasks("key2", || async { Ok(vec![]) })
500            .await
501            .unwrap();
502
503        assert_eq!(result1.len(), mock_tasks1.len());
504        assert_eq!(result2.len(), mock_tasks2.len());
505
506        let stats = cache.get_stats();
507        assert_eq!(stats.hits, 2);
508        assert_eq!(stats.misses, 2);
509    }
510
511    #[tokio::test]
512    async fn test_cache_entry_count() {
513        let cache = ThingsCache::new_default();
514
515        // Initially no entries
516        let stats = cache.get_stats();
517        assert_eq!(stats.entries, 0);
518
519        // Add some entries
520        let _ = cache.get_tasks("tasks", || async { Ok(vec![]) }).await;
521        let _ = cache
522            .get_projects("projects", || async { Ok(vec![]) })
523            .await;
524        let _ = cache.get_areas("areas", || async { Ok(vec![]) }).await;
525        let _ = cache
526            .get_search_results("search", || async { Ok(vec![]) })
527            .await;
528
529        // The entry count might not be immediately updated due to async nature
530        // Let's just verify that we can get stats without panicking
531        let stats = cache.get_stats();
532        // Verify stats can be retrieved without panicking
533        let _ = stats.entries;
534    }
535
536    #[tokio::test]
537    async fn test_cache_hit_rate_calculation() {
538        let cache = ThingsCache::new_default();
539
540        // Generate some hits and misses
541        let _ = cache.get_tasks("test", || async { Ok(vec![]) }).await; // miss
542        let _ = cache.get_tasks("test", || async { Ok(vec![]) }).await; // hit
543        let _ = cache.get_tasks("test", || async { Ok(vec![]) }).await; // hit
544
545        let stats = cache.get_stats();
546        assert_eq!(stats.hits, 2);
547        assert_eq!(stats.misses, 1);
548        assert!((stats.hit_rate - 2.0 / 3.0).abs() < 0.001);
549    }
550
551    #[test]
552    fn test_cache_dependency_matches_rules() {
553        use crate::models::ThingsId;
554        let id_a = ThingsId::new_v4();
555        let id_b = ThingsId::new_v4();
556        let dep_concrete = CacheDependency {
557            entity_type: "task".to_string(),
558            entity_id: Some(id_a.clone()),
559            invalidating_operations: vec!["task_updated".to_string()],
560        };
561        let dep_wildcard = CacheDependency {
562            entity_type: "task".to_string(),
563            entity_id: None,
564            invalidating_operations: vec!["task_updated".to_string()],
565        };
566
567        // concrete dep matches its own id, not a different id
568        assert!(dep_concrete.matches("task", Some(&id_a)));
569        assert!(!dep_concrete.matches("task", Some(&id_b)));
570        // wildcard request matches concrete dep
571        assert!(dep_concrete.matches("task", None));
572        // wildcard dep matches any concrete id of same type
573        assert!(dep_wildcard.matches("task", Some(&id_a)));
574        // type mismatch never matches
575        assert!(!dep_concrete.matches("project", Some(&id_a)));
576
577        // operation matching
578        assert!(dep_concrete.matches_operation("task_updated"));
579        assert!(!dep_concrete.matches_operation("task_deleted"));
580    }
581
582    /// Build a `Task` whose `uuid`, `project_uuid`, and `area_uuid` we control,
583    /// so dependency lists carry the IDs we expect.
584    fn task_with_ids(
585        uuid: crate::models::ThingsId,
586        project: Option<crate::models::ThingsId>,
587        area: Option<crate::models::ThingsId>,
588    ) -> crate::models::Task {
589        let mut t = create_mock_tasks().into_iter().next().unwrap();
590        t.uuid = uuid;
591        t.project_uuid = project;
592        t.area_uuid = area;
593        t
594    }
595
596    #[tokio::test]
597    async fn test_invalidate_by_entity_selective_by_id() {
598        use crate::models::ThingsId;
599        let cache = ThingsCache::new_default();
600        let id_x = ThingsId::new_v4();
601        let id_y = ThingsId::new_v4();
602
603        let id_x2 = id_x.clone();
604        let id_y2 = id_y.clone();
605        cache
606            .get_tasks("key_x", || async {
607                Ok(vec![task_with_ids(id_x2, None, None)])
608            })
609            .await
610            .unwrap();
611        cache
612            .get_tasks("key_y", || async {
613                Ok(vec![task_with_ids(id_y2, None, None)])
614            })
615            .await
616            .unwrap();
617
618        let removed = cache.invalidate_by_entity("task", Some(&id_x)).await;
619        assert_eq!(removed, 1, "only the entry depending on id_x should evict");
620        cache.tasks.run_pending_tasks().await;
621        assert!(cache.tasks.get("key_x").await.is_none());
622        assert!(cache.tasks.get("key_y").await.is_some());
623    }
624
625    #[tokio::test]
626    async fn test_invalidate_by_entity_wildcard_id() {
627        use crate::models::ThingsId;
628        let cache = ThingsCache::new_default();
629        let id_x = ThingsId::new_v4();
630        let id_y = ThingsId::new_v4();
631
632        let id_x2 = id_x.clone();
633        let id_y2 = id_y.clone();
634        cache
635            .get_tasks("key_x", || async {
636                Ok(vec![task_with_ids(id_x2, None, None)])
637            })
638            .await
639            .unwrap();
640        cache
641            .get_tasks("key_y", || async {
642                Ok(vec![task_with_ids(id_y2, None, None)])
643            })
644            .await
645            .unwrap();
646
647        let removed = cache.invalidate_by_entity("task", None).await;
648        assert_eq!(removed, 2);
649        cache.tasks.run_pending_tasks().await;
650        assert!(cache.tasks.get("key_x").await.is_none());
651        assert!(cache.tasks.get("key_y").await.is_none());
652    }
653
654    #[tokio::test]
655    async fn test_invalidate_by_entity_leaves_unrelated_caches() {
656        use crate::models::ThingsId;
657        let cache = ThingsCache::new_default();
658        let task_id = ThingsId::new_v4();
659        let project_id = ThingsId::new_v4();
660
661        let task_id2 = task_id.clone();
662        let project_id2 = project_id.clone();
663        // task entry depends on its own task_id AND on project_id
664        cache
665            .get_tasks("inbox", || async {
666                Ok(vec![task_with_ids(task_id2, Some(project_id2), None)])
667            })
668            .await
669            .unwrap();
670        // project entry: cached projects keyed under "projects:all"
671        let mut p = create_mock_projects().into_iter().next().unwrap();
672        p.uuid = project_id;
673        cache
674            .get_projects("projects:all", || async { Ok(vec![p]) })
675            .await
676            .unwrap();
677
678        // invalidate by *task* id — must not nuke the projects cache
679        let removed = cache.invalidate_by_entity("task", Some(&task_id)).await;
680        assert_eq!(removed, 1);
681        cache.tasks.run_pending_tasks().await;
682        cache.projects.run_pending_tasks().await;
683        assert!(cache.tasks.get("inbox").await.is_none());
684        assert!(cache.projects.get("projects:all").await.is_some());
685    }
686
687    #[tokio::test]
688    async fn test_invalidate_by_operation_selective() {
689        use crate::models::ThingsId;
690        let cache = ThingsCache::new_default();
691        let task_id = ThingsId::new_v4();
692        let area_id = ThingsId::new_v4();
693
694        let task_id2 = task_id.clone();
695        // task entry: invalidating_operations include "task_updated"
696        cache
697            .get_tasks("inbox", || async {
698                Ok(vec![task_with_ids(task_id2, None, None)])
699            })
700            .await
701            .unwrap();
702        // area entry: invalidating_operations include "area_updated", NOT "task_updated"
703        let mut a = create_mock_areas().into_iter().next().unwrap();
704        a.uuid = area_id;
705        cache
706            .get_areas("areas:all", || async { Ok(vec![a]) })
707            .await
708            .unwrap();
709
710        let removed = cache.invalidate_by_operation("task_updated").await;
711        assert_eq!(removed, 1);
712        cache.tasks.run_pending_tasks().await;
713        cache.areas.run_pending_tasks().await;
714        assert!(cache.tasks.get("inbox").await.is_none());
715        assert!(cache.areas.get("areas:all").await.is_some());
716    }
717
718    // ─── Predictive preloading (#94) ──────────────────────────────────────
719
720    /// Recording preloader: captures every `predict` and `warm` call so tests
721    /// can assert that the cache fired the hooks at the right moments.
722    struct RecordingPreloader {
723        predictions: Arc<RwLock<Vec<(String, u32)>>>,
724        seen_predict: Arc<RwLock<Vec<String>>>,
725        seen_warm: Arc<RwLock<Vec<String>>>,
726    }
727
728    impl RecordingPreloader {
729        fn new(predictions: Vec<(String, u32)>) -> Self {
730            Self {
731                predictions: Arc::new(RwLock::new(predictions)),
732                seen_predict: Arc::new(RwLock::new(Vec::new())),
733                seen_warm: Arc::new(RwLock::new(Vec::new())),
734            }
735        }
736    }
737
738    impl CachePreloader for RecordingPreloader {
739        fn predict(&self, accessed_key: &str) -> Vec<(String, u32)> {
740            self.seen_predict.write().push(accessed_key.to_string());
741            self.predictions.read().clone()
742        }
743        fn warm(&self, key: &str) {
744            self.seen_warm.write().push(key.to_string());
745        }
746    }
747
748    #[tokio::test]
749    async fn test_default_preloader_predict_rules() {
750        // All three heuristic rules tested against the real DefaultPreloader.
751        // predict() is pure (doesn't touch self.cache or self.db), so we only
752        // need a minimal DB to satisfy DefaultPreloader::new.
753        let f = tempfile::NamedTempFile::new().unwrap();
754        crate::test_utils::create_test_database(f.path())
755            .await
756            .unwrap();
757        let db = Arc::new(crate::ThingsDatabase::new(f.path()).await.unwrap());
758        let cache = Arc::new(ThingsCache::new_default());
759        let pre = DefaultPreloader::new(&cache, db);
760
761        assert_eq!(pre.predict("inbox:all"), vec![("today:all".to_string(), 8)]);
762        assert_eq!(
763            pre.predict("today:all"),
764            vec![("inbox:all".to_string(), 10)]
765        );
766        assert_eq!(
767            pre.predict("areas:all"),
768            vec![("projects:all".to_string(), 7)]
769        );
770        assert!(pre.predict("search:foo").is_empty());
771    }
772
773    #[tokio::test]
774    async fn test_predict_fires_on_get_tasks_miss_and_hit() {
775        let cache = ThingsCache::new_default();
776        let pre = Arc::new(RecordingPreloader::new(vec![]));
777        cache.set_preloader(pre.clone());
778
779        cache
780            .get_tasks("inbox:all", || async { Ok(vec![]) })
781            .await
782            .unwrap();
783        cache
784            .get_tasks("inbox:all", || async { Ok(vec![]) })
785            .await
786            .unwrap();
787
788        let seen = pre.seen_predict.read().clone();
789        assert_eq!(seen, vec!["inbox:all".to_string(), "inbox:all".to_string()]);
790    }
791
792    #[tokio::test]
793    async fn test_predict_enqueues_warming() {
794        let cache = ThingsCache::new_default();
795        let pre = Arc::new(RecordingPreloader::new(vec![("today:all".to_string(), 5)]));
796        cache.set_preloader(pre);
797
798        cache
799            .get_tasks("inbox:all", || async { Ok(vec![]) })
800            .await
801            .unwrap();
802
803        let entries = cache.warming_entries.read();
804        assert_eq!(entries.get("today:all"), Some(&5));
805    }
806
807    #[tokio::test]
808    async fn test_no_preloader_is_noop() {
809        // Default cache (no preloader) — get_* must not panic; stats counters
810        // for warming must stay at zero even if the warming loop ticks.
811        let config = CacheConfig {
812            warming_interval: Duration::from_millis(20),
813            ..Default::default()
814        };
815        let cache = ThingsCache::new(&config);
816        cache
817            .get_tasks("inbox:all", || async { Ok(vec![]) })
818            .await
819            .unwrap();
820        // Let the warming loop tick a few times.
821        tokio::time::sleep(Duration::from_millis(80)).await;
822        let stats = cache.get_stats();
823        assert_eq!(stats.warmed_keys, 0);
824        assert_eq!(stats.warming_runs, 0);
825    }
826
827    #[tokio::test]
828    async fn test_warming_loop_invokes_warm() {
829        let config = CacheConfig {
830            warming_interval: Duration::from_millis(20),
831            max_warming_entries: 10,
832            ..Default::default()
833        };
834        let cache = ThingsCache::new(&config);
835
836        let pre = Arc::new(RecordingPreloader::new(vec![]));
837        cache.set_preloader(pre.clone());
838
839        cache.add_to_warming("inbox:all".to_string(), 10);
840        cache.add_to_warming("today:all".to_string(), 8);
841
842        // Wait long enough for at least one warming-loop tick.
843        tokio::time::sleep(Duration::from_millis(100)).await;
844
845        let warmed = pre.seen_warm.read().clone();
846        assert!(warmed.contains(&"inbox:all".to_string()));
847        assert!(warmed.contains(&"today:all".to_string()));
848
849        // Queue should have been drained after dispatch.
850        assert!(cache.warming_entries.read().is_empty());
851
852        // Stats should reflect the work.
853        let stats = cache.get_stats();
854        assert!(stats.warming_runs >= 1);
855        assert!(stats.warmed_keys >= 2);
856    }
857
858    #[tokio::test]
859    async fn test_clear_preloader_disables_predict() {
860        let cache = ThingsCache::new_default();
861        let pre = Arc::new(RecordingPreloader::new(vec![("today:all".to_string(), 5)]));
862        cache.set_preloader(pre.clone());
863        cache
864            .get_tasks("inbox:all", || async { Ok(vec![]) })
865            .await
866            .unwrap();
867        assert_eq!(pre.seen_predict.read().len(), 1);
868
869        cache.clear_preloader();
870        cache
871            .get_tasks("inbox:all", || async { Ok(vec![]) })
872            .await
873            .unwrap();
874        // Cleared — no further calls.
875        assert_eq!(pre.seen_predict.read().len(), 1);
876    }
877
878    #[tokio::test]
879    async fn test_default_preloader_warms_via_db() {
880        // Full integration: real test DB, real DefaultPreloader, real warming
881        // loop. After fetching `inbox:all`, the loop should warm `today:all`.
882        let f = tempfile::NamedTempFile::new().unwrap();
883        crate::test_utils::create_test_database(f.path())
884            .await
885            .unwrap();
886        let db = Arc::new(crate::ThingsDatabase::new(f.path()).await.unwrap());
887
888        let config = CacheConfig {
889            warming_interval: Duration::from_millis(20),
890            ..Default::default()
891        };
892        let cache = Arc::new(ThingsCache::new(&config));
893        cache.set_preloader(DefaultPreloader::new(&cache, Arc::clone(&db)));
894
895        // Trigger predict("inbox:all") → enqueues "today:all" with priority 8
896        cache
897            .get_tasks("inbox:all", || async {
898                db.get_inbox(None).await.map_err(anyhow::Error::from)
899            })
900            .await
901            .unwrap();
902
903        // Wait for the warming loop to tick AND for the spawned warm() task
904        // (which calls back into cache.get_tasks) to complete.
905        tokio::time::sleep(Duration::from_millis(150)).await;
906
907        // After warming, "today:all" should hit cache without invoking the
908        // panicking fetcher.
909        let result = cache
910            .get_tasks("today:all", || async {
911                panic!("today:all should be served from warmed cache, not fetched")
912            })
913            .await
914            .unwrap();
915        // Sanity: result is whatever db.get_today returned (possibly empty).
916        let expected = db.get_today(None).await.unwrap();
917        assert_eq!(result.len(), expected.len());
918    }
919
920    #[tokio::test]
921    async fn test_default_preloader_weak_ref_breaks_cycle() {
922        // Drop the only Arc<ThingsCache>; DefaultPreloader.warm should noop.
923        let f = tempfile::NamedTempFile::new().unwrap();
924        crate::test_utils::create_test_database(f.path())
925            .await
926            .unwrap();
927        let db = Arc::new(crate::ThingsDatabase::new(f.path()).await.unwrap());
928
929        let cache = Arc::new(ThingsCache::new_default());
930        let preloader = DefaultPreloader::new(&cache, db);
931        let preloader_dyn: Arc<dyn CachePreloader> = preloader.clone();
932
933        drop(cache);
934
935        // Should not panic and should not spawn a doomed task.
936        preloader_dyn.warm("inbox:all");
937        // Sanity: weak ref upgrade inside warm returned None — no observable
938        // side effect to assert beyond "did not panic".
939        tokio::time::sleep(Duration::from_millis(20)).await;
940    }
941}