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 pub fn set_fetch_client(&self, fetch_client: Arc<dyn RemoteConfigFetchClient>) {
77 *self.inner.fetch_client.lock().unwrap() = fetch_client;
78 }
79
80 pub fn settings(&self) -> RemoteConfigSettings {
84 self.inner.settings.lock().unwrap().clone()
85 }
86
87 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 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 pub fn last_fetch_status(&self) -> FetchStatus {
266 self.inner.storage_cache.last_fetch_status()
267 }
268
269 pub fn active_template_version(&self) -> Option<u64> {
271 self.inner.storage_cache.active_config_template_version()
272 }
273
274 pub fn get_string(&self, key: &str) -> String {
278 self.get_value(key).as_string()
279 }
280
281 pub fn get_boolean(&self, key: &str) -> bool {
285 self.get_value(key).as_bool()
286 }
287
288 pub fn get_number(&self, key: &str) -> f64 {
292 self.get_value(key).as_number()
293 }
294
295 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 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}