1pub mod memory;
56pub mod types;
57
58use std::collections::BTreeMap;
59use std::future::Future;
60use std::sync::Arc;
61use std::time::Duration;
62
63use async_trait::async_trait;
64use serde::de::DeserializeOwned;
65use serde::{Deserialize, Serialize};
66use sha2::{Digest, Sha256};
67
68use crate::error::{Error, ErrorKind, Result};
69use crate::jwt;
70
71pub use memory::{MemoryCache, NoCache};
72pub use types::{
73 CachedImage, CategoryRow, ItemHit, ItemQuery, ItemRow, Near, RestaurantHit, RestaurantQuery,
74 RestaurantRow, effective_price, haversine_km, image_url_allowed, query_terms,
75};
76
77pub const NO_CACHE_ENV: &str = "GRAB_NO_CACHE";
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
85#[serde(rename_all = "snake_case")]
86pub enum CacheKind {
87 List,
89 Menu,
91 Details,
93 Categories,
95}
96
97impl CacheKind {
98 pub const ALL: [CacheKind; 4] = [Self::List, Self::Menu, Self::Details, Self::Categories];
99
100 pub fn as_str(self) -> &'static str {
101 match self {
102 Self::List => "list",
103 Self::Menu => "menu",
104 Self::Details => "details",
105 Self::Categories => "categories",
106 }
107 }
108
109 pub fn default_ttl(self) -> u64 {
111 match self {
112 Self::List => 10 * 60,
113 Self::Menu => 30 * 60,
114 Self::Details => 6 * 60 * 60,
115 Self::Categories => 24 * 60 * 60,
116 }
117 }
118}
119
120impl std::fmt::Display for CacheKind {
121 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
122 f.write_str(self.as_str())
123 }
124}
125
126impl std::str::FromStr for CacheKind {
127 type Err = String;
128 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
129 let s = s.trim().to_ascii_lowercase();
130 Self::ALL
131 .into_iter()
132 .find(|k| k.as_str() == s)
133 .ok_or_else(|| format!("unknown cache kind `{s}` (list|menu|details|categories)"))
134 }
135}
136
137#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
139pub enum CacheMode {
140 #[default]
142 Normal,
143 Refresh,
145 Off,
147}
148
149impl CacheMode {
150 pub fn from_flags(no_cache: bool, refresh: bool) -> Self {
153 if no_cache {
154 Self::Off
155 } else if refresh {
156 Self::Refresh
157 } else {
158 Self::Normal
159 }
160 }
161
162 pub fn reads(self) -> bool {
163 self == Self::Normal
164 }
165
166 pub fn writes(self) -> bool {
167 self != Self::Off
168 }
169}
170
171pub const DEFAULT_IMAGE_MAX_MB: u64 = 500;
173pub const DEFAULT_INDEX_MAX_AGE_DAYS: u64 = 30;
175
176#[derive(Debug, Clone, Default, Deserialize)]
190#[serde(default, deny_unknown_fields)]
191pub struct CacheConfig {
192 pub enabled: Option<bool>,
193 pub ttl: CacheTtls,
194 pub image_max_mb: Option<u64>,
195 pub index_max_age_days: Option<u64>,
196}
197
198#[derive(Debug, Clone, Default, Deserialize)]
200#[serde(default, deny_unknown_fields)]
201pub struct CacheTtls {
202 pub list: Option<u64>,
203 pub menu: Option<u64>,
204 pub details: Option<u64>,
205 pub categories: Option<u64>,
206}
207
208impl CacheConfig {
209 pub fn enabled(&self) -> bool {
210 self.enabled.unwrap_or(true)
211 }
212
213 pub fn ttl(&self, kind: CacheKind) -> u64 {
214 let o = match kind {
215 CacheKind::List => self.ttl.list,
216 CacheKind::Menu => self.ttl.menu,
217 CacheKind::Details => self.ttl.details,
218 CacheKind::Categories => self.ttl.categories,
219 };
220 o.unwrap_or_else(|| kind.default_ttl())
221 }
222
223 pub fn image_max_bytes(&self) -> u64 {
224 self.image_max_mb
225 .unwrap_or(DEFAULT_IMAGE_MAX_MB)
226 .saturating_mul(1024 * 1024)
227 }
228
229 pub fn index_max_age_secs(&self) -> u64 {
230 self.index_max_age_days
231 .unwrap_or(DEFAULT_INDEX_MAX_AGE_DAYS)
232 .saturating_mul(24 * 60 * 60)
233 }
234}
235
236#[derive(Debug, Clone, PartialEq, Eq)]
239pub struct CacheKey {
240 namespace: String,
241 kind: CacheKind,
242 endpoint: String,
243 params: BTreeMap<String, String>,
244}
245
246const COORD_DECIMALS: usize = 4;
249
250fn normalize_value(v: &str) -> String {
251 v.split_whitespace().collect::<Vec<_>>().join(" ")
252}
253
254pub fn round_coord_to(c: f64, decimals: usize) -> String {
255 let s = format!("{c:.decimals$}");
256 if s.trim_start_matches('-')
258 .chars()
259 .all(|ch| ch == '0' || ch == '.')
260 {
261 s.trim_start_matches('-').to_string()
262 } else {
263 s
264 }
265}
266
267fn round_coord(c: f64) -> String {
268 round_coord_to(c, COORD_DECIMALS)
269}
270
271impl CacheKey {
272 pub fn new(namespace: impl Into<String>, kind: CacheKind, endpoint: impl Into<String>) -> Self {
275 Self {
276 namespace: namespace.into(),
277 kind,
278 endpoint: endpoint.into(),
279 params: BTreeMap::new(),
280 }
281 }
282
283 #[must_use]
287 pub fn param(mut self, name: impl Into<String>, value: impl std::fmt::Display) -> Self {
288 self.params
289 .insert(name.into(), normalize_value(&value.to_string()));
290 self
291 }
292
293 #[must_use]
295 pub fn location(mut self, latitude: f64, longitude: f64) -> Self {
296 self.params.insert("lat".into(), round_coord(latitude));
297 self.params.insert("lng".into(), round_coord(longitude));
298 self
299 }
300
301 pub fn kind(&self) -> CacheKind {
302 self.kind
303 }
304
305 pub fn namespace(&self) -> &str {
306 &self.namespace
307 }
308
309 pub fn identity(&self) -> String {
311 serde_json::json!({
312 "ns": self.namespace,
313 "kind": self.kind.as_str(),
314 "endpoint": self.endpoint,
315 "params": self.params,
316 })
317 .to_string()
318 }
319
320 pub fn hash(&self) -> String {
322 hex_sha256(self.identity().as_bytes())
323 }
324}
325
326pub fn hex_sha256(bytes: &[u8]) -> String {
327 Sha256::digest(bytes)
328 .iter()
329 .map(|b| format!("{b:02x}"))
330 .collect()
331}
332
333#[derive(Debug, Clone, PartialEq)]
336pub struct Fetched<T> {
337 pub value: T,
338 pub cached_at: Option<i64>,
339}
340
341impl<T> Fetched<T> {
342 pub fn live(value: T) -> Self {
343 Self {
344 value,
345 cached_at: None,
346 }
347 }
348
349 pub fn is_cached(&self) -> bool {
350 self.cached_at.is_some()
351 }
352
353 pub fn age_secs(&self) -> Option<i64> {
355 self.cached_at.map(|t| (jwt::now() - t).max(0))
356 }
357}
358
359pub fn human_age(secs: i64) -> String {
361 let s = secs.max(0);
362 if s < 60 {
363 format!("{s}s")
364 } else if s < 3600 {
365 format!("{}m", s / 60)
366 } else if s < 86_400 {
367 format!("{}h", s / 3600)
368 } else {
369 format!("{}d", s / 86_400)
370 }
371}
372
373pub fn is_fresh(fetched_at: i64, expires_at: i64, ttl_secs: u64, now: i64) -> bool {
374 let ttl = i64::try_from(ttl_secs).unwrap_or(i64::MAX);
375 now >= fetched_at && now < expires_at && now - fetched_at < ttl
378}
379
380#[derive(Debug, Clone, PartialEq, Eq)]
382pub struct CacheEntry {
383 pub identity: String,
385 pub body: String,
387 pub fetched_at: i64,
388 pub expires_at: i64,
389}
390
391#[async_trait]
400pub trait Cache: Send + Sync {
401 async fn get_entry(&self, key: &CacheKey) -> Result<Option<CacheEntry>>;
403 async fn put_entry(&self, key: &CacheKey, entry: &CacheEntry) -> Result<()>;
405
406 async fn index_restaurants(
410 &self,
411 rows: &[RestaurantRow],
412 seen_from: Option<(f64, f64)>,
413 now: i64,
414 ) -> Result<()>;
415 async fn index_items(&self, items: &[ItemRow], now: i64) -> Result<()>;
417 async fn index_menu(
420 &self,
421 restaurant: &RestaurantRow,
422 categories: &[CategoryRow],
423 items: &[ItemRow],
424 seen_from: Option<(f64, f64)>,
425 now: i64,
426 ) -> Result<()>;
427 async fn search_restaurants(&self, q: &RestaurantQuery) -> Result<Vec<RestaurantHit>>;
429 async fn search_items(&self, q: &ItemQuery) -> Result<Vec<ItemHit>>;
431 async fn restaurant(&self, id: &str) -> Result<Option<RestaurantHit>>;
433
434 async fn image(&self, _url: &str, _now: i64) -> Result<Option<CachedImage>> {
436 Ok(None)
437 }
438 async fn put_image(
440 &self,
441 _url: &str,
442 _bytes: &[u8],
443 _content_type: &str,
444 _now: i64,
445 ) -> Result<CachedImage> {
446 Err(Error::invalid("this cache does not store images"))
447 }
448}
449
450#[derive(Clone)]
455pub struct CacheHandle {
456 backend: Arc<dyn Cache>,
457 config: CacheConfig,
458 mode: CacheMode,
459}
460
461impl std::fmt::Debug for CacheHandle {
462 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
463 f.debug_struct("CacheHandle")
464 .field("mode", &self.mode)
465 .finish_non_exhaustive()
466 }
467}
468
469const MAX_IMAGE_BYTES: usize = 10 * 1024 * 1024;
471
472impl CacheHandle {
473 pub fn new(backend: Arc<dyn Cache>, config: CacheConfig, mode: CacheMode) -> Self {
475 let mode = if config.enabled() {
476 mode
477 } else {
478 CacheMode::Off
479 };
480 Self {
481 backend,
482 config,
483 mode,
484 }
485 }
486
487 #[must_use]
489 pub fn with_mode(&self, mode: CacheMode) -> Self {
490 Self::new(Arc::clone(&self.backend), self.config.clone(), mode)
491 }
492
493 pub fn mode(&self) -> CacheMode {
494 self.mode
495 }
496
497 pub fn config(&self) -> &CacheConfig {
498 &self.config
499 }
500
501 pub fn ttl(&self, kind: CacheKind) -> u64 {
502 self.config.ttl(kind)
503 }
504
505 pub fn backend(&self) -> &Arc<dyn Cache> {
507 &self.backend
508 }
509
510 pub async fn get<T: DeserializeOwned>(&self, key: &CacheKey) -> Option<T> {
513 self.lookup(key).await.map(|f| f.value)
514 }
515
516 pub async fn lookup<T: DeserializeOwned>(&self, key: &CacheKey) -> Option<Fetched<T>> {
518 self.lookup_at(key, jwt::now()).await
519 }
520
521 #[doc(hidden)]
522 pub async fn lookup_at<T: DeserializeOwned>(
523 &self,
524 key: &CacheKey,
525 now: i64,
526 ) -> Option<Fetched<T>> {
527 if !self.mode.reads() {
528 return None;
529 }
530 let (value, fetched_at, expires_at) = self.read_entry(key).await?;
531 is_fresh(fetched_at, expires_at, self.ttl(key.kind()), now).then_some(Fetched {
532 value,
533 cached_at: Some(fetched_at),
534 })
535 }
536
537 pub async fn lookup_stale<T: DeserializeOwned>(&self, key: &CacheKey) -> Option<Fetched<T>> {
541 if !self.mode.writes() {
542 return None;
543 }
544 let (value, fetched_at, _) = self.read_entry(key).await?;
545 Some(Fetched {
546 value,
547 cached_at: Some(fetched_at),
548 })
549 }
550
551 async fn read_entry<T: DeserializeOwned>(&self, key: &CacheKey) -> Option<(T, i64, i64)> {
552 let e = self.backend.get_entry(key).await.ok()??;
553 if e.identity != key.identity() {
555 return None;
556 }
557 let value = serde_json::from_str(&e.body).ok()?;
558 Some((value, e.fetched_at, e.expires_at))
559 }
560
561 pub async fn put<T: Serialize>(&self, key: &CacheKey, value: &T) -> Result<()> {
563 self.put_at(key, value, jwt::now()).await
564 }
565
566 #[doc(hidden)]
567 pub async fn put_at<T: Serialize>(&self, key: &CacheKey, value: &T, now: i64) -> Result<()> {
568 let ttl = self.ttl(key.kind());
569 if !self.mode.writes() || ttl == 0 {
570 return Ok(());
571 }
572 let entry = CacheEntry {
573 identity: key.identity(),
574 body: serde_json::to_string(value)?,
575 fetched_at: now,
576 expires_at: now.saturating_add(i64::try_from(ttl).unwrap_or(i64::MAX)),
577 };
578 self.backend.put_entry(key, &entry).await
579 }
580
581 pub async fn get_or_fetch<T, F, Fut>(&self, key: &CacheKey, fetch: F) -> Result<T>
585 where
586 T: Serialize + DeserializeOwned,
587 F: FnOnce() -> Fut,
588 Fut: Future<Output = Result<T>>,
589 {
590 Ok(self.get_or_fetch_meta(key, fetch).await?.value)
591 }
592
593 pub async fn get_or_fetch_meta<T, F, Fut>(&self, key: &CacheKey, fetch: F) -> Result<Fetched<T>>
596 where
597 T: Serialize + DeserializeOwned,
598 F: FnOnce() -> Fut,
599 Fut: Future<Output = Result<T>>,
600 {
601 if let Some(hit) = self.lookup(key).await {
602 return Ok(hit);
603 }
604 let value = fetch().await?;
605 let _ = self.put(key, &value).await;
606 Ok(Fetched::live(value))
607 }
608
609 pub async fn index_restaurants(
611 &self,
612 rows: &[RestaurantRow],
613 seen_from: Option<(f64, f64)>,
614 ) -> Result<()> {
615 if !self.mode.writes() || rows.is_empty() {
616 return Ok(());
617 }
618 self.backend
619 .index_restaurants(rows, seen_from, jwt::now())
620 .await
621 }
622
623 pub async fn index_items(&self, items: &[ItemRow]) -> Result<()> {
625 if !self.mode.writes() || items.is_empty() {
626 return Ok(());
627 }
628 self.backend.index_items(items, jwt::now()).await
629 }
630
631 pub async fn index_menu(
633 &self,
634 restaurant: &RestaurantRow,
635 categories: &[CategoryRow],
636 items: &[ItemRow],
637 seen_from: Option<(f64, f64)>,
638 ) -> Result<()> {
639 if !self.mode.writes() || restaurant.id.is_empty() {
640 return Ok(());
641 }
642 self.backend
643 .index_menu(restaurant, categories, items, seen_from, jwt::now())
644 .await
645 }
646
647 pub async fn search_restaurants(&self, q: &RestaurantQuery) -> Result<Vec<RestaurantHit>> {
650 self.backend.search_restaurants(q).await
651 }
652
653 pub async fn search_items(&self, q: &ItemQuery) -> Result<Vec<ItemHit>> {
655 self.backend.search_items(q).await
656 }
657
658 pub async fn restaurant(&self, id: &str) -> Result<Option<RestaurantHit>> {
660 self.backend.restaurant(id).await
661 }
662
663 pub async fn cached_image(&self, url: &str) -> Option<CachedImage> {
665 if !self.mode.reads() && !self.mode.writes() {
666 return None;
667 }
668 self.backend.image(url, jwt::now()).await.ok().flatten()
669 }
670
671 pub async fn store_image(
673 &self,
674 url: &str,
675 bytes: &[u8],
676 content_type: &str,
677 ) -> Result<CachedImage> {
678 if !self.mode.writes() {
679 return Err(Error::invalid("the cache is off; images are not stored"));
680 }
681 self.backend
682 .put_image(url, bytes, content_type, jwt::now())
683 .await
684 }
685
686 pub async fn fetch_image(&self, url: &str) -> Result<CachedImage> {
689 if !image_url_allowed(url) {
690 return Err(Error::new(
691 ErrorKind::UrlNotAllowed,
692 "only https image URLs on grab.com hosts can be cached",
693 ));
694 }
695 if let Some(hit) = self.cached_image(url).await {
696 return Ok(hit);
697 }
698 let client = reqwest::Client::builder()
699 .timeout(Duration::from_secs(30))
700 .redirect(reqwest::redirect::Policy::none())
701 .build()?;
702 let resp = client.get(url).send().await?;
703 let status = resp.status();
704 if !status.is_success() {
705 return Err(Error::new(
706 if status.as_u16() == 404 {
707 ErrorKind::NotFound
708 } else {
709 ErrorKind::Api
710 },
711 format!("{} fetching image", status.as_u16()),
712 ));
713 }
714 let content_type = resp
715 .headers()
716 .get(reqwest::header::CONTENT_TYPE)
717 .and_then(|v| v.to_str().ok())
718 .unwrap_or("")
719 .to_string();
720 if !content_type.to_ascii_lowercase().starts_with("image/") {
721 return Err(Error::new(
722 ErrorKind::Api,
723 format!("not an image (content-type `{content_type}`)"),
724 ));
725 }
726 if resp
727 .content_length()
728 .is_some_and(|l| l > MAX_IMAGE_BYTES as u64)
729 {
730 return Err(Error::invalid("image is too large"));
731 }
732 let bytes = resp.bytes().await?;
733 if bytes.len() > MAX_IMAGE_BYTES {
734 return Err(Error::invalid("image is too large"));
735 }
736 self.store_image(url, &bytes, &content_type).await
737 }
738}
739
740#[cfg(test)]
741mod tests {
742 use super::*;
743 use serde_json::{Value, json};
744
745 fn handle(mode: CacheMode) -> (Arc<MemoryCache>, CacheHandle) {
746 let mem = Arc::new(MemoryCache::default());
747 let h = CacheHandle::new(mem.clone(), CacheConfig::default(), mode);
748 (mem, h)
749 }
750
751 fn key() -> CacheKey {
752 CacheKey::new("food", CacheKind::Menu, "merchant")
753 .param("id", "2-EXAMPLE")
754 .location(1.23, 103.81)
755 }
756
757 async fn get_at(c: &CacheHandle, k: &CacheKey, now: i64) -> Option<Value> {
758 c.lookup_at::<Value>(k, now).await.map(|f| f.value)
759 }
760
761 #[test]
762 fn key_normalization() {
763 let a = CacheKey::new("food", CacheKind::List, "search")
764 .param("keyword", " fried chicken ")
765 .param("page", 0)
766 .location(1.234_512_3, 103.812_34);
767 let b = CacheKey::new("food", CacheKind::List, "search")
768 .location(1.234_499, 103.812_251)
769 .param("page", "0")
770 .param("keyword", "fried chicken");
771 assert_eq!(a.identity(), b.identity());
772 assert_eq!(a.hash(), b.hash());
773 assert_eq!(a.hash().len(), 64);
774
775 let far = b.clone().location(1.2358, 103.8123);
777 assert_ne!(far.hash(), b.hash());
778 let mart = CacheKey::new("mart", CacheKind::List, "search")
779 .param("keyword", "fried chicken")
780 .param("page", 0)
781 .location(1.2345, 103.8123);
782 assert_ne!(mart.hash(), a.hash());
783 assert_eq!(round_coord(-0.000_01), round_coord(0.0));
785 }
786
787 #[tokio::test]
788 async fn roundtrip_and_ttl_expiry() -> Result<()> {
789 let (_, c) = handle(CacheMode::Normal);
790 let k = key();
791 let v = json!({"merchant": {"ID": "2-EXAMPLE"}});
792 c.put_at(&k, &v, 1_000).await?;
793 assert_eq!(get_at(&c, &k, 1_000).await, Some(v.clone()));
794 let ttl = CacheKind::Menu.default_ttl() as i64;
795 let hit = c.lookup_at::<Value>(&k, 1_000 + ttl - 1).await;
796 assert_eq!(hit.map(|h| h.cached_at), Some(Some(1_000)));
797 assert_eq!(get_at(&c, &k, 1_000 + ttl).await, None);
798 assert_eq!(get_at(&c, &k, 999).await, None);
800 assert!(c.lookup_stale::<Value>(&k).await.is_some());
802 Ok(())
803 }
804
805 #[tokio::test]
806 async fn config_ttl_override_and_disable() -> Result<()> {
807 let mem = Arc::new(MemoryCache::default());
808 let cfg: CacheConfig = toml::from_str("[ttl]\nmenu = 5\nlist = 0\n")
809 .map_err(|e| Error::invalid(e.to_string()))?;
810 let c = CacheHandle::new(mem.clone(), cfg, CacheMode::Normal);
811 let k = key();
812 c.put_at(&k, &json!(1), 100).await?;
813 assert_eq!(get_at(&c, &k, 104).await, Some(json!(1)));
814 assert_eq!(get_at(&c, &k, 105).await, None);
815 let lk = CacheKey::new("food", CacheKind::List, "search");
817 c.put_at(&lk, &json!(1), 100).await?;
818 assert!(mem.get_entry(&lk).await?.is_none());
819
820 let long = CacheHandle::new(mem.clone(), CacheConfig::default(), CacheMode::Normal);
822 long.put_at(&k, &json!(2), 100).await?;
823 assert_eq!(get_at(&c, &k, 110).await, None);
824 assert_eq!(get_at(&long, &k, 110).await, Some(json!(2)));
825
826 let off: CacheConfig =
827 toml::from_str("enabled = false").map_err(|e| Error::invalid(e.to_string()))?;
828 let h = CacheHandle::new(mem, off, CacheMode::Normal);
829 assert_eq!(h.mode(), CacheMode::Off);
830 assert_eq!(h.with_mode(CacheMode::Refresh).mode(), CacheMode::Off);
831 Ok(())
832 }
833
834 #[tokio::test]
835 async fn bad_rows_are_misses() -> Result<()> {
836 let (mem, c) = handle(CacheMode::Normal);
837 let k = key();
838 c.put(&k, &json!("a string")).await?;
840 assert_eq!(c.get::<Vec<u32>>(&k).await, None);
841 let mut e = mem
843 .get_entry(&k)
844 .await?
845 .ok_or_else(|| Error::invalid("none"))?;
846 e.identity = "something else".into();
847 mem.put_entry(&k, &e).await?;
848 assert_eq!(c.get::<Value>(&k).await, None);
849 c.put(&k, &json!(1)).await?;
851 let mut e = mem
852 .get_entry(&k)
853 .await?
854 .ok_or_else(|| Error::invalid("none"))?;
855 e.body = "{nope".into();
856 mem.put_entry(&k, &e).await?;
857 assert_eq!(c.get::<Value>(&k).await, None);
858 Ok(())
859 }
860
861 #[tokio::test]
862 async fn modes() -> Result<()> {
863 let k = key();
864 let (_, normal) = handle(CacheMode::Normal);
865
866 let v = normal
868 .get_or_fetch_meta(&k, || async { Ok(json!(1)) })
869 .await?;
870 assert_eq!((v.value, v.cached_at), (json!(1), None));
871 let v = normal
872 .get_or_fetch_meta::<Value, _, _>(&k, || async {
873 Err(Error::invalid("must not fetch"))
874 })
875 .await?;
876 assert_eq!(v.value, json!(1));
877 assert!(v.is_cached());
878 assert!(v.age_secs().is_some_and(|a| a >= 0));
879
880 let off = normal.with_mode(CacheMode::Off);
882 let v: Value = off.get_or_fetch(&k, || async { Ok(json!(2)) }).await?;
883 assert_eq!(v, json!(2));
884 assert_eq!(normal.get::<Value>(&k).await, Some(json!(1)));
885 assert!(off.lookup_stale::<Value>(&k).await.is_none());
886 assert!(
887 off.store_image("https://food-cms.grab.com/x.png", b"x", "image/png")
888 .await
889 .is_err()
890 );
891
892 let refresh = normal.with_mode(CacheMode::Refresh);
894 let v: Value = refresh.get_or_fetch(&k, || async { Ok(json!(3)) }).await?;
895 assert_eq!(v, json!(3));
896 assert_eq!(normal.get::<Value>(&k).await, Some(json!(3)));
897
898 let k2 = key().param("id", "other");
900 let r: Result<Value> = normal
901 .get_or_fetch(&k2, || async { Err(Error::invalid("boom")) })
902 .await;
903 assert!(r.is_err());
904 assert_eq!(normal.get::<Value>(&k2).await, None);
905
906 assert_eq!(CacheMode::from_flags(true, true), CacheMode::Off);
907 assert_eq!(CacheMode::from_flags(false, true), CacheMode::Refresh);
908 assert_eq!(CacheMode::from_flags(false, false), CacheMode::Normal);
909 Ok(())
910 }
911
912 #[tokio::test]
913 async fn off_mode_skips_catalog_writes() -> Result<()> {
914 let (mem, normal) = handle(CacheMode::Normal);
915 let off = normal.with_mode(CacheMode::Off);
916 let row = RestaurantRow {
917 id: "R1".into(),
918 namespace: "food".into(),
919 name: "Sisig Place".into(),
920 ..RestaurantRow::default()
921 };
922 off.index_restaurants(std::slice::from_ref(&row), None)
923 .await?;
924 assert!(mem.restaurant("R1").await?.is_none());
925 normal.index_restaurants(&[row], None).await?;
926 assert!(off.restaurant("R1").await?.is_some());
927 Ok(())
928 }
929
930 #[tokio::test]
931 async fn no_cache_never_hits() -> Result<()> {
932 let h = CacheHandle::new(Arc::new(NoCache), CacheConfig::default(), CacheMode::Normal);
933 h.put(&key(), &json!(1)).await?;
934 assert_eq!(h.get::<Value>(&key()).await, None);
935 Ok(())
936 }
937
938 #[test]
939 fn kind_parses() {
940 assert_eq!("Menu".parse::<CacheKind>(), Ok(CacheKind::Menu));
941 assert!("quote".parse::<CacheKind>().is_err());
942 }
943
944 #[test]
945 fn ages() {
946 assert_eq!(human_age(5), "5s");
947 assert_eq!(human_age(240), "4m");
948 assert_eq!(human_age(7200), "2h");
949 assert_eq!(human_age(3 * 86_400), "3d");
950 }
951}