firebase_rs_sdk/remote_config/
api.rs

1use std::collections::HashMap;
2use std::fmt;
3use std::sync::{Arc, LazyLock, Mutex};
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use crate::app;
7use crate::app::FirebaseApp;
8use crate::component::types::{
9    ComponentError, DynService, InstanceFactoryOptions, InstantiationMode,
10};
11use crate::component::{Component, ComponentType};
12use crate::remote_config::constants::REMOTE_CONFIG_COMPONENT_NAME;
13use crate::remote_config::error::{internal_error, invalid_argument, RemoteConfigResult};
14use crate::remote_config::fetch::{FetchRequest, NoopFetchClient, RemoteConfigFetchClient};
15use crate::remote_config::settings::{RemoteConfigSettings, RemoteConfigSettingsUpdate};
16use crate::remote_config::storage::{
17    FetchStatus, InMemoryRemoteConfigStorage, RemoteConfigStorage, RemoteConfigStorageCache,
18};
19use crate::remote_config::value::{RemoteConfigValue, RemoteConfigValueSource};
20
21#[derive(Clone)]
22pub struct RemoteConfig {
23    inner: Arc<RemoteConfigInner>,
24}
25
26struct RemoteConfigInner {
27    app: FirebaseApp,
28    defaults: Mutex<HashMap<String, String>>,
29    fetched_config: Mutex<HashMap<String, String>>,
30    fetched_etag: Mutex<Option<String>>,
31    fetched_template_version: Mutex<Option<u64>>,
32    activated: Mutex<bool>,
33    settings: Mutex<RemoteConfigSettings>,
34    fetch_client: Mutex<Arc<dyn RemoteConfigFetchClient>>,
35    storage_cache: RemoteConfigStorageCache,
36}
37static REMOTE_CONFIG_CACHE: LazyLock<Mutex<HashMap<String, Arc<RemoteConfig>>>> =
38    LazyLock::new(|| Mutex::new(HashMap::new()));
39
40impl RemoteConfig {
41    fn new(app: FirebaseApp) -> Self {
42        Self::with_storage(app, Arc::new(InMemoryRemoteConfigStorage::default()))
43    }
44
45    pub fn with_storage(app: FirebaseApp, storage: Arc<dyn RemoteConfigStorage>) -> Self {
46        let storage_cache = RemoteConfigStorageCache::new(storage);
47        let fetch_client: Arc<dyn RemoteConfigFetchClient> = Arc::new(NoopFetchClient::default());
48
49        Self {
50            inner: Arc::new(RemoteConfigInner {
51                app,
52                defaults: Mutex::new(HashMap::new()),
53                fetched_config: Mutex::new(HashMap::new()),
54                fetched_etag: Mutex::new(None),
55                fetched_template_version: Mutex::new(None),
56                activated: Mutex::new(false),
57                settings: Mutex::new(RemoteConfigSettings::default()),
58                fetch_client: Mutex::new(fetch_client),
59                storage_cache,
60            }),
61        }
62    }
63
64    pub fn app(&self) -> &FirebaseApp {
65        &self.inner.app
66    }
67
68    pub fn set_defaults(&self, defaults: HashMap<String, String>) {
69        *self.inner.defaults.lock().unwrap() = defaults;
70    }
71
72    /// Replaces the underlying fetch client.
73    ///
74    /// Useful for tests or environments that need to supply a custom transport implementation,
75    /// such as [`HttpRemoteConfigFetchClient`](crate::remote_config::fetch::HttpRemoteConfigFetchClient).
76    pub fn set_fetch_client(&self, fetch_client: Arc<dyn RemoteConfigFetchClient>) {
77        *self.inner.fetch_client.lock().unwrap() = fetch_client;
78    }
79
80    /// Returns a copy of the current Remote Config settings.
81    ///
82    /// Mirrors the JS `remoteConfig.settings` property.
83    pub fn settings(&self) -> RemoteConfigSettings {
84        self.inner.settings.lock().unwrap().clone()
85    }
86
87    /// Applies validated settings to the Remote Config instance.
88    ///
89    /// Equivalent to the JS `setConfigSettings` helper. Values are merged with the existing
90    /// configuration and validated before being applied.
91    ///
92    /// # Examples
93    ///
94    /// ```
95    /// use firebase_rs_sdk::remote_config::settings::RemoteConfigSettingsUpdate;
96    /// # use firebase_rs_sdk::remote_config::get_remote_config;
97    /// # use firebase_rs_sdk::app::api::initialize_app;
98    /// # use firebase_rs_sdk::app::{FirebaseOptions, FirebaseAppSettings};
99    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
100    /// # let app = initialize_app(FirebaseOptions::default(), Some(FirebaseAppSettings::default()))?;
101    /// let rc = get_remote_config(Some(app))?;
102    /// rc.set_config_settings(RemoteConfigSettingsUpdate {
103    ///     fetch_timeout_millis: Some(90_000),
104    ///     minimum_fetch_interval_millis: Some(3_600_000),
105    /// })?;
106    /// # Ok(())
107    /// # }
108    /// ```
109    pub fn set_config_settings(
110        &self,
111        update: RemoteConfigSettingsUpdate,
112    ) -> RemoteConfigResult<()> {
113        if update.is_empty() {
114            return Ok(());
115        }
116
117        let mut settings = self.inner.settings.lock().unwrap();
118
119        if let Some(fetch_timeout) = update.fetch_timeout_millis {
120            settings.set_fetch_timeout_millis(fetch_timeout)?;
121        }
122
123        if let Some(min_interval) = update.minimum_fetch_interval_millis {
124            settings.set_minimum_fetch_interval_millis(min_interval)?;
125        }
126
127        Ok(())
128    }
129
130    pub fn fetch(&self) -> RemoteConfigResult<()> {
131        let now = current_timestamp_millis();
132        let settings = self.inner.settings.lock().unwrap().clone();
133
134        if let Some(last_fetch) = self
135            .inner
136            .storage_cache
137            .last_successful_fetch_timestamp_millis()
138        {
139            let elapsed = now.saturating_sub(last_fetch);
140            if settings.minimum_fetch_interval_millis() > 0
141                && elapsed < settings.minimum_fetch_interval_millis()
142            {
143                self.inner
144                    .storage_cache
145                    .set_last_fetch_status(FetchStatus::Throttle)?;
146                return Err(invalid_argument(
147                    "minimum_fetch_interval_millis has not elapsed since the last successful fetch",
148                ));
149            }
150        }
151
152        let request = FetchRequest {
153            cache_max_age_millis: settings.minimum_fetch_interval_millis(),
154            timeout_millis: settings.fetch_timeout_millis(),
155            e_tag: self.inner.storage_cache.active_config_etag(),
156            custom_signals: None,
157        };
158
159        let fetch_client = self.inner.fetch_client.lock().unwrap().clone();
160        let response = fetch_client.fetch(request);
161
162        let response = match response {
163            Ok(res) => res,
164            Err(err) => {
165                self.inner
166                    .storage_cache
167                    .set_last_fetch_status(FetchStatus::Failure)?;
168                return Err(err);
169            }
170        };
171
172        match response.status {
173            200 => {
174                let config = response.config.unwrap_or_default();
175                let etag = response.etag;
176                {
177                    let mut fetched = self.inner.fetched_config.lock().unwrap();
178                    *fetched = config;
179                }
180                {
181                    let mut fetched_etag = self.inner.fetched_etag.lock().unwrap();
182                    *fetched_etag = etag;
183                }
184                {
185                    let mut fetched_template_version =
186                        self.inner.fetched_template_version.lock().unwrap();
187                    *fetched_template_version = response.template_version;
188                }
189                *self.inner.activated.lock().unwrap() = false;
190                self.inner
191                    .storage_cache
192                    .set_last_fetch_status(FetchStatus::Success)?;
193                self.inner
194                    .storage_cache
195                    .set_last_successful_fetch_timestamp_millis(now)?;
196                Ok(())
197            }
198            304 => {
199                self.inner
200                    .storage_cache
201                    .set_last_fetch_status(FetchStatus::Success)?;
202                self.inner
203                    .storage_cache
204                    .set_last_successful_fetch_timestamp_millis(now)?;
205                Ok(())
206            }
207            status => {
208                self.inner
209                    .storage_cache
210                    .set_last_fetch_status(FetchStatus::Failure)?;
211                Err(internal_error(format!(
212                    "fetch returned unexpected status {}",
213                    status
214                )))
215            }
216        }
217    }
218
219    pub fn activate(&self) -> RemoteConfigResult<bool> {
220        let mut activated = self.inner.activated.lock().unwrap();
221        let changed = !*activated;
222        if changed {
223            let mut fetched = self.inner.fetched_config.lock().unwrap();
224            let config = if fetched.is_empty() {
225                self.inner.defaults.lock().unwrap().clone()
226            } else {
227                fetched.clone()
228            };
229            fetched.clear();
230            drop(fetched);
231
232            let mut fetched_etag = self.inner.fetched_etag.lock().unwrap();
233            let etag = fetched_etag.take();
234            drop(fetched_etag);
235
236            let mut fetched_template_version = self.inner.fetched_template_version.lock().unwrap();
237            let template_version = fetched_template_version.take();
238            drop(fetched_template_version);
239
240            self.inner.storage_cache.set_active_config(config)?;
241            self.inner.storage_cache.set_active_config_etag(etag)?;
242            self.inner
243                .storage_cache
244                .set_active_config_template_version(template_version)?;
245        }
246        *activated = true;
247        Ok(changed)
248    }
249
250    /// Returns the timestamp (in milliseconds since epoch) of the last successful fetch.
251    ///
252    /// Mirrors `remoteConfig.fetchTimeMillis` from the JS SDK, returning `-1` when no successful
253    /// fetch has completed yet.
254    pub fn fetch_time_millis(&self) -> i64 {
255        self.inner
256            .storage_cache
257            .last_successful_fetch_timestamp_millis()
258            .map(|millis| millis as i64)
259            .unwrap_or(-1)
260    }
261
262    /// Returns the status of the last fetch attempt.
263    ///
264    /// Matches the JS `remoteConfig.lastFetchStatus` property.
265    pub fn last_fetch_status(&self) -> FetchStatus {
266        self.inner.storage_cache.last_fetch_status()
267    }
268
269    /// Returns the template version of the currently active Remote Config, if known.
270    pub fn active_template_version(&self) -> Option<u64> {
271        self.inner.storage_cache.active_config_template_version()
272    }
273
274    /// Returns the raw string value for a parameter.
275    ///
276    /// Mirrors the JS helper `getString` defined in `packages/remote-config/src/api.ts`.
277    pub fn get_string(&self, key: &str) -> String {
278        self.get_value(key).as_string()
279    }
280
281    /// Returns the value interpreted as a boolean.
282    ///
283    /// Maps to the JS helper `getBoolean`.
284    pub fn get_boolean(&self, key: &str) -> bool {
285        self.get_value(key).as_bool()
286    }
287
288    /// Returns the value interpreted as a number.
289    ///
290    /// Maps to the JS helper `getNumber`.
291    pub fn get_number(&self, key: &str) -> f64 {
292        self.get_value(key).as_number()
293    }
294
295    /// Returns a value wrapper that exposes typed accessors and the source of the parameter.
296    pub fn get_value(&self, key: &str) -> RemoteConfigValue {
297        if let Some(value) = self.inner.storage_cache.active_config().get(key).cloned() {
298            return RemoteConfigValue::new(RemoteConfigValueSource::Remote, value);
299        }
300        if let Some(value) = self.inner.defaults.lock().unwrap().get(key).cloned() {
301            return RemoteConfigValue::new(RemoteConfigValueSource::Default, value);
302        }
303        RemoteConfigValue::static_value()
304    }
305
306    /// Returns the union of default and active configs, with active values taking precedence.
307    pub fn get_all(&self) -> HashMap<String, RemoteConfigValue> {
308        let defaults = self.inner.defaults.lock().unwrap().clone();
309        let values = self.inner.storage_cache.active_config();
310
311        let mut all = HashMap::new();
312        for (key, value) in defaults {
313            all.insert(
314                key,
315                RemoteConfigValue::new(RemoteConfigValueSource::Default, value),
316            );
317        }
318        for (key, value) in values {
319            all.insert(
320                key,
321                RemoteConfigValue::new(RemoteConfigValueSource::Remote, value),
322            );
323        }
324        all
325    }
326}
327
328impl fmt::Debug for RemoteConfig {
329    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
330        let defaults_len = self.inner.defaults.lock().map(|map| map.len()).unwrap_or(0);
331        f.debug_struct("RemoteConfig")
332            .field("app", &self.app().name())
333            .field("defaults", &defaults_len)
334            .field("last_fetch_status", &self.last_fetch_status().as_str())
335            .finish()
336    }
337}
338
339static REMOTE_CONFIG_COMPONENT: LazyLock<()> = LazyLock::new(|| {
340    let component = Component::new(
341        REMOTE_CONFIG_COMPONENT_NAME,
342        Arc::new(remote_config_factory),
343        ComponentType::Public,
344    )
345    .with_instantiation_mode(InstantiationMode::Lazy);
346    let _ = app::registry::register_component(component);
347});
348
349fn remote_config_factory(
350    container: &crate::component::ComponentContainer,
351    _options: InstanceFactoryOptions,
352) -> Result<DynService, ComponentError> {
353    let app = container.root_service::<FirebaseApp>().ok_or_else(|| {
354        ComponentError::InitializationFailed {
355            name: REMOTE_CONFIG_COMPONENT_NAME.to_string(),
356            reason: "Firebase app not attached to component container".to_string(),
357        }
358    })?;
359
360    let rc = RemoteConfig::new((*app).clone());
361    Ok(Arc::new(rc) as DynService)
362}
363
364fn current_timestamp_millis() -> u64 {
365    SystemTime::now()
366        .duration_since(UNIX_EPOCH)
367        .map(|duration| duration.as_millis() as u64)
368        .unwrap_or(0)
369}
370
371fn ensure_registered() {
372    LazyLock::force(&REMOTE_CONFIG_COMPONENT);
373}
374
375pub fn register_remote_config_component() {
376    ensure_registered();
377}
378
379pub fn get_remote_config(app: Option<FirebaseApp>) -> RemoteConfigResult<Arc<RemoteConfig>> {
380    ensure_registered();
381    let app = match app {
382        Some(app) => app,
383        None => crate::app::api::get_app(None).map_err(|err| internal_error(err.to_string()))?,
384    };
385
386    if let Some(rc) = REMOTE_CONFIG_CACHE.lock().unwrap().get(app.name()).cloned() {
387        return Ok(rc);
388    }
389
390    let provider = app::registry::get_provider(&app, REMOTE_CONFIG_COMPONENT_NAME);
391    if let Some(rc) = provider.get_immediate::<RemoteConfig>() {
392        REMOTE_CONFIG_CACHE
393            .lock()
394            .unwrap()
395            .insert(app.name().to_string(), rc.clone());
396        return Ok(rc);
397    }
398
399    match provider.initialize::<RemoteConfig>(serde_json::Value::Null, None) {
400        Ok(rc) => {
401            REMOTE_CONFIG_CACHE
402                .lock()
403                .unwrap()
404                .insert(app.name().to_string(), rc.clone());
405            Ok(rc)
406        }
407        Err(crate::component::types::ComponentError::InstanceUnavailable { .. }) => {
408            if let Some(rc) = provider.get_immediate::<RemoteConfig>() {
409                REMOTE_CONFIG_CACHE
410                    .lock()
411                    .unwrap()
412                    .insert(app.name().to_string(), rc.clone());
413                Ok(rc)
414            } else {
415                let rc = Arc::new(RemoteConfig::new(app.clone()));
416                REMOTE_CONFIG_CACHE
417                    .lock()
418                    .unwrap()
419                    .insert(app.name().to_string(), rc.clone());
420                Ok(rc)
421            }
422        }
423        Err(err) => Err(internal_error(err.to_string())),
424    }
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430    use crate::app::api::initialize_app;
431    use crate::app::{FirebaseApp, FirebaseAppSettings, FirebaseOptions};
432    use crate::remote_config::error::internal_error;
433    use crate::remote_config::fetch::{FetchRequest, FetchResponse, RemoteConfigFetchClient};
434    use crate::remote_config::settings::{
435        RemoteConfigSettingsUpdate, DEFAULT_FETCH_TIMEOUT_MILLIS,
436        DEFAULT_MINIMUM_FETCH_INTERVAL_MILLIS,
437    };
438    use crate::remote_config::storage::{
439        FetchStatus, FileRemoteConfigStorage, RemoteConfigStorage,
440    };
441    use std::fs;
442    use std::sync::atomic::{AtomicUsize, Ordering};
443    use std::sync::Mutex as StdMutex;
444
445    fn remote_config(app: FirebaseApp) -> Arc<RemoteConfig> {
446        get_remote_config(Some(app)).unwrap()
447    }
448
449    fn unique_settings() -> FirebaseAppSettings {
450        use std::sync::atomic::{AtomicUsize, Ordering};
451        static COUNTER: AtomicUsize = AtomicUsize::new(0);
452        FirebaseAppSettings {
453            name: Some(format!(
454                "remote-config-{}",
455                COUNTER.fetch_add(1, Ordering::SeqCst)
456            )),
457            ..Default::default()
458        }
459    }
460
461    #[test]
462    fn defaults_activate() {
463        let options = FirebaseOptions {
464            project_id: Some("project".into()),
465            ..Default::default()
466        };
467        let app = initialize_app(options, Some(unique_settings())).unwrap();
468        let rc = remote_config(app);
469        rc.set_defaults(HashMap::from([(
470            String::from("welcome"),
471            String::from("hello"),
472        )]));
473        rc.fetch().unwrap();
474        assert!(rc.activate().unwrap());
475        assert_eq!(rc.get_string("welcome"), "hello");
476        assert_eq!(rc.last_fetch_status(), FetchStatus::Success);
477        assert!(rc.fetch_time_millis() > 0);
478    }
479
480    #[test]
481    fn activate_after_defaults_returns_false() {
482        let options = FirebaseOptions {
483            project_id: Some("project".into()),
484            ..Default::default()
485        };
486        let app = initialize_app(options, Some(unique_settings())).unwrap();
487        let rc = remote_config(app);
488        rc.set_defaults(HashMap::from([(String::from("flag"), String::from("off"))]));
489        rc.fetch().unwrap();
490        rc.activate().unwrap();
491        assert!(!rc.activate().unwrap());
492    }
493
494    #[test]
495    fn get_value_reports_default_source_prior_to_activation() {
496        let options = FirebaseOptions {
497            project_id: Some("project".into()),
498            ..Default::default()
499        };
500        let app = initialize_app(options, Some(unique_settings())).unwrap();
501        let rc = remote_config(app);
502        rc.set_defaults(HashMap::from([(
503            String::from("feature"),
504            String::from("true"),
505        )]));
506
507        let value = rc.get_value("feature");
508        assert_eq!(value.source(), RemoteConfigValueSource::Default);
509        assert!(value.as_bool());
510    }
511
512    #[test]
513    fn get_value_reports_remote_source_after_activation() {
514        let options = FirebaseOptions {
515            project_id: Some("project".into()),
516            ..Default::default()
517        };
518        let app = initialize_app(options, Some(unique_settings())).unwrap();
519        let rc = remote_config(app);
520        rc.set_defaults(HashMap::from([(
521            String::from("feature"),
522            String::from("true"),
523        )]));
524        rc.fetch().unwrap();
525        rc.activate().unwrap();
526
527        let value = rc.get_value("feature");
528        assert_eq!(value.source(), RemoteConfigValueSource::Remote);
529        assert!(value.as_bool());
530    }
531
532    #[test]
533    fn get_number_handles_invalid_values() {
534        let options = FirebaseOptions {
535            project_id: Some("project".into()),
536            ..Default::default()
537        };
538        let app = initialize_app(options, Some(unique_settings())).unwrap();
539        let rc = remote_config(app);
540        rc.set_defaults(HashMap::from([(
541            String::from("rate"),
542            String::from("not-a-number"),
543        )]));
544
545        assert_eq!(rc.get_number("rate"), 0.0);
546        assert_eq!(rc.get_number("missing"), 0.0);
547    }
548
549    #[test]
550    fn get_all_merges_defaults_and_remote_values() {
551        let options = FirebaseOptions {
552            project_id: Some("project".into()),
553            ..Default::default()
554        };
555        let app = initialize_app(options, Some(unique_settings())).unwrap();
556        let rc = remote_config(app);
557        rc.set_defaults(HashMap::from([
558            (String::from("feature"), String::from("true")),
559            (String::from("secondary"), String::from("value")),
560        ]));
561        rc.fetch().unwrap();
562        rc.activate().unwrap();
563        rc.set_defaults(HashMap::from([
564            (String::from("feature"), String::from("false")),
565            (String::from("secondary"), String::from("value")),
566            (String::from("fallback"), String::from("present")),
567        ]));
568
569        let all = rc.get_all();
570        assert_eq!(all.len(), 3);
571        assert_eq!(all["feature"].source(), RemoteConfigValueSource::Remote);
572        assert_eq!(all["feature"].as_bool(), true);
573        assert_eq!(all["secondary"].source(), RemoteConfigValueSource::Remote);
574        assert_eq!(all["fallback"].source(), RemoteConfigValueSource::Default);
575    }
576
577    #[test]
578    fn missing_key_returns_static_value() {
579        let options = FirebaseOptions {
580            project_id: Some("project".into()),
581            ..Default::default()
582        };
583        let app = initialize_app(options, Some(unique_settings())).unwrap();
584        let rc = remote_config(app);
585
586        let value = rc.get_value("not-present");
587        assert_eq!(value.source(), RemoteConfigValueSource::Static);
588        assert_eq!(value.as_string(), "");
589        assert!(!value.as_bool());
590        assert_eq!(value.as_number(), 0.0);
591    }
592
593    #[test]
594    fn settings_defaults_match_js_constants() {
595        let options = FirebaseOptions {
596            project_id: Some("project".into()),
597            ..Default::default()
598        };
599        let app = initialize_app(options, Some(unique_settings())).unwrap();
600        let rc = remote_config(app);
601
602        let settings = rc.settings();
603        assert_eq!(
604            settings.fetch_timeout_millis(),
605            DEFAULT_FETCH_TIMEOUT_MILLIS
606        );
607        assert_eq!(
608            settings.minimum_fetch_interval_millis(),
609            DEFAULT_MINIMUM_FETCH_INTERVAL_MILLIS
610        );
611    }
612
613    #[test]
614    fn set_config_settings_updates_values() {
615        let options = FirebaseOptions {
616            project_id: Some("project".into()),
617            ..Default::default()
618        };
619        let app = initialize_app(options, Some(unique_settings())).unwrap();
620        let rc = remote_config(app);
621
622        rc.set_config_settings(RemoteConfigSettingsUpdate {
623            fetch_timeout_millis: Some(90_000),
624            minimum_fetch_interval_millis: Some(3_600_000),
625        })
626        .unwrap();
627
628        let settings = rc.settings();
629        assert_eq!(settings.fetch_timeout_millis(), 90_000);
630        assert_eq!(settings.minimum_fetch_interval_millis(), 3_600_000);
631    }
632
633    #[test]
634    fn set_config_settings_rejects_zero_timeout() {
635        let options = FirebaseOptions {
636            project_id: Some("project".into()),
637            ..Default::default()
638        };
639        let app = initialize_app(options, Some(unique_settings())).unwrap();
640        let rc = remote_config(app);
641
642        let result = rc.set_config_settings(RemoteConfigSettingsUpdate {
643            fetch_timeout_millis: Some(0),
644            minimum_fetch_interval_millis: None,
645        });
646
647        assert!(result.is_err());
648        assert_eq!(
649            result.unwrap_err().code_str(),
650            crate::remote_config::error::RemoteConfigErrorCode::InvalidArgument.as_str()
651        );
652    }
653
654    #[test]
655    fn fetch_metadata_defaults() {
656        let options = FirebaseOptions {
657            project_id: Some("project".into()),
658            ..Default::default()
659        };
660        let app = initialize_app(options, Some(unique_settings())).unwrap();
661        let rc = remote_config(app);
662
663        assert_eq!(rc.last_fetch_status(), FetchStatus::NoFetchYet);
664        assert_eq!(rc.fetch_time_millis(), -1);
665    }
666
667    #[test]
668    fn fetch_respects_minimum_fetch_interval() {
669        let options = FirebaseOptions {
670            project_id: Some("project".into()),
671            ..Default::default()
672        };
673        let app = initialize_app(options, Some(unique_settings())).unwrap();
674        let rc = remote_config(app);
675
676        rc.fetch().unwrap();
677        let result = rc.fetch();
678
679        assert!(result.is_err());
680        assert_eq!(rc.last_fetch_status(), FetchStatus::Throttle);
681    }
682
683    #[test]
684    fn fetch_and_activate_uses_remote_values() {
685        let options = FirebaseOptions {
686            project_id: Some("project".into()),
687            ..Default::default()
688        };
689        let app = initialize_app(options, Some(unique_settings())).unwrap();
690        let rc = remote_config(app);
691
692        let response = FetchResponse {
693            status: 200,
694            etag: Some(String::from("etag-1")),
695            config: Some(HashMap::from([(
696                String::from("feature"),
697                String::from("remote"),
698            )])),
699            template_version: Some(7),
700        };
701
702        rc.set_fetch_client(Arc::new(StubFetchClient::new(response)));
703
704        rc.fetch().unwrap();
705        assert_eq!(rc.last_fetch_status(), FetchStatus::Success);
706
707        assert!(rc.activate().unwrap());
708        let value = rc.get_value("feature");
709        assert_eq!(value.source(), RemoteConfigValueSource::Remote);
710        assert_eq!(value.as_string(), "remote");
711        assert_eq!(rc.active_template_version(), Some(7));
712    }
713
714    struct StubFetchClient {
715        response: StdMutex<Option<FetchResponse>>,
716    }
717
718    impl StubFetchClient {
719        fn new(response: FetchResponse) -> Self {
720            Self {
721                response: StdMutex::new(Some(response)),
722            }
723        }
724    }
725
726    impl RemoteConfigFetchClient for StubFetchClient {
727        fn fetch(&self, _request: FetchRequest) -> RemoteConfigResult<FetchResponse> {
728            self.response
729                .lock()
730                .unwrap()
731                .take()
732                .ok_or_else(|| internal_error("no response queued"))
733        }
734    }
735
736    #[test]
737    fn with_storage_persists_across_instances() {
738        static COUNTER: AtomicUsize = AtomicUsize::new(0);
739        let storage_path = std::env::temp_dir().join(format!(
740            "firebase-remote-config-api-storage-{}.json",
741            COUNTER.fetch_add(1, Ordering::SeqCst)
742        ));
743
744        let options = FirebaseOptions {
745            project_id: Some("project".into()),
746            ..Default::default()
747        };
748        let app = initialize_app(options, Some(unique_settings())).unwrap();
749
750        let storage: Arc<dyn RemoteConfigStorage> =
751            Arc::new(FileRemoteConfigStorage::new(storage_path.clone()).unwrap());
752        let rc = RemoteConfig::with_storage(app.clone(), storage.clone());
753
754        rc.set_fetch_client(Arc::new(StubFetchClient::new(FetchResponse {
755            status: 200,
756            etag: Some(String::from("persist-etag")),
757            config: Some(HashMap::from([(
758                String::from("motd"),
759                String::from("hello"),
760            )])),
761            template_version: Some(5),
762        })));
763
764        rc.fetch().unwrap();
765        rc.activate().unwrap();
766
767        drop(rc);
768
769        let storage2: Arc<dyn RemoteConfigStorage> =
770            Arc::new(FileRemoteConfigStorage::new(storage_path.clone()).unwrap());
771        let rc2 = RemoteConfig::with_storage(app, storage2);
772
773        let value = rc2.get_value("motd");
774        assert_eq!(value.source(), RemoteConfigValueSource::Remote);
775        assert_eq!(value.as_string(), "hello");
776        assert_eq!(rc2.active_template_version(), Some(5));
777
778        let _ = fs::remove_file(storage_path);
779    }
780}